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:
+177
-15
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user