darktable-inspired reskin + metadata/collections, map, easy mode, select/export

UI overhaul to a darktable tone-and-manner and a set of features adapted from
darktable's proven patterns (reimplemented in our Electron/TS stack; no GPL code).

Design reskin:
- Dark neutral-gray palette + amber accent, flat/squared corners, no card shadows,
  compact darktable-style top bar (logo + pipe-separated view tabs), denser 15px base
- Done via design tokens (Tailwind slate/brand/radius/shadow remap) — minimal churn

Metadata & collections (Phase A/B):
- exifr now captures GPS + camera; asset table ALTER-migrated (gpsLat/gpsLon/camera,
  metaVersion backfill on re-index)
- Collection facet bar (year timeline / camera / color-label) filters the grid

Map & relation finder (Phase C):
- Leaflet + online OSM map tab; geotagged photos as markers
- relationService: related photos by place (GPS<1km) + time (+/-2d) + CLIP similarity

Easy mode (Phase D):
- easyMode setting (menu / onboarding); scales the whole UI (rem) + bigger thumbnails
  + large icon nav with plain labels (4050 accessibility)

Library usability:
- Video thumbnails (representative frame capture in the inference worker)
- Media filter (All / Photos / Videos) to separate them
- Clearer culling labels ("Good shots" / "To cull") + explanation tooltip
- Multi-select tiles -> Export selected to a folder (copy, best-cut extraction) and
  Delete to Recycle Bin (shell.trashItem) behind a confirm dialog
- ONNX Runtime wasm bundled locally (offline) via copy-ort-wasm + asarUnpack

