darktable-inspired reskin + metadata/collections, map, easy mode, select/export

UI overhaul to a darktable tone-and-manner and a set of features adapted from
darktable's proven patterns (reimplemented in our Electron/TS stack; no GPL code).

Design reskin:
- Dark neutral-gray palette + amber accent, flat/squared corners, no card shadows,
  compact darktable-style top bar (logo + pipe-separated view tabs), denser 15px base
- Done via design tokens (Tailwind slate/brand/radius/shadow remap) — minimal churn

Metadata & collections (Phase A/B):
- exifr now captures GPS + camera; asset table ALTER-migrated (gpsLat/gpsLon/camera,
  metaVersion backfill on re-index)
- Collection facet bar (year timeline / camera / color-label) filters the grid

Map & relation finder (Phase C):
- Leaflet + online OSM map tab; geotagged photos as markers
- relationService: related photos by place (GPS<1km) + time (+/-2d) + CLIP similarity

Easy mode (Phase D):
- easyMode setting (menu / onboarding); scales the whole UI (rem) + bigger thumbnails
  + large icon nav with plain labels (4050 accessibility)

Library usability:
- Video thumbnails (representative frame capture in the inference worker)
- Media filter (All / Photos / Videos) to separate them
- Clearer culling labels ("Good shots" / "To cull") + explanation tooltip
- Multi-select tiles -> Export selected to a folder (copy, best-cut extraction) and
  Delete to Recycle Bin (shell.trashItem) behind a confirm dialog
- ONNX Runtime wasm bundled locally (offline) via copy-ort-wasm + asarUnpack

