|
|
|
@@ -1,8 +1,9 @@
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
|
import { thumbUrl, baseName } from '../media'
|
|
|
|
|
import { thumbUrl, mediaUrl, baseName } from '../media'
|
|
|
|
|
import type { IndexedAsset } from '@shared/types'
|
|
|
|
|
|
|
|
|
|
type Tile = { img: HTMLImageElement; r: number; g: number; b: number }
|
|
|
|
|
type Tile = { img: HTMLImageElement; asset: IndexedAsset; r: number; g: number; b: number }
|
|
|
|
|
type GridMap = { cols: number; rows: number; cellPx: number; map: (Tile | null)[] }
|
|
|
|
|
|
|
|
|
|
/** crossOrigin 이미지를 로드 (캔버스 픽셀 읽기용). 실패 시 null */
|
|
|
|
|
function loadImage(src: string): Promise<HTMLImageElement | null> {
|
|
|
|
@@ -29,6 +30,7 @@ function averageColor(img: HTMLImageElement): { r: number; g: number; b: number
|
|
|
|
|
/**
|
|
|
|
|
* 포토모자이크 뷰어 — 대표(대상) 사진을 라이브러리 사진(타일)들로 재구성한다.
|
|
|
|
|
* 대상 이미지를 격자로 나눠 각 칸의 평균색과 가장 가까운 타일을 배치.
|
|
|
|
|
* 격자선으로 타일 경계를 보이게 하고, 타일을 클릭하면 그 원본 사진을 띄운다.
|
|
|
|
|
*/
|
|
|
|
|
export function MosaicView(props: {
|
|
|
|
|
target: IndexedAsset
|
|
|
|
@@ -40,10 +42,14 @@ export function MosaicView(props: {
|
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
|
|
|
const poolRef = useRef<Tile[]>([])
|
|
|
|
|
const targetImgRef = useRef<HTMLImageElement | null>(null)
|
|
|
|
|
const gridRef = useRef<GridMap>({ cols: 0, rows: 0, cellPx: 0, map: [] })
|
|
|
|
|
const [ready, setReady] = useState(false)
|
|
|
|
|
// 기본값을 낮춰(타일이 커서 사진이 보임) 모자이크 느낌이 나도록. 더 잘게는 슬라이더로.
|
|
|
|
|
const [cols, setCols] = useState(48)
|
|
|
|
|
const [blend, setBlend] = useState(0.35)
|
|
|
|
|
const [blend, setBlend] = useState(0.25)
|
|
|
|
|
const [unique, setUnique] = useState(true)
|
|
|
|
|
const [grid, setGrid] = useState(true)
|
|
|
|
|
const [picked, setPicked] = useState<IndexedAsset | null>(null)
|
|
|
|
|
|
|
|
|
|
// 타일/대상 이미지 로드 + 평균색 사전 계산 (1회)
|
|
|
|
|
useEffect(() => {
|
|
|
|
@@ -52,14 +58,14 @@ export function MosaicView(props: {
|
|
|
|
|
void (async () => {
|
|
|
|
|
const pool: Tile[] = []
|
|
|
|
|
const imgs = await Promise.all(tiles.map((a) => loadImage(thumbUrl(a.contentHash))))
|
|
|
|
|
for (const img of imgs) {
|
|
|
|
|
if (!img) continue
|
|
|
|
|
imgs.forEach((img, i) => {
|
|
|
|
|
if (!img) return
|
|
|
|
|
try {
|
|
|
|
|
pool.push({ img, ...averageColor(img) })
|
|
|
|
|
pool.push({ img, asset: tiles[i], ...averageColor(img) })
|
|
|
|
|
} catch {
|
|
|
|
|
/* 손상 타일 무시 */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
const tImg = await loadImage(thumbUrl(target.contentHash))
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
poolRef.current = pool
|
|
|
|
@@ -89,11 +95,19 @@ export function MosaicView(props: {
|
|
|
|
|
sctx.drawImage(tImg, 0, 0, gridCols, gridRows)
|
|
|
|
|
const cells = sctx.getImageData(0, 0, gridCols, gridRows).data
|
|
|
|
|
|
|
|
|
|
const cellPx = Math.max(8, Math.round(1800 / gridCols))
|
|
|
|
|
// 출력 캔버스 상한 3600px → 해상도를 높여도 타일이 뭉개지지 않고 고해상도로 저장
|
|
|
|
|
const cellPx = Math.max(8, Math.round(3600 / gridCols))
|
|
|
|
|
canvas.width = gridCols * cellPx
|
|
|
|
|
canvas.height = gridRows * cellPx
|
|
|
|
|
const ctx = canvas.getContext('2d')!
|
|
|
|
|
// 격자선 두께: 타일이 클수록 살짝 두껍게(보이게), 작을수록 얇게
|
|
|
|
|
const gap = grid ? Math.max(1, Math.round(cellPx * 0.06)) : 0
|
|
|
|
|
if (gap > 0) {
|
|
|
|
|
ctx.fillStyle = '#0c0c0d' // 타일 사이 '그라우트'(어두운 줄눈)
|
|
|
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const map: (Tile | null)[] = new Array(gridCols * gridRows).fill(null)
|
|
|
|
|
// 최근 사용 타일에 페널티를 주어 같은 타일이 뭉치는 것을 줄임
|
|
|
|
|
const recent = new Map<Tile, number>()
|
|
|
|
|
let step = 0
|
|
|
|
@@ -123,31 +137,51 @@ export function MosaicView(props: {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dx = x * cellPx
|
|
|
|
|
const dy = y * cellPx
|
|
|
|
|
const dx = x * cellPx + gap
|
|
|
|
|
const dy = y * cellPx + gap
|
|
|
|
|
const dw = cellPx - gap * 2
|
|
|
|
|
const dh = cellPx - gap * 2
|
|
|
|
|
map[y * gridCols + x] = best
|
|
|
|
|
if (best) {
|
|
|
|
|
ctx.drawImage(best.img, dx, dy, cellPx, cellPx)
|
|
|
|
|
ctx.drawImage(best.img, dx, dy, dw, dh)
|
|
|
|
|
recent.set(best, step)
|
|
|
|
|
} else {
|
|
|
|
|
ctx.fillStyle = `rgb(${r},${g},${b})`
|
|
|
|
|
ctx.fillRect(dx, dy, cellPx, cellPx)
|
|
|
|
|
ctx.fillRect(dx, dy, dw, dh)
|
|
|
|
|
}
|
|
|
|
|
// 색 보정: 대상 칸 색을 반투명으로 덮어 닮음새를 강화
|
|
|
|
|
if (blend > 0) {
|
|
|
|
|
ctx.globalAlpha = blend
|
|
|
|
|
ctx.fillStyle = `rgb(${r},${g},${b})`
|
|
|
|
|
ctx.fillRect(dx, dy, cellPx, cellPx)
|
|
|
|
|
ctx.fillRect(dx, dy, dw, dh)
|
|
|
|
|
ctx.globalAlpha = 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [cols, blend, unique])
|
|
|
|
|
gridRef.current = { cols: gridCols, rows: gridRows, cellPx, map }
|
|
|
|
|
}, [cols, blend, unique, grid])
|
|
|
|
|
|
|
|
|
|
// 준비 완료/설정 변경 시 재생성
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (ready) generate()
|
|
|
|
|
}, [ready, generate])
|
|
|
|
|
|
|
|
|
|
// 캔버스 클릭 → 해당 타일의 원본 사진 표시
|
|
|
|
|
const onCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
const canvas = canvasRef.current
|
|
|
|
|
const g = gridRef.current
|
|
|
|
|
if (!canvas || g.cellPx === 0) return
|
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
|
|
|
const cx = (e.clientX - rect.left) * (canvas.width / rect.width)
|
|
|
|
|
const cy = (e.clientY - rect.top) * (canvas.height / rect.height)
|
|
|
|
|
const col = Math.floor(cx / g.cellPx)
|
|
|
|
|
const row = Math.floor(cy / g.cellPx)
|
|
|
|
|
if (col < 0 || row < 0 || col >= g.cols || row >= g.rows) return
|
|
|
|
|
const tile = g.map[row * g.cols + col]
|
|
|
|
|
if (tile) setPicked(tile.asset)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const save = () => {
|
|
|
|
|
const canvas = canvasRef.current
|
|
|
|
|
if (!canvas) return
|
|
|
|
@@ -185,7 +219,7 @@ export function MosaicView(props: {
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={16}
|
|
|
|
|
max={96}
|
|
|
|
|
max={192}
|
|
|
|
|
step={2}
|
|
|
|
|
value={cols}
|
|
|
|
|
onChange={(e) => setCols(Number(e.target.value))}
|
|
|
|
@@ -206,6 +240,10 @@ export function MosaicView(props: {
|
|
|
|
|
/>
|
|
|
|
|
<span className="w-9 tabular-nums text-slate-400">{Math.round(blend * 100)}%</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
|
|
|
<input type="checkbox" checked={grid} onChange={(e) => setGrid(e.target.checked)} />
|
|
|
|
|
<span>{t('mosaic.grid')}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
|
|
|
<input type="checkbox" checked={unique} onChange={(e) => setUnique(e.target.checked)} />
|
|
|
|
|
<span>{t('mosaic.unique')}</span>
|
|
|
|
@@ -220,19 +258,57 @@ export function MosaicView(props: {
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 캔버스 */}
|
|
|
|
|
<div className="flex-1 min-h-0 overflow-auto flex items-center justify-center p-4" onClick={props.onClose}>
|
|
|
|
|
<div className="flex-1 min-h-0 overflow-auto flex flex-col items-center justify-center p-4 gap-2" onClick={props.onClose}>
|
|
|
|
|
{!ready ? (
|
|
|
|
|
<p className="text-slate-400 text-sm">{t('mosaic.building')}</p>
|
|
|
|
|
) : tileCount < 4 ? (
|
|
|
|
|
<p className="text-slate-400 text-sm max-w-md text-center">{t('mosaic.tooFew')}</p>
|
|
|
|
|
) : (
|
|
|
|
|
<canvas
|
|
|
|
|
ref={canvasRef}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
className="max-w-full max-h-full object-contain shadow-2xl"
|
|
|
|
|
/>
|
|
|
|
|
<>
|
|
|
|
|
<canvas
|
|
|
|
|
ref={canvasRef}
|
|
|
|
|
onClick={onCanvasClick}
|
|
|
|
|
className="max-w-full max-h-full object-contain shadow-2xl cursor-pointer"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-[11px] text-slate-500 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
{t('mosaic.clickHint')}
|
|
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타일 클릭 → 원본 사진 */}
|
|
|
|
|
{picked && <PickedPhoto asset={picked} onClose={() => setPicked(null)} />}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 모자이크 타일을 클릭했을 때 뜨는 원본 사진 오버레이 */
|
|
|
|
|
function PickedPhoto(props: { asset: IndexedAsset; onClose: () => void }): JSX.Element {
|
|
|
|
|
const { asset } = props
|
|
|
|
|
const [failed, setFailed] = useState(false)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === 'Escape') props.onClose()
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('keydown', onKey)
|
|
|
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
|
|
|
}, [props])
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-[60] bg-black/92 flex flex-col items-center justify-center gap-3 p-6"
|
|
|
|
|
onClick={props.onClose}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={failed ? thumbUrl(asset.contentHash) : mediaUrl(asset.path)}
|
|
|
|
|
alt={baseName(asset.path)}
|
|
|
|
|
className="max-w-full max-h-[82vh] object-contain shadow-2xl"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
onError={() => setFailed(true)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="mono text-xs text-slate-300 text-center" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
{baseName(asset.path)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|