Add NextGen library: index DB, thumbnails, AI culling, and CLIP search

Builds the "indexed library" foundation and first intelligent features on
top of the organizer (sql.js index, non-destructive in-place indexing).

Phase 0 — Library index:
- sql.js (WASM SQLite) index DB; contentHash-keyed assets, resumable indexing
  (skip by path+mtime), batch persistence (chosen over native better-sqlite3
  which fails to build on Node 24 / Python 3.12)
- Library folders (in place, non-destructive) + background indexer w/ progress
- Thumbnails generated in the AI worker (canvas->webp), cached in userData;
  served via photoai-media://thumb by hash; thumbnail grid w/ pagination

Phase 1 — AI quality assessment & culling:
- Focus (Laplacian variance), exposure (histogram), eyes-open (face-api EAR)
  computed in one analyze pass alongside the thumbnail
- Culling filters (candidate/rejected) + quality badges
- Adjustable thresholds (live SQL re-classification from stored raw scores,
  no re-analysis) + manual star rating (0-5) and color labels (usermeta)

Phase 2 — CLIP natural-language / similarity search:
- @huggingface/transformers (WASM/WebGPU, no native build)
- CLIP image/text embeddings (lazy-loaded); Korean queries auto-translated
  via opus-mt-ko-en into the English CLIP
- Embeddings stored as SQLite BLOBs; "build search index" batch w/ progress;
  brute-force cosine search; new Search tab
- Note: models download from HF Hub on first use; fully-offline ORT-wasm
  packaging and KO search-accuracy tuning are follow-ups

Tabs added (Organize / Library / Search). All typecheck/tests(12)/build green;
boot smoke verified across phases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:32:51 +09:00
parent 6dce580846
commit 72c41ae834
33 changed files with 3136 additions and 358 deletions
+59 -18
View File
@@ -8,10 +8,15 @@ import { RunControl } from './components/RunControl'
import { ProgressView } from './components/ProgressView'
import { FileList } from './components/FileList'
import { ReportView } from './components/ReportView'
import { LibraryView } from './components/LibraryView'
import { SearchView } from './components/SearchView'
import type { AppView } from './store'
export default function App(): JSX.Element {
const t = useT()
const phase = useStore((s) => s.phase)
const view = useStore((s) => s.view)
const setView = useStore((s) => s.setView)
const onboarded = useStore((s) => s.onboarded)
const refreshProfiles = useStore((s) => s.refreshProfiles)
const initSettings = useStore((s) => s.initSettings)
@@ -28,29 +33,65 @@ export default function App(): JSX.Element {
if (!ready) return <div className="h-full" />
if (!onboarded) return <Onboarding />
const tabs: { id: AppView; label: string }[] = [
{ id: 'organize', label: t('nav.organize') },
{ id: 'library', label: t('nav.library') },
{ id: 'search', label: t('nav.search') }
]
return (
<div className="h-full flex flex-col">
<header className="px-6 py-4 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-sm shrink-0">
<h1 className="text-xl font-bold text-brand-dark dark:text-brand">{t('app.title')}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{t('app.subtitle')}</p>
<header className="px-6 pt-4 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-sm shrink-0">
<div className="flex items-end justify-between">
<div>
<h1 className="text-xl font-bold text-brand-dark dark:text-brand">{t('app.title')}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{t('app.subtitle')}</p>
</div>
</div>
{/* 탭 네비 */}
<nav className="flex gap-1 mt-3 -mb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setView(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
view === tab.id
? 'border-brand text-brand'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-brand'
}`}
>
{tab.label}
</button>
))}
</nav>
</header>
<main className="flex-1 min-h-0 grid grid-cols-12 gap-4 p-6">
{/* 좌측: 설정 패널 (자체 스크롤) */}
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
<ProfileManager />
<FolderPicker />
<RunControl />
</section>
{view === 'organize' ? (
<main className="flex-1 min-h-0 grid grid-cols-12 gap-4 p-6">
{/* 좌측: 설정 패널 (자체 스크롤) */}
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
<ProfileManager />
<FolderPicker />
<RunControl />
</section>
{/* 우측: 진행/결과 — FileList만 내부 스크롤 */}
<section className="col-span-7 min-h-0 flex flex-col gap-4">
<div className="shrink-0">
{phase === 'done' ? <ReportView /> : <ProgressView />}
</div>
<FileList />
</section>
</main>
{/* 우측: 진행/결과 — FileList만 내부 스크롤 */}
<section className="col-span-7 min-h-0 flex flex-col gap-4">
<div className="shrink-0">
{phase === 'done' ? <ReportView /> : <ProgressView />}
</div>
<FileList />
</section>
</main>
) : view === 'library' ? (
<main className="flex-1 min-h-0 overflow-y-auto p-6">
<LibraryView />
</main>
) : (
<main className="flex-1 min-h-0 overflow-y-auto p-6">
<SearchView />
</main>
)}
</div>
)
}