diff --git a/src/renderer/components/MosaicView.tsx b/src/renderer/components/MosaicView.tsx index dd6445c..f8f254a 100644 --- a/src/renderer/components/MosaicView.tsx +++ b/src/renderer/components/MosaicView.tsx @@ -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 { @@ -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(null) const poolRef = useRef([]) const targetImgRef = useRef(null) + const gridRef = useRef({ cols: 0, rows: 0, cellPx: 0, map: [] }) const [ready, setReady] = useState(false) - const [cols, setCols] = useState(96) - const [blend, setBlend] = useState(0.35) + // 기본값을 낮춰(타일이 커서 사진이 보임) 모자이크 느낌이 나도록. 더 잘게는 슬라이더로. + const [cols, setCols] = useState(48) + const [blend, setBlend] = useState(0.25) const [unique, setUnique] = useState(true) + const [grid, setGrid] = useState(true) + const [picked, setPicked] = useState(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,12 +95,19 @@ export function MosaicView(props: { sctx.drawImage(tImg, 0, 0, gridCols, gridRows) const cells = sctx.getImageData(0, 0, gridCols, gridRows).data - // 출력 캔버스 상한 3600px → 해상도(열 수)를 높여도 타일이 뭉개지지 않고 고해상도로 저장 + // 출력 캔버스 상한 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() let step = 0 @@ -124,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) => { + 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 @@ -207,6 +240,10 @@ export function MosaicView(props: { /> {Math.round(blend * 100)}% +