Compare commits

...

2 Commits

Author SHA1 Message Date
koriweb fae7ddc2ee 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>
2026-06-02 18:08:16 +09:00
koriweb 20cec8cb00 v0.2.0: photo mosaic 2x resolution + version bump
- mosaic: resolution slider up to 192 cols (was 96), default 96, output canvas cap 3600px
  so higher density renders/saves at genuinely higher resolution

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:25:30 +09:00
4 changed files with 104 additions and 23 deletions
+1 -1
View File
@@ -7,5 +7,5 @@
"corePurpose": "",
"detailLevel": "standard",
"createdAt": "2026-06-01T04:16:09.722Z",
"updatedAt": "2026-06-02T05:39:25.175Z"
"updatedAt": "2026-06-02T07:18:12.522Z"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ai-photo-organizer",
"version": "0.1.0",
"version": "0.2.0",
"description": "Local-first AI photo organizer — face recognition + EXIF based auto archiving",
"author": "PhotoAI",
"license": "MIT",
+97 -21
View File
@@ -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>
)
}
+5
View File
@@ -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': {