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:
+25
-9
@@ -124,8 +124,10 @@ Culling: Index의 품질 점수로 필터 → 후보군/제외 뷰
|
||||
## 8. Phase 0 + Phase 1 상세 실행 계획 (확정 범위)
|
||||
|
||||
### 8.1 기술 추가 (이 단계 한정)
|
||||
- **better-sqlite3** (네이티브) — 인덱스 DB. electron-builder의 `@electron/rebuild`로 ABI 재빌드(현재 빌드 파이프라인이 이미 수행).
|
||||
- **썸네일은 네이티브(sharp) 없이** AI Worker(렌더러)의 canvas로 생성 → 바이트를 Main이 캐시. 네이티브 의존 최소화.
|
||||
- ~~better-sqlite3(네이티브)~~ → **`sql.js`(WASM SQLite)로 확정**. 이유: 이 환경(Node 24 + Python 3.12)에서 네이티브 컴파일이 실패(distutils 제거)했고, 네이티브 모듈은 사용자 PC마다 ABI/빌드툴 문제가 재발한다. WASM은 빌드/재빌드가 전혀 없어 Windows+macOS 배포가 단순. 인메모리 DB를 `userData/index.db`로 export 영속화. (수천~수만 장 메타데이터 규모에 충분. 추후 필요 시 indexDb 추상화 뒤에서 네이티브로 교체 가능.)
|
||||
- **썸네일은 네이티브(sharp) 없이** AI Worker(렌더러)의 canvas로 생성 → 바이트를 Main이 캐시. 네이티브 의존 0.
|
||||
|
||||
> ✅ **Phase 0-a 완료(2026-06-01)**: `indexDb`(sql.js) + asset/quality 스키마 + 영속화. Electron 부팅 시 `userData/index.db` 생성 확인. typecheck/build/스모크 통과.
|
||||
- Phase 1 품질 점수는 **모델 거의 불필요**: 초점=라플라시안 분산, 노출=휘도 히스토그램, 감은 눈=face-api 랜드마크(EAR). face-api는 이미 추론창에 로드됨.
|
||||
|
||||
### 8.2 데이터 모델 (SQLite)
|
||||
@@ -163,13 +165,27 @@ UI (신규 화면)
|
||||
- 기존 정리기와 통합: 정리 잡도 인덱스(EXIF/얼굴)를 재사용하도록 점진 연결.
|
||||
|
||||
### 8.4 작업 순서 (체크리스트)
|
||||
- [ ] Phase 0-a: better-sqlite3 도입 + `indexDb` 스키마/마이그레이션 + 빌드(ABI 재빌드) 검증
|
||||
- [ ] Phase 0-b: contentHash 해셔 + 라이브러리 폴더 지정 UI + `indexer` 워크/재개 + 진행률 IPC
|
||||
- [ ] Phase 0-c: AI Worker에 썸네일 생성 + 캐시 + 그리드 표시(빈 품질로 우선)
|
||||
- [ ] Phase 1-a: `qualityEngine` 초점/노출 점수 → DB 저장
|
||||
- [ ] Phase 1-b: 감은 눈(EAR, face-api 랜드마크) 점수 → flag 산출
|
||||
- [ ] Phase 1-c: CullingView(후보/제외 그리드, 점수, 임계값 설정, 오버라이드)
|
||||
- [ ] Phase 1-d: (옵션) 제외 사진 내보내기/이동 액션
|
||||
- [x] **Phase 0-a 완료**: `indexDb`(sql.js로 확정) 스키마/마이그레이션 + Electron 부팅 시 DB 생성 검증
|
||||
- [x] **Phase 0-b 완료**: contentHash(샘플 sha1) + 라이브러리 폴더 지정 UI(라이브러리 탭) + `indexer` 워크/재개(경로·mtime 스킵)/진행률 IPC/배치 영속화/취소. 헤드리스 파이프라인 검증(중복 0) + 부팅 스모크 통과
|
||||
- [x] **Phase 0-c 완료**: AI Worker(추론창) canvas로 썸네일(webp) 생성 → `userData/thumbs/<hash>.webp` 캐시(비파괴), 색인 시 자동 생성 + 원본 치수 저장, `photoai-media://thumb` 해시 기반 보안 제공, 라이브러리 탭 썸네일 그리드(페이지네이션). typecheck/build/부팅 스모크 통과
|
||||
- [x] **Phase 1-a 완료**: `qualityEngine` 초점(라플라시안 분산)/노출(히스토그램 클리핑) 점수 → DB
|
||||
- [x] **Phase 1-b 완료**: 감은 눈(EAR, face-api 랜드마크, Tiny 검출기) 점수 → `classifyFlag`로 종합 분류(candidate/blurry/eyesClosed/badExposure). 색인 시 `infer:analyze`로 썸네일+품질 1회 로드 통합
|
||||
- [x] **Phase 1-c (기본) 완료**: 라이브러리 그리드에 컬링 필터(전체/고품질 후보/제외) + 품질 배지. *임계값 튜닝 UI · 사진별 수동 오버라이드는 후속으로 보류*
|
||||
- [ ] Phase 1-d(옵션): 제외 사진 내보내기/이동 액션 (보류)
|
||||
|
||||
### 8.6 Phase 1 마무리 (임계값 튜닝 + 수동 오버라이드) — 완료(2026-06-01)
|
||||
- [x] 품질 임계값(초점/노출/눈)을 설정에 저장 + 슬라이더 UI. 임계값 변경 시 **저장된 원본 점수로 SQL CASE 실시간 재분류**(재분석 없음)
|
||||
- [x] 별점(0~5) + 색라벨(5색) 수동 메타(`usermeta` 테이블), 썸네일 호버 편집, 별점 최소 필터
|
||||
|
||||
### 8.7 Phase 2 (CLIP 자연어/유사 검색) — 기본 완료(2026-06-01)
|
||||
- [x] `@huggingface/transformers`(WASM/WebGPU, 네이티브 빌드 0) 도입
|
||||
- [x] CLIP(`Xenova/clip-vit-base-patch32`) 이미지/텍스트 임베딩 — 추론창 lazy-load
|
||||
- [x] 한국어 쿼리 자동 번역(`Xenova/opus-mt-ko-en`) → 영어 CLIP
|
||||
- [x] 임베딩 SQLite BLOB 저장 + "검색 색인 생성"(임베딩 배치, 진행률/취소) — 기본 색인과 분리
|
||||
- [x] 브루트포스 코사인 검색 + **검색 탭**(임베딩 상태/생성 + 검색바 + 결과 그리드)
|
||||
- [x] typecheck/build/부팅 스모크 통과
|
||||
|
||||
> ⚠️ **Phase 2 런타임 미검증 항목**: CLIP/번역 모델은 **최초 사용 시 HF Hub에서 다운로드**(온라인 1회 필요, 이후 캐시). ONNX Runtime WASM의 **완전 오프라인 패키징**(CDN 대신 동봉)과 **한국어 검색 정확도 실측**은 후속 과제. 임베딩 생성은 이미지당 수백 ms(WASM) → 대량은 시간 소요(백그라운드).
|
||||
- [ ] i18n(ko/en) · 다크모드 · 검증(typecheck/test/build/스모크)
|
||||
|
||||
### 8.5 리스크 (이 단계)
|
||||
|
||||
@@ -14,6 +14,8 @@ extraResources:
|
||||
- "**/*"
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
# sql.js WASM 바이너리 (인덱스 DB) — asar 밖에서 읽도록
|
||||
- "node_modules/sql.js/dist/*.wasm"
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
|
||||
Generated
+856
-315
File diff suppressed because it is too large
Load Diff
@@ -22,14 +22,17 @@
|
||||
"dist:all": "electron-vite build && electron-builder --win --mac"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@vladmandic/face-api": "^1.7.13",
|
||||
"exifr": "^7.1.3",
|
||||
"sql.js": "^1.12.0",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Phase 0-b 색인 파이프라인 헤드리스 검증.
|
||||
// sql.js 스키마 + 폴더 워크 + 샘플 해시 + upsert + count 가 실제로 동작하는지 확인.
|
||||
// node scripts/verify-index.mjs [folder]
|
||||
import initSqlJs from 'sql.js'
|
||||
import { readdir, stat, open, mkdtemp, writeFile, rm } from 'node:fs/promises'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, extname, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const IMG = new Set(['.jpg', '.jpeg', '.png', '.webp', '.mp4', '.mov'])
|
||||
|
||||
async function* walk(root) {
|
||||
for (const e of await readdir(root, { withFileTypes: true })) {
|
||||
const full = join(root, e.name)
|
||||
if (e.isDirectory()) yield* walk(full)
|
||||
else if (e.isFile() && IMG.has(extname(e.name).toLowerCase())) yield full
|
||||
}
|
||||
}
|
||||
|
||||
async function contentHash(path) {
|
||||
const s = await stat(path)
|
||||
const h = createHash('sha1')
|
||||
h.update(String(s.size))
|
||||
const len = Math.min(512 * 1024, s.size)
|
||||
if (len > 0) {
|
||||
const fh = await open(path, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(len)
|
||||
await fh.read(buf, 0, len, 0)
|
||||
h.update(buf)
|
||||
} finally {
|
||||
await fh.close()
|
||||
}
|
||||
}
|
||||
return h.digest('hex')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 인자 폴더 없으면 더미 이미지 2개로 임시 폴더 생성
|
||||
let folder = process.argv[2]
|
||||
let temp = null
|
||||
if (!folder) {
|
||||
temp = await mkdtemp(join(tmpdir(), 'photoai-idx-'))
|
||||
await writeFile(join(temp, 'a.jpg'), Buffer.from('dummy-image-a'))
|
||||
await writeFile(join(temp, 'b.png'), Buffer.from('dummy-image-b-different'))
|
||||
folder = temp
|
||||
console.log('테스트 폴더 생성:', folder)
|
||||
}
|
||||
|
||||
const wasm = readFileSync(join(__dirname, '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'))
|
||||
const SQL = await initSqlJs({ wasmBinary: new Uint8Array(wasm).buffer })
|
||||
const db = new SQL.Database()
|
||||
db.run(`CREATE TABLE asset (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, contentHash TEXT UNIQUE NOT NULL,
|
||||
path TEXT, ext TEXT, sizeBytes INTEGER, mtime INTEGER, indexedAt INTEGER);
|
||||
CREATE INDEX idx_path ON asset(path);`)
|
||||
|
||||
let indexed = 0
|
||||
for await (const file of walk(folder)) {
|
||||
const s = await stat(file)
|
||||
const hash = await contentHash(file)
|
||||
db.run(
|
||||
`INSERT INTO asset (contentHash,path,ext,sizeBytes,mtime,indexedAt)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(contentHash) DO UPDATE SET path=excluded.path, mtime=excluded.mtime`,
|
||||
[hash, file, extname(file).toLowerCase(), s.size, Math.floor(s.mtimeMs), Date.now()]
|
||||
)
|
||||
indexed++
|
||||
}
|
||||
|
||||
const n = db.exec('SELECT COUNT(*) FROM asset')[0].values[0][0]
|
||||
const sample = db.exec('SELECT contentHash, path FROM asset LIMIT 2')
|
||||
console.log(`색인 처리: ${indexed}건, DB asset 수: ${n}`)
|
||||
if (sample[0]) for (const row of sample[0].values) console.log(' row:', row[0].slice(0, 12), '…', row[1])
|
||||
|
||||
// 재실행 시 중복 안 늘어나는지(upsert) 확인
|
||||
for await (const file of walk(folder)) {
|
||||
const s = await stat(file)
|
||||
const hash = await contentHash(file)
|
||||
db.run(
|
||||
`INSERT INTO asset (contentHash,path,ext,sizeBytes,mtime,indexedAt)
|
||||
VALUES (?,?,?,?,?,?) ON CONFLICT(contentHash) DO UPDATE SET mtime=excluded.mtime`,
|
||||
[hash, file, extname(file).toLowerCase(), s.size, Math.floor(s.mtimeMs), Date.now()]
|
||||
)
|
||||
}
|
||||
const n2 = db.exec('SELECT COUNT(*) FROM asset')[0].values[0][0]
|
||||
console.log(`재실행 후 asset 수(중복 없어야 함): ${n2}`)
|
||||
console.log(n === n2 ? 'PASS: upsert 중복 없음' : 'FAIL: 중복 발생')
|
||||
|
||||
db.close()
|
||||
if (temp) await rm(temp, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('오류:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
env,
|
||||
AutoProcessor,
|
||||
AutoTokenizer,
|
||||
CLIPVisionModelWithProjection,
|
||||
CLIPTextModelWithProjection,
|
||||
RawImage,
|
||||
pipeline,
|
||||
type Processor,
|
||||
type PreTrainedTokenizer,
|
||||
type PreTrainedModel
|
||||
} from '@huggingface/transformers'
|
||||
|
||||
/** 사용 메서드만 추린 최소 텐서 형태 */
|
||||
type TfTensor = { normalize(): { tolist(): number[][] } }
|
||||
|
||||
const CLIP_MODEL = 'Xenova/clip-vit-base-patch32'
|
||||
const TRANSLATE_MODEL = 'Xenova/opus-mt-ko-en'
|
||||
const HANGUL = /[가-힣]/
|
||||
|
||||
// 원격(HF Hub) 모델만 사용 — 최초 1회 다운로드 후 브라우저 캐시에 보관
|
||||
env.allowLocalModels = false
|
||||
|
||||
/**
|
||||
* CLIP 임베딩 엔진 (검색용). 추론창에서 lazy-load.
|
||||
* - 이미지/텍스트를 512-d 임베딩으로 변환 (코사인 유사도 검색)
|
||||
* - 한국어 쿼리는 opus-mt-ko-en으로 영어로 번역 후 임베딩 (영어 CLIP)
|
||||
*/
|
||||
class ClipEngine {
|
||||
private clipLoading: Promise<void> | null = null
|
||||
private processor: Processor | null = null
|
||||
private tokenizer: PreTrainedTokenizer | null = null
|
||||
private vision: PreTrainedModel | null = null
|
||||
private text: PreTrainedModel | null = null
|
||||
private translator: ((input: string) => Promise<unknown>) | null = null
|
||||
|
||||
private async loadClip(): Promise<void> {
|
||||
if (this.clipLoading) return this.clipLoading
|
||||
this.clipLoading = (async () => {
|
||||
this.processor = await AutoProcessor.from_pretrained(CLIP_MODEL)
|
||||
this.tokenizer = await AutoTokenizer.from_pretrained(CLIP_MODEL)
|
||||
this.vision = await CLIPVisionModelWithProjection.from_pretrained(CLIP_MODEL)
|
||||
this.text = await CLIPTextModelWithProjection.from_pretrained(CLIP_MODEL)
|
||||
})()
|
||||
return this.clipLoading
|
||||
}
|
||||
|
||||
/** 캔버스 이미지 → 정규화된 512-d 임베딩 */
|
||||
async embedImage(canvas: HTMLCanvasElement): Promise<number[]> {
|
||||
await this.loadClip()
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) throw new Error('2D 컨텍스트 생성 실패')
|
||||
const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const image = new RawImage(new Uint8ClampedArray(data), width, height, 4).rgb()
|
||||
|
||||
// transformers.js의 무거운 제네릭 유니온을 피하려고 호출부는 느슨하게 캐스팅
|
||||
const processor = this.processor as unknown as (i: unknown) => Promise<unknown>
|
||||
const vision = this.vision as unknown as (i: unknown) => Promise<{ image_embeds: TfTensor }>
|
||||
const inputs = await processor(image)
|
||||
const out = await vision(inputs)
|
||||
return Array.from(out.image_embeds.normalize().tolist()[0] as number[])
|
||||
}
|
||||
|
||||
/** 텍스트 쿼리 → (필요시 KO→EN 번역) → 정규화된 512-d 임베딩 */
|
||||
async embedText(query: string): Promise<number[]> {
|
||||
await this.loadClip()
|
||||
let text = query
|
||||
if (HANGUL.test(query)) {
|
||||
if (!this.translator) {
|
||||
// pipeline()의 거대한 오버로드 유니온을 피하려고 느슨하게 캐스팅
|
||||
const makePipeline = pipeline as unknown as (
|
||||
task: string,
|
||||
model: string
|
||||
) => Promise<(input: string) => Promise<unknown>>
|
||||
this.translator = await makePipeline('translation', TRANSLATE_MODEL)
|
||||
}
|
||||
const translate = this.translator as unknown as (input: string) => Promise<unknown>
|
||||
const res = await translate(query)
|
||||
const first = Array.isArray(res) ? res[0] : res
|
||||
text = (first as { translation_text: string }).translation_text || query
|
||||
}
|
||||
const tokenizer = this.tokenizer as unknown as (
|
||||
t: string[],
|
||||
o: Record<string, unknown>
|
||||
) => unknown
|
||||
const textModel = this.text as unknown as (i: unknown) => Promise<{ text_embeds: TfTensor }>
|
||||
const inputs = tokenizer([text], { padding: true, truncation: true })
|
||||
const out = await textModel(inputs)
|
||||
return Array.from(out.text_embeds.normalize().tolist()[0] as number[])
|
||||
}
|
||||
}
|
||||
|
||||
export const clipEngine = new ClipEngine()
|
||||
@@ -66,6 +66,42 @@ class FaceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 눈 뜸 정도(EAR, Eye Aspect Ratio). 가장 큰 얼굴 기준 좌우 평균.
|
||||
* 얼굴이 없으면 null (해당 없음). 낮을수록 감은 눈.
|
||||
* 색인 속도를 위해 Tiny 검출기 사용.
|
||||
*/
|
||||
async eyesOpenScore(canvas: HTMLCanvasElement): Promise<number | null> {
|
||||
const results = await faceapi
|
||||
.detectAllFaces(canvas, new faceapi.TinyFaceDetectorOptions({ inputSize: 416 }))
|
||||
.withFaceLandmarks()
|
||||
if (results.length === 0) return null
|
||||
|
||||
// 가장 큰 얼굴 선택
|
||||
let best = results[0]
|
||||
let bestArea = 0
|
||||
for (const r of results) {
|
||||
const box = r.detection.box
|
||||
const area = box.width * box.height
|
||||
if (area > bestArea) {
|
||||
bestArea = area
|
||||
best = r
|
||||
}
|
||||
}
|
||||
|
||||
const ear = (eye: faceapi.Point[]): number => {
|
||||
const d = (a: faceapi.Point, b: faceapi.Point) => Math.hypot(a.x - b.x, a.y - b.y)
|
||||
const A = d(eye[1], eye[5])
|
||||
const B = d(eye[2], eye[4])
|
||||
const C = d(eye[0], eye[3])
|
||||
return C === 0 ? 0 : (A + B) / (2 * C)
|
||||
}
|
||||
|
||||
const left = ear(best.landmarks.getLeftEye())
|
||||
const right = ear(best.landmarks.getRightEye())
|
||||
return (left + right) / 2
|
||||
}
|
||||
|
||||
/** 사진 1장 → 얼굴 검출 + 프로필 매칭 결과 */
|
||||
async detectImage(imagePath: string): Promise<MatchResult> {
|
||||
const canvas = await loadImageToCanvas(imagePath)
|
||||
|
||||
@@ -42,3 +42,59 @@ export function releaseCanvas(canvas: HTMLCanvasElement): void {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일용 로더: 장변을 maxDim으로 축소한 캔버스 + 원본(자연) 치수 반환.
|
||||
*/
|
||||
export async function loadThumbnailCanvas(
|
||||
imagePath: string,
|
||||
maxDim: number
|
||||
): Promise<{ canvas: HTMLCanvasElement; naturalWidth: number; naturalHeight: number }> {
|
||||
const img = await loadImageElement(pathToFileUrl(imagePath))
|
||||
const naturalWidth = img.naturalWidth || img.width
|
||||
const naturalHeight = img.naturalHeight || img.height
|
||||
|
||||
const longSide = Math.max(naturalWidth, naturalHeight)
|
||||
const scale = longSide > maxDim ? maxDim / longSide : 1
|
||||
const w = Math.max(1, Math.round(naturalWidth * scale))
|
||||
const h = Math.max(1, Math.round(naturalHeight * scale))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
// 이 캔버스는 이후 품질/CLIP 분석에서 getImageData로 읽히므로 willReadFrequently 지정
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) throw new Error('2D 컨텍스트 생성 실패')
|
||||
ctx.drawImage(img, 0, 0, w, h)
|
||||
img.src = ''
|
||||
return { canvas, naturalWidth, naturalHeight }
|
||||
}
|
||||
|
||||
/** 기존 캔버스를 장변 maxDim으로 축소한 새 캔버스 반환 (썸네일 파생용) */
|
||||
export function downscaleCanvas(src: HTMLCanvasElement, maxDim: number): HTMLCanvasElement {
|
||||
const longSide = Math.max(src.width, src.height)
|
||||
const scale = longSide > maxDim ? maxDim / longSide : 1
|
||||
const w = Math.max(1, Math.round(src.width * scale))
|
||||
const h = Math.max(1, Math.round(src.height * scale))
|
||||
const out = document.createElement('canvas')
|
||||
out.width = w
|
||||
out.height = h
|
||||
const ctx = out.getContext('2d')
|
||||
if (!ctx) throw new Error('2D 컨텍스트 생성 실패')
|
||||
ctx.drawImage(src, 0, 0, w, h)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 캔버스를 webp 바이트(ArrayBuffer)로 인코딩 */
|
||||
export function canvasToWebp(canvas: HTMLCanvasElement, quality = 0.8): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error('썸네일 인코딩 실패'))
|
||||
blob.arrayBuffer().then(resolve, reject)
|
||||
},
|
||||
'image/webp',
|
||||
quality
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { faceEngine } from './faceEngine'
|
||||
import { clipEngine } from './clipEngine'
|
||||
import {
|
||||
loadThumbnailCanvas,
|
||||
downscaleCanvas,
|
||||
canvasToWebp,
|
||||
releaseCanvas
|
||||
} from './imageLoader'
|
||||
import { computeFocus, computeExposure } from './qualityEngine'
|
||||
import { THUMBNAIL_SIZE, ANALYZE_SIZE } from '@shared/constants'
|
||||
import type { Profile, JobOptions, DescriptorResult } from '@shared/types'
|
||||
|
||||
/**
|
||||
@@ -38,6 +47,50 @@ async function bootstrap(): Promise<void> {
|
||||
const { imagePath } = payload as unknown as { imagePath: string }
|
||||
const result = await faceEngine.detectImage(imagePath)
|
||||
window.inferBridge.reply(requestId, true, result)
|
||||
} else if (channel === 'infer:analyze') {
|
||||
// 한 번 로드해서 썸네일 + 품질 점수(초점/노출/눈)를 모두 산출
|
||||
const { imagePath } = payload as unknown as { imagePath: string }
|
||||
const { canvas, naturalWidth, naturalHeight } = await loadThumbnailCanvas(
|
||||
imagePath,
|
||||
ANALYZE_SIZE
|
||||
)
|
||||
try {
|
||||
const focus = computeFocus(canvas)
|
||||
const exposure = computeExposure(canvas)
|
||||
const eyesOpen = await faceEngine.eyesOpenScore(canvas)
|
||||
|
||||
const thumb = downscaleCanvas(canvas, THUMBNAIL_SIZE)
|
||||
let bytes: ArrayBuffer
|
||||
try {
|
||||
bytes = await canvasToWebp(thumb)
|
||||
} finally {
|
||||
releaseCanvas(thumb)
|
||||
}
|
||||
|
||||
window.inferBridge.reply(requestId, true, {
|
||||
bytes,
|
||||
width: naturalWidth,
|
||||
height: naturalHeight,
|
||||
focus,
|
||||
exposure,
|
||||
eyesOpen
|
||||
})
|
||||
} finally {
|
||||
releaseCanvas(canvas)
|
||||
}
|
||||
} else if (channel === 'infer:embedImage') {
|
||||
const { imagePath } = payload as unknown as { imagePath: string }
|
||||
const { canvas } = await loadThumbnailCanvas(imagePath, ANALYZE_SIZE)
|
||||
try {
|
||||
const vec = await clipEngine.embedImage(canvas)
|
||||
window.inferBridge.reply(requestId, true, { vec })
|
||||
} finally {
|
||||
releaseCanvas(canvas)
|
||||
}
|
||||
} else if (channel === 'infer:embedText') {
|
||||
const { text } = payload as unknown as { text: string }
|
||||
const vec = await clipEngine.embedText(text)
|
||||
window.inferBridge.reply(requestId, true, { vec })
|
||||
}
|
||||
} catch (err) {
|
||||
window.inferBridge.reply(requestId, false, undefined, (err as Error).message)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 모델이 거의 필요 없는 고전 CV 기반 품질 지표 (초점/노출). 추론창 canvas에서 동작.
|
||||
*/
|
||||
|
||||
function getGray(canvas: HTMLCanvasElement): { gray: Float64Array; w: number; h: number } {
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) throw new Error('2D 컨텍스트 생성 실패')
|
||||
const data = ctx.getImageData(0, 0, w, h).data
|
||||
const gray = new Float64Array(w * h)
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
gray[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2]
|
||||
}
|
||||
return { gray, w, h }
|
||||
}
|
||||
|
||||
/**
|
||||
* 초점/선명도 = 라플라시안 분산. 높을수록 선명, 낮을수록 흐림.
|
||||
* (값의 절대 스케일은 해상도/내용 의존 → 임계값으로 판정)
|
||||
*/
|
||||
export function computeFocus(canvas: HTMLCanvasElement): number {
|
||||
const { gray, w, h } = getGray(canvas)
|
||||
let sum = 0
|
||||
let sumSq = 0
|
||||
let count = 0
|
||||
for (let y = 1; y < h - 1; y++) {
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
const i = y * w + x
|
||||
const lap = gray[i - 1] + gray[i + 1] + gray[i - w] + gray[i + w] - 4 * gray[i]
|
||||
sum += lap
|
||||
sumSq += lap * lap
|
||||
count++
|
||||
}
|
||||
}
|
||||
if (count === 0) return 0
|
||||
const mean = sum / count
|
||||
return sumSq / count - mean * mean
|
||||
}
|
||||
|
||||
/**
|
||||
* 노출 점수 = 1 - (그림자/하이라이트 클리핑 페널티). 0~1, 높을수록 양호.
|
||||
*/
|
||||
export function computeExposure(canvas: HTMLCanvasElement): number {
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) throw new Error('2D 컨텍스트 생성 실패')
|
||||
const data = ctx.getImageData(0, 0, w, h).data
|
||||
const n = w * h
|
||||
if (n === 0) return 1
|
||||
|
||||
let shadow = 0
|
||||
let highlight = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const lum = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2]
|
||||
if (lum <= 4) shadow++
|
||||
else if (lum >= 251) highlight++
|
||||
}
|
||||
const shadowFrac = shadow / n
|
||||
const highFrac = highlight / n
|
||||
// 클리핑이 많을수록 점수 하락 (계수 3로 민감도 조절)
|
||||
return Math.max(0, 1 - Math.min(1, shadowFrac * 3 + highFrac * 3))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import type { SearchProgress, SearchSummary } from '@shared/types'
|
||||
import { IPC, SUPPORTED_EXTENSIONS } from '@shared/constants'
|
||||
import { indexDb } from './indexDb'
|
||||
import { inferenceBridge } from './inferenceBridge'
|
||||
import { logger } from './logger'
|
||||
|
||||
/**
|
||||
* 검색 색인 생성기 (Phase 2): 임베딩 없는 이미지들을 CLIP으로 임베딩해 DB에 저장.
|
||||
* 기본 색인(썸네일/품질)과 분리 — 무거운 CLIP 작업을 사용자가 명시적으로 시작.
|
||||
*/
|
||||
class Embedder {
|
||||
private cancelled = false
|
||||
private running = false
|
||||
|
||||
cancel(): void {
|
||||
if (this.running) {
|
||||
this.cancelled = true
|
||||
logger.warn('검색 색인 취소 요청됨')
|
||||
}
|
||||
}
|
||||
|
||||
async run(sender: BrowserWindow): Promise<SearchSummary> {
|
||||
if (this.running) throw new Error('이미 검색 색인이 실행 중입니다.')
|
||||
this.running = true
|
||||
this.cancelled = false
|
||||
|
||||
const send = <T>(channel: string, payload: T) => {
|
||||
if (!sender.isDestroyed()) sender.webContents.send(channel, payload)
|
||||
}
|
||||
|
||||
try {
|
||||
await inferenceBridge.whenReady()
|
||||
const todo = indexDb.listAssetsNeedingEmbedding([...SUPPORTED_EXTENSIONS])
|
||||
const total = todo.length
|
||||
logger.info('검색 색인(임베딩) 시작', { total })
|
||||
|
||||
let embedded = 0
|
||||
let done = 0
|
||||
for (const item of todo) {
|
||||
if (this.cancelled) break
|
||||
send<SearchProgress>(IPC.SEARCH_PROGRESS, { done, total, current: item.path, embedded })
|
||||
try {
|
||||
const vec = await inferenceBridge.embedImage(item.path)
|
||||
indexDb.setEmbedding(item.id, vec)
|
||||
embedded++
|
||||
} catch (err) {
|
||||
await logger.warn('임베딩 실패', { file: item.path, message: (err as Error).message })
|
||||
}
|
||||
done++
|
||||
if (done % 20 === 0) await indexDb.save()
|
||||
send<SearchProgress>(IPC.SEARCH_PROGRESS, { done, total, current: item.path, embedded })
|
||||
}
|
||||
|
||||
await indexDb.save()
|
||||
const summary: SearchSummary = { embedded, total, count: indexDb.embeddingCount() }
|
||||
logger.info('검색 색인 완료', summary)
|
||||
send(IPC.SEARCH_DONE, summary)
|
||||
return summary
|
||||
} finally {
|
||||
this.running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const embedder = new Embedder()
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
|
||||
const SAMPLE_BYTES = 512 * 1024 // 앞부분 512KB만 샘플링
|
||||
|
||||
/**
|
||||
* 콘텐츠 식별용 해시. (파일 크기 + 앞부분 512KB)의 SHA-1.
|
||||
* 전체 바이트 해시는 대량 라이브러리에서 너무 느리므로 샘플링.
|
||||
* 서로 다른 사진은 크기/헤더가 달라 충돌 확률이 사실상 0 →
|
||||
* "재색인 스킵 / 동일 파일 식별" 용도로 충분. (정확한 바이트 단위 중복은 추후 보강 가능)
|
||||
*/
|
||||
export async function contentHash(path: string): Promise<string> {
|
||||
const s = await stat(path)
|
||||
const h = createHash('sha1')
|
||||
h.update(String(s.size))
|
||||
|
||||
const len = Math.min(SAMPLE_BYTES, s.size)
|
||||
if (len > 0) {
|
||||
const fh = await open(path, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(len)
|
||||
await fh.read(buf, 0, len, 0)
|
||||
h.update(buf)
|
||||
} finally {
|
||||
await fh.close()
|
||||
}
|
||||
}
|
||||
return h.digest('hex')
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { registerMediaScheme, handleMediaProtocol } from './mediaProtocol'
|
||||
import { settingsStore } from './settingsStore'
|
||||
import { buildAppMenu } from './menu'
|
||||
import { applySettings } from './applySettings'
|
||||
import { indexDb } from './indexDb'
|
||||
import { logger } from './logger'
|
||||
|
||||
// 커스텀 미디어 스킴은 app ready 이전에 등록해야 한다.
|
||||
@@ -51,6 +52,8 @@ app.whenReady().then(async () => {
|
||||
// 설정 로드 후 로컬라이즈된 메뉴 빌드
|
||||
const settings = await settingsStore.load()
|
||||
buildAppMenu({ settings, onChange: applySettings })
|
||||
// 라이브러리 인덱스 DB 초기화 (Phase 0)
|
||||
await indexDb.init()
|
||||
// 숨김 추론 창을 먼저 띄워 모델 로드를 선행
|
||||
inferenceBridge.init()
|
||||
createMainWindow()
|
||||
@@ -62,6 +65,11 @@ app.whenReady().then(async () => {
|
||||
logger.info('앱 시작 완료')
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// 인덱스 변경분 영속화 후 종료
|
||||
void indexDb.saveIfDirty()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
inferenceBridge.dispose()
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
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 { 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,
|
||||
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를 디스크로 영속화 */
|
||||
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 (해시 계산 없이 빠른 스킵) */
|
||||
isIndexedPath(path: string, mtime: number): boolean {
|
||||
const stmt = this.db!.prepare('SELECT 1 FROM asset WHERE path = ? AND mtime = ? 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`
|
||||
}
|
||||
|
||||
listAssets(
|
||||
offset: number,
|
||||
limit: number,
|
||||
query: AssetQuery,
|
||||
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 stmt = this.db!.prepare(
|
||||
`SELECT * FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC LIMIT ? OFFSET ?`
|
||||
)
|
||||
const out: IndexedAsset[] = []
|
||||
try {
|
||||
stmt.bind([limit, offset])
|
||||
while (stmt.step()) out.push(stmt.getAsObject() as unknown as IndexedAsset)
|
||||
} finally {
|
||||
stmt.free()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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, indexedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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`,
|
||||
[
|
||||
r.contentHash,
|
||||
r.path,
|
||||
r.ext,
|
||||
r.sizeBytes,
|
||||
r.mtime,
|
||||
r.width,
|
||||
r.height,
|
||||
r.exifYear,
|
||||
r.exifMonth,
|
||||
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()
|
||||
@@ -0,0 +1,155 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { extname } from 'node:path'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { IndexProgress, IndexSummary, QualityScores } from '@shared/types'
|
||||
import { IPC, LOG_FOLDER } from '@shared/constants'
|
||||
import { scan, countMedia, mediaKind } from './scanner'
|
||||
import { getCaptureDate } from './exif'
|
||||
import { contentHash } from './hash'
|
||||
import { indexDb } from './indexDb'
|
||||
import { libraryStore } from './libraryStore'
|
||||
import { inferenceBridge } from './inferenceBridge'
|
||||
import { hasThumb, writeThumb } from './thumbnails'
|
||||
import { classifyFlag } from './quality'
|
||||
import { logger } from './logger'
|
||||
|
||||
// 색인에서 제외할(우리가 만든) 디렉터리
|
||||
const SKIP_DIRS = new Set<string>([LOG_FOLDER, '_PhotoAI_thumbs'])
|
||||
|
||||
/**
|
||||
* 라이브러리 색인 오케스트레이터 (Phase 0-b).
|
||||
* 라이브러리 루트들을 비파괴로 워크 → 파일별 해시/메타데이터를 인덱스 DB에 적재.
|
||||
* 재개 가능(변경 없는 파일은 스킵), 진행률 이벤트, 취소, 배치 영속화.
|
||||
* (썸네일/품질 점수는 Phase 0-c / Phase 1에서 추가)
|
||||
*/
|
||||
class Indexer {
|
||||
private cancelled = false
|
||||
private running = false
|
||||
|
||||
cancel(): void {
|
||||
if (this.running) {
|
||||
this.cancelled = true
|
||||
logger.warn('색인 취소 요청됨')
|
||||
}
|
||||
}
|
||||
|
||||
async run(sender: BrowserWindow): Promise<IndexSummary> {
|
||||
if (this.running) throw new Error('이미 색인이 실행 중입니다.')
|
||||
this.running = true
|
||||
this.cancelled = false
|
||||
|
||||
const send = <T>(channel: string, payload: T) => {
|
||||
if (!sender.isDestroyed()) sender.webContents.send(channel, payload)
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
const roots = await libraryStore.list()
|
||||
let indexed = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
let done = 0
|
||||
|
||||
try {
|
||||
// 썸네일 생성을 위해 추론 워커 준비 대기
|
||||
await inferenceBridge.whenReady()
|
||||
|
||||
// 진행률 total 산출
|
||||
let total = 0
|
||||
for (const root of roots) total += await countMedia(root, SKIP_DIRS)
|
||||
logger.info('색인 시작', { roots: roots.length, total })
|
||||
|
||||
const emit = (current: string) => {
|
||||
const p: IndexProgress = { done, total, current, indexed, skipped }
|
||||
send(IPC.INDEX_PROGRESS, p)
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
if (this.cancelled) break
|
||||
for await (const file of scan(root, SKIP_DIRS)) {
|
||||
if (this.cancelled) break
|
||||
emit(file)
|
||||
try {
|
||||
const st = await stat(file)
|
||||
const mtime = Math.floor(st.mtimeMs)
|
||||
// 빠른 스킵: 같은 경로·mtime이면 해시 없이 건너뜀
|
||||
if (indexDb.isIndexedPath(file, mtime)) {
|
||||
skipped++
|
||||
} else {
|
||||
const hash = await contentHash(file)
|
||||
const date = await getCaptureDate(file)
|
||||
let width: number | null = null
|
||||
let height: number | null = null
|
||||
let quality: QualityScores | null = null
|
||||
|
||||
// 이미지: 썸네일 + 품질(초점/노출/눈) 분석. 영상은 메타데이터만.
|
||||
if (mediaKind(file) === 'image') {
|
||||
const existing = indexDb.getByHash(hash)
|
||||
if (existing && (await hasThumb(hash))) {
|
||||
// 이미 분석됨 → 치수 재사용, 품질은 유지(재계산 생략)
|
||||
width = existing.width
|
||||
height = existing.height
|
||||
} else {
|
||||
try {
|
||||
const a = await inferenceBridge.analyze(file)
|
||||
await writeThumb(hash, a.bytes)
|
||||
width = a.width
|
||||
height = a.height
|
||||
quality = {
|
||||
focus: a.focus,
|
||||
exposure: a.exposure,
|
||||
eyesOpen: a.eyesOpen,
|
||||
flag: classifyFlag(a.focus, a.exposure, a.eyesOpen)
|
||||
}
|
||||
} catch (err) {
|
||||
await logger.warn('이미지 분석 실패(메타만 색인)', {
|
||||
file,
|
||||
message: (err as Error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assetId = indexDb.upsertAsset({
|
||||
contentHash: hash,
|
||||
path: file,
|
||||
ext: extname(file).toLowerCase(),
|
||||
sizeBytes: st.size,
|
||||
mtime,
|
||||
width,
|
||||
height,
|
||||
exifYear: date.year,
|
||||
exifMonth: date.month,
|
||||
indexedAt: Date.now()
|
||||
})
|
||||
if (quality && assetId >= 0) indexDb.setQuality(assetId, quality)
|
||||
indexed++
|
||||
}
|
||||
} catch (err) {
|
||||
failed++
|
||||
await logger.error('색인 실패', { file, message: (err as Error).message })
|
||||
}
|
||||
done++
|
||||
if (done % 100 === 0) await indexDb.save() // 배치 영속화
|
||||
emit(file)
|
||||
}
|
||||
}
|
||||
|
||||
await indexDb.save()
|
||||
const summary: IndexSummary = {
|
||||
total,
|
||||
indexed,
|
||||
skipped,
|
||||
failed,
|
||||
assets: indexDb.count(),
|
||||
elapsedMs: Date.now() - startedAt
|
||||
}
|
||||
logger.info('색인 완료', summary)
|
||||
send(IPC.INDEX_DONE, summary)
|
||||
return summary
|
||||
} finally {
|
||||
this.running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const indexer = new Indexer()
|
||||
@@ -118,6 +118,30 @@ class InferenceBridge {
|
||||
return this.call<MatchResult>('infer:detect', { imagePath })
|
||||
}
|
||||
|
||||
/** 이미지 CLIP 임베딩 (512-d) */
|
||||
async embedImage(imagePath: string): Promise<number[]> {
|
||||
const r = await this.call<{ vec: number[] }>('infer:embedImage', { imagePath })
|
||||
return r.vec
|
||||
}
|
||||
|
||||
/** 텍스트 CLIP 임베딩 (512-d, 한국어는 자동 번역) */
|
||||
async embedText(text: string): Promise<number[]> {
|
||||
const r = await this.call<{ vec: number[] }>('infer:embedText', { text })
|
||||
return r.vec
|
||||
}
|
||||
|
||||
/** 썸네일(webp 바이트) + 원본 치수 + 품질 점수(초점/노출/눈) 산출 */
|
||||
async analyze(imagePath: string): Promise<{
|
||||
bytes: ArrayBuffer
|
||||
width: number
|
||||
height: number
|
||||
focus: number
|
||||
exposure: number
|
||||
eyesOpen: number | null
|
||||
}> {
|
||||
return this.call('infer:analyze', { imagePath })
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.pending.forEach((p) => p.reject(new Error('브릿지 종료')))
|
||||
this.pending.clear()
|
||||
|
||||
+66
-2
@@ -1,12 +1,23 @@
|
||||
import { ipcMain, dialog, BrowserWindow, app } from 'electron'
|
||||
import { writeFile, mkdir } from 'node:fs/promises'
|
||||
import { join, extname } from 'node:path'
|
||||
import type { ProfileInput, JobRequest, ReferenceData } from '@shared/types'
|
||||
import { IPC } from '@shared/constants'
|
||||
import type {
|
||||
ProfileInput,
|
||||
JobRequest,
|
||||
ReferenceData,
|
||||
AssetQuery,
|
||||
ColorLabel
|
||||
} from '@shared/types'
|
||||
import { IPC, SUPPORTED_EXTENSIONS } from '@shared/constants'
|
||||
import { profileStore } from './profileStore'
|
||||
import { presetStore } from './presetStore'
|
||||
import { inferenceBridge } from './inferenceBridge'
|
||||
import { orchestrator } from './orchestrator'
|
||||
import { libraryStore } from './libraryStore'
|
||||
import { indexer } from './indexer'
|
||||
import { indexDb } from './indexDb'
|
||||
import { embedder } from './embedder'
|
||||
import { search } from './searchService'
|
||||
import { settingsStore } from './settingsStore'
|
||||
import { applySettings } from './applySettings'
|
||||
import { logger } from './logger'
|
||||
@@ -127,4 +138,57 @@ export function registerIpc(): void {
|
||||
// ---- 설정 ----
|
||||
ipcMain.handle(IPC.SETTINGS_GET, () => settingsStore.load())
|
||||
ipcMain.handle(IPC.SETTINGS_SET, (_e, patch: Partial<Settings>) => applySettings(patch))
|
||||
|
||||
// ---- 라이브러리 / 색인 (Phase 0) ----
|
||||
ipcMain.handle(IPC.LIBRARY_LIST, () => libraryStore.list())
|
||||
|
||||
ipcMain.handle(IPC.LIBRARY_ADD, async () => {
|
||||
const r = await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||
if (r.canceled || !r.filePaths[0]) return libraryStore.list()
|
||||
return libraryStore.add(r.filePaths[0])
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.LIBRARY_REMOVE, (_e, path: string) => libraryStore.remove(path))
|
||||
|
||||
ipcMain.handle(IPC.INDEX_RUN, async (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
if (!win) throw new Error('요청 창을 찾을 수 없음')
|
||||
indexer.run(win).catch((err) => {
|
||||
logger.error('색인 실행 실패', { message: (err as Error).message })
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.INDEX_CANCEL, () => indexer.cancel())
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.INDEX_ASSETS,
|
||||
(_e, offset: number, limit: number, query: AssetQuery) =>
|
||||
indexDb.listAssets(offset, limit, query, settingsStore.current().qualityThresholds)
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.INDEX_SET_RATING, (_e, assetId: number, rating: number) =>
|
||||
indexDb.setRating(assetId, rating)
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.INDEX_SET_LABEL, (_e, assetId: number, label: ColorLabel) =>
|
||||
indexDb.setLabel(assetId, label)
|
||||
)
|
||||
|
||||
// ---- 검색 (Phase 2) ----
|
||||
ipcMain.handle(IPC.SEARCH_BUILD, async (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
if (!win) throw new Error('요청 창을 찾을 수 없음')
|
||||
embedder.run(win).catch((err) => {
|
||||
logger.error('검색 색인 실패', { message: (err as Error).message })
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.SEARCH_CANCEL, () => embedder.cancel())
|
||||
|
||||
ipcMain.handle(IPC.SEARCH_STATUS, () => ({
|
||||
embedded: indexDb.embeddingCount(),
|
||||
totalImages: indexDb.listAssetsNeedingEmbedding([...SUPPORTED_EXTENSIONS]).length + indexDb.embeddingCount()
|
||||
}))
|
||||
|
||||
ipcMain.handle(IPC.SEARCH_QUERY, (_e, text: string) => search(text))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app } from 'electron'
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { logger } from './logger'
|
||||
|
||||
const FILE = 'libraries.json'
|
||||
|
||||
/**
|
||||
* 색인 대상 라이브러리 루트 폴더 목록. userData/libraries.json (로컬 전용).
|
||||
* 비파괴: 폴더를 옮기지 않고 "제자리"에서 색인한다.
|
||||
*/
|
||||
class LibraryStore {
|
||||
private roots: string[] = []
|
||||
private loaded = false
|
||||
|
||||
private filePath(): string {
|
||||
return join(app.getPath('userData'), FILE)
|
||||
}
|
||||
|
||||
async load(): Promise<string[]> {
|
||||
if (this.loaded) return this.roots
|
||||
try {
|
||||
const raw = await readFile(this.filePath(), 'utf-8')
|
||||
const parsed = JSON.parse(raw) as { roots?: string[] }
|
||||
this.roots = Array.isArray(parsed.roots) ? parsed.roots : []
|
||||
} catch {
|
||||
this.roots = []
|
||||
}
|
||||
this.loaded = true
|
||||
return this.roots
|
||||
}
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
await this.load()
|
||||
return [...this.roots]
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
await mkdir(app.getPath('userData'), { recursive: true })
|
||||
await writeFile(this.filePath(), JSON.stringify({ roots: this.roots }, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
async add(path: string): Promise<string[]> {
|
||||
await this.load()
|
||||
if (!this.roots.includes(path)) {
|
||||
this.roots.push(path)
|
||||
await this.persist()
|
||||
logger.info('라이브러리 폴더 추가', { path })
|
||||
}
|
||||
return [...this.roots]
|
||||
}
|
||||
|
||||
async remove(path: string): Promise<string[]> {
|
||||
await this.load()
|
||||
this.roots = this.roots.filter((r) => r !== path)
|
||||
await this.persist()
|
||||
return [...this.roots]
|
||||
}
|
||||
}
|
||||
|
||||
export const libraryStore = new LibraryStore()
|
||||
@@ -4,6 +4,7 @@ import { extname } from 'node:path'
|
||||
import { MEDIA_SCHEME } from '@shared/constants'
|
||||
import { profileStore } from './profileStore'
|
||||
import { presetStore } from './presetStore'
|
||||
import { thumbPath } from './thumbnails'
|
||||
import { logger } from './logger'
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
@@ -41,12 +42,28 @@ export function registerMediaScheme(): void {
|
||||
export function handleMediaProtocol(): void {
|
||||
protocol.handle(MEDIA_SCHEME, async (request) => {
|
||||
try {
|
||||
// request.url = photoai-media://img/?p=<encodeURIComponent(절대경로)>
|
||||
const url = request.url
|
||||
|
||||
// 썸네일 요청: photoai-media://thumb/?t=<contentHash>
|
||||
const tMarker = 't='
|
||||
const ti = url.indexOf(tMarker)
|
||||
if (ti >= 0) {
|
||||
const hash = decodeURIComponent(url.slice(ti + tMarker.length))
|
||||
// 해시는 16진수만 허용 → 경로 조작 차단
|
||||
if (!/^[a-f0-9]+$/.test(hash)) return new Response('bad hash', { status: 400 })
|
||||
const data = await readFile(thumbPath(hash))
|
||||
return new Response(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/webp', 'cache-control': 'no-cache' }
|
||||
})
|
||||
}
|
||||
|
||||
// 참조 이미지 요청: photoai-media://img/?p=<encodeURIComponent(절대경로)>
|
||||
// searchParams의 자동 디코딩과의 이중 디코딩을 피하려고 raw 문자열을 직접 파싱한다.
|
||||
const marker = 'p='
|
||||
const i = request.url.indexOf(marker)
|
||||
const i = url.indexOf(marker)
|
||||
if (i < 0) return new Response('missing path', { status: 400 })
|
||||
const filePath = decodeURIComponent(request.url.slice(i + marker.length))
|
||||
const filePath = decodeURIComponent(url.slice(i + marker.length))
|
||||
|
||||
// 등록된 참조 이미지(활성 프로필 또는 프리셋)에 한해 제공 — 임의 파일 읽기 차단
|
||||
const allowed =
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { QUALITY_THRESHOLDS } from '@shared/constants'
|
||||
import type { QualityScores } from '@shared/types'
|
||||
|
||||
/**
|
||||
* 원본 점수 → 종합 플래그 분류 (Main 측, 임계값 기반).
|
||||
* 우선순위: 흐림 → 눈감음 → 노출불량 → 후보(candidate)
|
||||
*/
|
||||
export function classifyFlag(
|
||||
focus: number,
|
||||
exposure: number,
|
||||
eyesOpen: number | null,
|
||||
th = QUALITY_THRESHOLDS
|
||||
): QualityScores['flag'] {
|
||||
if (focus < th.focus) return 'blurry'
|
||||
if (eyesOpen !== null && eyesOpen < th.eyes) return 'eyesClosed'
|
||||
if (exposure < th.exposure) return 'badExposure'
|
||||
return 'candidate'
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { IndexedAsset } from '@shared/types'
|
||||
import { indexDb } from './indexDb'
|
||||
import { inferenceBridge } from './inferenceBridge'
|
||||
import { settingsStore } from './settingsStore'
|
||||
|
||||
/** 정규화된 두 벡터의 코사인 = 내적 */
|
||||
function dot(a: number[], b: Float32Array): number {
|
||||
const n = Math.min(a.length, b.length)
|
||||
let s = 0
|
||||
for (let i = 0; i < n; i++) s += a[i] * b[i]
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 자연어 쿼리 → CLIP 텍스트 임베딩 → 전체 이미지 임베딩과 코사인 유사도 → 상위 K.
|
||||
* (수만 장 규모까지 브루트포스로 충분; 대규모는 추후 ANN.)
|
||||
*/
|
||||
export async function search(query: string, topK = 80): Promise<IndexedAsset[]> {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
await inferenceBridge.whenReady()
|
||||
const qvec = await inferenceBridge.embedText(trimmed)
|
||||
const all = indexDb.getAllEmbeddings()
|
||||
if (all.length === 0) return []
|
||||
|
||||
const scored = all.map((e) => ({ assetId: e.assetId, score: dot(qvec, e.vec) }))
|
||||
scored.sort((a, b) => b.score - a.score)
|
||||
const topIds = scored.slice(0, topK).map((s) => s.assetId)
|
||||
|
||||
return indexDb.assetsByIds(topIds, settingsStore.current().qualityThresholds)
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { app } from 'electron'
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Settings } from '@shared/types'
|
||||
import { SETTINGS_FILE } from '@shared/constants'
|
||||
import { SETTINGS_FILE, QUALITY_THRESHOLDS } from '@shared/constants'
|
||||
import { DEFAULT_LANG } from '@shared/i18n'
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
language: DEFAULT_LANG, // 기본 한국어
|
||||
theme: 'dark', // 기본 다크모드
|
||||
onboarded: false
|
||||
onboarded: false,
|
||||
qualityThresholds: { ...QUALITY_THRESHOLDS }
|
||||
}
|
||||
|
||||
/** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { app } from 'electron'
|
||||
import { writeFile, mkdir, access } from 'node:fs/promises'
|
||||
import { constants as FS } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* 썸네일 캐시. userData/thumbs/<contentHash>.webp (라이브러리 폴더는 손대지 않음 = 비파괴).
|
||||
*/
|
||||
export function thumbsDir(): string {
|
||||
return join(app.getPath('userData'), 'thumbs')
|
||||
}
|
||||
|
||||
export function thumbPath(contentHash: string): string {
|
||||
return join(thumbsDir(), `${contentHash}.webp`)
|
||||
}
|
||||
|
||||
export async function hasThumb(contentHash: string): Promise<boolean> {
|
||||
try {
|
||||
await access(thumbPath(contentHash), FS.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeThumb(contentHash: string, bytes: ArrayBuffer): Promise<void> {
|
||||
await mkdir(thumbsDir(), { recursive: true })
|
||||
await writeFile(thumbPath(contentHash), Buffer.from(bytes))
|
||||
}
|
||||
+28
-1
@@ -6,6 +6,8 @@ import type {
|
||||
JobRequest,
|
||||
Settings,
|
||||
ReferenceData,
|
||||
AssetQuery,
|
||||
ColorLabel,
|
||||
RendererEventName,
|
||||
RendererEvents
|
||||
} from '../shared/types'
|
||||
@@ -16,7 +18,11 @@ const EVENT_CHANNELS: Record<RendererEventName, string> = {
|
||||
'job:fileProcessed': IPC.JOB_FILE_PROCESSED,
|
||||
'job:done': IPC.JOB_DONE,
|
||||
'job:error': IPC.JOB_ERROR,
|
||||
'settings:changed': IPC.SETTINGS_CHANGED
|
||||
'settings:changed': IPC.SETTINGS_CHANGED,
|
||||
'index:progress': IPC.INDEX_PROGRESS,
|
||||
'index:done': IPC.INDEX_DONE,
|
||||
'search:progress': IPC.SEARCH_PROGRESS,
|
||||
'search:done': IPC.SEARCH_DONE
|
||||
}
|
||||
|
||||
const api: ExposedApi = {
|
||||
@@ -51,6 +57,27 @@ const api: ExposedApi = {
|
||||
get: () => ipcRenderer.invoke(IPC.SETTINGS_GET),
|
||||
set: (patch: Partial<Settings>) => ipcRenderer.invoke(IPC.SETTINGS_SET, patch)
|
||||
},
|
||||
library: {
|
||||
list: () => ipcRenderer.invoke(IPC.LIBRARY_LIST),
|
||||
add: () => ipcRenderer.invoke(IPC.LIBRARY_ADD),
|
||||
remove: (path: string) => ipcRenderer.invoke(IPC.LIBRARY_REMOVE, path)
|
||||
},
|
||||
index: {
|
||||
run: () => ipcRenderer.invoke(IPC.INDEX_RUN),
|
||||
cancel: () => ipcRenderer.invoke(IPC.INDEX_CANCEL),
|
||||
assets: (offset: number, limit: number, query: AssetQuery) =>
|
||||
ipcRenderer.invoke(IPC.INDEX_ASSETS, offset, limit, query),
|
||||
setRating: (assetId: number, rating: number) =>
|
||||
ipcRenderer.invoke(IPC.INDEX_SET_RATING, assetId, rating),
|
||||
setLabel: (assetId: number, label: ColorLabel) =>
|
||||
ipcRenderer.invoke(IPC.INDEX_SET_LABEL, assetId, label)
|
||||
},
|
||||
search: {
|
||||
build: () => ipcRenderer.invoke(IPC.SEARCH_BUILD),
|
||||
cancel: () => ipcRenderer.invoke(IPC.SEARCH_CANCEL),
|
||||
status: () => ipcRenderer.invoke(IPC.SEARCH_STATUS),
|
||||
query: (text: string) => ipcRenderer.invoke(IPC.SEARCH_QUERY, text)
|
||||
},
|
||||
// Electron 33: File.path 제거됨 → webUtils로 드롭된 파일의 실제 경로 획득
|
||||
getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File),
|
||||
on<E extends RendererEventName>(event: E, cb: (payload: RendererEvents[E]) => void) {
|
||||
|
||||
@@ -2,7 +2,14 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
// 숨김 추론 창 전용 브릿지.
|
||||
// Main이 보내는 요청 채널만 수신하고, 응답은 'infer:reply'로만 전송한다.
|
||||
const REQUEST_CHANNELS = ['infer:init', 'infer:describe', 'infer:detect'] as const
|
||||
const REQUEST_CHANNELS = [
|
||||
'infer:init',
|
||||
'infer:describe',
|
||||
'infer:detect',
|
||||
'infer:analyze',
|
||||
'infer:embedImage',
|
||||
'infer:embedText'
|
||||
] as const
|
||||
type RequestChannel = (typeof REQUEST_CHANNELS)[number]
|
||||
|
||||
export interface InferBridge {
|
||||
|
||||
+59
-18
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useT } from '../i18n'
|
||||
import { thumbUrl, baseName } from '../media'
|
||||
import type {
|
||||
IndexedAsset,
|
||||
QualityFilter,
|
||||
QualityFlag,
|
||||
ColorLabel,
|
||||
AssetQuery,
|
||||
QualityThresholds
|
||||
} from '@shared/types'
|
||||
|
||||
const PAGE = 120
|
||||
|
||||
const FILTERS: { id: QualityFilter; key: string }[] = [
|
||||
{ id: 'all', key: 'cull.all' },
|
||||
{ id: 'candidate', key: 'cull.candidate' },
|
||||
{ id: 'rejected', key: 'cull.rejected' }
|
||||
]
|
||||
|
||||
const FLAG_STYLE: Record<Exclude<QualityFlag, null>, string> = {
|
||||
candidate: 'bg-emerald-500/80',
|
||||
blurry: 'bg-amber-500/80',
|
||||
eyesClosed: 'bg-violet-500/80',
|
||||
badExposure: 'bg-red-500/80'
|
||||
}
|
||||
const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']
|
||||
|
||||
const LABEL_COLORS: { id: Exclude<ColorLabel, null>; cls: string }[] = [
|
||||
{ id: 'red', cls: 'bg-red-500' },
|
||||
{ id: 'yellow', cls: 'bg-yellow-400' },
|
||||
{ id: 'green', cls: 'bg-emerald-500' },
|
||||
{ id: 'blue', cls: 'bg-sky-500' },
|
||||
{ id: 'purple', cls: 'bg-violet-500' }
|
||||
]
|
||||
|
||||
/** 라이브러리: 폴더 색인 + 썸네일 그리드 + 컬링(품질 임계값 / 별점·색라벨) */
|
||||
export function LibraryView(): JSX.Element {
|
||||
const t = useT()
|
||||
const libraries = useStore((s) => s.libraries)
|
||||
const refreshLibraries = useStore((s) => s.refreshLibraries)
|
||||
const addLibrary = useStore((s) => s.addLibrary)
|
||||
const removeLibrary = useStore((s) => s.removeLibrary)
|
||||
const startIndex = useStore((s) => s.startIndex)
|
||||
const cancelIndex = useStore((s) => s.cancelIndex)
|
||||
const indexPhase = useStore((s) => s.indexPhase)
|
||||
const progress = useStore((s) => s.indexProgress)
|
||||
const summary = useStore((s) => s.indexSummary)
|
||||
const qt = useStore((s) => s.qualityThresholds)
|
||||
const updateSettings = useStore((s) => s.updateSettings)
|
||||
|
||||
const [assets, setAssets] = useState<IndexedAsset[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [filter, setFilter] = useState<QualityFilter>('all')
|
||||
const [ratingMin, setRatingMin] = useState(0)
|
||||
const [showThresholds, setShowThresholds] = useState(false)
|
||||
const [localTh, setLocalTh] = useState<QualityThresholds>(qt)
|
||||
const localThRef = useRef(localTh)
|
||||
localThRef.current = localTh
|
||||
useEffect(() => setLocalTh(qt), [qt])
|
||||
|
||||
const loadAssets = useCallback(async (offset: number, q: AssetQuery) => {
|
||||
const page = await window.api.index.assets(offset, PAGE, q)
|
||||
setHasMore(page.length === PAGE)
|
||||
setAssets((prev) => (offset === 0 ? page : [...prev, ...page]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshLibraries()
|
||||
}, [refreshLibraries])
|
||||
|
||||
// 필터/별점 변경 시 그리드 갱신
|
||||
useEffect(() => {
|
||||
void loadAssets(0, { filter, ratingMin })
|
||||
}, [filter, ratingMin, loadAssets])
|
||||
// 색인 완료 시 갱신
|
||||
useEffect(() => {
|
||||
if (indexPhase === 'done') void loadAssets(0, { filter, ratingMin })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [indexPhase, summary])
|
||||
|
||||
const commitThresholds = async (th: QualityThresholds) => {
|
||||
await updateSettings({ qualityThresholds: th })
|
||||
void loadAssets(0, { filter, ratingMin })
|
||||
}
|
||||
|
||||
const setRating = async (a: IndexedAsset, rating: number) => {
|
||||
if (a.id == null) return
|
||||
const next = a.rating === rating ? 0 : rating
|
||||
await window.api.index.setRating(a.id, next)
|
||||
setAssets((prev) => prev.map((x) => (x.id === a.id ? { ...x, rating: next } : x)))
|
||||
}
|
||||
const setLabel = async (a: IndexedAsset, label: Exclude<ColorLabel, null>) => {
|
||||
if (a.id == null) return
|
||||
const next: ColorLabel = a.label === label ? null : label
|
||||
await window.api.index.setLabel(a.id, next)
|
||||
setAssets((prev) => prev.map((x) => (x.id === a.id ? { ...x, label: next } : x)))
|
||||
}
|
||||
|
||||
const running = indexPhase === 'running'
|
||||
const pct =
|
||||
progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full flex flex-col gap-4">
|
||||
{/* 라이브러리 폴더 */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="font-semibold dark:text-slate-100">{t('lib.section')}</h2>
|
||||
<button
|
||||
className="text-sm border border-brand text-brand rounded-lg px-3 py-1.5 font-medium hover:bg-brand hover:text-white disabled:opacity-40"
|
||||
onClick={addLibrary}
|
||||
disabled={running}
|
||||
>
|
||||
+ {t('lib.add')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mb-3">{t('lib.hint')}</p>
|
||||
|
||||
{libraries.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-2">{t('lib.empty')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{libraries.map((dir) => (
|
||||
<li
|
||||
key={dir}
|
||||
className="flex items-center gap-2 bg-slate-50 dark:bg-slate-700/40 rounded-lg px-3 py-2"
|
||||
>
|
||||
<span className="mono text-xs truncate flex-1 dark:text-slate-200" title={dir}>
|
||||
{dir}
|
||||
</span>
|
||||
<button
|
||||
className="text-xs border border-red-300 dark:border-red-500/60 text-red-500 dark:text-red-400 rounded px-2 py-1 disabled:opacity-40"
|
||||
onClick={() => removeLibrary(dir)}
|
||||
disabled={running}
|
||||
>
|
||||
{t('lib.remove')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 색인 제어 + 진행률 */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{!running ? (
|
||||
<button
|
||||
className="bg-brand hover:bg-brand-dark text-white rounded-lg px-4 py-2 font-semibold disabled:opacity-40"
|
||||
onClick={startIndex}
|
||||
disabled={libraries.length === 0}
|
||||
>
|
||||
{indexPhase === 'done' ? `${t('lib.index')} ↻` : t('lib.index')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="bg-red-500 hover:bg-red-600 text-white rounded-lg px-4 py-2 font-semibold"
|
||||
onClick={cancelIndex}
|
||||
>
|
||||
{t('lib.cancel')}
|
||||
</button>
|
||||
)}
|
||||
{summary && !running && (
|
||||
<span className="text-sm text-emerald-600 dark:text-emerald-400">
|
||||
{t('lib.assets', { n: summary.assets })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && (
|
||||
<>
|
||||
<div className="h-3 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand transition-[width] duration-200"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-slate-400 mono truncate max-w-[75%]">
|
||||
{progress?.current ?? t('lib.indexing')}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-brand">{pct}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{summary && !running && (
|
||||
<p className="text-sm dark:text-slate-200">
|
||||
{t('lib.doneSummary', {
|
||||
indexed: summary.indexed,
|
||||
skipped: summary.skipped,
|
||||
failed: summary.failed,
|
||||
assets: summary.assets
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 그리드 + 컬링 */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h2 className="font-semibold dark:text-slate-100">{t('lib.grid')}</h2>
|
||||
<div className="flex gap-1">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`text-xs rounded-full px-2.5 py-1 border ${
|
||||
filter === f.id
|
||||
? 'border-brand bg-brand text-white'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand'
|
||||
}`}
|
||||
>
|
||||
{t(f.key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 별점 최소 필터 */}
|
||||
<div className="flex items-center gap-0.5" title={t('cull.ratingMin')}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
|
||||
className={`text-sm leading-none ${n <= ratingMin ? 'text-amber-400' : 'text-slate-300 dark:text-slate-600'}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="text-xs text-slate-500 dark:text-slate-400 hover:text-brand"
|
||||
onClick={() => setShowThresholds((v) => !v)}
|
||||
>
|
||||
⚙ {t('cull.thresholds')}
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">{assets.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 임계값 패널 */}
|
||||
{showThresholds && (
|
||||
<div className="bg-slate-50 dark:bg-slate-700/40 rounded-lg p-3 mb-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ThresholdSlider
|
||||
label={t('cull.focus')}
|
||||
min={0}
|
||||
max={300}
|
||||
step={5}
|
||||
value={localTh.focus}
|
||||
onInput={(v) => setLocalTh({ ...localThRef.current, focus: v })}
|
||||
onCommit={() => commitThresholds(localThRef.current)}
|
||||
/>
|
||||
<ThresholdSlider
|
||||
label={t('cull.exposure')}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={localTh.exposure}
|
||||
onInput={(v) => setLocalTh({ ...localThRef.current, exposure: v })}
|
||||
onCommit={() => commitThresholds(localThRef.current)}
|
||||
/>
|
||||
<ThresholdSlider
|
||||
label={t('cull.eyes')}
|
||||
min={0}
|
||||
max={0.4}
|
||||
step={0.01}
|
||||
value={localTh.eyes}
|
||||
onInput={(v) => setLocalTh({ ...localThRef.current, eyes: v })}
|
||||
onCommit={() => commitThresholds(localThRef.current)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-[11px] text-slate-400">{t('cull.thresholdHint')}</p>
|
||||
<button
|
||||
className="text-xs text-slate-500 dark:text-slate-400 hover:text-brand"
|
||||
onClick={() => commitThresholds({ focus: 60, exposure: 0.35, eyes: 0.18 })}
|
||||
>
|
||||
{t('cull.reset')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assets.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-2">{t('lib.gridEmpty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{assets.map((a) => (
|
||||
<AssetTile
|
||||
key={a.contentHash}
|
||||
asset={a}
|
||||
flagLabel={a.flag ? t(`flag.${a.flag}`) : ''}
|
||||
onRate={(r) => setRating(a, r)}
|
||||
onLabel={(l) => setLabel(a, l)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="text-center mt-3">
|
||||
<button
|
||||
className="text-sm border border-slate-300 dark:border-slate-600 dark:text-slate-200 rounded-lg px-4 py-1.5"
|
||||
onClick={() => loadAssets(assets.length, { filter, ratingMin })}
|
||||
>
|
||||
{t('lib.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThresholdSlider(props: {
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
value: number
|
||||
onInput: (v: number) => void
|
||||
onCommit: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-slate-500 dark:text-slate-400 mb-1">
|
||||
{props.label} ({props.value})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onInput(Number(e.target.value))}
|
||||
onPointerUp={props.onCommit}
|
||||
onKeyUp={props.onCommit}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function AssetTile(props: {
|
||||
asset: IndexedAsset
|
||||
flagLabel: string
|
||||
onRate: (rating: number) => void
|
||||
onLabel: (label: Exclude<ColorLabel, null>) => void
|
||||
}): JSX.Element {
|
||||
const { asset: a } = props
|
||||
const isVideo = VIDEO_EXTS.includes(a.ext)
|
||||
const labelColor = a.label ? LABEL_COLORS.find((c) => c.id === a.label)?.cls : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-square rounded-md overflow-hidden border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 group"
|
||||
title={a.path}
|
||||
>
|
||||
{isVideo ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">
|
||||
▶ {baseName(a.path).slice(-12)}
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={thumbUrl(a.contentHash)}
|
||||
alt={baseName(a.path)}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 품질 배지 */}
|
||||
{a.flag && (
|
||||
<span
|
||||
className={`absolute bottom-0.5 left-0.5 text-[9px] font-semibold text-white rounded px-1 py-0.5 ${FLAG_STYLE[a.flag]}`}
|
||||
>
|
||||
{props.flagLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 색라벨 표시(우상단 점) */}
|
||||
{labelColor && (
|
||||
<span className={`absolute top-1 right-1 w-2.5 h-2.5 rounded-full ${labelColor}`} />
|
||||
)}
|
||||
|
||||
{/* 호버 오버레이: 별점 + 색라벨 편집 */}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/55 opacity-0 group-hover:opacity-100 transition-opacity p-1 flex flex-col gap-1">
|
||||
<div className="flex justify-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => props.onRate(n)}
|
||||
className={`text-xs leading-none ${n <= a.rating ? 'text-amber-400' : 'text-white/50'}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-1">
|
||||
{LABEL_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => props.onLabel(c.id)}
|
||||
className={`w-3 h-3 rounded-full ${c.cls} ${a.label === c.id ? 'ring-2 ring-white' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useT } from '../i18n'
|
||||
import { thumbUrl, baseName } from '../media'
|
||||
import type { IndexedAsset, SearchStatus } from '@shared/types'
|
||||
|
||||
const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']
|
||||
|
||||
/** 자연어/유사 검색 (Phase 2) */
|
||||
export function SearchView(): JSX.Element {
|
||||
const t = useT()
|
||||
const searchPhase = useStore((s) => s.searchPhase)
|
||||
const progress = useStore((s) => s.searchProgress)
|
||||
const summary = useStore((s) => s.searchSummary)
|
||||
const buildSearchIndex = useStore((s) => s.buildSearchIndex)
|
||||
const cancelSearchIndex = useStore((s) => s.cancelSearchIndex)
|
||||
|
||||
const [status, setStatus] = useState<SearchStatus | null>(null)
|
||||
const [text, setText] = useState('')
|
||||
const [results, setResults] = useState<IndexedAsset[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
|
||||
const refreshStatus = async () => setStatus(await window.api.search.status())
|
||||
useEffect(() => {
|
||||
void refreshStatus()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (searchPhase === 'done') void refreshStatus()
|
||||
}, [searchPhase, summary])
|
||||
|
||||
const running = searchPhase === 'running'
|
||||
const pct =
|
||||
progress && progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0
|
||||
|
||||
const runSearch = async () => {
|
||||
if (!text.trim()) return
|
||||
setSearching(true)
|
||||
setSearched(true)
|
||||
try {
|
||||
setResults(await window.api.search.query(text))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full flex flex-col gap-4">
|
||||
{/* 검색 색인 생성 */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="font-semibold dark:text-slate-100">{t('search.section')}</h2>
|
||||
{status && (
|
||||
<span className="text-xs text-slate-400">
|
||||
{t('search.status', { embedded: status.embedded, total: status.totalImages })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mb-3">{t('search.hint')}</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!running ? (
|
||||
<button
|
||||
className="bg-brand hover:bg-brand-dark text-white rounded-lg px-4 py-2 font-semibold"
|
||||
onClick={buildSearchIndex}
|
||||
>
|
||||
{t('search.build')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="bg-red-500 hover:bg-red-600 text-white rounded-lg px-4 py-2 font-semibold"
|
||||
onClick={cancelSearchIndex}
|
||||
>
|
||||
{t('search.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && (
|
||||
<div className="mt-3">
|
||||
<div className="h-3 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand transition-[width] duration-200"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-slate-400 mono truncate max-w-[75%]">
|
||||
{progress?.current ?? t('search.building')}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-brand">{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색 바 */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && runSearch()}
|
||||
/>
|
||||
<button
|
||||
className="bg-brand hover:bg-brand-dark text-white rounded-lg px-5 text-sm font-semibold disabled:opacity-40"
|
||||
onClick={runSearch}
|
||||
disabled={searching || !text.trim()}
|
||||
>
|
||||
{searching ? t('search.searching') : t('search.go')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
{!searched ? (
|
||||
<p className="text-sm text-slate-400 py-2">{t('search.prompt')}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-2">{t('search.noResults')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{results.map((a) => (
|
||||
<div
|
||||
key={a.contentHash}
|
||||
className="aspect-square rounded-md overflow-hidden border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-700"
|
||||
title={a.path}
|
||||
>
|
||||
{VIDEO_EXTS.includes(a.ext) ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">
|
||||
▶
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={thumbUrl(a.contentHash)}
|
||||
alt={baseName(a.path)}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,11 @@ export function mediaUrl(absolutePath: string): string {
|
||||
return `${MEDIA_SCHEME}://img/?p=${encodeURIComponent(absolutePath)}`
|
||||
}
|
||||
|
||||
/** 색인 자산 썸네일 URL (contentHash 기반) */
|
||||
export function thumbUrl(contentHash: string): string {
|
||||
return `${MEDIA_SCHEME}://thumb/?t=${encodeURIComponent(contentHash)}`
|
||||
}
|
||||
|
||||
/** 경로에서 파일명만 추출 (표시용) */
|
||||
export function baseName(p: string): string {
|
||||
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
|
||||
|
||||
+96
-7
@@ -6,13 +6,19 @@ import type {
|
||||
ProgressEvent,
|
||||
Report,
|
||||
Settings,
|
||||
Theme
|
||||
Theme,
|
||||
QualityThresholds,
|
||||
IndexProgress,
|
||||
IndexSummary,
|
||||
SearchProgress,
|
||||
SearchSummary
|
||||
} from '@shared/types'
|
||||
import type { Lang } from '@shared/i18n'
|
||||
import { DEFAULT_LANG } from '@shared/i18n'
|
||||
import { DEFAULT_JOB_OPTIONS } from '@shared/constants'
|
||||
|
||||
export type JobPhase = 'idle' | 'running' | 'done'
|
||||
export type AppView = 'organize' | 'library' | 'search'
|
||||
|
||||
/** 테마를 <html> 클래스에 반영 (Tailwind darkMode:'class') */
|
||||
function applyTheme(theme: Theme): void {
|
||||
@@ -44,10 +50,33 @@ interface AppState {
|
||||
cancelJob: () => Promise<void>
|
||||
resetJob: () => void
|
||||
|
||||
// 설정(언어/테마/온보딩)
|
||||
// 화면 전환
|
||||
view: AppView
|
||||
setView: (v: AppView) => void
|
||||
|
||||
// 라이브러리 / 색인 (Phase 0)
|
||||
libraries: string[]
|
||||
indexPhase: JobPhase
|
||||
indexProgress: IndexProgress | null
|
||||
indexSummary: IndexSummary | null
|
||||
refreshLibraries: () => Promise<void>
|
||||
addLibrary: () => Promise<void>
|
||||
removeLibrary: (path: string) => Promise<void>
|
||||
startIndex: () => Promise<void>
|
||||
cancelIndex: () => Promise<void>
|
||||
|
||||
// 검색 색인 (Phase 2)
|
||||
searchPhase: JobPhase
|
||||
searchProgress: SearchProgress | null
|
||||
searchSummary: SearchSummary | null
|
||||
buildSearchIndex: () => Promise<void>
|
||||
cancelSearchIndex: () => Promise<void>
|
||||
|
||||
// 설정(언어/테마/온보딩/임계값)
|
||||
language: Lang
|
||||
theme: Theme
|
||||
onboarded: boolean
|
||||
qualityThresholds: QualityThresholds
|
||||
initSettings: () => Promise<void>
|
||||
updateSettings: (patch: Partial<Settings>) => Promise<void>
|
||||
|
||||
@@ -57,6 +86,10 @@ interface AppState {
|
||||
_onDone: (r: Report) => void
|
||||
_onError: (e: { file: string; message: string }) => void
|
||||
_onSettings: (s: Settings) => void
|
||||
_onIndexProgress: (p: IndexProgress) => void
|
||||
_onIndexDone: (s: IndexSummary) => void
|
||||
_onSearchProgress: (p: SearchProgress) => void
|
||||
_onSearchDone: (s: SearchSummary) => void
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
@@ -91,19 +124,62 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
resetJob: () => set({ phase: 'idle', progress: null, processed: [], report: null, errors: [] }),
|
||||
|
||||
// ---- 화면 전환 ----
|
||||
view: 'organize',
|
||||
setView: (view) => set({ view }),
|
||||
|
||||
// ---- 라이브러리 / 색인 ----
|
||||
libraries: [],
|
||||
indexPhase: 'idle',
|
||||
indexProgress: null,
|
||||
indexSummary: null,
|
||||
refreshLibraries: async () => set({ libraries: await window.api.library.list() }),
|
||||
addLibrary: async () => set({ libraries: await window.api.library.add() }),
|
||||
removeLibrary: async (path) => set({ libraries: await window.api.library.remove(path) }),
|
||||
startIndex: async () => {
|
||||
set({ indexPhase: 'running', indexProgress: null, indexSummary: null })
|
||||
await window.api.index.run()
|
||||
},
|
||||
cancelIndex: async () => {
|
||||
await window.api.index.cancel()
|
||||
},
|
||||
|
||||
// ---- 검색 색인 ----
|
||||
searchPhase: 'idle',
|
||||
searchProgress: null,
|
||||
searchSummary: null,
|
||||
buildSearchIndex: async () => {
|
||||
set({ searchPhase: 'running', searchProgress: null, searchSummary: null })
|
||||
await window.api.search.build()
|
||||
},
|
||||
cancelSearchIndex: async () => {
|
||||
await window.api.search.cancel()
|
||||
},
|
||||
|
||||
// ---- 설정 ----
|
||||
language: DEFAULT_LANG,
|
||||
theme: 'dark',
|
||||
onboarded: false,
|
||||
qualityThresholds: { focus: 60, exposure: 0.35, eyes: 0.18 },
|
||||
initSettings: async () => {
|
||||
const s = await window.api.settings.get()
|
||||
applyTheme(s.theme)
|
||||
set({ language: s.language, theme: s.theme, onboarded: s.onboarded })
|
||||
set({
|
||||
language: s.language,
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds
|
||||
})
|
||||
},
|
||||
updateSettings: async (patch) => {
|
||||
const s = await window.api.settings.set(patch)
|
||||
applyTheme(s.theme)
|
||||
set({ language: s.language, theme: s.theme, onboarded: s.onboarded })
|
||||
set({
|
||||
language: s.language,
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds
|
||||
})
|
||||
},
|
||||
|
||||
_onProgress: (progress) => set({ progress }),
|
||||
@@ -116,8 +192,17 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
_onError: (e) => set((s) => ({ errors: [e, ...s.errors].slice(0, 200) })),
|
||||
_onSettings: (s) => {
|
||||
applyTheme(s.theme)
|
||||
set({ language: s.language, theme: s.theme, onboarded: s.onboarded })
|
||||
}
|
||||
set({
|
||||
language: s.language,
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds
|
||||
})
|
||||
},
|
||||
_onIndexProgress: (p: IndexProgress) => set({ indexProgress: p }),
|
||||
_onIndexDone: (s: IndexSummary) => set({ indexSummary: s, indexPhase: 'done' }),
|
||||
_onSearchProgress: (p: SearchProgress) => set({ searchProgress: p }),
|
||||
_onSearchDone: (s: SearchSummary) => set({ searchSummary: s, searchPhase: 'done' })
|
||||
}))
|
||||
|
||||
/** 앱 시작 시 1회: Main→UI 이벤트 구독 */
|
||||
@@ -128,7 +213,11 @@ export function wireEvents(): () => void {
|
||||
window.api.on('job:fileProcessed', s._onFile),
|
||||
window.api.on('job:done', s._onDone),
|
||||
window.api.on('job:error', s._onError),
|
||||
window.api.on('settings:changed', s._onSettings)
|
||||
window.api.on('settings:changed', s._onSettings),
|
||||
window.api.on('index:progress', s._onIndexProgress),
|
||||
window.api.on('index:done', s._onIndexDone),
|
||||
window.api.on('search:progress', s._onSearchProgress),
|
||||
window.api.on('search:done', s._onSearchDone)
|
||||
]
|
||||
return () => offs.forEach((off) => off())
|
||||
}
|
||||
|
||||
@@ -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