diff --git a/.gitignore b/.gitignore index bad572d..5220d59 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ out/ release/ dist/ *.log +# dev 스모크 실행 잔여물 +smoke-* .DS_Store # TypeScript 증분 빌드 정보 (생성물) *.tsbuildinfo diff --git a/README.md b/README.md index 67159ee..6dab206 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ 2. 정리할 폴더(소스)와 결과 폴더(출력)를 고른다. 3. [정리 시작] → 각 사진을 스캔해 - 얼굴이 매칭되면 `출력/<인물>/YYYY/MM/` 로 **이동**(2·3순위 인물에게는 **복사**) - - 매칭 인물이 없으면 `출력/[미정]/YYYY/MM/` 로 이동 + - 매칭 인물이 없으면 `출력/Unsorted/YYYY/MM/` 로 이동 + - **영상 파일**(`.mp4 .mov .avi .mkv .webm .m4v`)은 얼굴인식 없이 `출력/Movie/YYYY/MM/` 로 이동 - EXIF 촬영일이 없으면 파일 수정일로 대체 데이터 안전: 이동은 **복사 → 무결성 검증 → 원본 삭제** 순서로 수행하고, 파일명 충돌 시 `_1`, `_2` 로 자동 리네임한다(덮어쓰기 없음). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b1b6a31..ea31462 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -12,12 +12,25 @@ | 3 | EXIF 촬영날짜 누락 시 | **파일 수정일(mtime)로 대체**하여 YYYY/MM 추출 | 대부분의 사진을 연/월 구조에 편입 가능. | | 4 | 다중 인물 사진의 Move 1순위 | **프로필 등록 순서** (1번 인물=이동, 2·3번=복사) | PRD 명세 "첫 번째 프로필 기준 이동 후 복사"와 일치. | +## 1-1. 영상 파일 처리 (2026-06-01 추가) + +| 항목 | 결정 | +|------|------| +| 대상 확장자 | `.mp4 .mov .avi .mkv .webm .m4v` | +| 처리 방식 | **얼굴인식 없이** 날짜 기준으로 **이동만** | +| 대상 폴더 | 출력 루트 하위 **`Movie/YYYY/MM/`** | +| 날짜 기준 | 파일 수정일(mtime). 영상은 EXIF 미적용 → mtime 직행 | +| 재처리 방지 | `Movie` 폴더는 스캔 제외 디렉터리에 포함 | +| 향후(비고) | "영상에 누가 나오나"가 필요하면 ffmpeg 프레임 샘플링으로 인물 매칭(phase 2) | + +> 근거: 영상은 프레임이 많아 정지 이미지 엔진으로 바로 매칭 불가. v1은 날짜 기준 분리로 안전·신속 처리하고, 사진 정리 결과를 어지럽히지 않도록 전용 `Movie` 폴더로 격리. + ## 2. 기본 가정 (Claude 기본값, 이의 없으면 채택) - **이동(Move) 구현 방식**: `복사 → 무결성 검증 → 원본 삭제` 순서로 수행 (Atomic 보장, 데이터 무결성 0 Error). - **유사도 임계값**: face-api Euclidean distance `< 0.5`를 매칭으로 간주 (튜닝 가능 파라미터로 노출). - **Reference 등록**: 인물당 다수 사진 등록 허용 → 평균 descriptor 사용으로 정확도 향상 (KPI ≥98% 대응). -- **미검출/인식 실패 처리**: `[미정]/YYYY/MM/` 으로 이동 (삭제 금지, Scenario 03). +- **미검출/인식 실패 처리**: `Unsorted/YYYY/MM/` 으로 이동 (삭제 금지, Scenario 03). 폴더명은 언어 중립을 위해 영문 `Unsorted` 사용(구 `[미정]`). - **대상 확장자**: `.jpg .jpeg .png .webp` 자동 감지. (`.heic`는 향후 확장 검토.) - **프로필 최대 인원**: 3명 (PRD 명세). @@ -26,7 +39,7 @@ ``` <출력루트>/ <프로필명>/YYYY/MM/... # 인물 매칭 사진 - [미정]/YYYY/MM/... # 미검출·인식실패 사진 + Unsorted/YYYY/MM/... # 미검출·인식실패 사진 ``` ## 4. KPI / 비기능 요구 diff --git a/docs/NEXTGEN_REVIEW.md b/docs/NEXTGEN_REVIEW.md new file mode 100644 index 0000000..a194440 --- /dev/null +++ b/docs/NEXTGEN_REVIEW.md @@ -0,0 +1,180 @@ +# NextGen Photo AI — 확장 기획 타당성 검토 & 로드맵 + +> 상태: **검토(Review) / 계획 초안** · 작성: 2026-06-01 +> 대상 기획: "NextGen Photo AI — 지능형 사진 관리 솔루션" +> 현재 베이스: [ARCHITECTURE.md](./ARCHITECTURE.md) (Electron + face-api 폴더 정리기) + +--- + +## 0. 한 줄 결론 + +기술적으로 **전부 로컬(On-device)로 구현 가능**합니다. 단, 이는 기능 추가가 아니라 **제품의 성격 전환**입니다: +현재 "폴더를 1회 스캔해 인물/날짜로 이동"하는 **무상태(stateless) 정리기** → 사진을 **지속적으로 색인(index)하고 검색·평가·그룹화**하는 **상태 기반 라이브러리(DAM)**. +→ 핵심은 새 기능들이 아니라 그 아래 깔릴 **"라이브러리 인덱스" 기반(임베딩 + 메타 + 썸네일 DB)** 입니다. 이것부터 만들어야 나머지가 전부 그 위에 올라갑니다. + +--- + +## 1. 기능별 타당성 (로컬 구현 관점) + +| 기능 | 핵심 기술 | 로컬 가능성 | 난이도 | 비고 | +|------|----------|:-----------:|:------:|------| +| **[P1] AI 품질 평가 / Culling** | 초점=라플라시안 분산, 노출=히스토그램, 감은 눈=face-api 랜드마크(EAR), 미학=NIMA(ONNX) | ✅ 높음 | 중 | 초점/노출/눈은 모델 없이 가능. 미학 점수만 ONNX 모델 필요 | +| **[P1] 자연어 검색** | CLIP 이미지/텍스트 임베딩(transformers.js, ONNX, WebGPU) + 벡터 검색 | ✅ 가능 | 상 | **한국어 쿼리 처리 방식 결정 필요**(§4-1) | +| **[P1] 시각적 유사 검색** | 동일 CLIP 임베딩 코사인 유사도 | ✅ 높음 | 중 | 임베딩만 있으면 쉬움 | +| **[P2] 스마트 그룹화** | 임베딩 클러스터링(k-means/임계값) | ✅ 높음 | 중 | Phase 2 임베딩 재사용 | +| **[P2] 자가 정화(중복/저품질)** | 지각적 해시(pHash/dHash) + 품질 점수 | ✅ 높음 | 중 | pHash는 순수 JS 구현 가능 | +| (시나리오 A) **RAW 파일** | libraw/dcraw (WASM 또는 네이티브) | ⚠️ 가능하나 무거움 | 상 | **범위 포함 여부 결정 필요**(§4-2) | + +→ **품질 평가/Culling이 모델 리스크가 가장 낮고 가치가 높음** → 1순위로 출시 권장. + +--- + +## 2. 필요한 기술 스택 추가 + +| 영역 | 후보 | 메모 | +|------|------|------| +| ML 런타임 | **transformers.js v3** (@huggingface/transformers) | ONNX Runtime Web, **WebGPU 가속** + WASM 폴백. 전부 로컬 | +| 임베딩 모델 | **CLIP ViT-B/32**(영어, ~경량) 또는 **다국어 CLIP**(jina-clip-v2 등, 무거움) | §4-1 결정에 따름 | +| 한국어 처리(옵션) | **opus-mt-ko-en**(쿼리 번역, 소형) | 영어 CLIP + 쿼리만 KO→EN 번역하는 절충안 | +| 미학 점수(옵션) | NIMA류 ONNX | 없으면 초점/노출/눈만으로 v1 | +| 인덱스 DB | **better-sqlite3** (메타+점수+pHash) + 임베딩(BLOB 또는 별도 바이너리) | JSON은 수천 장에서 한계 → SQLite 필요 | +| 벡터 검색 | 초기 **브루트포스 코사인**(~5만 장까지 OK) → 대규모 시 **sqlite-vec / hnswlib-wasm** | 단계적 | +| 썸네일 | sharp(네이티브) 또는 canvas 리사이즈 + 디스크 캐시 | 그리드 UI 필수 | +| 지각 해시 | dHash/aHash 순수 JS | 중복 탐지 | +| RAW(옵션) | libraw-wasm / dcraw | §4-2 결정 | + +> 모델 동봉 시 앱 용량이 **수백 MB** 증가합니다(CLIP ~150~350MB + 런타임). 로컬·오프라인 정책상 동봉 또는 최초 실행 시 1회 다운로드 방식 결정 필요. + +--- + +## 3. 아키텍처 변화 (가장 중요한 부분) + +현재 3-프로세스(Main / UI / 추론창)에 **2개 축**을 추가: + +``` +추가 1) Library Index (영속 상태) + SQLite DB: 파일별 { contentHash, path, exif, faces, + clipEmbedding(512d), qualityScores, pHash, thumbnailRef } + - 파일은 contentHash로 식별 → 경로가 바뀌어도 추적, 중복 인식 + - "이동/복사" 정리기와 공존: 정리기도 이 인덱스를 활용 + +추가 2) AI Worker (백그라운드 색인기) + - 기존 '숨김 추론창' 패턴 확장 또는 Web Worker(+WebGPU) + - 신규/변경 파일을 배치로: CLIP 임베딩 + 품질 점수 + pHash 계산 + - 재개 가능(resumable), UI 프리징 0 (별 프로세스/워커) +``` + +새 데이터 흐름: +``` +임포트/스캔 → (AI Worker) 임베딩·점수·해시 계산 → Index DB 저장 +검색: 쿼리 → (번역?) → 텍스트 임베딩 → DB 벡터 코사인 Top-K → 결과 그리드 +Culling: Index의 품질 점수로 필터 → 후보군/제외 뷰 +그룹화: 임베딩 클러스터링 → 클러스터 뷰 +정화: pHash 근접쌍 + 저품질 → 정리 제안 +``` + +> **비파괴(non-destructive) 색인**이 핵심: 검색/평가/그룹화는 파일을 옮기지 않고 "제자리에서" 인덱싱합니다. 현재의 이동 기반 정리기와는 철학이 다르므로, 둘을 어떻게 공존시킬지(§4-3)가 설계 관건입니다. + +--- + +## 4. 열린 질문 — 확정됨 (2026-06-01) + +| # | 항목 | 결정 | +|---|------|------| +| 1 | 한국어 자연어 검색 | **영어 CLIP + 쿼리 KO→EN 번역**(경량·빠름). Phase 2에서 정확도 실측 | +| 2 | RAW 지원 | **차후(Phase 4)**. 우선 JPEG/PNG/WebP | +| 3 | 라이브러리 모델 | **인덱스 기반 통합** — 비파괴 색인을 기본으로, 기존 이동/복사 정리기도 인덱스 활용 | +| 4 | 진행 범위 | **Phase 0 + Phase 1 먼저** | +| 5 | 모델 배포 | (미정 — Phase 1은 모델 거의 불필요. Phase 2 진입 시 결정) | +| 6 | 타깃 규모 | 초기 **수천~수만 장** 가정(브루트포스 벡터검색으로 충분), 대규모는 Phase 4 | + +--- + +## 5. 단계별 로드맵 (권장 시퀀스) + +각 Phase는 독립 출시 가능하도록 설계. + +- **Phase 0 — 라이브러리 인덱스 기반** *(필수 선행)* + SQLite 인덱스 + contentHash 식별 + 썸네일 캐시 + AI Worker 스캐폴드(재개 가능 배치). 화면 변화 없음, 토대만. +- **Phase 1 — [P1] 품질 평가 & 스마트 Culling** *(첫 출시 권장)* + 초점(라플라시안)·노출(히스토그램)·감은 눈(face-api 랜드마크) 점수 → '고품질 후보 / 제외' 뷰. 모델 리스크 최저, 가치 즉시. +- **Phase 2 — [P1] CLIP 임베딩 + 자연어/유사 검색** + 임베딩 색인 + 검색 UI(쿼리바·결과 그리드·"이 사진과 비슷한"). §4-1 결정 반영. +- **Phase 3 — [P2] 스마트 그룹화 + 자가 정화** + 임베딩 클러스터링 + pHash 중복/저품질 정리 제안. Phase 1·2 산출물 재사용. +- **Phase 4 — 옵션/결정 의존** + RAW 지원, 미학(NIMA) 점수, 대규모용 ANN 인덱스(sqlite-vec/HNSW). + +--- + +## 6. 리스크 & 솔직한 평가 + +- **규모**: 이 확장은 현재 앱과 **비슷하거나 더 큰 작업량**입니다(수 주 단위, 다중 Phase). "정리기"에서 "지능형 라이브러리"로의 제품 전환입니다. +- **한국어 검색 품질**: CLIP 계열은 영어 중심 → 번역/다국어 모델 선택에 따라 KPI(Search Success Rate)가 크게 좌우됨. **초기 POC로 실측 검증 필요**. +- **성능/하드웨어**: 수천 장 임베딩은 GPU(WebGPU)에서도 수 분~수십 분. 저사양(WASM)에서는 훨씬 느림 → 백그라운드·재개·진행률 UX 필수. +- **앱 용량/배포**: 모델 동봉 시 수백 MB. 배포 방식 결정 필요. +- **RAW**: 브라우저 환경 RAW 디코딩은 까다로움 → 범위 신중 결정. + +## 7. 제안 + +**Phase 0 + Phase 1**(인덱스 기반 + 품질/Culling)을 1차 목표로 삼는 것을 권장합니다. 모델 리스크가 낮고, 자연어 검색(Phase 2)의 토대(인덱스·워커)를 그대로 재사용합니다. 자연어 검색은 §4-1 결정 후 **소규모 POC로 한국어 정확도부터 실측**하고 본 구현에 들어가는 것을 권합니다. + +--- + +## 8. Phase 0 + Phase 1 상세 실행 계획 (확정 범위) + +### 8.1 기술 추가 (이 단계 한정) +- **better-sqlite3** (네이티브) — 인덱스 DB. electron-builder의 `@electron/rebuild`로 ABI 재빌드(현재 빌드 파이프라인이 이미 수행). +- **썸네일은 네이티브(sharp) 없이** AI Worker(렌더러)의 canvas로 생성 → 바이트를 Main이 캐시. 네이티브 의존 최소화. +- Phase 1 품질 점수는 **모델 거의 불필요**: 초점=라플라시안 분산, 노출=휘도 히스토그램, 감은 눈=face-api 랜드마크(EAR). face-api는 이미 추론창에 로드됨. + +### 8.2 데이터 모델 (SQLite) +``` +asset( + id INTEGER PK, contentHash TEXT UNIQUE, path TEXT, ext TEXT, + sizeBytes INT, mtime INT, width INT, height INT, + exifYear TEXT, exifMonth TEXT, indexedAt INT +) +quality( + assetId FK, focus REAL, exposure REAL, eyesOpen REAL, + flag TEXT -- 'candidate' | 'blurry' | 'eyesClosed' | 'badExposure' +) +-- Phase 2 예약: embedding(assetId, vec BLOB), phash(assetId, hash) +``` +- **contentHash**(파일 내용 해시)로 식별 → 경로가 바뀌어도 추적, 정확 중복 인식. `(contentHash, mtime)` 동일하면 재색인 스킵(재개 가능). +- 썸네일: `userData/thumbs/.webp`. + +### 8.3 프로세스/모듈 추가 +``` +Main + indexDb.ts SQLite 래퍼 (better-sqlite3) + libraryStore.ts 색인 대상 라이브러리 폴더 관리(settings) + indexer.ts 오케스트레이터: 라이브러리 워크 → 워커 디스패치 → DB upsert → 진행률 + cullingService.ts Phase1 결과 조회/뷰 데이터 제공 + +AI Worker (기존 숨김 추론창 확장 or 신규 'indexer' 창) + qualityEngine.ts 초점/노출/EAR 점수 + 썸네일 생성 (canvas + face-api 재사용) + +UI (신규 화면) + LibraryView 라이브러리 폴더 지정 + 색인 진행률 + CullingView '고품질 후보 / 제외(흐림·눈감음·노출)' 그리드 + 점수/오버라이드 +``` +- 비파괴: 색인은 파일을 옮기지 않음. Culling 결과로 '제외'를 **태그**만 하고, 원하면 사용자가 별도 폴더로 내보내기(기존 fileOps 재사용, 선택). +- 기존 정리기와 통합: 정리 잡도 인덱스(EXIF/얼굴)를 재사용하도록 점진 연결. + +### 8.4 작업 순서 (체크리스트) +- [ ] Phase 0-a: better-sqlite3 도입 + `indexDb` 스키마/마이그레이션 + 빌드(ABI 재빌드) 검증 +- [ ] Phase 0-b: contentHash 해셔 + 라이브러리 폴더 지정 UI + `indexer` 워크/재개 + 진행률 IPC +- [ ] Phase 0-c: AI Worker에 썸네일 생성 + 캐시 + 그리드 표시(빈 품질로 우선) +- [ ] Phase 1-a: `qualityEngine` 초점/노출 점수 → DB 저장 +- [ ] Phase 1-b: 감은 눈(EAR, face-api 랜드마크) 점수 → flag 산출 +- [ ] Phase 1-c: CullingView(후보/제외 그리드, 점수, 임계값 설정, 오버라이드) +- [ ] Phase 1-d: (옵션) 제외 사진 내보내기/이동 액션 +- [ ] i18n(ko/en) · 다크모드 · 검증(typecheck/test/build/스모크) + +### 8.5 리스크 (이 단계) +- **better-sqlite3 ABI**: Electron 버전별 재빌드 필요 → CI/빌드에서 `electron-builder`가 처리하나, dev 실행 시 `electron-rebuild` 1회 필요할 수 있음. +- **대량 색인 UX**: 수천 장 썸네일/점수 계산은 시간 소요 → 백그라운드·재개·진행률 필수(이미 설계 반영). +- EAR 기반 눈감음은 휴리스틱 → 임계값 튜닝 및 오버라이드 UI로 보완. + +> 본 계획 승인 시 **Phase 0-a(인덱스 DB 토대)**부터 착수. diff --git a/src/inference/imageLoader.ts b/src/inference/imageLoader.ts index f93673e..bea1167 100644 --- a/src/inference/imageLoader.ts +++ b/src/inference/imageLoader.ts @@ -18,7 +18,8 @@ export async function loadImageToCanvas(imagePath: string): Promise): Promise { + const settings = await settingsStore.set(patch) + buildAppMenu({ settings, onChange: applySettings }) + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(IPC.SETTINGS_CHANGED, settings) + } + return settings +} diff --git a/src/main/exif.ts b/src/main/exif.ts index 140c930..da755d1 100644 --- a/src/main/exif.ts +++ b/src/main/exif.ts @@ -8,6 +8,19 @@ function toYearMonth(d: Date, source: CaptureDate['source']): CaptureDate { return { year, month, source } } +/** + * 파일 시스템 수정일(mtime) 기준 날짜. + * 영상 등 EXIF가 없는 파일에 사용. stat 실패 시 현재 시각으로 최후 폴백. + */ +export async function getMtimeDate(path: string): Promise { + try { + const s = await stat(path) + return toYearMonth(s.mtime, 'mtime') + } catch { + return toYearMonth(new Date(), 'mtime') + } +} + /** * 촬영 날짜 추출. * 1) EXIF DateTimeOriginal (없으면 CreateDate/ModifyDate) 시도 @@ -31,11 +44,5 @@ export async function getCaptureDate(path: string): Promise { } // 2) mtime 폴백 - try { - const s = await stat(path) - return toYearMonth(s.mtime, 'mtime') - } catch { - // stat 마저 실패하면 현재 시각으로 최후 폴백 (파일 분류는 계속되어야 함) - return toYearMonth(new Date(), 'mtime') - } + return getMtimeDate(path) } diff --git a/src/main/guide.ts b/src/main/guide.ts new file mode 100644 index 0000000..32fd94e --- /dev/null +++ b/src/main/guide.ts @@ -0,0 +1,226 @@ +import type { Theme as ThemeT } from '@shared/types' +import type { Lang as LangT } from '@shared/i18n' + +/** 가이드 한 섹션 */ +interface Section { + h: string + body: string[] // 각 문단 또는 목록 항목(HTML 허용) +} + +const GUIDE: Record = { + ko: { + title: 'AI Photo Organizer 사용 가이드', + intro: + 'AI Photo Organizer는 얼굴 인식과 촬영일(EXIF) 정보를 이용해, 클라우드 업로드 없이 내 PC 안에서 사진을 자동으로 정리하는 데스크톱 앱입니다. 아래 순서대로 따라 하면 됩니다.', + sections: [ + { + h: '0. 개념 한눈에 보기', + body: [ + '소스 폴더: 정리할 사진/영상이 들어 있는 폴더입니다. (하위 폴더까지 모두 스캔)', + '출력 폴더: 정리 결과가 저장되는 폴더입니다. 인물별/날짜별 폴더가 자동으로 만들어집니다.', + '프로필: 찾고 싶은 인물입니다. 이름과 "참조 얼굴 사진"을 등록하면, 그 얼굴이 들어간 사진을 자동으로 모아줍니다.' + ] + }, + { + h: '1. 인물 프로필 등록', + body: [ + '왼쪽 1. 인물 프로필 영역에서 인물 이름(예: Alex)을 입력하고 [추가]를 누릅니다. 최대 3명까지 등록할 수 있습니다.', + '등록된 카드의 + 얼굴 추가 타일을 눌러 그 인물이 선명하게 정면으로 나온 참조 사진을 여러 장 등록하세요. 사진이 많을수록 인식 정확도가 올라갑니다.', + '썸네일에 마우스를 올리면 우측 상단 ×로 사진을 개별 삭제할 수 있습니다. 얼굴이 검출되지 않은 사진은 자동으로 제외됩니다.', + '프로필 순서가 중요합니다. 한 사진에 여러 인물이 함께 있으면, 1순위(맨 위) 인물 폴더로는 "이동", 나머지 인물 폴더로는 "복사"됩니다.' + ] + }, + { + h: '2. 폴더 선택', + body: [ + '정리할 폴더(소스)결과 저장 폴더(출력)를 각각 [찾기]로 지정합니다.', + '소스와 출력은 다른 폴더로 두는 것을 권장합니다. (출력 폴더 안에 만들어진 결과물은 재실행 시 다시 처리하지 않습니다.)' + ] + }, + { + h: '3. 실행 옵션', + body: [ + '매칭 임계값: 얼굴이 같은 사람인지 판단하는 기준입니다. 낮을수록 더 엄격(오인식↓, 놓침↑)합니다. 기본값 0.5에서 시작해, 다른 사람이 섞이면 낮추고, 본인을 놓치면 높여서 조정하세요.', + '동시 처리: 한 번에 처리할 파일 수입니다. 높이면 빠르지만 메모리를 더 씁니다.', + '검출 엔진: "정확도 우선(SSD)"이 기본이며 더 정확합니다. 사진이 매우 많아 속도가 필요하면 "속도 우선(Tiny)"을 선택하세요.' + ] + }, + { + h: '4. 정리 시작 & 결과 구조', + body: [ + '[정리 시작]을 누르면 진행률과 처리 내역이 실시간으로 표시됩니다.', + '결과는 다음 구조로 생성됩니다:', + '
출력폴더/\n  <인물이름>/YYYY/MM/   ← 그 인물이 있는 사진\n  Unsorted/YYYY/MM/      ← 얼굴 미검출·미등록 인물 사진\n  Movie/YYYY/MM/         ← 영상 파일 (얼굴인식 없이 날짜순)\n  _PhotoAI_logs/         ← 실행 로그
', + '촬영일은 EXIF에서 읽고, 없으면 파일 수정일로 대체합니다.' + ] + }, + { + h: '5. 이동 vs 복사, 그리고 영상', + body: [ + '한 사진에 등록 인물이 한 명이면 그 인물 폴더로 이동합니다.', + '여러 명이 함께 있으면 1순위 인물은 이동, 나머지는 복사되어 모든 인물 폴더에서 볼 수 있습니다.', + '영상 파일(.mp4 .mov .avi .mkv .webm .m4v)은 얼굴 인식 없이 Movie/연/월 폴더로 이동합니다.', + '같은 이름의 파일이 대상 폴더에 이미 있으면, 덮어쓰지 않고 이름_1.jpg처럼 자동으로 이름을 바꿔 저장합니다. (데이터 유실 없음)' + ] + }, + { + h: '6. 데이터 안전성', + body: [ + '"이동"은 복사 → 무결성 검증 → 원본 삭제 순서로 이뤄집니다. 검증을 통과한 뒤에만 원본을 지우므로, 중간에 오류가 나도 원본이 보존됩니다.', + '모든 사진은 클라우드로 전송되지 않고 내 컴퓨터 안에서만 처리됩니다.' + ] + }, + { + h: '7. 정확도를 높이는 팁', + body: [ + '인물당 참조 사진을 5장 이상, 다양한 각도/조명으로 등록하세요.', + '결과에 다른 사람이 섞이면 매칭 임계값을 0.45~0.4로 낮춰 보세요.', + '본인을 자주 놓치면 임계값을 0.55~0.6으로 높이거나 참조 사진을 더 추가하세요.' + ] + }, + { + h: '8. 문제 해결', + body: [ + '썸네일/사진이 안 보임: 참조 사진 원본 파일이 이동/삭제되지 않았는지 확인하세요.', + '모두 Unsorted로 감: 프로필에 얼굴(참조 사진)이 등록되었는지 확인하세요.', + '설정(언어/테마)은 메뉴 보기에서 언제든 바꿀 수 있습니다.' + ] + } + ] + }, + en: { + title: 'AI Photo Organizer — User Guide', + intro: + 'AI Photo Organizer sorts your photos automatically using face recognition and capture date (EXIF), entirely on your own PC with no cloud upload. Follow the steps below.', + sections: [ + { + h: '0. Key concepts', + body: [ + 'Source folder: the folder containing the photos/videos to organize (scanned recursively).', + 'Output folder: where results are saved. Per-person and per-date folders are created automatically.', + 'Profile: a person you want to find. Register a name and "reference face photos", and the app gathers photos containing that face.' + ] + }, + { + h: '1. Register people profiles', + body: [ + 'In the left 1. Person Profiles panel, type a name (e.g. Alex) and click [Add]. Up to 3 people.', + 'Click the + Add face tile on a card to register several clear, front-facing reference photos of that person. More photos = better accuracy.', + 'Hover a thumbnail and click the × to delete it individually. Photos with no detectable face are skipped automatically.', + 'Profile order matters. If a photo contains multiple people, it is moved into the #1 (top) person’s folder and copied into the others.' + ] + }, + { + h: '2. Select folders', + body: [ + 'Pick the source folder and the output folder with [Browse].', + 'Keep source and output separate. (Results created inside the output folder are skipped on re-runs.)' + ] + }, + { + h: '3. Run options', + body: [ + 'Match threshold: how strict face matching is. Lower = stricter (fewer false matches, more misses). Start at 0.5; lower it if strangers leak in, raise it if the person is missed.', + 'Concurrency: how many files are processed at once. Higher is faster but uses more memory.', + 'Detection engine: "Accuracy first (SSD)" is the default and more precise. Choose "Speed first (Tiny)" for very large collections.' + ] + }, + { + h: '4. Start & output structure', + body: [ + 'Click [Start] to see live progress and history.', + 'Results are created like this:', + '
output/\n  <person>/YYYY/MM/   ← photos with that person\n  Unsorted/YYYY/MM/    ← no face / unregistered people\n  Movie/YYYY/MM/       ← video files (by date, no face match)\n  _PhotoAI_logs/       ← run logs
', + 'Capture date is read from EXIF, falling back to the file modified time.' + ] + }, + { + h: '5. Move vs Copy, and videos', + body: [ + 'If a photo has one registered person, it is moved into that person’s folder.', + 'If several are present, the #1 person gets a move and the rest get copies, so it appears in every person’s folder.', + 'Video files (.mp4 .mov .avi .mkv .webm .m4v) are moved into Movie/YYYY/MM without face recognition.', + 'If a file with the same name already exists at the destination, it is auto-renamed like name_1.jpg instead of overwriting (no data loss).' + ] + }, + { + h: '6. Data safety', + body: [ + 'A "move" is done as copy → verify integrity → delete original. The original is removed only after verification, so it survives any mid-way error.', + 'No photo ever leaves your computer.' + ] + }, + { + h: '7. Tips for accuracy', + body: [ + 'Register 5+ reference photos per person, with varied angles and lighting.', + 'If strangers leak into a person’s folder, lower the threshold to ~0.45–0.4.', + 'If the person is often missed, raise it to ~0.55–0.6 or add more reference photos.' + ] + }, + { + h: '8. Troubleshooting', + body: [ + 'Thumbnails/photos not showing: make sure the original reference files were not moved or deleted.', + 'Everything goes to Unsorted: check that the profile actually has reference faces registered.', + 'Language/theme can be changed anytime from the View menu.' + ] + } + ] + } +} + +/** 가이드 전체를 하나의 HTML 문서로 렌더 (테마 반영) */ +export function guideHtml(lang: LangT, theme: ThemeT): string { + const g = GUIDE[lang] + const dark = theme === 'dark' + const bg = dark ? '#0f1117' : '#f7f8fb' + const surface = dark ? '#171a23' : '#ffffff' + const fg = dark ? '#e6e8ee' : '#1f2330' + const muted = dark ? '#9aa0ad' : '#5b6270' + const border = dark ? '#262a36' : '#e2e6ee' + const accent = '#5b7cfa' + + const sectionsHtml = g.sections + .map( + (s) => ` +
+

