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
+34
View File
@@ -50,6 +50,22 @@ export const DEFAULT_JOB_OPTIONS = {
/** 추론 시 이미지 장변 최대 픽셀 (다운스케일 기준) */
export const MAX_IMAGE_DIMENSION = 1024
/** 썸네일 장변 픽셀 */
export const THUMBNAIL_SIZE = 256
/** 품질 분석 시 이미지 장변 픽셀 (초점/노출/얼굴 계산용) */
export const ANALYZE_SIZE = 512
/** 품질 판정 기본 임계값 (Phase 1) */
export const QUALITY_THRESHOLDS = {
/** 라플라시안 분산이 이 값 미만이면 흐림 (512px 기준) */
focus: 60,
/** 노출 점수(0~1)가 이 값 미만이면 노출 불량 */
exposure: 0.35,
/** EAR(눈 종횡비)이 이 값 미만이면 눈 감음 */
eyes: 0.18
}
/** IPC 채널명 */
export const IPC = {
// UI → Main (invoke)
@@ -73,6 +89,24 @@ export const IPC = {
SETTINGS_GET: 'settings:get',
SETTINGS_SET: 'settings:set',
SETTINGS_CHANGED: 'settings:changed',
// 라이브러리 / 색인 (Phase 0)
LIBRARY_LIST: 'library:list',
LIBRARY_ADD: 'library:add',
LIBRARY_REMOVE: 'library:remove',
INDEX_RUN: 'index:run',
INDEX_CANCEL: 'index:cancel',
INDEX_PROGRESS: 'index:progress',
INDEX_DONE: 'index:done',
INDEX_ASSETS: 'index:assets',
INDEX_SET_RATING: 'index:setRating',
INDEX_SET_LABEL: 'index:setLabel',
// 검색 (Phase 2)
SEARCH_BUILD: 'search:build',
SEARCH_CANCEL: 'search:cancel',
SEARCH_STATUS: 'search:status',
SEARCH_QUERY: 'search:query',
SEARCH_PROGRESS: 'search:progress',
SEARCH_DONE: 'search:done',
// Main → UI (send)
JOB_PROGRESS: 'job:progress',
JOB_FILE_PROCESSED: 'job:fileProcessed',
+69
View File
@@ -141,6 +141,75 @@ export const MESSAGES: Table = {
'dur.ms': { ko: '{m}분 {s}초', en: '{m}m {s}s' },
'dur.s': { ko: '{s}초', en: '{s}s' },
// 내비게이션 / 라이브러리 (Phase 0)
'nav.organize': { ko: '정리', en: 'Organize' },
'nav.library': { ko: '라이브러리', en: 'Library' },
'nav.search': { ko: '검색', en: 'Search' },
// 검색 (Phase 2)
'search.section': { ko: '검색 색인', en: 'Search index' },
'search.hint': {
ko: '자연어로 사진을 검색하려면 먼저 CLIP 임베딩 색인을 생성하세요(최초 1회 모델 다운로드 필요).',
en: 'Build the CLIP embedding index to search photos by natural language (first run downloads the model).'
},
'search.build': { ko: '검색 색인 생성', en: 'Build search index' },
'search.cancel': { ko: '취소', en: 'Cancel' },
'search.building': { ko: '임베딩 중…', en: 'Embedding…' },
'search.status': { ko: '임베딩 {embedded} / {total}', en: 'Embedded {embedded} / {total}' },
'search.placeholder': {
ko: '예: 푸른 바다, 노을, 강아지가 있는 사진…',
en: 'e.g. blue ocean, sunset, photos with a dog…'
},
'search.go': { ko: '검색', en: 'Search' },
'search.searching': { ko: '검색 중…', en: 'Searching…' },
'search.noResults': { ko: '결과가 없습니다.', en: 'No results.' },
'search.prompt': {
ko: '검색어를 입력하세요.',
en: 'Type a query to search.'
},
'lib.section': { ko: '라이브러리 폴더', en: 'Library Folders' },
'lib.hint': {
ko: '색인할 폴더를 추가하세요. 사진은 옮기지 않고 제자리에서 색인됩니다(비파괴).',
en: 'Add folders to index. Photos are indexed in place, never moved (non-destructive).'
},
'lib.add': { ko: '폴더 추가', en: 'Add folder' },
'lib.empty': { ko: '색인할 라이브러리 폴더가 없습니다.', en: 'No library folders yet.' },
'lib.remove': { ko: '제거', en: 'Remove' },
'lib.index': { ko: '색인 시작', en: 'Start indexing' },
'lib.cancel': { ko: '취소', en: 'Cancel' },
'lib.indexing': { ko: '색인 중…', en: 'Indexing…' },
'lib.assets': { ko: '색인된 자산 {n}개', en: '{n} assets indexed' },
'lib.progress': { ko: '{done} / {total} · 신규 {indexed} · 스킵 {skipped}', en: '{done} / {total} · {indexed} new · {skipped} skipped' },
'lib.doneSummary': {
ko: '완료 — 신규 {indexed} · 스킵 {skipped} · 실패 {failed} · 총 {assets}개',
en: 'Done — {indexed} new · {skipped} skipped · {failed} failed · {assets} total'
},
'lib.grid': { ko: '색인된 사진', en: 'Indexed photos' },
'lib.gridEmpty': {
ko: '색인된 사진이 없습니다. 폴더를 추가하고 색인을 실행하세요.',
en: 'No indexed photos. Add a folder and run indexing.'
},
'lib.loadMore': { ko: '더 보기', en: 'Load more' },
// 컬링 필터 / 품질 플래그 (Phase 1)
'cull.all': { ko: '전체', en: 'All' },
'cull.candidate': { ko: '고품질 후보', en: 'Candidates' },
'cull.rejected': { ko: '제외 후보', en: 'Rejected' },
'flag.candidate': { ko: '후보', en: 'Keep' },
'flag.blurry': { ko: '흐림', en: 'Blurry' },
'flag.eyesClosed': { ko: '눈감음', en: 'Eyes closed' },
'flag.badExposure': { ko: '노출', en: 'Exposure' },
'cull.thresholds': { ko: '품질 임계값', en: 'Quality thresholds' },
'cull.focus': { ko: '초점', en: 'Focus' },
'cull.exposure': { ko: '노출', en: 'Exposure' },
'cull.eyes': { ko: '눈 뜸', en: 'Eyes open' },
'cull.thresholdHint': {
ko: '값을 올리면 더 엄격하게 제외됩니다. 변경 즉시 재분석 없이 반영됩니다.',
en: 'Higher = stricter rejection. Applied instantly without re-analysis.'
},
'cull.reset': { ko: '기본값', en: 'Reset' },
'cull.ratingMin': { ko: '별점', en: 'Rating' },
// 메뉴
'menu.file': { ko: '파일', en: 'File' },
'menu.edit': { ko: '편집', en: 'Edit' },
+145
View File
@@ -5,12 +5,21 @@ import type { Lang } from './i18n'
/** UI 테마 */
export type Theme = 'dark' | 'light'
/** 품질 판정 임계값 (사용자 조절 가능) */
export interface QualityThresholds {
focus: number
exposure: number
eyes: number
}
/** 앱 설정 (userData/settings.json) */
export interface Settings {
language: Lang
theme: Theme
/** 첫 실행 온보딩(언어/테마 선택) 완료 여부 */
onboarded: boolean
/** 컬링 품질 임계값 */
qualityThresholds: QualityThresholds
}
/** 등록된 인물 프로필 */
@@ -76,6 +85,111 @@ export interface DescriptorResult {
descriptor: number[] | null
}
/** 라이브러리 인덱스의 자산(사진/영상) 레코드 — SQLite asset 테이블 */
export interface AssetRecord {
id?: number
/** 파일 내용 해시 — 경로가 바뀌어도 추적, 정확 중복 식별 */
contentHash: string
path: string
ext: string
sizeBytes: number
mtime: number
width: number | null
height: number | null
exifYear: string | null
exifMonth: string | null
indexedAt: number
}
/** 색인 진행률 이벤트 */
export interface IndexProgress {
done: number
total: number
current: string
indexed: number
skipped: number
}
/** 색인 완료 요약 */
export interface IndexSummary {
total: number
indexed: number
skipped: number
failed: number
assets: number
elapsedMs: number
}
/** 품질 종합 분류 */
export type QualityFlag = 'candidate' | 'blurry' | 'eyesClosed' | 'badExposure' | null
/** 품질 평가 점수 (Phase 1) — SQLite quality 테이블 */
export interface QualityScores {
/** 초점/선명도 (라플라시안 분산 기반, 높을수록 선명) */
focus: number | null
/** 노출 (히스토그램 기반) */
exposure: number | null
/** 눈 뜸 정도 (face-api 랜드마크 EAR, 1=뜸) */
eyesOpen: number | null
/** 종합 분류 */
flag: QualityFlag
}
/** 사용자 수동 메타 (별점/색라벨) */
export type ColorLabel = 'red' | 'yellow' | 'green' | 'blue' | 'purple' | null
/** 그리드/컬링용 — 자산 + 품질 + 사용자 메타 결합 레코드 */
export interface IndexedAsset extends AssetRecord {
focus: number | null
exposure: number | null
eyesOpen: number | null
flag: QualityFlag
/** 사용자 별점 0~5 */
rating: number
/** 사용자 색라벨 */
label: ColorLabel
}
/** 컬링 필터 */
export type QualityFilter =
| 'all'
| 'candidate'
| 'rejected'
| 'blurry'
| 'eyesClosed'
| 'badExposure'
/** 자산 조회 옵션 */
export interface AssetQuery {
filter: QualityFilter
/** 최소 별점 (0이면 무시) */
ratingMin: number
}
/** 검색 색인(임베딩) 생성 진행률 */
export interface SearchProgress {
done: number
total: number
current: string
embedded: number
}
/** 검색 색인 생성 요약 */
export interface SearchSummary {
embedded: number
total: number
/** 누적 임베딩 보유 수 */
count: number
}
/** 검색 색인 상태 */
export interface SearchStatus {
/** 임베딩 보유 수 */
embedded: number
/** 색인된 전체 이미지 수 */
totalImages: number
}
/** 촬영 날짜 (EXIF 또는 mtime 폴백) */
export interface CaptureDate {
year: string // "2024"
@@ -149,6 +263,10 @@ export interface RendererEvents {
'job:done': Report
'job:error': { file: string; message: string }
'settings:changed': Settings
'index:progress': IndexProgress
'index:done': IndexSummary
'search:progress': SearchProgress
'search:done': SearchSummary
}
export type RendererEventName = keyof RendererEvents
@@ -185,6 +303,33 @@ export interface ExposedApi {
get(): Promise<Settings>
set(patch: Partial<Settings>): Promise<Settings>
}
/** 라이브러리 폴더(색인 대상 루트) 관리 */
library: {
list(): Promise<string[]>
/** 폴더 선택 다이얼로그 후 추가. 추가된 목록 반환(취소 시 변경 없음) */
add(): Promise<string[]>
remove(path: string): Promise<string[]>
}
/** 색인(인덱싱) 제어 */
index: {
run(): Promise<void>
cancel(): Promise<void>
/** 색인된 자산 목록 (최근순, 페이지네이션, 품질/별점 필터) */
assets(offset: number, limit: number, query: AssetQuery): Promise<IndexedAsset[]>
/** 별점(0~5) 설정 */
setRating(assetId: number, rating: number): Promise<void>
/** 색라벨 설정 */
setLabel(assetId: number, label: ColorLabel): Promise<void>
}
/** 자연어/유사 검색 (Phase 2) */
search: {
/** 검색 색인(CLIP 임베딩) 생성 시작 */
build(): Promise<void>
cancel(): Promise<void>
status(): Promise<SearchStatus>
/** 자연어 쿼리 → 유사도 상위 결과 */
query(text: string): Promise<IndexedAsset[]>
}
/** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */
getPathForFile(file: unknown): string
on<E extends RendererEventName>(