diff --git a/.gitignore b/.gitignore index 5220d59..8ea7279 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ smoke-* # 모델 가중치는 용량이 커서 git에 올리지 않음 — npm run models:download 로 받음 models/*.bin models/*-weights_manifest.json +# ORT WASM은 용량이 커서 git 제외 — postinstall(scripts/copy-ort-wasm) 로 생성 +public/ort/ diff --git a/docs/DARKTABLE_REVIEW.md b/docs/DARKTABLE_REVIEW.md new file mode 100644 index 0000000..d45b45f --- /dev/null +++ b/docs/DARKTABLE_REVIEW.md @@ -0,0 +1,107 @@ +# darktable 차용 검토 & 메타데이터/탐색 기능 로드맵 + +> 상태: 검토/계획 초안 · 2026-06-01 +> 대상: darktable 5.4.1 소스(C/GTK/Lua) 참고 + PhotoAI 기획서("메타데이터 기반 자동 분류/연관 탐색") +> 베이스: 현재 PhotoAI (Electron + React/TS, 인덱스 DB + 얼굴/CLIP/컬링) + +--- + +## 0. 먼저, 솔직한 핵심 정정 2가지 + +**(1) "darktable 엔진을 가져다 쓴다"는 건 사실상 불가합니다 — 하지만 안 가져와도 됩니다.** +- darktable은 **C/GTK/Lua** 데스크톱 앱입니다. 우리는 **Electron/TypeScript**라 darktable의 코드/엔진을 in-process로 링크할 수 없습니다. +- darktable을 끌어오는 유일한 방법은 (a) 바이너리 동봉 후 CLI 호출(`darktable-cli`) 또는 (b) 같은 하위 라이브러리(`exiv2`) 사용인데, **둘 다 GPL3 전염 + 무거운 네이티브 의존**(우리가 better-sqlite3에서 겪은 빌드 지옥)을 유발합니다. 권장하지 않습니다. +- **좋은 소식**: darktable이 메타데이터에 쓰는 기능 대부분을, 우리는 **이미 설치된 `exifr`로 그대로 얻습니다.** exifr는 EXIF뿐 아니라 **GPS(위도/경도), IPTC, 카메라/렌즈** 까지 파싱합니다. 즉 "메타데이터 추출 엔진"은 **darktable 없이 이미 확보**되어 있습니다. + +→ 결론: **darktable의 코드가 아니라 "검증된 UX 패턴과 데이터 모델"을 차용**하고, 추출은 exifr로 우리 스택에서 재구현합니다. (GPL/네이티브 회피) + +**(2) 기획서의 "자녀별 자동 분류(시나리오 A)"는 이미 상당 부분 구현되어 있습니다.** +- 우리 **정리(Organize) 탭 = 얼굴 인식으로 인물(자녀)별 + 날짜별 폴더 자동 분류**. 이게 정확히 시나리오 A입니다. "AI 태깅"을 새로 만들 필요 없이 기존 얼굴 매칭이 그 역할을 합니다. +- 따라서 이번 단계의 **진짜 새 가치는 "장소(GPS) 기반 탐색"과 "탐색 UX(필터 트리/타임라인/지도)", "4050 쉬운 모드"** 입니다. + +--- + +## 1. darktable에서 가져올 만한 것 (모듈 매핑) + +| darktable 소스 | 개념 | 우리 차용 방식 | 가치 | +|----------------|------|----------------|------| +| `views/lighttable.c` | 그리드 + 필름스트립 + 별점/색라벨 + 줌 | **이미 보유**(라이브러리 그리드/별점/색라벨). 필름스트립/줌은 보강 여지 | 중 | +| `libs/collect.c` | 좌측 **컬렉션 필터 트리**(폴더/날짜/카메라/태그/색라벨/평점/**GPS 위치**) | 우리 인덱스 DB로 **필터 트리 패널** 재구현 | **높음** | +| lighttable 하단 | **타임라인**(연/월 빠른 이동) | 인덱스의 exifYear/Month로 타임라인 바 | **높음** | +| `views/map.c` + `libs/geotagging.c` | **GPS 지도 뷰** + 지오태깅 | Leaflet(JS) + OSM 타일로 지도/클러스터 재구현 | **높음(신규)** | +| `libs/metadata.c` / `metadata_view.c` | 메타데이터 표시 + **XMP 사이드카** | 메타 패널은 재구현. XMP는 **선택적 내보내기**(상호운용) | 중 | +| `libs/tagging.c` | 태그 | 향후(현재 별점/색라벨로 일부 대체) | 낮음 | +| `views/darkroom.c` | RAW 편집 | **비목표(기획서 동의)** — 복잡도의 근원, 제외 | - | + +--- + +## 2. 우리 현황 ↔ 기획서 매핑 + +| 기획서 항목 | 현재 상태 | 필요 작업 | +|-------------|----------|-----------| +| 자녀별 자동 분류(시나리오 A) | ✅ **구현됨**(정리 탭, 얼굴+날짜) | (유지) | +| 메타데이터 추출(EXIF) | ✅ 부분(촬영일) | **GPS/IPTC/카메라 추가**(exifr, 쉬움) | +| 썸네일 캐싱/프리뷰 | ✅ 구현됨(canvas→webp) | (유지) | +| 컬렉션 필터 트리(인물/연/도시) | ❌ | **신규** — 인덱스 기반 트리 패널 | +| 타임라인 | ❌ | **신규** — 연/월 스크롤 바 | +| 장소/여행지 탐색(시나리오 B) | ❌ | **신규** — GPS 지도 + 연관 탐색 | +| Relation Finder(동일 GPS/시간) | ❌ | **신규** — GPS+시간(+인물) 연관 그룹 | +| 4050 쉬운 모드(대형 버튼/구어체) | ❌ | **신규** — 접근성 UI 모드 | +| XMP 사이드카 | ⚠️ 우리 SQLite 인덱스가 대체 | **선택** — 내보내기로 상호운용 | +| 사용성(구어체 레이블) | ⚠️ 일부 | 쉬운 모드와 함께 | + +--- + +## 3. 기획서 평가 + 정정 제안 + +- 👍 방향성(메타데이터 기반 분류 + 연관 탐색 + 단순 UX)은 우리 자산과 잘 맞습니다. +- ✏️ **정정 1**: "darktable 엔진 확보"는 exifr로 대체(§0-1). 기술 리스크/라이선스 회피. +- ✏️ **정정 2**: "AI 태깅으로 자녀 분류"는 이미 있는 **얼굴 인식**으로 충족(§0-2). 중복 개발 불필요. +- ➕ **추가 제안**: 연관 탐색을 GPS만이 아니라 **GPS + 시간 + 인물(얼굴) + 시각유사도(CLIP)** 를 결합하면 darktable보다 강력합니다(darktable엔 얼굴/의미검색이 없음). "이 사진과 관련된 사진" = 같은 장소·시기·인물·비슷한 장면. +- ➕ **추가 제안**: "쉬운 모드"는 별도 앱이 아니라 **기존 UI에 토글되는 접근성 레이아웃**으로 — 유지보수 1벌. + +--- + +## 4. 리파인된 로드맵 (제안) + +- **Phase A — 풍부한 메타데이터 캡처** *(선행, 저비용)* + 인덱서의 exifr 호출을 확장해 **GPS(위/경도) · 카메라/렌즈 · IPTC**를 인덱스 DB에 저장. (지도/필터 트리의 데이터 토대) +- **Phase B — 컬렉션 필터 트리 + 타임라인** *(탐색 UX 핵심)* + 좌측 패널: **인물 / 연도 / 도시(역지오코딩) / 카메라**로 즉시 필터. 하단 **타임라인**으로 연·월 점프. (darktable collect.c + lighttable 타임라인) +- **Phase C — 지도(Place) 뷰 + Relation Finder** *(시나리오 B)* + GPS 좌표를 지도에 클러스터로 표시. 사진 클릭 → **연관 사진**(같은 장소·시기·인물·유사장면) 패널. (darktable map.c를 우리식으로) +- **Phase D — 4050 쉬운 모드(접근성)** *(차별화 UX)* + 대형 버튼·큰 썸네일·구어체 레이블("언제 찍었나요?/어디인가요?")의 **토글형 간편 레이아웃**. (기획서 "리모컨 UI") +- **(선택) XMP 사이드카 내보내기** — Lightroom/darktable 상호운용. + +> 권장 1순위: **Phase A + B**(데이터 + 탐색 UX). 우리가 이미 색인한 자산이 즉시 "탐색 가능한 라이브러리"가 됩니다. GPS 지도(C)는 그 다음, 쉬운 모드(D)는 UX 마감. + +--- + +## 5. 결정 — 확정(2026-06-01) +- **진행**: **A+B 먼저**(메타데이터 + 컬렉션 트리/타임라인). 이후 C(지도) → D(쉬운 모드). +- **지도 타일(Phase C)**: **온라인 OSM**. +- 비고: 라이브러리 그리드의 **인물(자녀)별 필터**는 인덱스에 얼굴-프로필 매칭을 저장하는 후속 작업(B.2)으로 둠. A+B는 연도/카메라/평점/색라벨/타임라인 우선. + +## 5-1. 구현 현황 + +- [x] **Phase A 완료(2026-06-01)**: exifr로 **GPS(위/경도) + 카메라 모델** 추출(`readMeta`), `asset` 테이블에 `gpsLat/gpsLon/camera` 컬럼 + 기존 DB **ALTER 마이그레이션**. `metaVersion`으로 구버전 행은 재색인 시 **GPS/카메라 backfill**(기존 썸네일 재사용 → 저비용). 부팅 시 110-asset DB 마이그레이션 무오류 확인. +- [x] **Phase B 완료(2026-06-01)**: `indexDb.facets`(연도/카메라/색라벨 집계) + `AssetQuery` 확장(year/camera/label) + 라이브러리 그리드에 **컬렉션 패싯 바**(연도 타임라인 · 카메라 칩 · 색라벨, 카운트 포함) 추가. 필터는 즉시 그리드에 반영. +- 비고: **인물(자녀)별 필터**(B.2)와 **월 단위 타임라인**, **쉬운모드(D)**는 후속. 기존 사진의 GPS/카메라는 **재색인 1회**로 채워짐. +- [x] **Phase C 완료(2026-06-01)**: **Leaflet + 온라인 OSM 타일** 지도 탭 — GPS 사진을 마커로 표시(클릭 시 썸네일 팝업). **연관 탐색(Relation Finder)**: `relationService`가 **장소(GPS 1km 이내) + 시간(±2일) + 시각유사도(CLIP)** 를 결합해 "이 사진과 관련된 사진"을 랭킹(darktable의 GPS 연관에 인물/의미까지 확장). CSP에 OSM 타일 도메인 허용. typecheck/build/부팅 스모크 통과. +- [x] **Windows 설치파일 빌드**: `AI Photo Organizer-0.1.0-win-x64.exe`(190MB, ORT wasm/모델 동봉, asar 언팩 검증). +- [x] **Phase D 완료(2026-06-01)**: **4050 쉬운 모드** — `easyMode` 설정(영속화) 토글(메뉴 보기 · 온보딩). 켜면 ``로 ① rem 기준 **전체 UI 확대**(16→20px) ② **썸네일 그리드 3열로 큼직** ③ 상단이 **대형 아이콘+구어체 버튼**(사진 정리/내 사진/사진 찾기/지도/중복 정리). 기존 UI를 토글 1벌로 재사용(유지보수 단순). typecheck/build/부팅 스모크 통과. + +## 7-1. 최종 구현 현황 (요약) + +탭 5개: **정리 / 라이브러리 / 검색 / 지도 / 그룹·정화** + **쉬운 모드** 토글. +- 정리: 얼굴+날짜 자동 분류(시나리오 A) · 라이브러리: 색인/썸네일/컬링/별점·색라벨/컬렉션 필터(연도·카메라) +- 검색: CLIP 자연어(한국어 번역) · 지도: GPS+연관탐색(시나리오 B) · 그룹·정화: 근접중복+휴지통 +- 남은 후속: 인물(자녀)별 필터(B.2) · 월 타임라인 · 역지오코딩(도시명) · 가족 공유(미래) + +## 6. (구) 결정이 필요했던 사항 + +1. **지도 타일 소스**: 온라인 OSM 타일(간단, 인터넷 필요) vs 오프라인 타일 동봉(용량 큼). → 온라인 권장(로컬-퍼스트지만 지도는 예외적으로 온라인 허용). +2. **역지오코딩(좌표→도시명)**: 온라인 API(Nominatim 등, 쿼터/인터넷) vs 좌표만 표시. → v1은 좌표/지도 우선, 도시명은 후속. +3. **쉬운 모드 범위**: 별도 단순 화면 1개 vs 전체 탭의 대형화 토글. +4. **진행 우선순위**: A+B 먼저 / 지도(C) 우선 / 쉬운 모드(D) 우선 / 전부. diff --git a/docs/NEXTGEN_REVIEW.md b/docs/NEXTGEN_REVIEW.md index a38fcc0..a4da755 100644 --- a/docs/NEXTGEN_REVIEW.md +++ b/docs/NEXTGEN_REVIEW.md @@ -185,7 +185,17 @@ UI (신규 화면) - [x] 브루트포스 코사인 검색 + **검색 탭**(임베딩 상태/생성 + 검색바 + 결과 그리드) - [x] typecheck/build/부팅 스모크 통과 -> ⚠️ **Phase 2 런타임 미검증 항목**: CLIP/번역 모델은 **최초 사용 시 HF Hub에서 다운로드**(온라인 1회 필요, 이후 캐시). ONNX Runtime WASM의 **완전 오프라인 패키징**(CDN 대신 동봉)과 **한국어 검색 정확도 실측**은 후속 과제. 임베딩 생성은 이미지당 수백 ms(WASM) → 대량은 시간 소요(백그라운드). +> ⚠️ **Phase 2 런타임 미검증 항목**: CLIP/번역 모델은 **최초 사용 시 HF Hub에서 다운로드**(온라인 1회 필요, 이후 캐시). 한국어 검색 정확도 실측은 후속. 임베딩 생성은 이미지당 수백 ms(WASM) → 대량은 시간 소요(백그라운드). + +### 8.8 Phase 2 마무리 — 완료(2026-06-01) +- [x] **ONNX Runtime WASM 오프라인 동봉**: transformers.js의 `ort-wasm-simd-threaded.jsep.wasm`(20.6MB)을 `public/ort`로 복사(`scripts/copy-ort-wasm`, postinstall/prebuild) → `env.backends.onnx.wasm.wasmPaths`를 로컬 경로로 지정(CDN 불필요). electron-builder asarUnpack 처리. (모델 가중치 자체는 여전히 최초 1회 다운로드) +- [x] **쿼리 템플릿**: 텍스트 임베딩 시 `"a photo of {query}"` 템플릿 적용(CLIP 정확도 향상), 단일 스레드(numThreads=1)로 COEP 미보장 환경 대응 + +### 8.9 Phase 3 (스마트 그룹화 + 자가정화) — 완료(2026-06-01) +- [x] **그룹화**: 임베딩 코사인 유사도 그리디 클러스터링(`groupingService`), 유사도 임계값 슬라이더, 그룹별 **보관 추천(가장 선명)** 자동 표시 +- [x] **자가정화**: 그룹의 보관 추천 외 항목을 선택 → **OS 휴지통으로 이동**(`shell.trashItem`, 복구 가능) + 인덱스에서 삭제(연관 메타/임베딩 cascade) +- [x] **그룹·정화 탭** 신설(확인 다이얼로그 포함), typecheck/build/부팅 스모크 통과 +- 비고: 저품질 정화는 라이브러리 탭의 '제외 후보' 필터로 이미 접근 가능. 근접중복이 본 단계의 핵심. - [ ] i18n(ko/en) · 다크모드 · 검증(typecheck/test/build/스모크) ### 8.5 리스크 (이 단계) diff --git a/electron-builder.yml b/electron-builder.yml index f52607d..d6c6692 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -16,6 +16,8 @@ asarUnpack: - "**/*.node" # sql.js WASM 바이너리 (인덱스 DB) — asar 밖에서 읽도록 - "node_modules/sql.js/dist/*.wasm" + # ONNX Runtime WASM (CLIP 검색) — asar 밖에서 fetch 가능하도록 + - "out/renderer/ort/**" win: target: - nsis diff --git a/package-lock.json b/package-lock.json index 0aadcb8..a78a967 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,15 +7,18 @@ "": { "name": "ai-photo-organizer", "version": "0.1.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@huggingface/transformers": "^3.8.1", "@vladmandic/face-api": "^1.7.13", "exifr": "^7.1.3", + "leaflet": "^1.9.4", "sql.js": "^1.12.0", "zustand": "^4.5.5" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/node": "^20.16.0", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", @@ -2561,6 +2564,13 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -2578,6 +2588,16 @@ "@types/node": "*" } }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -6137,6 +6157,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/package.json b/package.json index fe650fa..fd4cf81 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "license": "MIT", "main": "./out/main/index.js", "scripts": { + "ort:copy": "node scripts/copy-ort-wasm.mjs", + "postinstall": "node scripts/copy-ort-wasm.mjs", "dev": "electron-vite dev", - "build": "electron-vite build", + "build": "node scripts/copy-ort-wasm.mjs && electron-vite build", "start": "electron-vite preview", "typecheck:node": "tsc --noEmit -p tsconfig.node.json", "typecheck:web": "tsc --noEmit -p tsconfig.web.json", @@ -25,10 +27,12 @@ "@huggingface/transformers": "^3.8.1", "@vladmandic/face-api": "^1.7.13", "exifr": "^7.1.3", + "leaflet": "^1.9.4", "sql.js": "^1.12.0", "zustand": "^4.5.5" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/node": "^20.16.0", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", diff --git a/scripts/copy-ort-wasm.mjs b/scripts/copy-ort-wasm.mjs new file mode 100644 index 0000000..79ed089 --- /dev/null +++ b/scripts/copy-ort-wasm.mjs @@ -0,0 +1,41 @@ +// transformers.js의 ONNX Runtime WASM을 public/ort 로 복사 → 오프라인 동작(CDN 불필요). +// postinstall 및 빌드 전에 실행. (wasm은 용량이 커 git에는 올리지 않음 — gitignore) +import { readdir, mkdir, copyFile, access } from 'node:fs/promises' +import { constants } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const SRC = join(__dirname, '..', 'node_modules', '@huggingface', 'transformers', 'dist') +const DEST = join(__dirname, '..', 'public', 'ort') + +async function exists(p) { + try { + await access(p, constants.F_OK) + return true + } catch { + return false + } +} + +async function main() { + if (!(await exists(SRC))) { + console.warn('transformers dist 없음 — ORT wasm 복사 건너뜀:', SRC) + return + } + await mkdir(DEST, { recursive: true }) + const files = await readdir(SRC) + // ORT 런타임 파일(wasm + 로더 mjs)만 복사 + const targets = files.filter((f) => /ort-.*\.(wasm|mjs)$/.test(f)) + let n = 0 + for (const f of targets) { + await copyFile(join(SRC, f), join(DEST, f)) + n++ + } + console.log(`ORT wasm 복사 완료: ${n}개 → ${DEST}`) +} + +main().catch((e) => { + console.error('ORT 복사 오류:', e.message) + // 빌드를 막지 않도록 실패해도 통과 (CDN 폴백 가능) +}) diff --git a/src/inference/clipEngine.ts b/src/inference/clipEngine.ts index 7298896..95ad4d9 100644 --- a/src/inference/clipEngine.ts +++ b/src/inference/clipEngine.ts @@ -20,6 +20,20 @@ const HANGUL = /[가-힣]/ // 원격(HF Hub) 모델만 사용 — 최초 1회 다운로드 후 브라우저 캐시에 보관 env.allowLocalModels = false +// ONNX Runtime WASM을 로컬(public/ort)에서 로드 → CDN 불필요(오프라인 런타임). +// 추론창 html 위치(.../src/inference/index.html) 기준 ../../ort/ = 렌더러 루트의 ort/ +try { + const wasm = env.backends?.onnx?.wasm as + | { wasmPaths?: string; numThreads?: number } + | undefined + if (wasm) { + wasm.wasmPaths = new URL('../../ort/', location.href).href + // file:///COEP 미보장 환경에서 SharedArrayBuffer 의존을 피하려 단일 스레드 + wasm.numThreads = 1 + } +} catch { + // location 불가 등 예외 시 기본(CDN) 폴백 +} /** * CLIP 임베딩 엔진 (검색용). 추론창에서 lazy-load. @@ -79,12 +93,14 @@ class ClipEngine { const first = Array.isArray(res) ? res[0] : res text = (first as { translation_text: string }).translation_text || query } + // CLIP은 "a photo of ..." 템플릿에서 정확도가 더 좋음 + const prompt = `a photo of ${text}` const tokenizer = this.tokenizer as unknown as ( t: string[], o: Record ) => unknown const textModel = this.text as unknown as (i: unknown) => Promise<{ text_embeds: TfTensor }> - const inputs = tokenizer([text], { padding: true, truncation: true }) + const inputs = tokenizer([prompt], { padding: true, truncation: true }) const out = await textModel(inputs) return Array.from(out.text_embeds.normalize().tolist()[0] as number[]) } diff --git a/src/inference/imageLoader.ts b/src/inference/imageLoader.ts index 21da20c..deafd03 100644 --- a/src/inference/imageLoader.ts +++ b/src/inference/imageLoader.ts @@ -85,6 +85,69 @@ export function downscaleCanvas(src: HTMLCanvasElement, maxDim: number): HTMLCan return out } +/** + * 영상에서 대표 프레임을 추출해 썸네일(webp 바이트 + 원본 치수)을 만든다. + * Chromium이 디코딩 가능한 포맷(mp4/h264, webm, 일부 mov)만 성공. 실패 시 throw. + */ +export async function videoThumbnail( + path: string, + maxDim: number +): Promise<{ bytes: ArrayBuffer; width: number; height: number }> { + const video = document.createElement('video') + video.muted = true + video.preload = 'auto' + video.src = pathToFileUrl(path) + + const withTimeout = (p: Promise, ms: number): Promise => + Promise.race([ + p, + new Promise((_, rej) => setTimeout(() => rej(new Error('영상 처리 시간 초과')), ms)) + ]) + + try { + await withTimeout( + new Promise((res, rej) => { + video.onloadedmetadata = () => res() + video.onerror = () => rej(new Error('영상 로드 실패')) + }), + 20000 + ) + + // 너무 앞(검은 화면) 회피 위해 1초 또는 절반 지점으로 seek + const seekTo = Math.min(1, (video.duration || 2) / 2) + await withTimeout( + new Promise((res, rej) => { + video.onseeked = () => res() + video.onerror = () => rej(new Error('영상 seek 실패')) + video.currentTime = seekTo + }), + 20000 + ) + + const vw = video.videoWidth + const vh = video.videoHeight + if (!vw || !vh) throw new Error('영상 프레임 없음') + + const scale = Math.max(vw, vh) > maxDim ? maxDim / Math.max(vw, vh) : 1 + const w = Math.max(1, Math.round(vw * scale)) + const h = Math.max(1, Math.round(vh * scale)) + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('2D 컨텍스트 생성 실패') + ctx.drawImage(video, 0, 0, w, h) + const bytes = await canvasToWebp(canvas) + canvas.width = 0 + canvas.height = 0 + return { bytes, width: vw, height: vh } + } finally { + video.src = '' + video.removeAttribute('src') + video.load() + } +} + /** 캔버스를 webp 바이트(ArrayBuffer)로 인코딩 */ export function canvasToWebp(canvas: HTMLCanvasElement, quality = 0.8): Promise { return new Promise((resolve, reject) => { diff --git a/src/inference/main.ts b/src/inference/main.ts index 837c2f5..da97bd5 100644 --- a/src/inference/main.ts +++ b/src/inference/main.ts @@ -4,7 +4,8 @@ import { loadThumbnailCanvas, downscaleCanvas, canvasToWebp, - releaseCanvas + releaseCanvas, + videoThumbnail } from './imageLoader' import { computeFocus, computeExposure } from './qualityEngine' import { THUMBNAIL_SIZE, ANALYZE_SIZE } from '@shared/constants' @@ -91,6 +92,10 @@ async function bootstrap(): Promise { const { text } = payload as unknown as { text: string } const vec = await clipEngine.embedText(text) window.inferBridge.reply(requestId, true, { vec }) + } else if (channel === 'infer:videoThumbnail') { + const { imagePath } = payload as unknown as { imagePath: string } + const v = await videoThumbnail(imagePath, THUMBNAIL_SIZE) + window.inferBridge.reply(requestId, true, v) } } catch (err) { window.inferBridge.reply(requestId, false, undefined, (err as Error).message) diff --git a/src/main/exif.ts b/src/main/exif.ts index da755d1..26aaccd 100644 --- a/src/main/exif.ts +++ b/src/main/exif.ts @@ -21,6 +21,52 @@ export async function getMtimeDate(path: string): Promise { } } +/** 확장 메타데이터 (촬영일 + GPS + 카메라) */ +export interface ExtendedMeta { + date: CaptureDate + gpsLat: number | null + gpsLon: number | null + camera: string | null +} + +/** + * 이미지의 확장 메타데이터를 한 번에 추출 (촬영일 + GPS 좌표 + 카메라 모델). + * GPS/카메라가 없으면 null. 날짜는 EXIF 우선, 없으면 mtime 폴백. + * 어떤 경우에도 throw 하지 않는다. + */ +export async function readMeta(path: string): Promise { + let date: CaptureDate | null = null + let gpsLat: number | null = null + let gpsLon: number | null = null + let camera: string | null = null + + try { + const exif = await exifr.parse(path, { + pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate', 'Make', 'Model'] + }) + const raw: unknown = exif?.DateTimeOriginal ?? exif?.CreateDate ?? exif?.ModifyDate + if (raw instanceof Date && !Number.isNaN(raw.getTime())) date = toYearMonth(raw, 'exif') + const make = (exif?.Make ?? '').toString().trim() + const model = (exif?.Model ?? '').toString().trim() + camera = model || make || null + } catch { + // EXIF 실패 → 날짜는 mtime 폴백, GPS/카메라는 null + } + + try { + const gps = await exifr.gps(path) + if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') { + gpsLat = gps.latitude + gpsLon = gps.longitude + } + } catch { + // GPS 없음 + } + + if (!date) date = await getMtimeDate(path) + return { date, gpsLat, gpsLon, camera } +} + /** * 촬영 날짜 추출. * 1) EXIF DateTimeOriginal (없으면 CreateDate/ModifyDate) 시도 diff --git a/src/main/groupingService.ts b/src/main/groupingService.ts new file mode 100644 index 0000000..c995b63 --- /dev/null +++ b/src/main/groupingService.ts @@ -0,0 +1,52 @@ +import type { AssetGroup } from '@shared/types' +import { indexDb } from './indexDb' +import { settingsStore } from './settingsStore' + +function dot(a: Float32Array, 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 +} + +/** + * 임베딩 코사인 유사도로 이미지를 그룹핑 (스마트 그룹화 / 근접 중복 정화). + * 시드 기반 그리디 클러스터링: 임베딩 보유 이미지를 대상으로 O(N²). + * threshold가 높을수록(예: 0.95) 거의 동일한 사진만 묶임. + * 크기 2 이상 그룹만 반환(= 유사/중복 후보). 그룹별 보관 추천은 초점 점수 최고. + */ +export function buildGroups(threshold: number): AssetGroup[] { + const all = indexDb.getAllEmbeddings() + const n = all.length + const assigned = new Array(n).fill(false) + const clusters: number[][] = [] + + for (let i = 0; i < n; i++) { + if (assigned[i]) continue + assigned[i] = true + const cluster = [all[i].assetId] + for (let j = i + 1; j < n; j++) { + if (assigned[j]) continue + if (dot(all[i].vec, all[j].vec) >= threshold) { + assigned[j] = true + cluster.push(all[j].assetId) + } + } + if (cluster.length > 1) clusters.push(cluster) + } + + const th = settingsStore.current().qualityThresholds + const groups: AssetGroup[] = clusters.map((ids) => { + const members = indexDb.assetsByIds(ids, th) + // 보관 추천 = 초점(선명도) 최고 + const best = members.reduce( + (a, b) => ((b.focus ?? -1) > (a.focus ?? -1) ? b : a), + members[0] + ) + return { bestId: best?.id ?? ids[0], members } + }) + + // 큰 그룹부터 + groups.sort((a, b) => b.members.length - a.members.length) + return groups +} diff --git a/src/main/indexDb.ts b/src/main/indexDb.ts index 1debf39..4aeab47 100644 --- a/src/main/indexDb.ts +++ b/src/main/indexDb.ts @@ -11,6 +11,7 @@ import type { QualityThresholds, ColorLabel } from '@shared/types' +import { SUPPORTED_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS } from '@shared/constants' import { logger } from './logger' /** @@ -55,6 +56,9 @@ class IndexDb { height INTEGER, exifYear TEXT, exifMonth TEXT, + gpsLat REAL, + gpsLon REAL, + camera TEXT, indexedAt INTEGER ); CREATE TABLE IF NOT EXISTS quality ( @@ -76,6 +80,22 @@ class IndexDb { CREATE INDEX IF NOT EXISTS idx_asset_hash ON asset(contentHash); CREATE INDEX IF NOT EXISTS idx_asset_path ON asset(path); `) + + // 기존 DB(컬럼 없음)에 대한 마이그레이션 — ALTER ADD COLUMN + this.ensureColumn('asset', 'gpsLat', 'REAL') + this.ensureColumn('asset', 'gpsLon', 'REAL') + this.ensureColumn('asset', 'camera', 'TEXT') + // metaVersion: 확장 메타(GPS/카메라) 적재 버전. 구버전 행(0/NULL)은 재색인 시 backfill + this.ensureColumn('asset', 'metaVersion', 'INTEGER') + } + + /** 테이블에 컬럼이 없으면 추가 (sql.js는 ADD COLUMN IF NOT EXISTS 미지원) */ + private ensureColumn(table: string, col: string, type: string): void { + const res = this.db!.exec(`PRAGMA table_info(${table})`) + const names = res.length ? res[0].values.map((r) => String(r[1])) : [] + if (!names.includes(col)) { + this.db!.run(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`) + } } /** 인메모리 DB를 디스크로 영속화 */ @@ -97,9 +117,14 @@ class IndexDb { return res.length ? Number(res[0].values[0][0]) : 0 } - /** 같은 경로가 같은 mtime으로 이미 색인되어 있으면 true (해시 계산 없이 빠른 스킵) */ + /** + * 같은 경로·mtime으로 이미 **확장 메타까지** 색인되었으면 true (빠른 스킵). + * metaVersion이 없는 구버전 행은 false → 재색인 시 GPS/카메라 backfill. + */ isIndexedPath(path: string, mtime: number): boolean { - const stmt = this.db!.prepare('SELECT 1 FROM asset WHERE path = ? AND mtime = ? LIMIT 1') + const stmt = this.db!.prepare( + 'SELECT 1 FROM asset WHERE path = ? AND mtime = ? AND metaVersion >= 1 LIMIT 1' + ) try { stmt.bind([path, mtime]) return stmt.step() @@ -146,6 +171,36 @@ class IndexDb { LEFT JOIN usermeta um ON um.assetId = a.id` } + /** 쿼리 → (where절, 바인딩 파라미터). 고정 열거/숫자는 인라인, 사용자 값은 바인딩 */ + private buildWhere(query: AssetQuery): { where: string; params: (string | number)[] } { + const conds: string[] = [] + const params: (string | number)[] = [] + if (query.filter === 'rejected') { + conds.push("flag IN ('blurry', 'eyesClosed', 'badExposure')") + } else if (query.filter !== 'all') { + conds.push(`flag = '${query.filter}'`) + } + if (query.ratingMin > 0) conds.push(`rating >= ${Number(query.ratingMin)}`) + if (query.kind === 'image') { + conds.push(`ext IN (${SUPPORTED_EXTENSIONS.map((e) => `'${e}'`).join(',')})`) + } else if (query.kind === 'video') { + conds.push(`ext IN (${SUPPORTED_VIDEO_EXTENSIONS.map((e) => `'${e}'`).join(',')})`) + } + if (query.year) { + conds.push('exifYear = ?') + params.push(query.year) + } + if (query.camera) { + conds.push('camera = ?') + params.push(query.camera) + } + if (query.label) { + conds.push('label = ?') + params.push(query.label) + } + return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', params } + } + listAssets( offset: number, limit: number, @@ -153,21 +208,13 @@ class IndexDb { 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 { where, params } = this.buildWhere(query) const stmt = this.db!.prepare( `SELECT * FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC LIMIT ? OFFSET ?` ) const out: IndexedAsset[] = [] try { - stmt.bind([limit, offset]) + stmt.bind([...params, limit, offset]) while (stmt.step()) out.push(stmt.getAsObject() as unknown as IndexedAsset) } finally { stmt.free() @@ -175,6 +222,43 @@ class IndexDb { return out } + /** 쿼리에 매칭되는 전체 자산 id (전체 선택/필터 전체 내보내기용) */ + listAssetIds(query: AssetQuery, th: QualityThresholds): number[] { + const inner = this.innerSelect(th) + const { where, params } = this.buildWhere(query) + const stmt = this.db!.prepare( + `SELECT id FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC` + ) + const out: number[] = [] + try { + stmt.bind(params) + while (stmt.step()) out.push(Number((stmt.getAsObject() as { id: number }).id)) + } finally { + stmt.free() + } + return out + } + + /** 컬렉션 패싯 집계 (연도/카메라/색라벨) */ + facets(): import('@shared/types').Facets { + const q = (sql: string): import('@shared/types').FacetItem[] => { + const res = this.db!.exec(sql) + if (!res.length) return [] + return res[0].values.map((row) => ({ value: String(row[0]), count: Number(row[1]) })) + } + return { + years: q( + "SELECT exifYear, COUNT(*) FROM asset WHERE exifYear IS NOT NULL GROUP BY exifYear ORDER BY exifYear DESC" + ), + cameras: q( + "SELECT camera, COUNT(*) FROM asset WHERE camera IS NOT NULL AND camera <> '' GROUP BY camera ORDER BY COUNT(*) DESC" + ), + labels: q( + 'SELECT label, COUNT(*) FROM usermeta WHERE label IS NOT NULL GROUP BY label ORDER BY COUNT(*) DESC' + ) + } + } + setRating(assetId: number, rating: number): void { const r = Math.max(0, Math.min(5, Math.round(rating))) this.db!.run( @@ -225,6 +309,58 @@ class IndexDb { this.dirty = true } + /** GPS 좌표가 있는 자산(지도 마커용) */ + assetsWithGps(): { id: number; contentHash: string; path: string; gpsLat: number; gpsLon: number }[] { + const stmt = this.db!.prepare( + 'SELECT id, contentHash, path, gpsLat, gpsLon FROM asset WHERE gpsLat IS NOT NULL AND gpsLon IS NOT NULL' + ) + const out: { id: number; contentHash: string; path: string; gpsLat: number; gpsLon: number }[] = + [] + try { + while (stmt.step()) { + const r = stmt.getAsObject() as unknown as { + id: number + contentHash: string + path: string + gpsLat: number + gpsLon: number + } + out.push(r) + } + } finally { + stmt.free() + } + return out + } + + /** 특정 자산의 임베딩 (연관 탐색용) */ + getEmbedding(assetId: number): Float32Array | null { + const stmt = this.db!.prepare('SELECT vec FROM embedding WHERE assetId = ?') + try { + stmt.bind([assetId]) + if (!stmt.step()) return null + const u8 = (stmt.getAsObject() as { vec: Uint8Array }).vec + return new Float32Array(u8.buffer, u8.byteOffset, Math.floor(u8.byteLength / 4)) + } finally { + stmt.free() + } + } + + /** mtime이 ±window 이내인 자산 id (시간 연관용) */ + assetsNearTime(mtime: number, windowMs: number, excludeId: number, limit = 200): number[] { + const stmt = this.db!.prepare( + 'SELECT id FROM asset WHERE mtime BETWEEN ? AND ? AND id <> ? LIMIT ?' + ) + const out: number[] = [] + try { + stmt.bind([mtime - windowMs, mtime + windowMs, excludeId, limit]) + while (stmt.step()) out.push(Number((stmt.getAsObject() as { id: number }).id)) + } finally { + stmt.free() + } + return out + } + embeddingCount(): number { const res = this.db!.exec('SELECT COUNT(*) AS n FROM embedding') return res.length ? Number(res[0].values[0][0]) : 0 @@ -267,6 +403,26 @@ class IndexDb { return ids.map((id) => byId.get(id)).filter((a): a is IndexedAsset => !!a) } + getById(id: number): AssetRecord | null { + const stmt = this.db!.prepare('SELECT * FROM asset WHERE id = ?') + try { + stmt.bind([id]) + if (!stmt.step()) return null + return stmt.getAsObject() as unknown as AssetRecord + } finally { + stmt.free() + } + } + + /** 자산 + 연관 메타(품질/사용자메타/임베딩) 전부 삭제 */ + deleteAsset(id: number): void { + this.db!.run('DELETE FROM embedding WHERE assetId = ?', [id]) + this.db!.run('DELETE FROM quality WHERE assetId = ?', [id]) + this.db!.run('DELETE FROM usermeta WHERE assetId = ?', [id]) + this.db!.run('DELETE FROM asset WHERE id = ?', [id]) + this.dirty = true + } + getByHash(contentHash: string): AssetRecord | null { const stmt = this.db!.prepare('SELECT * FROM asset WHERE contentHash = ?') try { @@ -282,12 +438,15 @@ class IndexDb { upsertAsset(r: AssetRecord): number { this.db!.run( `INSERT INTO asset - (contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth, indexedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth, + gpsLat, gpsLon, camera, metaVersion, indexedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) ON CONFLICT(contentHash) DO UPDATE SET path=excluded.path, ext=excluded.ext, sizeBytes=excluded.sizeBytes, mtime=excluded.mtime, width=excluded.width, height=excluded.height, - exifYear=excluded.exifYear, exifMonth=excluded.exifMonth, indexedAt=excluded.indexedAt`, + exifYear=excluded.exifYear, exifMonth=excluded.exifMonth, + gpsLat=excluded.gpsLat, gpsLon=excluded.gpsLon, camera=excluded.camera, + metaVersion=1, indexedAt=excluded.indexedAt`, [ r.contentHash, r.path, @@ -298,6 +457,9 @@ class IndexDb { r.height, r.exifYear, r.exifMonth, + r.gpsLat, + r.gpsLon, + r.camera, r.indexedAt ] ) diff --git a/src/main/indexer.ts b/src/main/indexer.ts index ecc0da4..698b9c5 100644 --- a/src/main/indexer.ts +++ b/src/main/indexer.ts @@ -4,7 +4,7 @@ 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 { getMtimeDate, readMeta } from './exif' import { contentHash } from './hash' import { indexDb } from './indexDb' import { libraryStore } from './libraryStore' @@ -76,13 +76,18 @@ class Indexer { skipped++ } else { const hash = await contentHash(file) - const date = await getCaptureDate(file) + const isImage = mediaKind(file) === 'image' + // 이미지: GPS/카메라 포함 확장 메타. 영상: 날짜(mtime)만. + const meta = isImage + ? await readMeta(file) + : { date: await getMtimeDate(file), gpsLat: null, gpsLon: null, camera: null } + const date = meta.date let width: number | null = null let height: number | null = null let quality: QualityScores | null = null - // 이미지: 썸네일 + 품질(초점/노출/눈) 분석. 영상은 메타데이터만. - if (mediaKind(file) === 'image') { + // 이미지: 썸네일 + 품질(초점/노출/눈) 분석. + if (isImage) { const existing = indexDb.getByHash(hash) if (existing && (await hasThumb(hash))) { // 이미 분석됨 → 치수 재사용, 품질은 유지(재계산 생략) @@ -107,6 +112,25 @@ class Indexer { }) } } + } else { + // 영상: 대표 프레임 썸네일 생성(베스트 에포트). 디코딩 불가 포맷은 스킵. + const existing = indexDb.getByHash(hash) + if (existing && (await hasThumb(hash))) { + width = existing.width + height = existing.height + } else { + try { + const v = await inferenceBridge.videoThumbnail(file) + await writeThumb(hash, v.bytes) + width = v.width + height = v.height + } catch (err) { + await logger.warn('영상 썸네일 실패(메타만 색인)', { + file, + message: (err as Error).message + }) + } + } } const assetId = indexDb.upsertAsset({ @@ -119,6 +143,9 @@ class Indexer { height, exifYear: date.year, exifMonth: date.month, + gpsLat: meta.gpsLat, + gpsLon: meta.gpsLon, + camera: meta.camera, indexedAt: Date.now() }) if (quality && assetId >= 0) indexDb.setQuality(assetId, quality) diff --git a/src/main/inferenceBridge.ts b/src/main/inferenceBridge.ts index 33531a7..d56eee6 100644 --- a/src/main/inferenceBridge.ts +++ b/src/main/inferenceBridge.ts @@ -130,6 +130,13 @@ class InferenceBridge { return r.vec } + /** 영상 대표 프레임 썸네일(webp 바이트) + 원본 치수 */ + async videoThumbnail( + imagePath: string + ): Promise<{ bytes: ArrayBuffer; width: number; height: number }> { + return this.call('infer:videoThumbnail', { imagePath }) + } + /** 썸네일(webp 바이트) + 원본 치수 + 품질 점수(초점/노출/눈) 산출 */ async analyze(imagePath: string): Promise<{ bytes: ArrayBuffer diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3107730..8c4cb5d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +1,7 @@ -import { ipcMain, dialog, BrowserWindow, app } from 'electron' +import { ipcMain, dialog, BrowserWindow, app, shell } from 'electron' import { writeFile, mkdir } from 'node:fs/promises' -import { join, extname } from 'node:path' +import { join, extname, basename } from 'node:path' +import { safeCopy } from './fileOps' import type { ProfileInput, JobRequest, @@ -18,6 +19,8 @@ import { indexer } from './indexer' import { indexDb } from './indexDb' import { embedder } from './embedder' import { search } from './searchService' +import { buildGroups } from './groupingService' +import { relatedAssets } from './relationService' import { settingsStore } from './settingsStore' import { applySettings } from './applySettings' import { logger } from './logger' @@ -166,6 +169,33 @@ export function registerIpc(): void { indexDb.listAssets(offset, limit, query, settingsStore.current().qualityThresholds) ) + ipcMain.handle(IPC.INDEX_ASSET_IDS, (_e, query: AssetQuery) => + indexDb.listAssetIds(query, settingsStore.current().qualityThresholds) + ) + + ipcMain.handle(IPC.INDEX_FACETS, () => indexDb.facets()) + + ipcMain.handle(IPC.INDEX_EXPORT, async (e, assetIds: number[]) => { + const win = BrowserWindow.fromWebContents(e.sender) + const r = await dialog.showOpenDialog(win!, { + properties: ['openDirectory', 'createDirectory'] + }) + if (r.canceled || !r.filePaths[0]) return null + const dest = r.filePaths[0] + let count = 0 + for (const id of assetIds) { + const a = indexDb.getById(id) + if (!a) continue + try { + await safeCopy(a.path, join(dest, basename(a.path))) // 복사(원본 보존) + 충돌 자동 리네임 + count++ + } catch (err) { + logger.error('내보내기 실패', { path: a.path, message: (err as Error).message }) + } + } + return { count, dest } + }) + ipcMain.handle(IPC.INDEX_SET_RATING, (_e, assetId: number, rating: number) => indexDb.setRating(assetId, rating) ) @@ -191,4 +221,28 @@ export function registerIpc(): void { })) ipcMain.handle(IPC.SEARCH_QUERY, (_e, text: string) => search(text)) + + // ---- 지도 / 연관 탐색 (Phase C) ---- + ipcMain.handle(IPC.MAP_ASSETS, () => indexDb.assetsWithGps()) + ipcMain.handle(IPC.MAP_RELATED, (_e, assetId: number) => relatedAssets(assetId)) + + // ---- 그룹화 / 자가정화 (Phase 3) ---- + ipcMain.handle(IPC.GROUPS_BUILD, (_e, threshold: number) => buildGroups(threshold)) + + ipcMain.handle(IPC.GROUPS_TRASH, async (_e, assetIds: number[]) => { + let trashed = 0 + for (const id of assetIds) { + const a = indexDb.getById(id) + if (!a) continue + try { + await shell.trashItem(a.path) // OS 휴지통(복구 가능)으로 이동 + indexDb.deleteAsset(id) + trashed++ + } catch (err) { + logger.error('휴지통 이동 실패', { path: a.path, message: (err as Error).message }) + } + } + await indexDb.save() + return trashed + }) } diff --git a/src/main/menu.ts b/src/main/menu.ts index 0eb379b..e1cb108 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -109,6 +109,12 @@ export function buildAppMenu({ settings, onChange }: BuildOpts): void { click: () => onChange({ language: l }) })) }, + { + label: t('menu.easyMode'), + type: 'checkbox', + checked: settings.easyMode, + click: () => onChange({ easyMode: !settings.easyMode }) + }, { type: 'separator' }, { role: 'reload', label: t('menu.reload') }, { role: 'toggleDevTools', label: t('menu.devtools') }, diff --git a/src/main/relationService.ts b/src/main/relationService.ts new file mode 100644 index 0000000..b143b83 --- /dev/null +++ b/src/main/relationService.ts @@ -0,0 +1,69 @@ +import type { IndexedAsset } from '@shared/types' +import { indexDb } from './indexDb' +import { settingsStore } from './settingsStore' + +/** 두 좌표 간 거리(km) — Haversine */ +function haversineKm(la1: number, lo1: number, la2: number, lo2: number): number { + const R = 6371 + const dLa = ((la2 - la1) * Math.PI) / 180 + const dLo = ((lo2 - lo1) * Math.PI) / 180 + const a = + Math.sin(dLa / 2) ** 2 + + Math.cos((la1 * Math.PI) / 180) * Math.cos((la2 * Math.PI) / 180) * Math.sin(dLo / 2) ** 2 + return 2 * R * Math.asin(Math.min(1, Math.sqrt(a))) +} + +function dot(a: Float32Array, 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 +} + +const GPS_RADIUS_KM = 1.0 +const TIME_WINDOW_MS = 2 * 24 * 3600 * 1000 // ±2일 +const CLIP_MIN_SIM = 0.7 + +/** + * "이 사진과 관련된 사진" — 장소(GPS 근접) + 시간(±2일) + 시각유사도(CLIP)를 결합해 랭킹. + * darktable의 GPS 연관에 인물·의미 유사도까지 더한 형태. + */ +export function relatedAssets(assetId: number, limit = 40): IndexedAsset[] { + const src = indexDb.getById(assetId) + if (!src) return [] + + const scores = new Map() + const add = (id: number, s: number) => { + if (id === assetId) return + scores.set(id, (scores.get(id) ?? 0) + s) + } + + // 1) 장소: 같은 위치(1km 이내), 가까울수록 가점 + if (src.gpsLat != null && src.gpsLon != null) { + for (const g of indexDb.assetsWithGps()) { + if (g.id === assetId) continue + const d = haversineKm(src.gpsLat, src.gpsLon, g.gpsLat, g.gpsLon) + if (d <= GPS_RADIUS_KM) add(g.id, 1.5 * (1 - d / GPS_RADIUS_KM)) + } + } + + // 2) 시간: ±2일 이내 + for (const id of indexDb.assetsNearTime(src.mtime, TIME_WINDOW_MS, assetId)) add(id, 0.5) + + // 3) 시각 유사도: CLIP 임베딩 코사인 + const srcVec = indexDb.getEmbedding(assetId) + if (srcVec) { + for (const e of indexDb.getAllEmbeddings()) { + if (e.assetId === assetId) continue + const sim = dot(srcVec, e.vec) + if (sim > CLIP_MIN_SIM) add(e.assetId, sim) + } + } + + const ranked = [...scores.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([id]) => id) + + return indexDb.assetsByIds(ranked, settingsStore.current().qualityThresholds) +} diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index 9d623b6..eac92e3 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -9,7 +9,8 @@ const DEFAULTS: Settings = { language: DEFAULT_LANG, // 기본 한국어 theme: 'dark', // 기본 다크모드 onboarded: false, - qualityThresholds: { ...QUALITY_THRESHOLDS } + qualityThresholds: { ...QUALITY_THRESHOLDS }, + easyMode: false } /** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */ diff --git a/src/preload/index.ts b/src/preload/index.ts index ff6817c..098f417 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,9 @@ const api: ExposedApi = { cancel: () => ipcRenderer.invoke(IPC.INDEX_CANCEL), assets: (offset: number, limit: number, query: AssetQuery) => ipcRenderer.invoke(IPC.INDEX_ASSETS, offset, limit, query), + assetIds: (query: AssetQuery) => ipcRenderer.invoke(IPC.INDEX_ASSET_IDS, query), + facets: () => ipcRenderer.invoke(IPC.INDEX_FACETS), + export: (assetIds: number[]) => ipcRenderer.invoke(IPC.INDEX_EXPORT, assetIds), setRating: (assetId: number, rating: number) => ipcRenderer.invoke(IPC.INDEX_SET_RATING, assetId, rating), setLabel: (assetId: number, label: ColorLabel) => @@ -78,6 +81,14 @@ const api: ExposedApi = { status: () => ipcRenderer.invoke(IPC.SEARCH_STATUS), query: (text: string) => ipcRenderer.invoke(IPC.SEARCH_QUERY, text) }, + map: { + assets: () => ipcRenderer.invoke(IPC.MAP_ASSETS), + related: (assetId: number) => ipcRenderer.invoke(IPC.MAP_RELATED, assetId) + }, + groups: { + build: (threshold: number) => ipcRenderer.invoke(IPC.GROUPS_BUILD, threshold), + trash: (assetIds: number[]) => ipcRenderer.invoke(IPC.GROUPS_TRASH, assetIds) + }, // Electron 33: File.path 제거됨 → webUtils로 드롭된 파일의 실제 경로 획득 getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File), on(event: E, cb: (payload: RendererEvents[E]) => void) { diff --git a/src/preload/inference.ts b/src/preload/inference.ts index 78a27d4..9555ae7 100644 --- a/src/preload/inference.ts +++ b/src/preload/inference.ts @@ -8,7 +8,8 @@ const REQUEST_CHANNELS = [ 'infer:detect', 'infer:analyze', 'infer:embedImage', - 'infer:embedText' + 'infer:embedText', + 'infer:videoThumbnail' ] as const type RequestChannel = (typeof REQUEST_CHANNELS)[number] diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e00b9f3..8c17ce3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -10,6 +10,8 @@ import { FileList } from './components/FileList' import { ReportView } from './components/ReportView' import { LibraryView } from './components/LibraryView' import { SearchView } from './components/SearchView' +import { GroupsView } from './components/GroupsView' +import { MapView } from './components/MapView' import type { AppView } from './store' export default function App(): JSX.Element { @@ -17,6 +19,7 @@ export default function App(): JSX.Element { const phase = useStore((s) => s.phase) const view = useStore((s) => s.view) const setView = useStore((s) => s.setView) + const easyMode = useStore((s) => s.easyMode) const onboarded = useStore((s) => s.onboarded) const refreshProfiles = useStore((s) => s.refreshProfiles) const initSettings = useStore((s) => s.initSettings) @@ -33,41 +36,67 @@ export default function App(): JSX.Element { if (!ready) return
if (!onboarded) return - const tabs: { id: AppView; label: string }[] = [ - { id: 'organize', label: t('nav.organize') }, - { id: 'library', label: t('nav.library') }, - { id: 'search', label: t('nav.search') } + const tabs: { id: AppView; label: string; easyLabel: string; icon: string }[] = [ + { id: 'organize', label: t('nav.organize'), easyLabel: t('easynav.organize'), icon: '📂' }, + { id: 'library', label: t('nav.library'), easyLabel: t('easynav.library'), icon: '🖼️' }, + { id: 'search', label: t('nav.search'), easyLabel: t('easynav.search'), icon: '🔍' }, + { id: 'map', label: t('nav.map'), easyLabel: t('easynav.map'), icon: '🗺️' }, + { id: 'groups', label: t('nav.groups'), easyLabel: t('easynav.groups'), icon: '🧹' } ] return (
-
-
-
-

{t('app.title')}

-

{t('app.subtitle')}

+ {easyMode ? ( + /* 쉬운 모드: 대형 아이콘+구어체 버튼 네비 */ +
+ +
+ ) : ( + /* darktable식 컴팩트 상단바: 좌측 로고 · 우측 파이프 구분 탭 */ +
+
+ + + photoai +
-
- {/* 탭 네비 */} - -
+ + + )} {view === 'organize' ? ( -
+
{/* 좌측: 설정 패널 (자체 스크롤) */}
@@ -84,13 +113,21 @@ export default function App(): JSX.Element {
) : view === 'library' ? ( -
+
- ) : ( -
+ ) : view === 'search' ? ( +
+ ) : view === 'map' ? ( +
+ +
+ ) : ( +
+ +
)}
) diff --git a/src/renderer/components/GroupsView.tsx b/src/renderer/components/GroupsView.tsx new file mode 100644 index 0000000..8c81303 --- /dev/null +++ b/src/renderer/components/GroupsView.tsx @@ -0,0 +1,188 @@ +import { useState } from 'react' +import { useT } from '../i18n' +import { thumbUrl, baseName } from '../media' +import type { AssetGroup, IndexedAsset } from '@shared/types' + +const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'] + +/** 스마트 그룹화 + 자가정화 (Phase 3) */ +export function GroupsView(): JSX.Element { + const t = useT() + const [threshold, setThreshold] = useState(0.92) + const [groups, setGroups] = useState([]) + const [finding, setFinding] = useState(false) + const [searched, setSearched] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [trashing, setTrashing] = useState(false) + + const find = async () => { + setFinding(true) + setSearched(true) + try { + const g = await window.api.groups.build(threshold) + setGroups(g) + // 기본 선택: 각 그룹에서 보관 추천을 제외한 나머지(중복 후보) + const pre = new Set() + for (const grp of g) { + for (const m of grp.members) { + if (m.id != null && m.id !== grp.bestId) pre.add(m.id) + } + } + setSelected(pre) + } finally { + setFinding(false) + } + } + + const toggle = (id: number) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const trash = async () => { + if (selected.size === 0) return + if (!window.confirm(t('groups.confirmTrash', { n: selected.size }))) return + setTrashing(true) + try { + const ids = [...selected] + const n = await window.api.groups.trash(ids) + // 휴지통으로 보낸 항목 제거 + const removed = new Set(ids) + setGroups((prev) => + prev + .map((g) => ({ + ...g, + members: g.members.filter((m) => m.id == null || !removed.has(m.id)) + })) + .filter((g) => g.members.length > 1) + ) + setSelected(new Set()) + window.alert(t('groups.trashed', { n })) + } finally { + setTrashing(false) + } + } + + return ( +
+
+
+

{t('groups.section')}

+ {searched && !finding && ( + {t('groups.count', { n: groups.length })} + )} +
+

{t('groups.hint')}

+ +
+ + + {selected.size > 0 && ( + + )} +
+
+ + {searched && !finding && groups.length === 0 && ( +

{t('groups.empty')}

+ )} + + {groups.map((g, gi) => ( +
+
+ {t('groups.groupSize', { n: g.members.length })} +
+
+ {g.members.map((m) => ( + m.id != null && toggle(m.id)} + /> + ))} +
+
+ ))} +
+ ) +} + +function GroupTile(props: { + asset: IndexedAsset + isBest: boolean + checked: boolean + keepLabel: string + onToggle: () => void +}): JSX.Element { + const { asset: a, isBest, checked } = props + const isVideo = VIDEO_EXTS.includes(a.ext) + return ( +
+ {isVideo ? ( +
+ ) : ( + {baseName(a.path)} + )} + {isBest ? ( + + ★ {props.keepLabel} + + ) : ( + + )} +
+ ) +} diff --git a/src/renderer/components/LibraryView.tsx b/src/renderer/components/LibraryView.tsx index 39e1a0a..cd471ba 100644 --- a/src/renderer/components/LibraryView.tsx +++ b/src/renderer/components/LibraryView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { useT } from '../i18n' import { thumbUrl, baseName } from '../media' @@ -8,7 +8,9 @@ import type { QualityFlag, ColorLabel, AssetQuery, - QualityThresholds + QualityThresholds, + Facets, + MediaFilter } from '@shared/types' const PAGE = 120 @@ -19,6 +21,12 @@ const FILTERS: { id: QualityFilter; key: string }[] = [ { id: 'rejected', key: 'cull.rejected' } ] +const MEDIA: { id: MediaFilter; key: string }[] = [ + { id: 'all', key: 'media.all' }, + { id: 'image', key: 'media.image' }, + { id: 'video', key: 'media.video' } +] + const FLAG_STYLE: Record, string> = { candidate: 'bg-emerald-500/80', blurry: 'bg-amber-500/80', @@ -53,36 +61,56 @@ export function LibraryView(): JSX.Element { const [assets, setAssets] = useState([]) const [hasMore, setHasMore] = useState(false) const [filter, setFilter] = useState('all') + const [kind, setKind] = useState('all') const [ratingMin, setRatingMin] = useState(0) + const [year, setYear] = useState(null) + const [camera, setCamera] = useState(null) + const [labelFilter, setLabelFilter] = useState(null) + const [facets, setFacets] = useState(null) + const [selected, setSelected] = useState>(new Set()) + const [busy, setBusy] = useState(false) const [showThresholds, setShowThresholds] = useState(false) const [localTh, setLocalTh] = useState(qt) const localThRef = useRef(localTh) localThRef.current = localTh useEffect(() => setLocalTh(qt), [qt]) + const query = useMemo( + () => ({ filter, kind, ratingMin, year, camera, label: labelFilter }), + [filter, kind, ratingMin, year, camera, labelFilter] + ) + 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])) }, []) + const refreshFacets = useCallback(async () => { + setFacets(await window.api.index.facets()) + }, []) + useEffect(() => { void refreshLibraries() - }, [refreshLibraries]) + void refreshFacets() + }, [refreshLibraries, refreshFacets]) - // 필터/별점 변경 시 그리드 갱신 + // 필터/패싯 변경 시 그리드 갱신 useEffect(() => { - void loadAssets(0, { filter, ratingMin }) - }, [filter, ratingMin, loadAssets]) - // 색인 완료 시 갱신 + void loadAssets(0, query) + }, [query, loadAssets]) + // 색인 완료 시 그리드 + 패싯 갱신 useEffect(() => { - if (indexPhase === 'done') void loadAssets(0, { filter, ratingMin }) + if (indexPhase === 'done') { + void loadAssets(0, query) + void refreshFacets() + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [indexPhase, summary]) const commitThresholds = async (th: QualityThresholds) => { await updateSettings({ qualityThresholds: th }) - void loadAssets(0, { filter, ratingMin }) + void loadAssets(0, query) } const setRating = async (a: IndexedAsset, rating: number) => { @@ -96,6 +124,49 @@ export function LibraryView(): JSX.Element { 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))) + void refreshFacets() + } + + // ---- 선택 / 내보내기 / 삭제 ---- + const toggleSelect = (id: number) => + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + const clearSelection = () => setSelected(new Set()) + const selectAll = async () => { + const ids = await window.api.index.assetIds(query) + setSelected(new Set(ids)) + } + + const exportSelected = async () => { + if (selected.size === 0) return + setBusy(true) + try { + const r = await window.api.index.export([...selected]) + if (r) window.alert(t('sel.exported', { n: r.count, dest: r.dest })) + } finally { + setBusy(false) + } + } + + const deleteSelected = async () => { + if (selected.size === 0) return + if (!window.confirm(t('sel.confirmDelete', { n: selected.size }))) return + setBusy(true) + try { + const ids = [...selected] + const n = await window.api.groups.trash(ids) + const removed = new Set(ids) + setAssets((prev) => prev.filter((x) => x.id == null || !removed.has(x.id))) + clearSelection() + void refreshFacets() + window.alert(t('sel.deleted', { n })) + } finally { + setBusy(false) + } } const running = indexPhase === 'running' @@ -203,12 +274,29 @@ export function LibraryView(): JSX.Element {

