Files
photoai/src/main/indexDb.ts
T
koriweb 3e73967c7b 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>
2026-06-01 19:22:19 +09:00

490 lines
17 KiB
TypeScript

import { app } from 'electron'
import initSqlJs, { type Database, type SqlJsStatic } from 'sql.js'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import type {
AssetRecord,
QualityScores,
IndexedAsset,
AssetQuery,
QualityThresholds,
ColorLabel
} from '@shared/types'
import { SUPPORTED_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS } from '@shared/constants'
import { logger } from './logger'
/**
* 라이브러리 인덱스 DB. WASM SQLite(sql.js) 사용 — 네이티브 빌드/ABI 재빌드 불필요.
* sql.js는 인메모리 → 변경분을 주기적으로 파일(userData/index.db)로 export 하여 영속화.
* (수천~수만 장 메타데이터 규모에 충분. 대규모/임베딩은 Phase 2+에서 별도 전략.)
*/
class IndexDb {
private SQL: SqlJsStatic | null = null
private db: Database | null = null
private dbPath = ''
private dirty = false
async init(): Promise<void> {
if (this.db) return
// sql.js wasm 바이트를 직접 읽어 전달 (asar/패키징 환경에서도 안전).
// wasmBinary는 ArrayBuffer를 기대하므로 Buffer → ArrayBuffer 변환.
const wasmPath = join(app.getAppPath(), 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm')
const wasmBuf = readFileSync(wasmPath)
this.SQL = await initSqlJs({ wasmBinary: new Uint8Array(wasmBuf).buffer })
this.dbPath = join(app.getPath('userData'), 'index.db')
const existing = existsSync(this.dbPath) ? await readFile(this.dbPath) : undefined
this.db = new this.SQL.Database(existing)
this.migrate()
await this.save()
logger.info('인덱스 DB 준비', { path: this.dbPath, assets: this.count() })
}
private migrate(): void {
this.db!.run(`
CREATE TABLE IF NOT EXISTS asset (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contentHash TEXT UNIQUE NOT NULL,
path TEXT NOT NULL,
ext TEXT,
sizeBytes INTEGER,
mtime INTEGER,
width INTEGER,
height INTEGER,
exifYear TEXT,
exifMonth TEXT,
gpsLat REAL,
gpsLon REAL,
camera TEXT,
indexedAt INTEGER
);
CREATE TABLE IF NOT EXISTS quality (
assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE,
focus REAL,
exposure REAL,
eyesOpen REAL,
flag TEXT
);
CREATE TABLE IF NOT EXISTS usermeta (
assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE,
rating INTEGER DEFAULT 0,
label TEXT
);
CREATE TABLE IF NOT EXISTS embedding (
assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE,
vec BLOB
);
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를 디스크로 영속화 */
async save(): Promise<void> {
if (!this.db) return
const data = this.db.export()
await mkdir(dirname(this.dbPath), { recursive: true })
await writeFile(this.dbPath, Buffer.from(data))
this.dirty = false
}
/** 변경이 있을 때만 저장 (배치 색인 후 호출) */
async saveIfDirty(): Promise<void> {
if (this.dirty) await this.save()
}
count(): number {
const res = this.db!.exec('SELECT COUNT(*) AS n FROM asset')
return res.length ? Number(res[0].values[0][0]) : 0
}
/**
* 같은 경로·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 = ? AND metaVersion >= 1 LIMIT 1'
)
try {
stmt.bind([path, mtime])
return stmt.step()
} finally {
stmt.free()
}
}
/** 이미 색인되었고 mtime이 동일하면 재색인 불필요 */
needsIndex(contentHash: string, mtime: number): boolean {
const stmt = this.db!.prepare('SELECT mtime FROM asset WHERE contentHash = ?')
try {
stmt.bind([contentHash])
if (!stmt.step()) return true // 미존재 → 색인 필요
const row = stmt.getAsObject() as { mtime: number }
return row.mtime !== mtime
} finally {
stmt.free()
}
}
/**
* 최근 색인순 자산 목록 + 품질 + 사용자메타. 품질 플래그는 임계값으로 **실시간 계산**
* (임계값 변경 시 재분석 없이 즉시 반영). 별점/색라벨 필터 지원.
*/
/** 자산+품질(실시간 플래그)+사용자메타를 결합하는 공통 SELECT */
private innerSelect(th: QualityThresholds): string {
const f = Number(th.focus)
const x = Number(th.exposure)
const e = Number(th.eyes)
return `
SELECT a.*,
q.focus AS focus, q.exposure AS exposure, q.eyesOpen AS eyesOpen,
COALESCE(um.rating, 0) AS rating, um.label AS label,
CASE
WHEN q.assetId IS NULL THEN NULL
WHEN q.focus < ${f} THEN 'blurry'
WHEN q.eyesOpen IS NOT NULL AND q.eyesOpen < ${e} THEN 'eyesClosed'
WHEN q.exposure < ${x} THEN 'badExposure'
ELSE 'candidate'
END AS flag
FROM asset a
LEFT JOIN quality q ON q.assetId = a.id
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,
query: AssetQuery,
th: QualityThresholds
): IndexedAsset[] {
const inner = this.innerSelect(th)
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([...params, limit, offset])
while (stmt.step()) out.push(stmt.getAsObject() as unknown as IndexedAsset)
} finally {
stmt.free()
}
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(
`INSERT INTO usermeta (assetId, rating) VALUES (?, ?)
ON CONFLICT(assetId) DO UPDATE SET rating = excluded.rating`,
[assetId, r]
)
this.dirty = true
}
setLabel(assetId: number, label: ColorLabel): void {
this.db!.run(
`INSERT INTO usermeta (assetId, label) VALUES (?, ?)
ON CONFLICT(assetId) DO UPDATE SET label = excluded.label`,
[assetId, label]
)
this.dirty = true
}
// ---- 임베딩 / 검색 (Phase 2) ----
/** 임베딩 미보유 이미지(영상 제외) 목록 — 검색 색인 생성용 */
listAssetsNeedingEmbedding(imageExts: string[]): { id: number; path: string }[] {
const placeholders = imageExts.map(() => '?').join(',')
const stmt = this.db!.prepare(
`SELECT a.id AS id, a.path AS path
FROM asset a LEFT JOIN embedding e ON e.assetId = a.id
WHERE e.assetId IS NULL AND a.ext IN (${placeholders})
ORDER BY a.id`
)
const out: { id: number; path: string }[] = []
try {
stmt.bind(imageExts)
while (stmt.step()) out.push(stmt.getAsObject() as unknown as { id: number; path: string })
} finally {
stmt.free()
}
return out
}
setEmbedding(assetId: number, vec: number[]): void {
const bytes = new Uint8Array(new Float32Array(vec).buffer)
this.db!.run(
`INSERT INTO embedding (assetId, vec) VALUES (?, ?)
ON CONFLICT(assetId) DO UPDATE SET vec = excluded.vec`,
[assetId, bytes]
)
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
}
/** 전체 임베딩 로드 (브루트포스 코사인 검색용) */
getAllEmbeddings(): { assetId: number; vec: Float32Array }[] {
const stmt = this.db!.prepare('SELECT assetId, vec FROM embedding')
const out: { assetId: number; vec: Float32Array }[] = []
try {
while (stmt.step()) {
const row = stmt.getAsObject() as { assetId: number; vec: Uint8Array }
const u8 = row.vec
const vec = new Float32Array(u8.buffer, u8.byteOffset, Math.floor(u8.byteLength / 4))
out.push({ assetId: Number(row.assetId), vec })
}
} finally {
stmt.free()
}
return out
}
/** id 목록을 입력 순서대로 IndexedAsset으로 조회 (검색 결과 정렬 유지) */
assetsByIds(ids: number[], th: QualityThresholds): IndexedAsset[] {
if (ids.length === 0) return []
const placeholders = ids.map(() => '?').join(',')
const stmt = this.db!.prepare(
`SELECT * FROM (${this.innerSelect(th)}) WHERE id IN (${placeholders})`
)
const byId = new Map<number, IndexedAsset>()
try {
stmt.bind(ids)
while (stmt.step()) {
const a = stmt.getAsObject() as unknown as IndexedAsset
if (a.id != null) byId.set(a.id, a)
}
} finally {
stmt.free()
}
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 {
stmt.bind([contentHash])
if (!stmt.step()) return null
return stmt.getAsObject() as unknown as AssetRecord
} finally {
stmt.free()
}
}
/** 자산 upsert (contentHash 기준). 반환: asset id */
upsertAsset(r: AssetRecord): number {
this.db!.run(
`INSERT INTO asset
(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,
gpsLat=excluded.gpsLat, gpsLon=excluded.gpsLon, camera=excluded.camera,
metaVersion=1, indexedAt=excluded.indexedAt`,
[
r.contentHash,
r.path,
r.ext,
r.sizeBytes,
r.mtime,
r.width,
r.height,
r.exifYear,
r.exifMonth,
r.gpsLat,
r.gpsLon,
r.camera,
r.indexedAt
]
)
this.dirty = true
const res = this.db!.exec('SELECT id FROM asset WHERE contentHash = ?', [r.contentHash])
return res.length ? Number(res[0].values[0][0]) : -1
}
setQuality(assetId: number, q: QualityScores): void {
this.db!.run(
`INSERT INTO quality (assetId, focus, exposure, eyesOpen, flag)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(assetId) DO UPDATE SET
focus=excluded.focus, exposure=excluded.exposure,
eyesOpen=excluded.eyesOpen, flag=excluded.flag`,
[assetId, q.focus, q.exposure, q.eyesOpen, q.flag]
)
this.dirty = true
}
close(): void {
this.db?.close()
this.db = null
}
}
export const indexDb = new IndexDb()