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:
@@ -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/
|
||||
|
||||
@@ -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
@@ -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 리스크 (이 단계)
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+26
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 폴백 가능)
|
||||
})
|
||||
@@ -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[])
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) 시도
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' : ''}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user