{t('lib.grid')}

+ {/* 미디어 종류(사진/영상 분리) */}
+ {MEDIA.map((m) => ( + + ))} +
+ {/* 품질 컬링(좋은 사진/걸러낼 사진) */} +
{FILTERS.map((f) => (
+ {/* 컬렉션 패싯: 타임라인(연도) + 카메라 + 색라벨 */} + {facets && (facets.years.length > 0 || facets.cameras.length > 0) && ( +
+ {facets.years.length > 0 && ( +
+ {t('col.year')} + {facets.years.map((y) => ( + setYear(year === y.value ? null : y.value)} + /> + ))} +
+ )} + {facets.cameras.length > 0 && ( +
+ {t('col.camera')} + {facets.cameras.slice(0, 8).map((c) => ( + setCamera(camera === c.value ? null : c.value)} + /> + ))} +
+ )} + {facets.labels.length > 0 && ( +
+ {t('col.label')} + {LABEL_COLORS.map((c) => { + const f = facets.labels.find((l) => l.value === c.id) + if (!f) return null + return ( + + ) + })} +
+ )} +
+ )} + {/* 임계값 패널 */} {showThresholds && (
@@ -286,6 +433,48 @@ export function LibraryView(): JSX.Element {
)} + {/* 선택 액션바 */} +
+ {selected.size > 0 ? ( + <> + + {t('sel.count', { n: selected.size })} + + + + + + ) : ( + <> + + {t('sel.hint')} + + )} +
+ {assets.length === 0 ? (

{t('lib.gridEmpty')}

) : ( @@ -296,6 +485,8 @@ export function LibraryView(): JSX.Element { key={a.contentHash} asset={a} flagLabel={a.flag ? t(`flag.${a.flag}`) : ''} + selected={a.id != null && selected.has(a.id)} + onToggleSelect={() => a.id != null && toggleSelect(a.id)} onRate={(r) => setRating(a, r)} onLabel={(l) => setLabel(a, l)} /> @@ -305,7 +496,7 @@ export function LibraryView(): JSX.Element {
@@ -318,6 +509,27 @@ export function LibraryView(): JSX.Element { ) } +function FacetChip(props: { + label: string + count: number + active: boolean + onClick: () => void +}): JSX.Element { + return ( + + ) +} + function ThresholdSlider(props: { label: string min: number @@ -350,17 +562,24 @@ function ThresholdSlider(props: { function AssetTile(props: { asset: IndexedAsset flagLabel: string + selected: boolean + onToggleSelect: () => void onRate: (rating: number) => void onLabel: (label: Exclude) => void }): JSX.Element { - const { asset: a } = props + const { asset: a, selected } = props const isVideo = VIDEO_EXTS.includes(a.ext) const labelColor = a.label ? LABEL_COLORS.find((c) => c.id === a.label)?.cls : null + // 별점/색라벨 클릭이 선택 토글로 번지지 않도록 + const stop = (e: { stopPropagation: () => void }) => e.stopPropagation() return (
{isVideo ? (
@@ -375,6 +594,17 @@ function AssetTile(props: { /> )} + {/* 선택 체크 (좌상단) — 선택됨 또는 호버 시 표시 */} + + {selected ? '✓' : ''} + + {/* 품질 배지 */} {a.flag && ( )} - {/* 호버 오버레이: 별점 + 색라벨 편집 */} -
+ {/* 호버 오버레이: 별점 + 색라벨 편집 (선택 토글과 분리) */} +
{[1, 2, 3, 4, 5].map((n) => (
+ {/* 화면 크기 (쉬운 모드) */} +
+
+ {t('onboard.easy')} +
+
+ {[ + { on: false, label: t('onboard.easyOff') }, + { on: true, label: t('onboard.easyOn') } + ].map((opt) => ( + + ))} +
+
+