mosaic: stronger mosaic look + click-to-view source tile
- lower default resolution (96->48) and blend (35%->25%) so individual tile photos are visible and edges are less noisy - add grid-line (grout) toggle, default on, to make the tile structure read clearly - click a mosaic tile to open the source library photo it was placed from (Esc/click to close) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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(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<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,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<Tile, number>()
|
||||
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<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
|
||||
@@ -207,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>
|
||||
@@ -221,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -299,6 +299,11 @@ export const MESSAGES: Table = {
|
||||
'mosaic.resolution': { ko: '해상도', en: 'Resolution' },
|
||||
'mosaic.blend': { ko: '색 보정', en: 'Color blend' },
|
||||
'mosaic.unique': { ko: '중복 줄이기', en: 'Reduce repeats' },
|
||||
'mosaic.grid': { ko: '격자선', en: 'Grid lines' },
|
||||
'mosaic.clickHint': {
|
||||
ko: '타일을 클릭하면 그 자리에 쓰인 원본 사진을 봅니다.',
|
||||
en: 'Click a tile to see the source photo placed there.'
|
||||
},
|
||||
'mosaic.save': { ko: 'PNG 저장', en: 'Save PNG' },
|
||||
'mosaic.building': { ko: '타일 색상 분석 중…', en: 'Analyzing tile colors…' },
|
||||
'mosaic.tooFew': {
|
||||
|
||||
Reference in New Issue
Block a user