Docs: DARKTABLE_REVIEW (feasibility + roadmap A->D). All typecheck/tests/build green;
boot smoke verified each phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:22:19 +09:00
parent 72c41ae834
commit 3e73967c7b
33 changed files with 1670 additions and 96 deletions
+2
View File
@@ -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/
+107
View File
@@ -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` 설정(영속화) 토글(메뉴 보기 · 온보딩). 켜면 `<html class="easy">`로 ① 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) 우선 / 전부.
+11 -1
View File
@@ -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 리스크 (이 단계)
+2
View File
@@ -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
+26
View File
@@ -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",
+5 -1
View File
@@ -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",
+41
View File
@@ -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 폴백 가능)
})
+17 -1
View File
@@ -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<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 inputs = tokenizer([prompt], { padding: true, truncation: true })
const out = await textModel(inputs)
return Array.from(out.text_embeds.normalize().tolist()[0] as number[])
}
+63
View File
@@ -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 = <T>(p: Promise<T>, ms: number): Promise<T> =>
Promise.race([
p,
new Promise<T>((_, rej) => setTimeout(() => rej(new Error('영상 처리 시간 초과')), ms))
])
try {
await withTimeout(
new Promise<void>((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<void>((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<ArrayBuffer> {
return new Promise((resolve, reject) => {
+6 -1
View File
@@ -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<void> {
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)
+46
View File
@@ -21,6 +21,52 @@ export async function getMtimeDate(path: string): Promise<CaptureDate> {
}
}
/** 확장 메타데이터 (촬영일 + 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<ExtendedMeta> {
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) 시도
+52
View File
@@ -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<boolean>(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
}
+177 -15
View File
@@ -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
]
)
+31 -4
View File
@@ -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)
+7
View File
@@ -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
+56 -2
View File
@@ -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
})
}
+6
View File
@@ -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') },
+69
View File
@@ -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<number, number>()
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)
}
+2 -1
View File
@@ -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 */
+11
View File
@@ -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<E extends RendererEventName>(event: E, cb: (payload: RendererEvents[E]) => void) {
+2 -1
View File
@@ -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]
+68 -31
View File
@@ -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 <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') }
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 (
<div className="h-full flex flex-col">
<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>
{easyMode ? (
/* 쉬운 모드: 대형 아이콘+구어체 버튼 네비 */
<header className="px-5 py-3 bg-slate-100 dark:bg-slate-800 border-b border-slate-300 dark:border-slate-700 shrink-0">
<nav className="grid grid-cols-5 gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setView(tab.id)}
className={`flex flex-col items-center gap-1 rounded-md py-3 border transition-colors ${
view === tab.id
? 'border-brand bg-brand/15 text-brand'
: 'border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-brand'
}`}
>
<span className="text-2xl leading-none">{tab.icon}</span>
<span className="text-base font-semibold">{tab.easyLabel}</span>
</button>
))}
</nav>
</header>
) : (
/* darktable식 컴팩트 상단바: 좌측 로고 · 우측 파이프 구분 탭 */
<header className="h-11 px-4 bg-slate-100 dark:bg-slate-800 border-b border-slate-300 dark:border-slate-700 shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2 select-none">
<span className="text-brand text-base leading-none"></span>
<span className="text-sm font-semibold tracking-widest lowercase text-slate-600 dark:text-slate-300">
photoai
</span>
</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>
<nav className="flex items-center">
{tabs.map((tab, i) => (
<span key={tab.id} className="flex items-center">
{i > 0 && <span className="text-slate-300 dark:text-slate-600 px-1.5">|</span>}
<button
onClick={() => setView(tab.id)}
className={`text-sm tracking-wide transition-colors ${
view === tab.id
? 'text-brand font-semibold'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100'
}`}
>
{tab.label}
</button>
</span>
))}
</nav>
</header>
)}
{view === 'organize' ? (
<main className="flex-1 min-h-0 grid grid-cols-12 gap-4 p-6">
<main className="flex-1 min-h-0 grid grid-cols-12 gap-3 p-4">
{/* 좌측: 설정 패널 (자체 스크롤) */}
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
<ProfileManager />
@@ -84,13 +113,21 @@ export default function App(): JSX.Element {
</section>
</main>
) : view === 'library' ? (
<main className="flex-1 min-h-0 overflow-y-auto p-6">
<main className="flex-1 min-h-0 overflow-y-auto p-4">
<LibraryView />
</main>
) : (
<main className="flex-1 min-h-0 overflow-y-auto p-6">
) : view === 'search' ? (
<main className="flex-1 min-h-0 overflow-y-auto p-4">
<SearchView />
</main>
) : view === 'map' ? (
<main className="flex-1 min-h-0 overflow-y-auto p-4">
<MapView />
</main>
) : (
<main className="flex-1 min-h-0 overflow-y-auto p-4">
<GroupsView />
</main>
)}
</div>
)
+188
View File
@@ -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<AssetGroup[]>([])
const [finding, setFinding] = useState(false)
const [searched, setSearched] = useState(false)
const [selected, setSelected] = useState<Set<number>>(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<number>()
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 (
<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('groups.section')}</h2>
{searched && !finding && (
<span className="text-xs text-slate-400">{t('groups.count', { n: groups.length })}</span>
)}
</div>
<p className="text-[11px] text-slate-400 mb-3">{t('groups.hint')}</p>
<div className="flex items-center gap-4">
<label className="text-sm flex-1 max-w-xs">
<span className="block text-xs text-slate-500 dark:text-slate-400 mb-1">
{t('groups.threshold', { v: threshold.toFixed(2) })}
</span>
<input
type="range"
min={0.8}
max={0.99}
step={0.01}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className="w-full"
/>
</label>
<button
className="bg-brand hover:bg-brand-dark text-white rounded-lg px-4 py-2 font-semibold disabled:opacity-40"
onClick={find}
disabled={finding}
>
{finding ? t('groups.finding') : t('groups.find')}
</button>
{selected.size > 0 && (
<button
className="bg-red-500 hover:bg-red-600 text-white rounded-lg px-4 py-2 font-semibold disabled:opacity-40 ml-auto"
onClick={trash}
disabled={trashing}
>
🗑 {t('groups.trashSelected', { n: selected.size })}
</button>
)}
</div>
</div>
{searched && !finding && groups.length === 0 && (
<p className="text-sm text-slate-400">{t('groups.empty')}</p>
)}
{groups.map((g, gi) => (
<div
key={gi}
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"
>
<div className="text-xs text-slate-400 mb-2">
{t('groups.groupSize', { n: g.members.length })}
</div>
<div className="grid grid-cols-6 gap-2">
{g.members.map((m) => (
<GroupTile
key={m.contentHash}
asset={m}
isBest={m.id === g.bestId}
checked={m.id != null && selected.has(m.id)}
keepLabel={t('groups.keep')}
onToggle={() => m.id != null && toggle(m.id)}
/>
))}
</div>
</div>
))}
</div>
)
}
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 (
<div
className={`relative aspect-square rounded-md overflow-hidden border-2 bg-slate-100 dark:bg-slate-700 ${
isBest ? 'border-emerald-500' : checked ? 'border-red-400' : 'border-transparent'
}`}
title={a.path}
>
{isVideo ? (
<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"
/>
)}
{isBest ? (
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-semibold text-white rounded px-1 py-0.5 bg-emerald-500/90">
{props.keepLabel}
</span>
) : (
<button
className="absolute top-1 right-1 w-5 h-5 rounded bg-black/50 flex items-center justify-center"
onClick={props.onToggle}
title={a.path}
>
<span className={`text-xs ${checked ? 'text-red-400' : 'text-white/60'}`}>
{checked ? '✓' : '○'}
</span>
</button>
)}
</div>
)
}
+256 -17
View File
@@ -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<Exclude<QualityFlag, null>, string> = {
candidate: 'bg-emerald-500/80',
blurry: 'bg-amber-500/80',
@@ -53,36 +61,56 @@ export function LibraryView(): JSX.Element {
const [assets, setAssets] = useState<IndexedAsset[]>([])
const [hasMore, setHasMore] = useState(false)
const [filter, setFilter] = useState<QualityFilter>('all')
const [kind, setKind] = useState<MediaFilter>('all')
const [ratingMin, setRatingMin] = useState(0)
const [year, setYear] = useState<string | null>(null)
const [camera, setCamera] = useState<string | null>(null)
const [labelFilter, setLabelFilter] = useState<ColorLabel>(null)
const [facets, setFacets] = useState<Facets | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [busy, setBusy] = useState(false)
const [showThresholds, setShowThresholds] = useState(false)
const [localTh, setLocalTh] = useState<QualityThresholds>(qt)
const localThRef = useRef(localTh)
localThRef.current = localTh
useEffect(() => setLocalTh(qt), [qt])
const query = useMemo<AssetQuery>(
() => ({ 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 {
<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">
{MEDIA.map((m) => (
<button
key={m.id}
onClick={() => setKind(m.id)}
className={`text-xs rounded px-2.5 py-1 border ${
kind === m.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(m.key)}
</button>
))}
</div>
{/* 품질 컬링(좋은 사진/걸러낼 사진) */}
<div className="flex gap-1" title={t('cull.help')}>
{FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`text-xs rounded-full px-2.5 py-1 border ${
className={`text-xs rounded 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'
@@ -242,6 +330,65 @@ export function LibraryView(): JSX.Element {
</div>
</div>
{/* 컬렉션 패싯: 타임라인(연도) + 카메라 + 색라벨 */}
{facets && (facets.years.length > 0 || facets.cameras.length > 0) && (
<div className="bg-slate-50 dark:bg-slate-700/40 rounded-lg p-3 mb-3 flex flex-col gap-2">
{facets.years.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-slate-400 w-12 shrink-0">{t('col.year')}</span>
{facets.years.map((y) => (
<FacetChip
key={y.value}
label={y.value}
count={y.count}
active={year === y.value}
onClick={() => setYear(year === y.value ? null : y.value)}
/>
))}
</div>
)}
{facets.cameras.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-slate-400 w-12 shrink-0">{t('col.camera')}</span>
{facets.cameras.slice(0, 8).map((c) => (
<FacetChip
key={c.value}
label={c.value}
count={c.count}
active={camera === c.value}
onClick={() => setCamera(camera === c.value ? null : c.value)}
/>
))}
</div>
)}
{facets.labels.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-slate-400 w-12 shrink-0">{t('col.label')}</span>
{LABEL_COLORS.map((c) => {
const f = facets.labels.find((l) => l.value === c.id)
if (!f) return null
return (
<button
key={c.id}
onClick={() => setLabelFilter(labelFilter === c.id ? null : c.id)}
className={`flex items-center gap-1 rounded-full pl-1 pr-2 py-0.5 border ${
labelFilter === c.id
? 'border-brand'
: 'border-slate-300 dark:border-slate-600'
}`}
>
<span className={`w-2.5 h-2.5 rounded-full ${c.cls}`} />
<span className="text-[11px] text-slate-500 dark:text-slate-300">
{f.count}
</span>
</button>
)
})}
</div>
)}
</div>
)}
{/* 임계값 패널 */}
{showThresholds && (
<div className="bg-slate-50 dark:bg-slate-700/40 rounded-lg p-3 mb-3">
@@ -286,6 +433,48 @@ export function LibraryView(): JSX.Element {
</div>
)}
{/* 선택 액션바 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{selected.size > 0 ? (
<>
<span className="text-sm font-medium text-brand">
{t('sel.count', { n: selected.size })}
</span>
<button
className="text-xs border border-slate-300 dark:border-slate-600 dark:text-slate-200 rounded px-2.5 py-1"
onClick={clearSelection}
>
{t('sel.clear')}
</button>
<button
className="text-xs bg-brand hover:bg-brand-dark text-white rounded px-3 py-1 font-medium disabled:opacity-40"
onClick={exportSelected}
disabled={busy}
>
{t('sel.export')}
</button>
<button
className="text-xs bg-red-600 hover:bg-red-700 text-white rounded px-3 py-1 font-medium disabled:opacity-40"
onClick={deleteSelected}
disabled={busy}
>
🗑 {t('sel.delete')}
</button>
</>
) : (
<>
<button
className="text-xs border border-slate-300 dark:border-slate-600 dark:text-slate-200 rounded px-2.5 py-1 disabled:opacity-40"
onClick={selectAll}
disabled={assets.length === 0}
>
{t('sel.selectAll')}
</button>
<span className="text-[11px] text-slate-400">{t('sel.hint')}</span>
</>
)}
</div>
{assets.length === 0 ? (
<p className="text-sm text-slate-400 py-2">{t('lib.gridEmpty')}</p>
) : (
@@ -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 {
<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 })}
onClick={() => loadAssets(assets.length, query)}
>
{t('lib.loadMore')}
</button>
@@ -318,6 +509,27 @@ export function LibraryView(): JSX.Element {
)
}
function FacetChip(props: {
label: string
count: number
active: boolean
onClick: () => void
}): JSX.Element {
return (
<button
onClick={props.onClick}
title={props.label}
className={`text-xs rounded-full px-2.5 py-1 border max-w-[180px] truncate ${
props.active
? 'border-brand bg-brand text-white'
: 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand'
}`}
>
{props.label} <span className="opacity-60">{props.count}</span>
</button>
)
}
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<ColorLabel, null>) => 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 (
<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"
className={`relative aspect-square rounded-md overflow-hidden border-2 bg-slate-100 dark:bg-slate-700 group cursor-pointer ${
selected ? 'border-brand' : 'border-slate-200 dark:border-slate-600'
}`}
title={a.path}
onClick={props.onToggleSelect}
>
{isVideo ? (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">
@@ -375,6 +594,17 @@ function AssetTile(props: {
/>
)}
{/* 선택 체크 (좌상단) — 선택됨 또는 호버 시 표시 */}
<span
className={`absolute top-1 left-1 w-5 h-5 rounded-full flex items-center justify-center text-[11px] font-bold transition-opacity ${
selected
? 'bg-brand text-white opacity-100'
: 'bg-black/50 text-white/70 opacity-0 group-hover:opacity-100'
}`}
>
{selected ? '✓' : ''}
</span>
{/* 품질 배지 */}
{a.flag && (
<span
@@ -389,13 +619,19 @@ function AssetTile(props: {
<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="absolute inset-x-0 bottom-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity p-1 flex flex-col gap-1"
onClick={stop}
>
<div className="flex justify-center gap-0.5">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => props.onRate(n)}
onClick={(e) => {
stop(e)
props.onRate(n)
}}
className={`text-xs leading-none ${n <= a.rating ? 'text-amber-400' : 'text-white/50'}`}
>
@@ -406,7 +642,10 @@ function AssetTile(props: {
{LABEL_COLORS.map((c) => (
<button
key={c.id}
onClick={() => props.onLabel(c.id)}
onClick={(e) => {
stop(e)
props.onLabel(c.id)
}}
className={`w-3 h-3 rounded-full ${c.cls} ${a.label === c.id ? 'ring-2 ring-white' : ''}`}
/>
))}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { useT } from '../i18n'
import { thumbUrl, baseName } from '../media'
import type { GpsAsset, IndexedAsset } from '@shared/types'
const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']
/** GPS 지도 + 연관 탐색 (Phase C, 온라인 OSM 타일) */
export function MapView(): JSX.Element {
const t = useT()
const mapEl = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<L.Map | null>(null)
const [count, setCount] = useState<number | null>(null)
const [related, setRelated] = useState<IndexedAsset[]>([])
const [loadingRel, setLoadingRel] = useState(false)
const showRelated = async (assetId: number) => {
setLoadingRel(true)
try {
setRelated(await window.api.map.related(assetId))
} finally {
setLoadingRel(false)
}
}
useEffect(() => {
if (!mapEl.current || mapRef.current) return
const map = L.map(mapEl.current, { center: [36.5, 127.8], zoom: 6 })
mapRef.current = map
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 19
}).addTo(map)
let cancelled = false
void window.api.map.assets().then((assets: GpsAsset[]) => {
if (cancelled) return
setCount(assets.length)
const pts: L.LatLngExpression[] = []
for (const a of assets) {
const ll: L.LatLngExpression = [a.gpsLat, a.gpsLon]
pts.push(ll)
const marker = L.circleMarker(ll, {
radius: 6,
color: '#3f5ad6',
fillColor: '#5b7cfa',
fillOpacity: 0.85,
weight: 1
}).addTo(map)
marker.bindPopup(
`<img src="${thumbUrl(a.contentHash)}" style="width:120px;height:120px;object-fit:cover;border-radius:6px" />`
)
marker.on('click', () => {
void showRelated(a.id)
})
}
if (pts.length > 0) map.fitBounds(L.latLngBounds(pts).pad(0.2))
})
return () => {
cancelled = true
map.remove()
mapRef.current = null
}
}, [])
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-2">
<h2 className="font-semibold dark:text-slate-100">{t('map.section')}</h2>
{count !== null && (
<span className="text-xs text-slate-400">{t('map.count', { n: count })}</span>
)}
</div>
{count === 0 && <p className="text-[11px] text-amber-600 dark:text-amber-400 mb-2">{t('map.empty')}</p>}
<div
ref={mapEl}
className="w-full rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700"
style={{ height: '60vh' }}
/>
</div>
{/* 연관 사진 패널 */}
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h2 className="font-semibold dark:text-slate-100 mb-1">{t('map.related')}</h2>
<p className="text-[11px] text-slate-400 mb-3">{t('map.relatedHint')}</p>
{loadingRel ? (
<p className="text-sm text-slate-400">{t('map.loading')}</p>
) : related.length === 0 ? (
<p className="text-sm text-slate-400">{t('map.selectHint')}</p>
) : (
<div className="grid grid-cols-6 gap-2">
{related.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>
)
}
+28 -1
View File
@@ -8,11 +8,13 @@ export function Onboarding(): JSX.Element {
const t = useT()
const language = useStore((s) => s.language)
const theme = useStore((s) => s.theme)
const easyMode = useStore((s) => s.easyMode)
const updateSettings = useStore((s) => s.updateSettings)
// 선택 즉시 미리보기로 반영(테마/언어), 완료 시 onboarded 저장
// 선택 즉시 미리보기로 반영(테마/언어/크기), 완료 시 onboarded 저장
const pickLang = (l: Lang) => updateSettings({ language: l })
const pickTheme = (th: Theme) => updateSettings({ theme: th })
const pickEasy = (on: boolean) => updateSettings({ easyMode: on })
const finish = () => updateSettings({ onboarded: true })
return (
@@ -67,6 +69,31 @@ export function Onboarding(): JSX.Element {
</div>
</div>
{/* 화면 크기 (쉬운 모드) */}
<div className="mb-8">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
{t('onboard.easy')}
</div>
<div className="grid grid-cols-2 gap-2">
{[
{ on: false, label: t('onboard.easyOff') },
{ on: true, label: t('onboard.easyOn') }
].map((opt) => (
<button
key={String(opt.on)}
onClick={() => pickEasy(opt.on)}
className={`py-2.5 rounded-lg border text-sm font-medium transition-colors ${
easyMode === opt.on
? 'border-brand bg-brand text-white'
: 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200 hover:border-brand'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<button
onClick={finish}
className="w-full py-3 rounded-lg bg-brand hover:bg-brand-dark text-white font-semibold"
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data: file: photoai-media:; style-src 'self' 'unsafe-inline';"
content="default-src 'self'; img-src 'self' data: file: photoai-media: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline';"
/>
<title>AI Photo Organizer</title>
</head>
+17 -4
View File
@@ -18,13 +18,18 @@ 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'
export type AppView = 'organize' | 'library' | 'search' | 'groups' | 'map'
/** 테마를 <html> 클래스에 반영 (Tailwind darkMode:'class') */
function applyTheme(theme: Theme): void {
document.documentElement.classList.toggle('dark', theme === 'dark')
}
/** 쉬운 모드를 <html> 클래스에 반영 (CSS가 전체 스케일/그리드 조정) */
function applyEasy(easy: boolean): void {
document.documentElement.classList.toggle('easy', easy)
}
interface AppState {
// 프로필
profiles: Profile[]
@@ -77,6 +82,7 @@ interface AppState {
theme: Theme
onboarded: boolean
qualityThresholds: QualityThresholds
easyMode: boolean
initSettings: () => Promise<void>
updateSettings: (patch: Partial<Settings>) => Promise<void>
@@ -161,24 +167,29 @@ export const useStore = create<AppState>((set, get) => ({
theme: 'dark',
onboarded: false,
qualityThresholds: { focus: 60, exposure: 0.35, eyes: 0.18 },
easyMode: false,
initSettings: async () => {
const s = await window.api.settings.get()
applyTheme(s.theme)
applyEasy(s.easyMode)
set({
language: s.language,
theme: s.theme,
onboarded: s.onboarded,
qualityThresholds: s.qualityThresholds
qualityThresholds: s.qualityThresholds,
easyMode: s.easyMode
})
},
updateSettings: async (patch) => {
const s = await window.api.settings.set(patch)
applyTheme(s.theme)
applyEasy(s.easyMode)
set({
language: s.language,
theme: s.theme,
onboarded: s.onboarded,
qualityThresholds: s.qualityThresholds
qualityThresholds: s.qualityThresholds,
easyMode: s.easyMode
})
},
@@ -192,11 +203,13 @@ export const useStore = create<AppState>((set, get) => ({
_onError: (e) => set((s) => ({ errors: [e, ...s.errors].slice(0, 200) })),
_onSettings: (s) => {
applyTheme(s.theme)
applyEasy(s.easyMode)
set({
language: s.language,
theme: s.theme,
onboarded: s.onboarded,
qualityThresholds: s.qualityThresholds
qualityThresholds: s.qualityThresholds,
easyMode: s.easyMode
})
},
_onIndexProgress: (p: IndexProgress) => set({ indexProgress: p }),
+57 -8
View File
@@ -8,20 +8,69 @@ body,
height: 100%;
}
body {
margin: 0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #f5f6fa;
color: #1f2330;
/* darktable 톤: 밀도 높은 프로 도구 느낌 (기본 15px, 쉬운 모드에서 20px) */
html {
font-size: 15px;
}
/* 다크 테마: <html class="dark"> 토글에 반응 */
body {
margin: 0;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #e6e5e3; /* light: 중성 회색 */
color: #2a2926;
-webkit-font-smoothing: antialiased;
}
/* 다크(기본) 테마 = darktable 다크 */
html.dark body {
background: #0f1117;
color: #e6e8ee;
background: #1a1918;
color: #cfceca;
}
/* 파일 목록 가독성용 모노 폰트 */
.mono {
font-family: 'Cascadia Code', 'Consolas', monospace;
}
/* darktable식 얇은 스크롤바 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #4a4a48;
border-radius: 2px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: #5e5e5b;
background-clip: content-box;
}
html:not(.dark) ::-webkit-scrollbar-thumb {
background: #bdbcb8;
background-clip: content-box;
}
/* 입력 요소 포커스 링을 앰버로 */
input:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 1px solid #d98c3f;
outline-offset: 1px;
}
/* 4050 쉬운 모드: <html class="easy"> — rem 기준 전체 확대 + 큰 썸네일 */
html.easy {
font-size: 20px; /* 기본 16px → rem 기반 Tailwind 유틸 전반 확대 */
}
/* 썸네일 그리드를 더 크게 (열 수 축소) */
html.easy .grid-cols-6 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
html.easy .grid-cols-5 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
+9
View File
@@ -98,6 +98,9 @@ export const IPC = {
INDEX_PROGRESS: 'index:progress',
INDEX_DONE: 'index:done',
INDEX_ASSETS: 'index:assets',
INDEX_ASSET_IDS: 'index:assetIds',
INDEX_FACETS: 'index:facets',
INDEX_EXPORT: 'index:export',
INDEX_SET_RATING: 'index:setRating',
INDEX_SET_LABEL: 'index:setLabel',
// 검색 (Phase 2)
@@ -107,6 +110,12 @@ export const IPC = {
SEARCH_QUERY: 'search:query',
SEARCH_PROGRESS: 'search:progress',
SEARCH_DONE: 'search:done',
// 그룹화 / 자가정화 (Phase 3)
GROUPS_BUILD: 'groups:build',
GROUPS_TRASH: 'groups:trash',
// 지도 / 연관 탐색 (Phase C)
MAP_ASSETS: 'map:assets',
MAP_RELATED: 'map:related',
// Main → UI (send)
JOB_PROGRESS: 'job:progress',
JOB_FILE_PROCESSED: 'job:fileProcessed',
+84 -4
View File
@@ -33,6 +33,9 @@ export const MESSAGES: Table = {
'onboard.theme': { ko: '테마', en: 'Theme' },
'onboard.dark': { ko: '다크', en: 'Dark' },
'onboard.light': { ko: '라이트', en: 'Light' },
'onboard.easy': { ko: '화면 크기', en: 'Display size' },
'onboard.easyOn': { ko: '크게 (쉬운 모드)', en: 'Large (Easy)' },
'onboard.easyOff': { ko: '보통', en: 'Normal' },
'onboard.start': { ko: '시작하기', en: 'Get Started' },
// 1. 프로필
@@ -145,6 +148,52 @@ export const MESSAGES: Table = {
'nav.organize': { ko: '정리', en: 'Organize' },
'nav.library': { ko: '라이브러리', en: 'Library' },
'nav.search': { ko: '검색', en: 'Search' },
'nav.groups': { ko: '그룹·정화', en: 'Groups' },
'nav.map': { ko: '지도', en: 'Map' },
// 쉬운 모드 대형 네비(구어체)
'easynav.organize': { ko: '사진 정리', en: 'Organize' },
'easynav.library': { ko: '내 사진', en: 'My Photos' },
'easynav.search': { ko: '사진 찾기', en: 'Find' },
'easynav.map': { ko: '지도로 보기', en: 'Map' },
'easynav.groups': { ko: '중복 정리', en: 'Cleanup' },
// 지도 / 연관 탐색 (Phase C)
'map.section': { ko: '지도 (촬영 장소)', en: 'Map (photo locations)' },
'map.count': { ko: 'GPS 사진 {n}장', en: '{n} geotagged' },
'map.empty': {
ko: 'GPS 정보가 있는 사진이 없습니다. (라이브러리에서 재색인하면 GPS가 채워집니다)',
en: 'No geotagged photos yet. (Re-index in Library to populate GPS)'
},
'map.related': { ko: '연관 사진', en: 'Related photos' },
'map.relatedHint': {
ko: '지도의 사진을 클릭하면 같은 장소·시기·비슷한 장면의 사진을 모아 보여줍니다.',
en: 'Click a photo on the map to see ones from the same place, time, and similar scenes.'
},
'map.selectHint': { ko: '지도에서 사진을 클릭하세요.', en: 'Click a photo on the map.' },
'map.loading': { ko: '불러오는 중…', en: 'Loading…' },
// 그룹화 / 자가정화 (Phase 3)
'groups.section': { ko: '유사 사진 그룹 · 자가정화', en: 'Similar groups · Cleanup' },
'groups.hint': {
ko: '검색 색인(임베딩)을 기반으로 비슷한 사진을 묶습니다. 각 그룹의 보관 추천(가장 선명)만 남기고 나머지를 휴지통으로 보낼 수 있습니다.',
en: 'Groups similar photos using the search embeddings. Keep the recommended (sharpest) one per group and send the rest to the trash.'
},
'groups.threshold': { ko: '유사도 ({v})', en: 'Similarity ({v})' },
'groups.find': { ko: '그룹 찾기', en: 'Find groups' },
'groups.finding': { ko: '찾는 중…', en: 'Finding…' },
'groups.empty': {
ko: '유사 그룹이 없습니다. (먼저 검색 탭에서 임베딩 색인을 생성하세요)',
en: 'No similar groups. (Build the embedding index in the Search tab first)'
},
'groups.count': { ko: '{n}개 그룹', en: '{n} groups' },
'groups.keep': { ko: '추천 보관', en: 'Keep' },
'groups.groupSize': { ko: '유사 {n}장', en: '{n} similar' },
'groups.trashSelected': { ko: '선택 {n}개 휴지통으로', en: 'Trash {n} selected' },
'groups.confirmTrash': {
ko: '선택한 {n}개 사진을 휴지통(복구 가능)으로 보냅니다. 계속할까요?',
en: 'Move {n} selected photos to the trash (recoverable)? Continue?'
},
'groups.trashed': { ko: '{n}개를 휴지통으로 이동했습니다.', en: 'Moved {n} to trash.' },
// 검색 (Phase 2)
'search.section': { ko: '검색 색인', en: 'Search index' },
@@ -191,11 +240,37 @@ export const MESSAGES: Table = {
},
'lib.loadMore': { ko: '더 보기', en: 'Load more' },
// 선택 / 내보내기 / 삭제 (Library)
'sel.count': { ko: '{n}개 선택', en: '{n} selected' },
'sel.selectAll': { ko: '전체 선택', en: 'Select all' },
'sel.clear': { ko: '선택 해제', en: 'Clear' },
'sel.export': { ko: '폴더로 내보내기', en: 'Export to folder' },
'sel.delete': { ko: '삭제', en: 'Delete' },
'sel.hint': { ko: '사진을 클릭해 선택하세요.', en: 'Click photos to select.' },
'sel.confirmDelete': {
ko: '선택한 {n}개를 휴지통으로 보냅니다.\n(원본 파일이 휴지통으로 이동되며 복구할 수 있습니다)\n계속할까요?',
en: 'Move {n} selected items to the Recycle Bin?\n(Originals are moved to the trash and can be restored.)\nContinue?'
},
'sel.deleted': { ko: '{n}개를 휴지통으로 이동했습니다.', en: 'Moved {n} to the trash.' },
'sel.exported': {
ko: '{n}개를 내보냈습니다.\n{dest}',
en: 'Exported {n} items.\n{dest}'
},
// 미디어 종류 (사진/영상 분리)
'media.all': { ko: '전체', en: 'All' },
'media.image': { ko: '사진', en: 'Photos' },
'media.video': { ko: '영상', en: 'Videos' },
// 컬링 필터 / 품질 플래그 (Phase 1)
'cull.all': { ko: '전체', en: 'All' },
'cull.candidate': { ko: '고품질 후보', en: 'Candidates' },
'cull.rejected': { ko: '제외 후보', en: 'Rejected' },
'flag.candidate': { ko: '후보', en: 'Keep' },
'cull.all': { ko: '품질 전체', en: 'All' },
'cull.candidate': { ko: '잘 나온 사진', en: 'Good shots' },
'cull.rejected': { ko: '걸러낼 사진', en: 'To cull' },
'cull.help': {
ko: '흐리거나 눈 감은·노출 나쁜 사진을 자동으로 표시해, 잘 나온 사진만 빠르게 고를 수 있게 도와줍니다.',
en: 'Auto-flags blurry / closed-eye / badly-exposed shots so you can quickly keep the good ones.'
},
'flag.candidate': { ko: '좋음', en: 'Good' },
'flag.blurry': { ko: '흐림', en: 'Blurry' },
'flag.eyesClosed': { ko: '눈감음', en: 'Eyes closed' },
'flag.badExposure': { ko: '노출', en: 'Exposure' },
@@ -209,6 +284,10 @@ export const MESSAGES: Table = {
},
'cull.reset': { ko: '기본값', en: 'Reset' },
'cull.ratingMin': { ko: '별점', en: 'Rating' },
// 컬렉션 패싯 (Phase B)
'col.year': { ko: '연도', en: 'Year' },
'col.camera': { ko: '카메라', en: 'Camera' },
'col.label': { ko: '색라벨', en: 'Label' },
// 메뉴
'menu.file': { ko: '파일', en: 'File' },
@@ -235,6 +314,7 @@ export const MESSAGES: Table = {
'menu.theme.dark': { ko: '다크 모드', en: 'Dark' },
'menu.theme.light': { ko: '라이트 모드', en: 'Light' },
'menu.language': { ko: '언어', en: 'Language' },
'menu.easyMode': { ko: '쉬운 모드 (큰 화면)', en: 'Easy mode (large)' },
'menu.guide': { ko: '사용 방법 가이드', en: 'User Guide' },
'menu.about': { ko: '정보', en: 'About' }
}
+68 -1
View File
@@ -20,6 +20,8 @@ export interface Settings {
onboarded: boolean
/** 컬링 품질 임계값 */
qualityThresholds: QualityThresholds
/** 4050 쉬운 모드(대형 UI/구어체) */
easyMode: boolean
}
/** 등록된 인물 프로필 */
@@ -98,6 +100,11 @@ export interface AssetRecord {
height: number | null
exifYear: string | null
exifMonth: string | null
/** GPS 좌표 (없으면 null) */
gpsLat: number | null
gpsLon: number | null
/** 카메라 모델 (없으면 null) */
camera: string | null
indexedAt: number
}
@@ -159,11 +166,35 @@ export type QualityFilter =
| 'eyesClosed'
| 'badExposure'
/** 미디어 종류 필터 (사진/영상 분리) */
export type MediaFilter = 'all' | 'image' | 'video'
/** 자산 조회 옵션 */
export interface AssetQuery {
filter: QualityFilter
/** 미디어 종류 */
kind: MediaFilter
/** 최소 별점 (0이면 무시) */
ratingMin: number
/** 연도 필터 (예: "2024") */
year?: string | null
/** 카메라 모델 필터 */
camera?: string | null
/** 색라벨 필터 */
label?: ColorLabel
}
/** 컬렉션 패싯 한 항목 */
export interface FacetItem {
value: string
count: number
}
/** 컬렉션 필터용 패싯 집계 */
export interface Facets {
years: FacetItem[]
cameras: FacetItem[]
labels: FacetItem[]
}
/** 검색 색인(임베딩) 생성 진행률 */
@@ -182,6 +213,22 @@ export interface SearchSummary {
count: number
}
/** 지도 마커용 GPS 자산 */
export interface GpsAsset {
id: number
contentHash: string
path: string
gpsLat: number
gpsLon: number
}
/** 유사 이미지 그룹 (스마트 그룹화 / 근접 중복 정화) */
export interface AssetGroup {
/** 보관 추천(품질 최고) 자산 id */
bestId: number
members: IndexedAsset[]
}
/** 검색 색인 상태 */
export interface SearchStatus {
/** 임베딩 보유 수 */
@@ -314,8 +361,14 @@ export interface ExposedApi {
index: {
run(): Promise<void>
cancel(): Promise<void>
/** 색인된 자산 목록 (최근순, 페이지네이션, 품질/별점 필터) */
/** 색인된 자산 목록 (최근순, 페이지네이션, 품질/별점/연도/카메라/라벨 필터) */
assets(offset: number, limit: number, query: AssetQuery): Promise<IndexedAsset[]>
/** 쿼리에 매칭되는 전체 자산 id (전체 선택용) */
assetIds(query: AssetQuery): Promise<number[]>
/** 컬렉션 패싯(연도/카메라/색라벨 집계) */
facets(): Promise<Facets>
/** 선택 자산을 폴더로 내보내기(복사). 폴더 선택 다이얼로그 후 복사. 취소 시 null */
export(assetIds: number[]): Promise<{ count: number; dest: string } | null>
/** 별점(0~5) 설정 */
setRating(assetId: number, rating: number): Promise<void>
/** 색라벨 설정 */
@@ -330,6 +383,20 @@ export interface ExposedApi {
/** 자연어 쿼리 → 유사도 상위 결과 */
query(text: string): Promise<IndexedAsset[]>
}
/** 지도 / 연관 탐색 (Phase C) */
map: {
/** GPS 좌표가 있는 자산 목록(지도 마커) */
assets(): Promise<GpsAsset[]>
/** 특정 사진과 관련된 사진(장소+시간+유사도) */
related(assetId: number): Promise<IndexedAsset[]>
}
/** 스마트 그룹화 / 자가정화 (Phase 3) */
groups: {
/** 임베딩 유사도(threshold) 이상으로 묶인 그룹(크기 2+) 반환 */
build(threshold: number): Promise<AssetGroup[]>
/** 선택 자산을 OS 휴지통으로 이동하고 인덱스에서 제거. 처리 수 반환 */
trash(assetIds: number[]): Promise<number>
}
/** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */
getPathForFile(file: unknown): string
on<E extends RendererEventName>(
+36 -2
View File
@@ -5,10 +5,44 @@ export default {
theme: {
extend: {
colors: {
// darktable 톤: 앰버(주황) 강조
brand: {
DEFAULT: '#5b7cfa',
dark: '#3f5ad6'
DEFAULT: '#d98c3f',
dark: '#b9742e'
},
// 'slate' 스케일을 어두운 중성 회색 램프로 재정의 → 앱 크롬 전체 reskin
// (컴포넌트는 그대로 두고 토큰만 교체)
slate: {
50: '#f1f1f0',
100: '#e6e5e3',
200: '#d4d3d0',
300: '#aeadab',
400: '#8a8987',
500: '#6f6e6c',
600: '#454442',
700: '#2d2c2b',
800: '#232221',
900: '#1a1918'
}
},
// darktable처럼 각진(거의 평평한) 모서리
borderRadius: {
none: '0',
sm: '2px',
DEFAULT: '2px',
md: '3px',
lg: '3px',
xl: '4px',
'2xl': '5px',
'3xl': '6px',
full: '9999px'
},
// 플랫한 그림자 (카드 느낌 제거)
boxShadow: {
sm: 'none',
DEFAULT: '0 1px 2px rgba(0,0,0,0.35)',
md: '0 2px 6px rgba(0,0,0,0.4)',
lg: '0 8px 24px rgba(0,0,0,0.5)'
}
}
},