Docs: DARKTABLE_REVIEW (feasibility + roadmap A->D). All typecheck/tests/build green;
boot smoke verified each phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:22:19 +09:00
parent 72c41ae834
commit 3e73967c7b
33 changed files with 1670 additions and 96 deletions
+177 -15
View File
@@ -11,6 +11,7 @@ import type {
QualityThresholds,
ColorLabel
} from '@shared/types'
import { SUPPORTED_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS } from '@shared/constants'
import { logger } from './logger'
/**
@@ -55,6 +56,9 @@ class IndexDb {
height INTEGER,
exifYear TEXT,
exifMonth TEXT,
gpsLat REAL,
gpsLon REAL,
camera TEXT,
indexedAt INTEGER
);
CREATE TABLE IF NOT EXISTS quality (
@@ -76,6 +80,22 @@ class IndexDb {
CREATE INDEX IF NOT EXISTS idx_asset_hash ON asset(contentHash);
CREATE INDEX IF NOT EXISTS idx_asset_path ON asset(path);
`)
// 기존 DB(컬럼 없음)에 대한 마이그레이션 — ALTER ADD COLUMN
this.ensureColumn('asset', 'gpsLat', 'REAL')
this.ensureColumn('asset', 'gpsLon', 'REAL')
this.ensureColumn('asset', 'camera', 'TEXT')
// metaVersion: 확장 메타(GPS/카메라) 적재 버전. 구버전 행(0/NULL)은 재색인 시 backfill
this.ensureColumn('asset', 'metaVersion', 'INTEGER')
}
/** 테이블에 컬럼이 없으면 추가 (sql.js는 ADD COLUMN IF NOT EXISTS 미지원) */
private ensureColumn(table: string, col: string, type: string): void {
const res = this.db!.exec(`PRAGMA table_info(${table})`)
const names = res.length ? res[0].values.map((r) => String(r[1])) : []
if (!names.includes(col)) {
this.db!.run(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`)
}
}
/** 인메모리 DB를 디스크로 영속화 */
@@ -97,9 +117,14 @@ class IndexDb {
return res.length ? Number(res[0].values[0][0]) : 0
}
/** 같은 경로가 같은 mtime으로 이미 색인되어 있으면 true (해시 계산 없이 빠른 스킵) */
/**
* 같은 경로·mtime으로 이미 **확장 메타까지** 색인되었으면 true (빠른 스킵).
* metaVersion이 없는 구버전 행은 false → 재색인 시 GPS/카메라 backfill.
*/
isIndexedPath(path: string, mtime: number): boolean {
const stmt = this.db!.prepare('SELECT 1 FROM asset WHERE path = ? AND mtime = ? LIMIT 1')
const stmt = this.db!.prepare(
'SELECT 1 FROM asset WHERE path = ? AND mtime = ? AND metaVersion >= 1 LIMIT 1'
)
try {
stmt.bind([path, mtime])
return stmt.step()
@@ -146,6 +171,36 @@ class IndexDb {
LEFT JOIN usermeta um ON um.assetId = a.id`
}
/** 쿼리 → (where절, 바인딩 파라미터). 고정 열거/숫자는 인라인, 사용자 값은 바인딩 */
private buildWhere(query: AssetQuery): { where: string; params: (string | number)[] } {
const conds: string[] = []
const params: (string | number)[] = []
if (query.filter === 'rejected') {
conds.push("flag IN ('blurry', 'eyesClosed', 'badExposure')")
} else if (query.filter !== 'all') {
conds.push(`flag = '${query.filter}'`)
}
if (query.ratingMin > 0) conds.push(`rating >= ${Number(query.ratingMin)}`)
if (query.kind === 'image') {
conds.push(`ext IN (${SUPPORTED_EXTENSIONS.map((e) => `'${e}'`).join(',')})`)
} else if (query.kind === 'video') {
conds.push(`ext IN (${SUPPORTED_VIDEO_EXTENSIONS.map((e) => `'${e}'`).join(',')})`)
}
if (query.year) {
conds.push('exifYear = ?')
params.push(query.year)
}
if (query.camera) {
conds.push('camera = ?')
params.push(query.camera)
}
if (query.label) {
conds.push('label = ?')
params.push(query.label)
}
return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', params }
}
listAssets(
offset: number,
limit: number,
@@ -153,21 +208,13 @@ class IndexDb {
th: QualityThresholds
): IndexedAsset[] {
const inner = this.innerSelect(th)
const conds: string[] = []
if (query.filter === 'rejected') {
conds.push("flag IN ('blurry', 'eyesClosed', 'badExposure')")
} else if (query.filter !== 'all') {
conds.push(`flag = '${query.filter}'`) // 고정 열거값
}
if (query.ratingMin > 0) conds.push(`rating >= ${Number(query.ratingMin)}`)
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''
const { where, params } = this.buildWhere(query)
const stmt = this.db!.prepare(
`SELECT * FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC LIMIT ? OFFSET ?`
)
const out: IndexedAsset[] = []
try {
stmt.bind([limit, offset])
stmt.bind([...params, limit, offset])
while (stmt.step()) out.push(stmt.getAsObject() as unknown as IndexedAsset)
} finally {
stmt.free()
@@ -175,6 +222,43 @@ class IndexDb {
return out
}
/** 쿼리에 매칭되는 전체 자산 id (전체 선택/필터 전체 내보내기용) */
listAssetIds(query: AssetQuery, th: QualityThresholds): number[] {
const inner = this.innerSelect(th)
const { where, params } = this.buildWhere(query)
const stmt = this.db!.prepare(
`SELECT id FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC`
)
const out: number[] = []
try {
stmt.bind(params)
while (stmt.step()) out.push(Number((stmt.getAsObject() as { id: number }).id))
} finally {
stmt.free()
}
return out
}
/** 컬렉션 패싯 집계 (연도/카메라/색라벨) */
facets(): import('@shared/types').Facets {
const q = (sql: string): import('@shared/types').FacetItem[] => {
const res = this.db!.exec(sql)
if (!res.length) return []
return res[0].values.map((row) => ({ value: String(row[0]), count: Number(row[1]) }))
}
return {
years: q(
"SELECT exifYear, COUNT(*) FROM asset WHERE exifYear IS NOT NULL GROUP BY exifYear ORDER BY exifYear DESC"
),
cameras: q(
"SELECT camera, COUNT(*) FROM asset WHERE camera IS NOT NULL AND camera <> '' GROUP BY camera ORDER BY COUNT(*) DESC"
),
labels: q(
'SELECT label, COUNT(*) FROM usermeta WHERE label IS NOT NULL GROUP BY label ORDER BY COUNT(*) DESC'
)
}
}
setRating(assetId: number, rating: number): void {
const r = Math.max(0, Math.min(5, Math.round(rating)))
this.db!.run(
@@ -225,6 +309,58 @@ class IndexDb {
this.dirty = true
}
/** GPS 좌표가 있는 자산(지도 마커용) */
assetsWithGps(): { id: number; contentHash: string; path: string; gpsLat: number; gpsLon: number }[] {
const stmt = this.db!.prepare(
'SELECT id, contentHash, path, gpsLat, gpsLon FROM asset WHERE gpsLat IS NOT NULL AND gpsLon IS NOT NULL'
)
const out: { id: number; contentHash: string; path: string; gpsLat: number; gpsLon: number }[] =
[]
try {
while (stmt.step()) {
const r = stmt.getAsObject() as unknown as {
id: number
contentHash: string
path: string
gpsLat: number
gpsLon: number
}
out.push(r)
}
} finally {
stmt.free()
}
return out
}
/** 특정 자산의 임베딩 (연관 탐색용) */
getEmbedding(assetId: number): Float32Array | null {
const stmt = this.db!.prepare('SELECT vec FROM embedding WHERE assetId = ?')
try {
stmt.bind([assetId])
if (!stmt.step()) return null
const u8 = (stmt.getAsObject() as { vec: Uint8Array }).vec
return new Float32Array(u8.buffer, u8.byteOffset, Math.floor(u8.byteLength / 4))
} finally {
stmt.free()
}
}
/** mtime이 ±window 이내인 자산 id (시간 연관용) */
assetsNearTime(mtime: number, windowMs: number, excludeId: number, limit = 200): number[] {
const stmt = this.db!.prepare(
'SELECT id FROM asset WHERE mtime BETWEEN ? AND ? AND id <> ? LIMIT ?'
)
const out: number[] = []
try {
stmt.bind([mtime - windowMs, mtime + windowMs, excludeId, limit])
while (stmt.step()) out.push(Number((stmt.getAsObject() as { id: number }).id))
} finally {
stmt.free()
}
return out
}
embeddingCount(): number {
const res = this.db!.exec('SELECT COUNT(*) AS n FROM embedding')
return res.length ? Number(res[0].values[0][0]) : 0
@@ -267,6 +403,26 @@ class IndexDb {
return ids.map((id) => byId.get(id)).filter((a): a is IndexedAsset => !!a)
}
getById(id: number): AssetRecord | null {
const stmt = this.db!.prepare('SELECT * FROM asset WHERE id = ?')
try {
stmt.bind([id])
if (!stmt.step()) return null
return stmt.getAsObject() as unknown as AssetRecord
} finally {
stmt.free()
}
}
/** 자산 + 연관 메타(품질/사용자메타/임베딩) 전부 삭제 */
deleteAsset(id: number): void {
this.db!.run('DELETE FROM embedding WHERE assetId = ?', [id])
this.db!.run('DELETE FROM quality WHERE assetId = ?', [id])
this.db!.run('DELETE FROM usermeta WHERE assetId = ?', [id])
this.db!.run('DELETE FROM asset WHERE id = ?', [id])
this.dirty = true
}
getByHash(contentHash: string): AssetRecord | null {
const stmt = this.db!.prepare('SELECT * FROM asset WHERE contentHash = ?')
try {
@@ -282,12 +438,15 @@ class IndexDb {
upsertAsset(r: AssetRecord): number {
this.db!.run(
`INSERT INTO asset
(contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth, indexedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth,
gpsLat, gpsLon, camera, metaVersion, indexedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
ON CONFLICT(contentHash) DO UPDATE SET
path=excluded.path, ext=excluded.ext, sizeBytes=excluded.sizeBytes,
mtime=excluded.mtime, width=excluded.width, height=excluded.height,
exifYear=excluded.exifYear, exifMonth=excluded.exifMonth, indexedAt=excluded.indexedAt`,
exifYear=excluded.exifYear, exifMonth=excluded.exifMonth,
gpsLat=excluded.gpsLat, gpsLon=excluded.gpsLon, camera=excluded.camera,
metaVersion=1, indexedAt=excluded.indexedAt`,
[
r.contentHash,
r.path,
@@ -298,6 +457,9 @@ class IndexDb {
r.height,
r.exifYear,
r.exifMonth,
r.gpsLat,
r.gpsLon,
r.camera,
r.indexedAt
]
)