${s.h}

+ ${s.body.map((b) => (b.startsWith('
') ? b : `

${b}

`)).join('\n')} +
` + ) + .join('\n') + + return ` + + + +${g.title} + + + +
+

${g.title}

+

${g.intro}

+ ${sectionsHtml} +
+ +` +} diff --git a/src/main/index.ts b/src/main/index.ts index 79cfacb..b36f563 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,8 +2,15 @@ import { app, BrowserWindow, shell } from 'electron' import { join } from 'node:path' import { registerIpc } from './ipc' import { inferenceBridge } from './inferenceBridge' +import { registerMediaScheme, handleMediaProtocol } from './mediaProtocol' +import { settingsStore } from './settingsStore' +import { buildAppMenu } from './menu' +import { applySettings } from './applySettings' import { logger } from './logger' +// 커스텀 미디어 스킴은 app ready 이전에 등록해야 한다. +registerMediaScheme() + let mainWindow: BrowserWindow | null = null function createMainWindow(): void { @@ -38,8 +45,12 @@ function createMainWindow(): void { } } -app.whenReady().then(() => { +app.whenReady().then(async () => { + handleMediaProtocol() registerIpc() + // 설정 로드 후 로컬라이즈된 메뉴 빌드 + const settings = await settingsStore.load() + buildAppMenu({ settings, onChange: applySettings }) // 숨김 추론 창을 먼저 띄워 모델 로드를 선행 inferenceBridge.init() createMainWindow() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0a5e3e1..9cbe40f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,10 +1,16 @@ -import { ipcMain, dialog, BrowserWindow } from 'electron' -import type { ProfileInput, JobRequest } from '@shared/types' +import { ipcMain, dialog, BrowserWindow, app } from 'electron' +import { writeFile, mkdir } from 'node:fs/promises' +import { join, extname } from 'node:path' +import type { ProfileInput, JobRequest, ReferenceData } from '@shared/types' import { IPC } from '@shared/constants' import { profileStore } from './profileStore' +import { presetStore } from './presetStore' import { inferenceBridge } from './inferenceBridge' import { orchestrator } from './orchestrator' +import { settingsStore } from './settingsStore' +import { applySettings } from './applySettings' import { logger } from './logger' +import type { Settings } from '@shared/types' /** UI/다이얼로그/잡 관련 IPC 핸들러 등록 */ export function registerIpc(): void { @@ -17,6 +23,28 @@ export function registerIpc(): void { ipcMain.handle(IPC.PROFILES_REMOVE, (_e, id: string) => profileStore.remove(id)) + ipcMain.handle(IPC.PROFILES_REMOVE_REFERENCE, (_e, id: string, imagePath: string) => + profileStore.removeReference(id, imagePath) + ) + + // ---- 프리셋 ---- + ipcMain.handle(IPC.PRESETS_LIST, () => presetStore.list()) + + ipcMain.handle(IPC.PRESETS_SAVE_FROM, async (_e, profileId: string) => { + const profiles = await profileStore.list() + const profile = profiles.find((p) => p.id === profileId) + if (!profile) throw new Error('프로필을 찾을 수 없습니다.') + return presetStore.saveFromProfile(profile) + }) + + ipcMain.handle(IPC.PRESETS_REMOVE, (_e, id: string) => presetStore.remove(id)) + + ipcMain.handle(IPC.PROFILES_APPLY_PRESET, async (_e, presetId: string) => { + const preset = await presetStore.get(presetId) + if (!preset) throw new Error('프리셋을 찾을 수 없습니다.') + return profileStore.createFromPreset(preset) + }) + ipcMain.handle( IPC.PROFILES_ADD_REFERENCE, async (_e, id: string, imagePaths: string[]) => { @@ -33,6 +61,35 @@ export function registerIpc(): void { } ) + // 경로 없는 이미지(붙여넣기/드롭된 메모리 이미지)를 userData/refs// 에 저장 후 등록 + ipcMain.handle( + IPC.PROFILES_ADD_REFERENCE_DATA, + async (_e, id: string, items: ReferenceData[]) => { + await inferenceBridge.whenReady() + const dir = join(app.getPath('userData'), 'refs', id) + await mkdir(dir, { recursive: true }) + + const savedPaths: string[] = [] + for (const item of items) { + const ext = (extname(item.name || '') || '.png').toLowerCase() + const dest = join(dir, `${crypto.randomUUID()}${ext}`) + await writeFile(dest, Buffer.from(item.data)) + savedPaths.push(dest) + } + + const results = await inferenceBridge.describe(savedPaths, 'ssd') + const valid = results.filter((r) => r.descriptor !== null) + if (valid.length === 0) { + throw new Error('붙여넣은/드롭한 이미지에서 얼굴을 찾지 못했습니다.') + } + return profileStore.addReference( + id, + valid.map((r) => r.imagePath), + valid.map((r) => r.descriptor as number[]) + ) + } + ) + // ---- 다이얼로그 ---- ipcMain.handle(IPC.DIALOG_PICK_SOURCE, async () => { const r = await dialog.showOpenDialog({ properties: ['openDirectory'] }) @@ -66,4 +123,8 @@ export function registerIpc(): void { }) ipcMain.handle(IPC.JOB_CANCEL, () => orchestrator.cancel()) + + // ---- 설정 ---- + ipcMain.handle(IPC.SETTINGS_GET, () => settingsStore.load()) + ipcMain.handle(IPC.SETTINGS_SET, (_e, patch: Partial) => applySettings(patch)) } diff --git a/src/main/mediaProtocol.ts b/src/main/mediaProtocol.ts new file mode 100644 index 0000000..8ad2bfa --- /dev/null +++ b/src/main/mediaProtocol.ts @@ -0,0 +1,69 @@ +import { protocol } from 'electron' +import { readFile } from 'node:fs/promises' +import { extname } from 'node:path' +import { MEDIA_SCHEME } from '@shared/constants' +import { profileStore } from './profileStore' +import { presetStore } from './presetStore' +import { logger } from './logger' + +const MIME: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp' +} + +/** + * 로컬 참조 이미지를 UI 렌더러에 안전하게 표시하기 위한 커스텀 프로토콜. + * + * URL 형식: photoai-media://img/?p= + * + * 보안: 렌더러가 임의 파일을 읽지 못하도록, **현재 등록된 참조 이미지 경로**에 + * 한해서만 파일을 제공한다. (그 외 경로는 403) + */ + +/** app.whenReady 이전에 호출해야 함 — 스킴을 권한 있는 표준 스킴으로 등록 */ +export function registerMediaScheme(): void { + protocol.registerSchemesAsPrivileged([ + { + scheme: MEDIA_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + stream: true + } + } + ]) +} + +/** app.whenReady 이후에 호출 — 실제 요청 핸들러 등록 */ +export function handleMediaProtocol(): void { + protocol.handle(MEDIA_SCHEME, async (request) => { + try { + // request.url = photoai-media://img/?p= + // searchParams의 자동 디코딩과의 이중 디코딩을 피하려고 raw 문자열을 직접 파싱한다. + const marker = 'p=' + const i = request.url.indexOf(marker) + if (i < 0) return new Response('missing path', { status: 400 }) + const filePath = decodeURIComponent(request.url.slice(i + marker.length)) + + // 등록된 참조 이미지(활성 프로필 또는 프리셋)에 한해 제공 — 임의 파일 읽기 차단 + const allowed = + (await profileStore.isReferenceImage(filePath)) || + (await presetStore.isReferenceImage(filePath)) + if (!allowed) return new Response('forbidden', { status: 403 }) + + // net.fetch(file://) 대신 fs로 직접 읽어 바이트 반환 → 한글/공백 경로에도 안전 + const data = await readFile(filePath) + const mime = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream' + return new Response(new Uint8Array(data), { + status: 200, + headers: { 'content-type': mime, 'cache-control': 'no-cache' } + }) + } catch (err) { + logger.error('media 프로토콜 처리 실패', { message: (err as Error).message }) + return new Response('not found', { status: 404 }) + } + }) +} diff --git a/src/main/menu.ts b/src/main/menu.ts new file mode 100644 index 0000000..0eb379b --- /dev/null +++ b/src/main/menu.ts @@ -0,0 +1,140 @@ +import { Menu, BrowserWindow, app, type MenuItemConstructorOptions } from 'electron' +import type { Settings } from '@shared/types' +import { translate, LANGS, LANG_LABEL, type Lang } from '@shared/i18n' +import { guideHtml } from './guide' + +let guideWin: BrowserWindow | null = null + +/** 사용 가이드 창 열기 (현재 언어/테마 반영) */ +export function openGuideWindow(settings: Settings): void { + if (guideWin && !guideWin.isDestroyed()) { + guideWin.focus() + return + } + guideWin = new BrowserWindow({ + width: 920, + height: 780, + title: translate(settings.language, 'menu.guide'), + autoHideMenuBar: true, + webPreferences: { contextIsolation: true, nodeIntegration: false } + }) + const html = guideHtml(settings.language, settings.theme) + guideWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)) + guideWin.on('closed', () => { + guideWin = null + }) +} + +interface BuildOpts { + settings: Settings + /** 테마/언어 변경 시 호출 (설정 저장 + 메뉴 재빌드 + 렌더러 통지) */ + onChange: (patch: Partial) => void +} + +/** 로컬라이즈된 애플리케이션 메뉴를 빌드/적용 */ +export function buildAppMenu({ settings, onChange }: BuildOpts): void { + const t = (k: string, p?: Record) => + translate(settings.language, k, p) + const isMac = process.platform === 'darwin' + const template: MenuItemConstructorOptions[] = [] + + if (isMac) { + template.push({ + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit', label: t('menu.quit') } + ] + }) + } + + // File + template.push({ + label: t('menu.file'), + submenu: [ + isMac + ? { role: 'close', label: t('menu.close') } + : { role: 'quit', label: t('menu.quit') } + ] + }) + + // Edit + template.push({ + label: t('menu.edit'), + submenu: [ + { role: 'undo', label: t('menu.undo') }, + { role: 'redo', label: t('menu.redo') }, + { type: 'separator' }, + { role: 'cut', label: t('menu.cut') }, + { role: 'copy', label: t('menu.copy') }, + { role: 'paste', label: t('menu.paste') }, + { role: 'selectAll', label: t('menu.selectall') } + ] + }) + + // View — 테마/언어 + 표준 보기 도구 + template.push({ + label: t('menu.view'), + submenu: [ + { + label: t('menu.appearance'), + submenu: [ + { + label: t('menu.theme.dark'), + type: 'radio', + checked: settings.theme === 'dark', + click: () => onChange({ theme: 'dark' }) + }, + { + label: t('menu.theme.light'), + type: 'radio', + checked: settings.theme === 'light', + click: () => onChange({ theme: 'light' }) + } + ] + }, + { + label: t('menu.language'), + submenu: LANGS.map((l: Lang) => ({ + label: LANG_LABEL[l], + type: 'radio' as const, + checked: settings.language === l, + click: () => onChange({ language: l }) + })) + }, + { type: 'separator' }, + { role: 'reload', label: t('menu.reload') }, + { role: 'toggleDevTools', label: t('menu.devtools') }, + { type: 'separator' }, + { role: 'resetZoom', label: t('menu.resetzoom') }, + { role: 'zoomIn', label: t('menu.zoomin') }, + { role: 'zoomOut', label: t('menu.zoomout') }, + { type: 'separator' }, + { role: 'togglefullscreen', label: t('menu.fullscreen') } + ] + }) + + // Window + template.push({ + label: t('menu.window'), + submenu: [ + { role: 'minimize', label: t('menu.minimize') }, + { role: 'close', label: t('menu.close') } + ] + }) + + // Help + template.push({ + label: t('menu.help'), + submenu: [{ label: t('menu.guide'), click: () => openGuideWindow(settings) }] + }) + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} diff --git a/src/main/orchestrator.ts b/src/main/orchestrator.ts index 069e92a..b36ec5e 100644 --- a/src/main/orchestrator.ts +++ b/src/main/orchestrator.ts @@ -7,9 +7,10 @@ import type { ProfileMatch } from '@shared/types' import { IPC } from '@shared/constants' -import { scan, countImages, defaultSkipDirs } from './scanner' -import { getCaptureDate } from './exif' +import { scan, countMedia, defaultSkipDirs, mediaKind } from './scanner' +import { getCaptureDate, getMtimeDate } from './exif' import { buildTargetPath } from './pathBuilder' +import { MOVIE_FOLDER } from '@shared/constants' import { safeMove, safeCopy } from './fileOps' import { profileStore } from './profileStore' import { inferenceBridge } from './inferenceBridge' @@ -57,8 +58,8 @@ class Orchestrator { const skip = defaultSkipDirs(profiles.map((p) => p.name)) // 진행률 total 산출 - const total = await countImages(req.source, skip) - logger.info('스캔 대상 이미지 수', { total }) + const total = await countMedia(req.source, skip) + logger.info('스캔 대상 미디어 수', { total }) let done = 0 const limit = createLimiter(Math.max(1, req.options.concurrency)) @@ -103,6 +104,16 @@ class Orchestrator { ): Promise { void profilesOrdered try { + // 영상은 얼굴인식 없이 날짜 기준으로 Movie 폴더로 이동 + if (mediaKind(file) === 'video') { + const vdate = await getMtimeDate(file) + const dest = await safeMove( + file, + buildTargetPath(req.outputRoot, MOVIE_FOLDER, vdate, file) + ) + return { file, kind: 'movie', targets: [dest], matchedNames: [], date: vdate } + } + // 얼굴 인식 + 날짜 추출 병렬 const [match, date] = await Promise.all([ inferenceBridge.detect(file), diff --git a/src/main/presetStore.ts b/src/main/presetStore.ts new file mode 100644 index 0000000..a36eeb9 --- /dev/null +++ b/src/main/presetStore.ts @@ -0,0 +1,94 @@ +import { app } from 'electron' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import type { Preset, Profile } from '@shared/types' +import { PRESET_STORE_FILE, MAX_PRESETS } from '@shared/constants' +import { logger } from './logger' + +/** + * 프리셋(자주 쓰는 인물 라이브러리) 영속화. userData/presets.json (로컬 전용). + * PC/환경이 바뀌면 이 데이터는 사라진다 (클라우드 동기화 없음). + */ +class PresetStore { + private presets: Preset[] = [] + private loaded = false + + private filePath(): string { + return join(app.getPath('userData'), PRESET_STORE_FILE) + } + + async load(): Promise { + if (this.loaded) return this.presets + try { + const raw = await readFile(this.filePath(), 'utf-8') + const parsed = JSON.parse(raw) as { presets?: Preset[] } + this.presets = Array.isArray(parsed.presets) ? parsed.presets : [] + } catch { + this.presets = [] + } + this.loaded = true + return this.presets + } + + async list(): Promise { + await this.load() + return [...this.presets].sort((a, b) => a.createdAt - b.createdAt) + } + + private async persist(): Promise { + await mkdir(app.getPath('userData'), { recursive: true }) + await writeFile( + this.filePath(), + JSON.stringify({ presets: this.presets }, null, 2), + 'utf-8' + ) + } + + /** 프로필을 프리셋으로 저장. 같은 이름이 있으면 덮어쓰기, 없으면 추가(최대 5). */ + async saveFromProfile(profile: Profile): Promise { + await this.load() + if (profile.descriptors.length === 0) { + throw new Error('얼굴(참조 사진)이 등록된 프로필만 프리셋으로 저장할 수 있습니다.') + } + const existing = this.presets.find((p) => p.name === profile.name) + if (existing) { + existing.referenceImages = [...profile.referenceImages] + existing.descriptors = profile.descriptors.map((d) => [...d]) + existing.createdAt = Date.now() + } else { + if (this.presets.length >= MAX_PRESETS) { + throw new Error(`프리셋은 최대 ${MAX_PRESETS}개까지 저장할 수 있습니다.`) + } + this.presets.push({ + id: globalThis.crypto.randomUUID(), + name: profile.name, + referenceImages: [...profile.referenceImages], + descriptors: profile.descriptors.map((d) => [...d]), + createdAt: Date.now() + }) + } + await this.persist() + logger.info('프리셋 저장', { name: profile.name }) + return this.list() + } + + async remove(id: string): Promise { + await this.load() + this.presets = this.presets.filter((p) => p.id !== id) + await this.persist() + return this.list() + } + + async get(id: string): Promise { + await this.load() + return this.presets.find((p) => p.id === id) + } + + /** media 프로토콜 보안 검사: 프리셋 참조 이미지인지 */ + async isReferenceImage(path: string): Promise { + await this.load() + return this.presets.some((p) => p.referenceImages.includes(path)) + } +} + +export const presetStore = new PresetStore() diff --git a/src/main/profileStore.ts b/src/main/profileStore.ts index a942cfe..add2210 100644 --- a/src/main/profileStore.ts +++ b/src/main/profileStore.ts @@ -1,7 +1,7 @@ import { app } from 'electron' import { readFile, writeFile, mkdir } from 'node:fs/promises' import { join } from 'node:path' -import type { Profile, ProfileInput } from '@shared/types' +import type { Profile, ProfileInput, Preset } from '@shared/types' import { PROFILE_STORE_FILE, MAX_PROFILES } from '@shared/constants' import { logger } from './logger' @@ -77,6 +77,47 @@ class ProfileStore { await this.persist() } + /** 프리셋 데이터로 새 활성 프로필 생성 (descriptor 재계산 없이 그대로 사용) */ + async createFromPreset(preset: Preset): Promise { + await this.load() + if (this.profiles.length >= MAX_PROFILES) { + throw new Error(`프로필은 최대 ${MAX_PROFILES}명까지 등록 가능합니다.`) + } + const profile: Profile = { + id: cryptoRandomId(), + name: preset.name, + order: this.profiles.length, + referenceImages: [...preset.referenceImages], + descriptors: preset.descriptors.map((d) => [...d]) + } + this.profiles.push(profile) + await this.persist() + logger.info('프리셋에서 프로필 생성', { name: profile.name }) + return profile + } + + /** 주어진 절대경로가 현재 등록된 참조 이미지인지 (media 프로토콜 보안 검사용) */ + async isReferenceImage(path: string): Promise { + await this.load() + return this.profiles.some((p) => p.referenceImages.includes(path)) + } + + /** 참조 이미지 1장 제거 (해당 descriptor도 같은 인덱스에서 함께 제거) */ + async removeReference(id: string, imagePath: string): Promise { + await this.load() + const p = this.profiles.find((x) => x.id === id) + if (!p) throw new Error(`프로필을 찾을 수 없음: ${id}`) + const idx = p.referenceImages.indexOf(imagePath) + if (idx >= 0) { + // referenceImages와 descriptors는 1:1 정렬 상태이므로 같은 인덱스 제거 + p.referenceImages.splice(idx, 1) + p.descriptors.splice(idx, 1) + await this.persist() + logger.info('참조 이미지 제거', { id, imagePath }) + } + return p + } + /** 참조 이미지 + 계산된 descriptor 추가 */ async addReference( id: string, diff --git a/src/main/reporter.ts b/src/main/reporter.ts index 64cfdae..d9f09dd 100644 --- a/src/main/reporter.ts +++ b/src/main/reporter.ts @@ -10,6 +10,7 @@ export class Reporter { private moved = 0 private copied = 0 private unmatched = 0 + private movies = 0 private failed = 0 private total = 0 private readonly startedAt: number @@ -29,6 +30,9 @@ export class Reporter { case 'unmatched': this.unmatched++ break + case 'movie': + this.movies++ + break case 'failed': this.failed++ break @@ -51,6 +55,7 @@ export class Reporter { moved: this.moved, copied: this.copied, unmatched: this.unmatched, + movies: this.movies, failed: this.failed, elapsedMs: finishedAt - this.startedAt, logPath, diff --git a/src/main/scanner.ts b/src/main/scanner.ts index 173f4f6..f3962ec 100644 --- a/src/main/scanner.ts +++ b/src/main/scanner.ts @@ -1,11 +1,28 @@ import { readdir } from 'node:fs/promises' import { extname, join } from 'node:path' -import { SUPPORTED_EXTENSIONS, LOG_FOLDER, UNMATCHED_FOLDER } from '@shared/constants' +import { + SUPPORTED_EXTENSIONS, + SUPPORTED_VIDEO_EXTENSIONS, + LOG_FOLDER, + UNMATCHED_FOLDER, + MOVIE_FOLDER +} from '@shared/constants' -const EXT_SET = new Set(SUPPORTED_EXTENSIONS) +const IMAGE_EXT_SET = new Set(SUPPORTED_EXTENSIONS) +const VIDEO_EXT_SET = new Set(SUPPORTED_VIDEO_EXTENSIONS) -function isSupportedImage(filename: string): boolean { - return EXT_SET.has(extname(filename).toLowerCase()) +export type MediaKind = 'image' | 'video' + +/** 확장자로 미디어 종류 판별. 지원하지 않으면 null. */ +export function mediaKind(filename: string): MediaKind | null { + const ext = extname(filename).toLowerCase() + if (IMAGE_EXT_SET.has(ext)) return 'image' + if (VIDEO_EXT_SET.has(ext)) return 'video' + return null +} + +function isSupportedMedia(filename: string): boolean { + return mediaKind(filename) !== null } /** @@ -32,7 +49,7 @@ export async function* scan( // 우리 자신이 만든 폴더(프로필/[미정]/로그)는 재귀 제외 if (skipDirs.has(entry.name)) continue yield* scan(full, skipDirs) - } else if (entry.isFile() && isSupportedImage(entry.name)) { + } else if (entry.isFile() && isSupportedMedia(entry.name)) { yield full } } @@ -42,7 +59,7 @@ export async function* scan( * 전체 개수를 먼저 세는 헬퍼 (진행률 total 표시용). * 스캔을 한 번 더 도는 비용이 있으나, 정확한 진행률을 위해 사용. */ -export async function countImages( +export async function countMedia( root: string, skipDirs: ReadonlySet = new Set() ): Promise { @@ -56,5 +73,5 @@ export async function countImages( /** 출력물 재처리 방지를 위한 기본 제외 디렉터리 집합 */ export function defaultSkipDirs(profileNames: string[]): Set { - return new Set([LOG_FOLDER, UNMATCHED_FOLDER, ...profileNames]) + return new Set([LOG_FOLDER, UNMATCHED_FOLDER, MOVIE_FOLDER, ...profileNames]) } diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts new file mode 100644 index 0000000..f722ae4 --- /dev/null +++ b/src/main/settingsStore.ts @@ -0,0 +1,50 @@ +import { app } from 'electron' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import type { Settings } from '@shared/types' +import { SETTINGS_FILE } from '@shared/constants' +import { DEFAULT_LANG } from '@shared/i18n' + +const DEFAULTS: Settings = { + language: DEFAULT_LANG, // 기본 한국어 + theme: 'dark', // 기본 다크모드 + onboarded: false +} + +/** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */ +class SettingsStore { + private settings: Settings = { ...DEFAULTS } + private loaded = false + + private filePath(): string { + return join(app.getPath('userData'), SETTINGS_FILE) + } + + async load(): Promise { + if (this.loaded) return this.settings + try { + const raw = await readFile(this.filePath(), 'utf-8') + const parsed = JSON.parse(raw) as Partial + this.settings = { ...DEFAULTS, ...parsed } + } catch { + this.settings = { ...DEFAULTS } + } + this.loaded = true + return this.settings + } + + /** 동기 접근(load 이후). 메뉴 빌드 등에서 사용 */ + current(): Settings { + return this.settings + } + + async set(patch: Partial): Promise { + await this.load() + this.settings = { ...this.settings, ...patch } + await mkdir(app.getPath('userData'), { recursive: true }) + await writeFile(this.filePath(), JSON.stringify(this.settings, null, 2), 'utf-8') + return this.settings + } +} + +export const settingsStore = new SettingsStore() diff --git a/src/preload/index.ts b/src/preload/index.ts index 7ae2386..d76665b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,9 +1,11 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webUtils } from 'electron' import { IPC } from '../shared/constants' import type { ExposedApi, ProfileInput, JobRequest, + Settings, + ReferenceData, RendererEventName, RendererEvents } from '../shared/types' @@ -13,7 +15,8 @@ const EVENT_CHANNELS: Record = { 'job:progress': IPC.JOB_PROGRESS, 'job:fileProcessed': IPC.JOB_FILE_PROCESSED, 'job:done': IPC.JOB_DONE, - 'job:error': IPC.JOB_ERROR + 'job:error': IPC.JOB_ERROR, + 'settings:changed': IPC.SETTINGS_CHANGED } const api: ExposedApi = { @@ -22,7 +25,18 @@ const api: ExposedApi = { upsert: (input: ProfileInput) => ipcRenderer.invoke(IPC.PROFILES_UPSERT, input), remove: (id: string) => ipcRenderer.invoke(IPC.PROFILES_REMOVE, id), addReference: (id: string, imagePaths: string[]) => - ipcRenderer.invoke(IPC.PROFILES_ADD_REFERENCE, id, imagePaths) + ipcRenderer.invoke(IPC.PROFILES_ADD_REFERENCE, id, imagePaths), + addReferenceData: (id: string, items: ReferenceData[]) => + ipcRenderer.invoke(IPC.PROFILES_ADD_REFERENCE_DATA, id, items), + removeReference: (id: string, imagePath: string) => + ipcRenderer.invoke(IPC.PROFILES_REMOVE_REFERENCE, id, imagePath), + applyPreset: (presetId: string) => + ipcRenderer.invoke(IPC.PROFILES_APPLY_PRESET, presetId) + }, + presets: { + list: () => ipcRenderer.invoke(IPC.PRESETS_LIST), + saveFrom: (profileId: string) => ipcRenderer.invoke(IPC.PRESETS_SAVE_FROM, profileId), + remove: (id: string) => ipcRenderer.invoke(IPC.PRESETS_REMOVE, id) }, dialog: { pickSource: () => ipcRenderer.invoke(IPC.DIALOG_PICK_SOURCE), @@ -33,6 +47,12 @@ const api: ExposedApi = { run: (req: JobRequest) => ipcRenderer.invoke(IPC.JOB_RUN, req), cancel: () => ipcRenderer.invoke(IPC.JOB_CANCEL) }, + settings: { + get: () => ipcRenderer.invoke(IPC.SETTINGS_GET), + set: (patch: Partial) => ipcRenderer.invoke(IPC.SETTINGS_SET, patch) + }, + // Electron 33: File.path 제거됨 → webUtils로 드롭된 파일의 실제 경로 획득 + getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File), on(event: E, cb: (payload: RendererEvents[E]) => void) { const channel = EVENT_CHANNELS[event] const listener = (_e: unknown, payload: RendererEvents[E]) => cb(payload) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ba6c549..8196791 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,7 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useStore, wireEvents } from './store' +import { useT } from './i18n' +import { Onboarding } from './components/Onboarding' import { ProfileManager } from './components/ProfileManager' import { FolderPicker } from './components/FolderPicker' import { RunControl } from './components/RunControl' @@ -8,35 +10,44 @@ import { FileList } from './components/FileList' import { ReportView } from './components/ReportView' export default function App(): JSX.Element { + const t = useT() const phase = useStore((s) => s.phase) + const onboarded = useStore((s) => s.onboarded) const refreshProfiles = useStore((s) => s.refreshProfiles) + const initSettings = useStore((s) => s.initSettings) + const [ready, setReady] = useState(false) useEffect(() => { const unwire = wireEvents() + // 설정 로드(테마 적용 포함) 후 화면 표시 + void initSettings().then(() => setReady(true)) void refreshProfiles() return unwire - }, [refreshProfiles]) + }, [refreshProfiles, initSettings]) + + if (!ready) return
+ if (!onboarded) return return ( -
-
-

AI Photo Organizer

-

- 얼굴 인식 + 촬영일 기준 자동 사진 정리 · 로컬 전용 -

+
+
+

{t('app.title')}

+

{t('app.subtitle')}

-
- {/* 좌측: 설정 패널 */} -
+
+ {/* 좌측: 설정 패널 (자체 스크롤) */} +
- {/* 우측: 진행/결과 */} -
- {phase === 'done' ? : } + {/* 우측: 진행/결과 — FileList만 내부 스크롤 */} +
+
+ {phase === 'done' ? : } +
diff --git a/src/renderer/components/FileList.tsx b/src/renderer/components/FileList.tsx index 114d237..1be9d6f 100644 --- a/src/renderer/components/FileList.tsx +++ b/src/renderer/components/FileList.tsx @@ -1,56 +1,57 @@ import { useStore } from '../store' +import { useT } from '../i18n' +import { baseName } from '../media' import type { FileDecisionKind } from '@shared/types' -const KIND_STYLE: Record = { - moved: { label: '이동', cls: 'bg-emerald-100 text-emerald-700' }, - copied: { label: '복사', cls: 'bg-sky-100 text-sky-700' }, - unmatched: { label: '미정', cls: 'bg-slate-200 text-slate-600' }, - failed: { label: '실패', cls: 'bg-red-100 text-red-700' } +const KIND_CLS: Record = { + moved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300', + copied: 'bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300', + unmatched: 'bg-slate-200 text-slate-600 dark:bg-slate-600/40 dark:text-slate-300', + movie: 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300', + failed: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300' } -function baseName(p: string): string { - const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) - return idx >= 0 ? p.slice(idx + 1) : p -} - -/** 처리 결과 스트림 (최근 건 상단) */ +/** 처리 결과 스트림 (최근 건 상단) — 이 블록만 내부 스크롤 */ export function FileList(): JSX.Element { + const t = useT() const processed = useStore((s) => s.processed) return ( -
-
-

처리 내역

- 최근 {processed.length}건 +
+
+

{t('history.section')}

+ + {t('history.recent', { n: processed.length })} +
-
+
{processed.length === 0 ? ( -

아직 처리된 파일이 없습니다.

+

{t('history.empty')}

) : ( -
    - {processed.map((f, i) => { - const style = KIND_STYLE[f.kind] - return ( -
  • - - {style.label} - - - {baseName(f.file)} - - - {f.matchedNames.length > 0 - ? f.matchedNames.join(', ') - : f.error - ? f.error.slice(0, 40) - : '—'} - -
  • - ) - })} +
      + {processed.map((f, i) => ( +
    • + + {t(`kind.${f.kind}`)} + + + {baseName(f.file)} + + + {f.matchedNames.length > 0 + ? f.matchedNames.join(', ') + : f.error + ? f.error.slice(0, 40) + : '—'} + +
    • + ))}
    )}
diff --git a/src/renderer/components/FolderPicker.tsx b/src/renderer/components/FolderPicker.tsx index 3b7e61c..f83d786 100644 --- a/src/renderer/components/FolderPicker.tsx +++ b/src/renderer/components/FolderPicker.tsx @@ -1,7 +1,9 @@ import { useStore } from '../store' +import { useT } from '../i18n' /** 소스 폴더 + 출력 루트 선택 */ export function FolderPicker(): JSX.Element { + const t = useT() const source = useStore((s) => s.source) const outputRoot = useStore((s) => s.outputRoot) const setSource = useStore((s) => s.setSource) @@ -17,11 +19,11 @@ export function FolderPicker(): JSX.Element { } return ( -
-

2. 폴더 선택

+
+

{t('folder.section')}

- - + +
) } @@ -29,20 +31,22 @@ export function FolderPicker(): JSX.Element { function Row(props: { label: string value: string | null + placeholder: string + browse: string onPick: () => void }): JSX.Element { return (
-
{props.label}
+
{props.label}
-
- {props.value ?? '미선택'} +
+ {props.value ?? props.placeholder}
diff --git a/src/renderer/components/Onboarding.tsx b/src/renderer/components/Onboarding.tsx new file mode 100644 index 0000000..ac6757e --- /dev/null +++ b/src/renderer/components/Onboarding.tsx @@ -0,0 +1,79 @@ +import { useStore } from '../store' +import { useT } from '../i18n' +import { LANGS, LANG_LABEL, type Lang } from '@shared/i18n' +import type { Theme } from '@shared/types' + +/** 첫 실행 시 언어/테마를 고르는 온보딩 화면 ("installation 단계" 대용) */ +export function Onboarding(): JSX.Element { + const t = useT() + const language = useStore((s) => s.language) + const theme = useStore((s) => s.theme) + const updateSettings = useStore((s) => s.updateSettings) + + // 선택 즉시 미리보기로 반영(테마/언어), 완료 시 onboarded 저장 + const pickLang = (l: Lang) => updateSettings({ language: l }) + const pickTheme = (th: Theme) => updateSettings({ theme: th }) + const finish = () => updateSettings({ onboarded: true }) + + return ( +
+
+

+ {t('app.title')} +

+

{t('onboard.subtitle')}

+ + {/* 언어 */} +
+
+ {t('onboard.language')} +
+
+ {LANGS.map((l) => ( + + ))} +
+
+ + {/* 테마 */} +
+
+ {t('onboard.theme')} +
+
+ {(['dark', 'light'] as Theme[]).map((th) => ( + + ))} +
+
+ + +
+
+ ) +} diff --git a/src/renderer/components/ProfileManager.tsx b/src/renderer/components/ProfileManager.tsx index 4371910..7e040b9 100644 --- a/src/renderer/components/ProfileManager.tsx +++ b/src/renderer/components/ProfileManager.tsx @@ -1,21 +1,75 @@ -import { useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useStore } from '../store' -import { MAX_PROFILES } from '@shared/constants' +import { useT } from '../i18n' +import { MAX_PROFILES, MAX_PRESETS } from '@shared/constants' +import { mediaUrl, baseName } from '../media' +import type { Profile, Preset } from '@shared/types' -/** 최대 3인 프로필 등록/수정 + 참조 이미지 추가 */ +const IMG_RE = /\.(jpe?g|png|webp)$/i +function isImageFile(f: File): boolean { + return f.type.startsWith('image/') || IMG_RE.test(f.name) +} + +/** 최대 3인 프로필 등록/수정 + 참조 이미지 썸네일 카드 (클릭/드래그&드롭/붙여넣기) */ export function ProfileManager(): JSX.Element { + const t = useT() const profiles = useStore((s) => s.profiles) const refreshProfiles = useStore((s) => s.refreshProfiles) const [name, setName] = useState('') - const [busy, setBusy] = useState(null) const [error, setError] = useState(null) + const [busyId, setBusyId] = useState(null) + const [activeId, setActiveId] = useState(null) + const [presets, setPresets] = useState([]) + const activeIdRef = useRef(null) + activeIdRef.current = activeId + + const refreshPresets = useCallback(async () => { + setPresets(await window.api.presets.list()) + }, []) + useEffect(() => { + void refreshPresets() + }, [refreshPresets]) + + // 현재 프로필을 프리셋으로 저장 + const savePreset = useCallback( + async (profileId: string) => { + setError(null) + try { + setPresets(await window.api.presets.saveFrom(profileId)) + } catch (e) { + setError((e as Error).message) + } + }, + [] + ) + + // 프리셋을 활성 프로필로 불러오기 + const applyPreset = useCallback( + async (presetId: string) => { + if (profiles.length >= MAX_PROFILES) { + setError(t('profile.profilesFull', { max: MAX_PROFILES })) + return + } + setError(null) + try { + await window.api.profiles.applyPreset(presetId) + await refreshProfiles() + } catch (e) { + setError((e as Error).message) + } + }, + [profiles.length, refreshProfiles, t] + ) + + const removePreset = useCallback(async (presetId: string) => { + setPresets(await window.api.presets.remove(presetId)) + }, []) const addProfile = async () => { const trimmed = name.trim() if (!trimmed) return setError(null) try { - // 등록 순서 = 현재 인원 수 (뒤에 추가) await window.api.profiles.upsert({ name: trimmed, order: profiles.length }) setName('') await refreshProfiles() @@ -24,39 +78,95 @@ export function ProfileManager(): JSX.Element { } } - const addReference = async (id: string) => { - const paths = await window.api.dialog.pickImages() - if (paths.length === 0) return - setBusy(id) - setError(null) - try { - await window.api.profiles.addReference(id, paths) - await refreshProfiles() - } catch (e) { - setError((e as Error).message) - } finally { - setBusy(null) - } - } + /** File[] 을 받아 경로/메모리 데이터로 나눠 등록 */ + const ingest = useCallback( + async (profileId: string, fileList: ArrayLike) => { + const files = Array.from(fileList).filter(isImageFile) + if (files.length === 0) return + setBusyId(profileId) + setError(null) + try { + const paths: string[] = [] + const data: { name: string; data: ArrayBuffer }[] = [] + for (const f of files) { + const p = window.api.getPathForFile(f) + if (p) paths.push(p) + else data.push({ name: f.name || 'pasted.png', data: await f.arrayBuffer() }) + } + if (paths.length) await window.api.profiles.addReference(profileId, paths) + if (data.length) await window.api.profiles.addReferenceData(profileId, data) + await refreshProfiles() + } catch (e) { + setError((e as Error).message) + } finally { + setBusyId(null) + } + }, + [refreshProfiles] + ) - const remove = async (id: string) => { - await window.api.profiles.remove(id) - await refreshProfiles() - } + const pickAndAdd = useCallback( + async (profileId: string) => { + const paths = await window.api.dialog.pickImages() + if (paths.length === 0) return + setBusyId(profileId) + setError(null) + try { + await window.api.profiles.addReference(profileId, paths) + await refreshProfiles() + } catch (e) { + setError((e as Error).message) + } finally { + setBusyId(null) + } + }, + [refreshProfiles] + ) + + // 붙여넣기(Ctrl+V): 마우스가 올라가 있는(=활성) 카드에 추가. + // 또한 카드 밖에 파일을 떨어뜨려도 창이 이동하지 않도록 전역 기본동작 차단. + useEffect(() => { + const onPaste = (e: ClipboardEvent) => { + const id = activeIdRef.current + if (!id || !e.clipboardData) return + const imgs: File[] = [] + const items = e.clipboardData.items + for (let i = 0; i < items.length; i++) { + const it = items[i] + if (it.kind === 'file' && it.type.startsWith('image/')) { + const f = it.getAsFile() + if (f) imgs.push(f) + } + } + if (imgs.length) { + e.preventDefault() + void ingest(id, imgs) + } + } + const prevent = (e: DragEvent) => e.preventDefault() + document.addEventListener('paste', onPaste) + window.addEventListener('dragover', prevent) + window.addEventListener('drop', prevent) + return () => { + document.removeEventListener('paste', onPaste) + window.removeEventListener('dragover', prevent) + window.removeEventListener('drop', prevent) + } + }, [ingest]) return ( -
+
-

1. 인물 프로필

+

{t('profile.section')}

- {profiles.length}/{MAX_PROFILES}명 · 순서 = 이동 우선순위 + {t('profile.count', { n: profiles.length, max: MAX_PROFILES })}
setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addProfile()} @@ -67,48 +177,194 @@ export function ProfileManager(): JSX.Element { onClick={addProfile} disabled={profiles.length >= MAX_PROFILES || !name.trim()} > - 추가 + {t('common.add')}
- {error &&

{error}

} + {error &&

{error}

} -
    +
      {profiles.map((p, i) => ( -
    • -
      - #{i + 1} - {p.name} - - 참조 {p.descriptors.length}장 - -
      -
      - - -
      -
    • + profile={p} + index={i} + busy={busyId === p.id} + active={activeId === p.id} + onActivate={() => setActiveId(p.id)} + onPick={() => pickAndAdd(p.id)} + onDropFiles={(files) => ingest(p.id, files)} + onSavePreset={() => savePreset(p.id)} + onRefresh={refreshProfiles} + /> ))} {profiles.length === 0 && ( -
    • - 등록된 프로필이 없습니다. 이름을 추가하고 참조 얼굴 사진을 등록하세요. -
    • +
    • {t('profile.empty')}
    • )}
    + + {/* 프리셋(인물 라이브러리) */} +
    +
    +

    {t('profile.presets')}

    + + {presets.length}/{MAX_PRESETS} + +
    +

    {t('profile.presetsHint')}

    + + {presets.length === 0 ? ( +

    {t('profile.presetsEmpty')}

    + ) : ( +
    + {presets.map((ps) => ( +
    applyPreset(ps.id)} + > + {ps.referenceImages[0] ? ( + {ps.name} + ) : ( + + {ps.name.slice(0, 1).toUpperCase()} + + )} + {ps.name} + +
    + ))} +
    + )} +
) } + +function ProfileCard(props: { + profile: Profile + index: number + busy: boolean + active: boolean + onActivate: () => void + onPick: () => void + onDropFiles: (files: FileList) => void + onSavePreset: () => void + onRefresh: () => Promise +}): JSX.Element { + const t = useT() + const { profile: p, index, busy, active } = props + const canSavePreset = p.descriptors.length > 0 + const [dragging, setDragging] = useState(false) + + const removeProfile = async () => { + await window.api.profiles.remove(p.id) + await props.onRefresh() + } + const removeReference = async (imagePath: string) => { + await window.api.profiles.removeReference(p.id, imagePath) + await props.onRefresh() + } + + return ( +
  • { + e.preventDefault() + setDragging(true) + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault() + setDragging(false) + props.onDropFiles(e.dataTransfer.files) + }} + > +
    +
    + #{index + 1} + {p.name} + + {t('profile.refCount', { n: p.referenceImages.length })} + +
    +
    + + +
    +
    + + {/* 참조 이미지 썸네일 그리드 */} +
    + {p.referenceImages.map((img) => ( +
    + {baseName(img)} + +
    + ))} + + {/* 추가 타일 (클릭) */} + +
    + +

    {t('profile.dndHint')}

    +
  • + ) +} diff --git a/src/renderer/components/ProgressView.tsx b/src/renderer/components/ProgressView.tsx index bf49643..6937179 100644 --- a/src/renderer/components/ProgressView.tsx +++ b/src/renderer/components/ProgressView.tsx @@ -1,7 +1,9 @@ import { useStore } from '../store' +import { useT } from '../i18n' /** 실시간 진행률 바 + 현재 처리 파일 */ export function ProgressView(): JSX.Element { + const t = useT() const phase = useStore((s) => s.phase) const progress = useStore((s) => s.progress) @@ -10,15 +12,15 @@ export function ProgressView(): JSX.Element { const pct = total > 0 ? Math.round((done / total) * 100) : 0 return ( -
    +
    -

    진행 상황

    - - {phase === 'running' ? `${done} / ${total}` : '대기 중'} +

    {t('progress.section')}

    + + {phase === 'running' ? `${done} / ${total}` : t('progress.waiting')}
    -
    +
    - {progress?.current ?? (phase === 'running' ? '스캔 중…' : '실행 대기')} + {progress?.current ?? (phase === 'running' ? t('progress.scanning') : t('progress.idle'))} {pct}%
    diff --git a/src/renderer/components/ReportView.tsx b/src/renderer/components/ReportView.tsx index a6fe143..7f3f0e4 100644 --- a/src/renderer/components/ReportView.tsx +++ b/src/renderer/components/ReportView.tsx @@ -1,36 +1,41 @@ import { useStore } from '../store' - -function fmtDuration(ms: number): string { - const s = Math.round(ms / 1000) - const m = Math.floor(s / 60) - const rem = s % 60 - return m > 0 ? `${m}분 ${rem}초` : `${rem}초` -} +import { useT } from '../i18n' /** 잡 완료 후 결과 리포트 */ export function ReportView(): JSX.Element { + const t = useT() const report = useStore((s) => s.report) const errors = useStore((s) => s.errors) if (!report) return <> + const fmtDuration = (ms: number): string => { + const s = Math.round(ms / 1000) + const m = Math.floor(s / 60) + const rem = s % 60 + return m > 0 ? t('dur.ms', { m, s: rem }) : t('dur.s', { s: rem }) + } + const stats = [ - { label: '총 처리', value: report.total, cls: 'text-slate-700' }, - { label: '이동', value: report.moved, cls: 'text-emerald-600' }, - { label: '복사', value: report.copied, cls: 'text-sky-600' }, - { label: '미정', value: report.unmatched, cls: 'text-slate-500' }, - { label: '실패', value: report.failed, cls: 'text-red-600' } + { label: t('report.total'), value: report.total, cls: 'text-slate-700 dark:text-slate-200' }, + { label: t('kind.moved'), value: report.moved, cls: 'text-emerald-600 dark:text-emerald-400' }, + { label: t('kind.copied'), value: report.copied, cls: 'text-sky-600 dark:text-sky-400' }, + { label: t('kind.unmatched'), value: report.unmatched, cls: 'text-slate-500 dark:text-slate-400' }, + { label: t('kind.movie'), value: report.movies, cls: 'text-violet-600 dark:text-violet-400' }, + { label: t('kind.failed'), value: report.failed, cls: 'text-red-600 dark:text-red-400' } ] return ( -
    +
    -

    ✅ 작업 완료

    - 소요 {fmtDuration(report.elapsedMs)} +

    {t('report.done')}

    + + {t('report.elapsed', { d: fmtDuration(report.elapsedMs) })} +
    -
    +
    {stats.map((s) => ( -
    +
    {s.value}
    {s.label}
    @@ -38,15 +43,15 @@ export function ReportView(): JSX.Element {
    - 로그: {report.logPath} + {t('report.log', { path: report.logPath })}
    {errors.length > 0 && (
    - - 오류 {errors.length}건 보기 + + {t('report.errors', { n: errors.length })} -
      +
        {errors.map((e, i) => (
      • {e.file}: {e.message} diff --git a/src/renderer/components/RunControl.tsx b/src/renderer/components/RunControl.tsx index 98250b8..cbd3ee1 100644 --- a/src/renderer/components/RunControl.tsx +++ b/src/renderer/components/RunControl.tsx @@ -1,7 +1,9 @@ import { useStore } from '../store' +import { useT } from '../i18n' /** 실행/취소 + 옵션(임계값, 동시성, 검출기) */ export function RunControl(): JSX.Element { + const t = useT() const { source, outputRoot, profiles, options, phase } = useStore((s) => ({ source: s.source, outputRoot: s.outputRoot, @@ -19,13 +21,14 @@ export function RunControl(): JSX.Element { const running = phase === 'running' return ( -
        -

        3. 실행 옵션

        +
        +

        {t('run.section')}

        -
        {!hasDescriptors && ( -

        - ⚠️ 등록된 얼굴이 없습니다. 매칭 인물 없이 모두 [미정]으로 분류됩니다. -

        +

        {t('run.noFaceWarn')}

        )}
        {!running ? ( ) : ( )} {phase === 'done' && ( )}
        diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts new file mode 100644 index 0000000..7a79f5c --- /dev/null +++ b/src/renderer/i18n.ts @@ -0,0 +1,11 @@ +import { useStore } from './store' +import { translate } from '@shared/i18n' + +/** + * 현재 선택된 언어로 바인딩된 번역 함수를 반환하는 훅. + * 언어가 바뀌면 컴포넌트가 자동 리렌더된다. + */ +export function useT(): (key: string, params?: Record) => string { + const language = useStore((s) => s.language) + return (key, params) => translate(language, key, params) +} diff --git a/src/renderer/index.html b/src/renderer/index.html index fa76ead..2af53eb 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ AI Photo Organizer diff --git a/src/renderer/media.ts b/src/renderer/media.ts new file mode 100644 index 0000000..8f4fdaf --- /dev/null +++ b/src/renderer/media.ts @@ -0,0 +1,15 @@ +import { MEDIA_SCHEME } from '@shared/constants' + +/** + * 로컬 절대경로를 photoai-media 프로토콜 URL로 변환. + * main/mediaProtocol.ts 의 핸들러가 이 형식을 해석한다. + */ +export function mediaUrl(absolutePath: string): string { + return `${MEDIA_SCHEME}://img/?p=${encodeURIComponent(absolutePath)}` +} + +/** 경로에서 파일명만 추출 (표시용) */ +export function baseName(p: string): string { + const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + return idx >= 0 ? p.slice(idx + 1) : p +} diff --git a/src/renderer/store.ts b/src/renderer/store.ts index 1b5da23..70ab99b 100644 --- a/src/renderer/store.ts +++ b/src/renderer/store.ts @@ -4,12 +4,21 @@ import type { JobOptions, FileProcessed, ProgressEvent, - Report + Report, + Settings, + Theme } from '@shared/types' +import type { Lang } from '@shared/i18n' +import { DEFAULT_LANG } from '@shared/i18n' import { DEFAULT_JOB_OPTIONS } from '@shared/constants' export type JobPhase = 'idle' | 'running' | 'done' +/** 테마를 클래스에 반영 (Tailwind darkMode:'class') */ +function applyTheme(theme: Theme): void { + document.documentElement.classList.toggle('dark', theme === 'dark') +} + interface AppState { // 프로필 profiles: Profile[] @@ -35,11 +44,19 @@ interface AppState { cancelJob: () => Promise resetJob: () => void + // 설정(언어/테마/온보딩) + language: Lang + theme: Theme + onboarded: boolean + initSettings: () => Promise + updateSettings: (patch: Partial) => Promise + // 이벤트 핸들러(내부) _onProgress: (p: ProgressEvent) => void _onFile: (f: FileProcessed) => void _onDone: (r: Report) => void _onError: (e: { file: string; message: string }) => void + _onSettings: (s: Settings) => void } export const useStore = create((set, get) => ({ @@ -74,6 +91,21 @@ export const useStore = create((set, get) => ({ }, resetJob: () => set({ phase: 'idle', progress: null, processed: [], report: null, errors: [] }), + // ---- 설정 ---- + language: DEFAULT_LANG, + theme: 'dark', + onboarded: false, + initSettings: async () => { + const s = await window.api.settings.get() + applyTheme(s.theme) + set({ language: s.language, theme: s.theme, onboarded: s.onboarded }) + }, + updateSettings: async (patch) => { + const s = await window.api.settings.set(patch) + applyTheme(s.theme) + set({ language: s.language, theme: s.theme, onboarded: s.onboarded }) + }, + _onProgress: (progress) => set({ progress }), _onFile: (f) => set((s) => ({ @@ -81,7 +113,11 @@ export const useStore = create((set, get) => ({ processed: [f, ...s.processed].slice(0, 500) })), _onDone: (report) => set({ report, phase: 'done' }), - _onError: (e) => set((s) => ({ errors: [e, ...s.errors].slice(0, 200) })) + _onError: (e) => set((s) => ({ errors: [e, ...s.errors].slice(0, 200) })), + _onSettings: (s) => { + applyTheme(s.theme) + set({ language: s.language, theme: s.theme, onboarded: s.onboarded }) + } })) /** 앱 시작 시 1회: Main→UI 이벤트 구독 */ @@ -91,7 +127,8 @@ export function wireEvents(): () => void { window.api.on('job:progress', s._onProgress), window.api.on('job:fileProcessed', s._onFile), window.api.on('job:done', s._onDone), - window.api.on('job:error', s._onError) + window.api.on('job:error', s._onError), + window.api.on('settings:changed', s._onSettings) ] return () => offs.forEach((off) => off()) } diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index a85b515..cf5ad89 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -2,8 +2,10 @@ @tailwind components; @tailwind utilities; -:root { - color-scheme: light; +html, +body, +#root { + height: 100%; } body { @@ -13,6 +15,12 @@ body { color: #1f2330; } +/* 다크 테마: 토글에 반응 */ +html.dark body { + background: #0f1117; + color: #e6e8ee; +} + /* 파일 목록 가독성용 모노 폰트 */ .mono { font-family: 'Cascadia Code', 'Consolas', monospace; diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4465b01..a11f4d7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -3,8 +3,24 @@ /** 처리 대상 이미지 확장자 (소문자, 점 포함) */ export const SUPPORTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const -/** 미검출/인식실패 사진이 들어가는 폴더명 */ -export const UNMATCHED_FOLDER = '[미정]' +/** 처리 대상 영상 확장자 (소문자, 점 포함) */ +export const SUPPORTED_VIDEO_EXTENSIONS = [ + '.mp4', + '.mov', + '.avi', + '.mkv', + '.webm', + '.m4v' +] as const + +/** 미검출/인식실패 사진이 들어가는 폴더명 (언어 중립 — 영어 모드에서 한글 노출 방지) */ +export const UNMATCHED_FOLDER = 'Unsorted' + +/** 영상 파일이 들어가는 폴더명 (얼굴인식 없이 날짜 기준 이동) */ +export const MOVIE_FOLDER = 'Movie' + +/** 로컬 참조 이미지를 UI 창에 안전하게 표시하기 위한 커스텀 프로토콜 스킴 */ +export const MEDIA_SCHEME = 'photoai-media' /** 로그 폴더명 (출력 루트 하위) */ export const LOG_FOLDER = '_PhotoAI_logs' @@ -12,9 +28,18 @@ export const LOG_FOLDER = '_PhotoAI_logs' /** 프로필 영속화 파일명 (userData 하위) */ export const PROFILE_STORE_FILE = 'profiles.json' +/** 앱 설정 영속화 파일명 (userData 하위) */ +export const SETTINGS_FILE = 'settings.json' + +/** 프리셋(인물 라이브러리) 영속화 파일명 (userData 하위, 로컬 전용) */ +export const PRESET_STORE_FILE = 'presets.json' + /** 최대 프로필 인원 (PRD) */ export const MAX_PROFILES = 3 +/** 최대 프리셋(저장 인물) 수 */ +export const MAX_PRESETS = 5 + /** 기본 잡 옵션 */ export const DEFAULT_JOB_OPTIONS = { matchThreshold: 0.5, @@ -32,11 +57,22 @@ export const IPC = { PROFILES_UPSERT: 'profiles:upsert', PROFILES_REMOVE: 'profiles:remove', PROFILES_ADD_REFERENCE: 'profiles:addReference', + PROFILES_ADD_REFERENCE_DATA: 'profiles:addReferenceData', + PROFILES_REMOVE_REFERENCE: 'profiles:removeReference', + PROFILES_APPLY_PRESET: 'profiles:applyPreset', + // 프리셋(인물 라이브러리) + PRESETS_LIST: 'presets:list', + PRESETS_SAVE_FROM: 'presets:saveFrom', + PRESETS_REMOVE: 'presets:remove', DIALOG_PICK_SOURCE: 'dialog:pickSource', DIALOG_PICK_OUTPUT: 'dialog:pickOutput', DIALOG_PICK_IMAGES: 'dialog:pickImages', JOB_RUN: 'job:run', JOB_CANCEL: 'job:cancel', + // 설정 + SETTINGS_GET: 'settings:get', + SETTINGS_SET: 'settings:set', + SETTINGS_CHANGED: 'settings:changed', // Main → UI (send) JOB_PROGRESS: 'job:progress', JOB_FILE_PROCESSED: 'job:fileProcessed', diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts new file mode 100644 index 0000000..486f634 --- /dev/null +++ b/src/shared/i18n.ts @@ -0,0 +1,187 @@ +// 다국어(i18n) 문자열 테이블. ko/en 두 언어를 지원한다. +// 메인(메뉴/가이드)과 렌더러(UI)가 공용으로 사용한다. + +export type Lang = 'ko' | 'en' + +export const LANGS: Lang[] = ['ko', 'en'] +export const DEFAULT_LANG: Lang = 'ko' + +export const LANG_LABEL: Record = { + ko: '한국어', + en: 'English' +} + +/** 키 → 언어별 문자열. {token} 형태의 치환자를 지원. */ +type Table = Record> + +export const MESSAGES: Table = { + // 공통 + 'app.title': { ko: 'AI Photo Organizer', en: 'AI Photo Organizer' }, + 'app.subtitle': { + ko: '얼굴 인식 + 촬영일 기준 자동 사진 정리 · 로컬 전용', + en: 'Auto-organize photos by face recognition + capture date · Local only' + }, + 'common.add': { ko: '추가', en: 'Add' }, + + // 온보딩 + 'onboard.title': { ko: '시작하기 전에', en: 'Before you start' }, + 'onboard.subtitle': { + ko: '언어와 테마를 선택하세요. 나중에 메뉴에서 바꿀 수 있습니다.', + en: 'Choose your language and theme. You can change these later from the menu.' + }, + 'onboard.language': { ko: '언어', en: 'Language' }, + 'onboard.theme': { ko: '테마', en: 'Theme' }, + 'onboard.dark': { ko: '다크', en: 'Dark' }, + 'onboard.light': { ko: '라이트', en: 'Light' }, + 'onboard.start': { ko: '시작하기', en: 'Get Started' }, + + // 1. 프로필 + 'profile.section': { ko: '1. 인물 프로필', en: '1. Person Profiles' }, + 'profile.count': { + ko: '{n}/{max}명 · 순서 = 이동 우선순위', + en: '{n}/{max} people · order = move priority' + }, + 'profile.namePlaceholder': { + ko: '인물 이름 (예: Alex)', + en: 'Person name (e.g. Alex)' + }, + 'profile.dndHint': { + ko: '타일 클릭 · 드래그&드롭 · 붙여넣기(Ctrl+V)로 추가', + en: 'Add by click, drag & drop, or paste (Ctrl+V)' + }, + 'profile.empty': { + ko: '등록된 프로필이 없습니다. 이름을 추가하고 참조 얼굴 사진을 등록하세요.', + en: 'No profiles yet. Add a name and register reference face photos.' + }, + 'profile.refCount': { ko: '참조 {n}장', en: '{n} refs' }, + 'profile.delete': { ko: '프로필 삭제', en: 'Delete profile' }, + 'profile.savePreset': { ko: '프리셋 저장', en: 'Save preset' }, + 'profile.presets': { ko: '프리셋', en: 'Presets' }, + 'profile.presetsHint': { + ko: '자주 쓰는 인물을 저장해두고 클릭으로 불러옵니다 (로컬 전용)', + en: 'Save people you use often and load them with a click (local only)' + }, + 'profile.presetsEmpty': { + ko: "저장된 프리셋이 없습니다. 프로필의 '프리셋 저장'으로 추가하세요.", + en: "No presets yet. Use 'Save preset' on a profile." + }, + 'profile.applyPresetTitle': { + ko: '클릭해서 인물 프로필에 추가', + en: 'Click to add to profiles' + }, + 'profile.deletePreset': { ko: '프리셋 삭제', en: 'Delete preset' }, + 'profile.presetsFull': { + ko: '프리셋이 가득 찼습니다 (최대 {max}개).', + en: 'Presets are full (max {max}).' + }, + 'profile.profilesFull': { + ko: '프로필이 가득 찼습니다 (최대 {max}명).', + en: 'Profiles are full (max {max}).' + }, + 'profile.addFace': { ko: '얼굴 추가', en: 'Add face' }, + 'profile.analyzing': { ko: '분석 중', en: 'Analyzing' }, + 'profile.deletePhoto': { ko: '이 사진 삭제', en: 'Delete this photo' }, + + // 2. 폴더 + 'folder.section': { ko: '2. 폴더 선택', en: '2. Select Folders' }, + 'folder.source': { ko: '정리할 폴더 (소스)', en: 'Folder to organize (source)' }, + 'folder.output': { ko: '결과 저장 폴더 (출력)', en: 'Output folder' }, + 'folder.unselected': { ko: '미선택', en: 'Not selected' }, + 'folder.browse': { ko: '찾기', en: 'Browse' }, + + // 3. 실행 옵션 + 'run.section': { ko: '3. 실행 옵션', en: '3. Run Options' }, + 'run.threshold': { ko: '매칭 임계값 ({v})', en: 'Match threshold ({v})' }, + 'run.thresholdHint': { ko: '낮을수록 엄격', en: 'Lower = stricter' }, + 'run.thresholdTip': { + ko: '두 얼굴이 같은 사람인지 판단하는 거리 기준입니다.\n• 낮추면(예: 0.4) 더 엄격 → 다른 사람이 섞일 확률↓, 대신 본인을 놓칠 수 있음\n• 높이면(예: 0.6) 더 너그러움 → 본인을 잘 찾음, 대신 다른 사람이 섞일 수 있음\n권장: 0.45~0.55', + en: 'Distance threshold for deciding whether two faces are the same person.\n• Lower (e.g. 0.4) = stricter → fewer false matches, but may miss the person\n• Higher (e.g. 0.6) = looser → catches the person more, but may include others\nRecommended: 0.45–0.55' + }, + 'run.concurrency': { ko: '동시 처리 ({v})', en: 'Concurrency ({v})' }, + 'run.concurrencyTip': { + ko: '한 번에 동시에 처리하는 파일 수입니다.\n• 높이면 처리 속도↑, 대신 메모리(RAM)·CPU 사용량↑\n• 사양이 낮거나 고해상도 사진이 많으면 2~3 권장', + en: 'How many files are processed at the same time.\n• Higher = faster, but uses more memory (RAM) and CPU\n• On lower-end machines or with many high-resolution photos, 2–3 is recommended' + }, + 'run.detector': { ko: '검출 엔진', en: 'Detection engine' }, + 'run.detectorSsd': { + ko: '정확도 우선 (SSD MobileNet)', + en: 'Accuracy first (SSD MobileNet)' + }, + 'run.detectorTiny': { ko: '속도 우선 (Tiny Face)', en: 'Speed first (Tiny Face)' }, + 'run.noFaceWarn': { + ko: "⚠️ 등록된 얼굴이 없습니다. 매칭 인물 없이 모두 'Unsorted' 폴더로 분류됩니다.", + en: "⚠️ No registered faces. Everything will be sorted into the 'Unsorted' folder." + }, + 'run.start': { ko: '정리 시작', en: 'Start' }, + 'run.rerun': { ko: '다시 실행', en: 'Run again' }, + 'run.cancel': { ko: '취소', en: 'Cancel' }, + 'run.reset': { ko: '초기화', en: 'Reset' }, + + // 진행/결과 + 'progress.section': { ko: '진행 상황', en: 'Progress' }, + 'progress.waiting': { ko: '대기 중', en: 'Waiting' }, + 'progress.scanning': { ko: '스캔 중…', en: 'Scanning…' }, + 'progress.idle': { ko: '실행 대기', en: 'Idle' }, + + 'history.section': { ko: '처리 내역', en: 'History' }, + 'history.recent': { ko: '최근 {n}건', en: 'Recent {n}' }, + 'history.empty': { ko: '아직 처리된 파일이 없습니다.', en: 'No files processed yet.' }, + + 'kind.moved': { ko: '이동', en: 'Moved' }, + 'kind.copied': { ko: '복사', en: 'Copied' }, + 'kind.unmatched': { ko: '미정', en: 'Unsorted' }, + 'kind.movie': { ko: '영상', en: 'Video' }, + 'kind.failed': { ko: '실패', en: 'Failed' }, + + 'report.done': { ko: '✅ 작업 완료', en: '✅ Done' }, + 'report.elapsed': { ko: '소요 {d}', en: 'Took {d}' }, + 'report.total': { ko: '총 처리', en: 'Total' }, + 'report.log': { ko: '로그: {path}', en: 'Log: {path}' }, + 'report.errors': { ko: '오류 {n}건 보기', en: 'View {n} errors' }, + 'dur.ms': { ko: '{m}분 {s}초', en: '{m}m {s}s' }, + 'dur.s': { ko: '{s}초', en: '{s}s' }, + + // 메뉴 + 'menu.file': { ko: '파일', en: 'File' }, + 'menu.edit': { ko: '편집', en: 'Edit' }, + 'menu.view': { ko: '보기', en: 'View' }, + 'menu.window': { ko: '창', en: 'Window' }, + 'menu.help': { ko: '도움말', en: 'Help' }, + 'menu.quit': { ko: '종료', en: 'Quit' }, + 'menu.undo': { ko: '실행 취소', en: 'Undo' }, + 'menu.redo': { ko: '다시 실행', en: 'Redo' }, + 'menu.cut': { ko: '잘라내기', en: 'Cut' }, + 'menu.copy': { ko: '복사', en: 'Copy' }, + 'menu.paste': { ko: '붙여넣기', en: 'Paste' }, + 'menu.selectall': { ko: '모두 선택', en: 'Select All' }, + 'menu.reload': { ko: '새로고침', en: 'Reload' }, + 'menu.devtools': { ko: '개발자 도구', en: 'Toggle DevTools' }, + 'menu.resetzoom': { ko: '실제 크기', en: 'Actual Size' }, + 'menu.zoomin': { ko: '확대', en: 'Zoom In' }, + 'menu.zoomout': { ko: '축소', en: 'Zoom Out' }, + 'menu.fullscreen': { ko: '전체 화면', en: 'Toggle Full Screen' }, + 'menu.minimize': { ko: '최소화', en: 'Minimize' }, + 'menu.close': { ko: '닫기', en: 'Close' }, + 'menu.appearance': { ko: '테마', en: 'Appearance' }, + 'menu.theme.dark': { ko: '다크 모드', en: 'Dark' }, + 'menu.theme.light': { ko: '라이트 모드', en: 'Light' }, + 'menu.language': { ko: '언어', en: 'Language' }, + 'menu.guide': { ko: '사용 방법 가이드', en: 'User Guide' }, + 'menu.about': { ko: '정보', en: 'About' } +} + +/** 번역. 누락 키는 키 자체를 반환(개발 중 가시성). {token} 치환 지원. */ +export function translate( + lang: Lang, + key: string, + params?: Record +): string { + const entry = MESSAGES[key] + let text = entry ? entry[lang] : key + if (params) { + for (const [k, v] of Object.entries(params)) { + text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)) + } + } + return text +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 2942c09..6a777cd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,18 @@ // 전 프로세스(Main/Preload/Renderer/Inference)가 공유하는 타입 정의 +import type { Lang } from './i18n' + +/** UI 테마 */ +export type Theme = 'dark' | 'light' + +/** 앱 설정 (userData/settings.json) */ +export interface Settings { + language: Lang + theme: Theme + /** 첫 실행 온보딩(언어/테마 선택) 완료 여부 */ + onboarded: boolean +} + /** 등록된 인물 프로필 */ export interface Profile { id: string @@ -20,6 +33,23 @@ export interface ProfileInput { order: number } +/** 프리셋: 저장된 인물(라이브러리). 클릭하면 활성 프로필로 불러온다. 로컬 전용. */ +export interface Preset { + id: string + name: string + referenceImages: string[] + descriptors: number[][] + createdAt: number +} + +/** 경로가 없는(붙여넣기/메모리) 이미지 데이터 — main이 파일로 저장 후 참조로 등록 */ +export interface ReferenceData { + /** 표시/확장자 추출용 파일명 */ + name: string + /** 이미지 바이트 (IPC 구조화 복제로 전달) */ + data: ArrayBuffer +} + /** 한 사진에 대한 단일 인물 매칭 결과 */ export interface ProfileMatch { profileId: string @@ -72,7 +102,7 @@ export interface JobRequest { } /** 파일 1건 처리 후 결정 종류 */ -export type FileDecisionKind = 'moved' | 'copied' | 'unmatched' | 'failed' +export type FileDecisionKind = 'moved' | 'copied' | 'unmatched' | 'movie' | 'failed' /** 파일 1건 처리 결과 (UI 스트림 + 리포트용) */ export interface FileProcessed { @@ -101,6 +131,8 @@ export interface Report { moved: number copied: number unmatched: number + /** Movie 폴더로 이동된 영상 수 */ + movies: number failed: number /** 소요 시간(ms) */ elapsedMs: number @@ -116,6 +148,7 @@ export interface RendererEvents { 'job:fileProcessed': FileProcessed 'job:done': Report 'job:error': { file: string; message: string } + 'settings:changed': Settings } export type RendererEventName = keyof RendererEvents @@ -127,6 +160,17 @@ export interface ExposedApi { upsert(input: ProfileInput): Promise remove(id: string): Promise addReference(id: string, imagePaths: string[]): Promise + /** 경로 없는 이미지(붙여넣기/드롭된 메모리 이미지)를 저장 후 참조로 등록 */ + addReferenceData(id: string, items: ReferenceData[]): Promise + removeReference(id: string, imagePath: string): Promise + /** 프리셋을 활성 프로필로 불러와 새 프로필 생성 */ + applyPreset(presetId: string): Promise + } + presets: { + list(): Promise + /** 현재 활성 프로필을 프리셋으로 저장 */ + saveFrom(profileId: string): Promise + remove(id: string): Promise } dialog: { pickSource(): Promise @@ -137,6 +181,12 @@ export interface ExposedApi { run(req: JobRequest): Promise cancel(): Promise } + settings: { + get(): Promise + set(patch: Partial): Promise + } + /** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */ + getPathForFile(file: unknown): string on( event: E, cb: (payload: RendererEvents[E]) => void diff --git a/tailwind.config.mjs b/tailwind.config.mjs index 96b424d..f1a624d 100644 --- a/tailwind.config.mjs +++ b/tailwind.config.mjs @@ -1,5 +1,6 @@ /** @type {import('tailwindcss').Config} */ export default { + darkMode: 'class', content: ['./src/renderer/**/*.{html,ts,tsx}'], theme: { extend: { diff --git a/tests/pathBuilder.test.ts b/tests/pathBuilder.test.ts index fc22344..17a35a6 100644 --- a/tests/pathBuilder.test.ts +++ b/tests/pathBuilder.test.ts @@ -10,9 +10,14 @@ describe('buildTargetPath', () => { expect(p.replace(/\\/g, '/')).toBe('/out/seunghyun/2024/03/photo.jpg') }) - it('미검출(who=null) 시 [미정] 폴더로 보낸다', () => { + it('미검출(who=null) 시 Unsorted 폴더로 보낸다', () => { const p = buildTargetPath('/out', null, date, '/src/photo.png') - expect(p.replace(/\\/g, '/')).toBe('/out/[미정]/2024/03/photo.png') + expect(p.replace(/\\/g, '/')).toBe('/out/Unsorted/2024/03/photo.png') + }) + + it('영상은 Movie/YYYY/MM 폴더로 보낸다', () => { + const p = buildTargetPath('/out', 'Movie', date, '/src/clip.mp4') + expect(p.replace(/\\/g, '/')).toBe('/out/Movie/2024/03/clip.mp4') }) }) diff --git a/tests/scanner.test.ts b/tests/scanner.test.ts new file mode 100644 index 0000000..e1ef65f --- /dev/null +++ b/tests/scanner.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { mediaKind } from '../src/main/scanner' + +describe('mediaKind', () => { + it('이미지 확장자를 image로 분류한다', () => { + expect(mediaKind('a.jpg')).toBe('image') + expect(mediaKind('a.JPEG')).toBe('image') + expect(mediaKind('a.png')).toBe('image') + expect(mediaKind('a.webp')).toBe('image') + }) + + it('영상 확장자를 video로 분류한다', () => { + expect(mediaKind('a.mp4')).toBe('video') + expect(mediaKind('a.MOV')).toBe('video') + expect(mediaKind('a.mkv')).toBe('video') + expect(mediaKind('a.m4v')).toBe('video') + }) + + it('지원하지 않는 확장자는 null', () => { + expect(mediaKind('a.txt')).toBeNull() + expect(mediaKind('a.heic')).toBeNull() + expect(mediaKind('noext')).toBeNull() + }) +})