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:
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user