Add video sorting, reference thumbnails, theme/i18n, menu, DnD/paste, presets

Feature work on top of the initial organizer:

- Videos: .mp4/.mov/.avi/.mkv/.webm/.m4v sorted into output/Movie/YYYY/MM
- Profiles: reference-image thumbnail cards via a secure photoai-media:// protocol
  (serves only registered reference images); per-thumbnail delete; add via
  click, drag & drop, or paste (Ctrl+V) using webUtils.getPathForFile + a
  byte-based addReferenceData path for clipboard images
- Presets: local person library (max 5) saved to userData; one-click apply into
  active profiles; reusing stored descriptors (no recompute)
- Theme: dark/light with dark default (Tailwind class strategy)
- i18n: ko/en table-based localization; first-run onboarding to pick
  language + theme; English-neutral "Unsorted" folder (was [미정])
- App menu: File/Edit/View/Window/Help, localized; Help opens a detailed,
  theme-aware user guide window
- UI: History block scrolls internally (no whole-window scroll);
  threshold/concurrency tooltips; generic example name
- Settings persisted to userData/settings.json; menu + renderer kept in sync
- Docs: NextGen Photo AI feasibility review + Phase 0/1 roadmap

All typecheck/tests (12) /build green; boot smoke verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:40:44 +09:00
parent 8a8c10248c
commit 6dce580846
38 changed files with 1916 additions and 212 deletions
+2
View File
@@ -3,6 +3,8 @@ out/
release/
dist/
*.log
# dev 스모크 실행 잔여물
smoke-*
.DS_Store
# TypeScript 증분 빌드 정보 (생성물)
*.tsbuildinfo
+2 -1
View File
@@ -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` 로 자동 리네임한다(덮어쓰기 없음).
+15 -2
View File
@@ -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 / 비기능 요구
+180
View File
@@ -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/<contentHash>.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 토대)**부터 착수.
+2 -1
View File
@@ -18,7 +18,8 @@ export async function loadImageToCanvas(imagePath: string): Promise<HTMLCanvasEl
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
// willReadFrequently: face-api가 픽셀을 반복 readback 하므로 켜두면 빠르고 경고도 사라짐
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) throw new Error('2D 컨텍스트 생성 실패')
ctx.drawImage(img, 0, 0, w, h)
+19
View File
@@ -0,0 +1,19 @@
import { BrowserWindow } from 'electron'
import type { Settings } from '@shared/types'
import { IPC } from '@shared/constants'
import { settingsStore } from './settingsStore'
import { buildAppMenu } from './menu'
/**
* 설정 변경 단일 진입점:
* 1) 영속화 2) 메뉴 재빌드(라디오 체크/언어 라벨 갱신) 3) 모든 창에 변경 통지
* 메뉴 클릭과 렌더러(IPC) 양쪽에서 동일하게 사용한다.
*/
export async function applySettings(patch: Partial<Settings>): Promise<Settings> {
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
}
+14 -7
View File
@@ -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<CaptureDate> {
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<CaptureDate> {
}
// 2) mtime 폴백
try {
const s = await stat(path)
return toYearMonth(s.mtime, 'mtime')
} catch {
// stat 마저 실패하면 현재 시각으로 최후 폴백 (파일 분류는 계속되어야 함)
return toYearMonth(new Date(), 'mtime')
}
return getMtimeDate(path)
}
+226
View File
@@ -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<LangT, { title: string; intro: string; sections: Section[] }> = {
ko: {
title: 'AI Photo Organizer 사용 가이드',
intro:
'AI Photo Organizer는 얼굴 인식과 촬영일(EXIF) 정보를 이용해, 클라우드 업로드 없이 내 PC 안에서 사진을 자동으로 정리하는 데스크톱 앱입니다. 아래 순서대로 따라 하면 됩니다.',
sections: [
{
h: '0. 개념 한눈에 보기',
body: [
'<b>소스 폴더</b>: 정리할 사진/영상이 들어 있는 폴더입니다. (하위 폴더까지 모두 스캔)',
'<b>출력 폴더</b>: 정리 결과가 저장되는 폴더입니다. 인물별/날짜별 폴더가 자동으로 만들어집니다.',
'<b>프로필</b>: 찾고 싶은 인물입니다. 이름과 "참조 얼굴 사진"을 등록하면, 그 얼굴이 들어간 사진을 자동으로 모아줍니다.'
]
},
{
h: '1. 인물 프로필 등록',
body: [
'왼쪽 <b>1. 인물 프로필</b> 영역에서 인물 이름(예: <code>Alex</code>)을 입력하고 [추가]를 누릅니다. 최대 3명까지 등록할 수 있습니다.',
'등록된 카드의 <b>+ 얼굴 추가</b> 타일을 눌러 그 인물이 <b>선명하게 정면으로 나온 참조 사진</b>을 여러 장 등록하세요. 사진이 많을수록 인식 정확도가 올라갑니다.',
'썸네일에 마우스를 올리면 우측 상단 <b>×</b>로 사진을 개별 삭제할 수 있습니다. 얼굴이 검출되지 않은 사진은 자동으로 제외됩니다.',
'<b>프로필 순서가 중요합니다.</b> 한 사진에 여러 인물이 함께 있으면, <b>1순위(맨 위) 인물 폴더로는 "이동"</b>, 나머지 인물 폴더로는 <b>"복사"</b>됩니다.'
]
},
{
h: '2. 폴더 선택',
body: [
'<b>정리할 폴더(소스)</b>와 <b>결과 저장 폴더(출력)</b>를 각각 [찾기]로 지정합니다.',
'소스와 출력은 다른 폴더로 두는 것을 권장합니다. (출력 폴더 안에 만들어진 결과물은 재실행 시 다시 처리하지 않습니다.)'
]
},
{
h: '3. 실행 옵션',
body: [
'<b>매칭 임계값</b>: 얼굴이 같은 사람인지 판단하는 기준입니다. <b>낮을수록 더 엄격</b>(오인식↓, 놓침↑)합니다. 기본값 0.5에서 시작해, 다른 사람이 섞이면 낮추고, 본인을 놓치면 높여서 조정하세요.',
'<b>동시 처리</b>: 한 번에 처리할 파일 수입니다. 높이면 빠르지만 메모리를 더 씁니다.',
'<b>검출 엔진</b>: "정확도 우선(SSD)"이 기본이며 더 정확합니다. 사진이 매우 많아 속도가 필요하면 "속도 우선(Tiny)"을 선택하세요.'
]
},
{
h: '4. 정리 시작 & 결과 구조',
body: [
'[정리 시작]을 누르면 진행률과 처리 내역이 실시간으로 표시됩니다.',
'결과는 다음 구조로 생성됩니다:',
'<pre>출력폴더/\n &lt;인물이름&gt;/YYYY/MM/ ← 그 인물이 있는 사진\n Unsorted/YYYY/MM/ ← 얼굴 미검출·미등록 인물 사진\n Movie/YYYY/MM/ ← 영상 파일 (얼굴인식 없이 날짜순)\n _PhotoAI_logs/ ← 실행 로그</pre>',
'촬영일은 EXIF에서 읽고, 없으면 파일 수정일로 대체합니다.'
]
},
{
h: '5. 이동 vs 복사, 그리고 영상',
body: [
'한 사진에 등록 인물이 한 명이면 그 인물 폴더로 <b>이동</b>합니다.',
'여러 명이 함께 있으면 <b>1순위 인물은 이동, 나머지는 복사</b>되어 모든 인물 폴더에서 볼 수 있습니다.',
'영상 파일(.mp4 .mov .avi .mkv .webm .m4v)은 얼굴 인식 없이 <b>Movie/연/월</b> 폴더로 이동합니다.',
'같은 이름의 파일이 대상 폴더에 이미 있으면, 덮어쓰지 않고 <code>이름_1.jpg</code>처럼 자동으로 이름을 바꿔 저장합니다. (데이터 유실 없음)'
]
},
{
h: '6. 데이터 안전성',
body: [
'"이동"은 <b>복사 → 무결성 검증 → 원본 삭제</b> 순서로 이뤄집니다. 검증을 통과한 뒤에만 원본을 지우므로, 중간에 오류가 나도 원본이 보존됩니다.',
'모든 사진은 클라우드로 전송되지 않고 내 컴퓨터 안에서만 처리됩니다.'
]
},
{
h: '7. 정확도를 높이는 팁',
body: [
'인물당 참조 사진을 <b>5장 이상</b>, 다양한 각도/조명으로 등록하세요.',
'결과에 다른 사람이 섞이면 매칭 임계값을 0.45~0.4로 낮춰 보세요.',
'본인을 자주 놓치면 임계값을 0.55~0.6으로 높이거나 참조 사진을 더 추가하세요.'
]
},
{
h: '8. 문제 해결',
body: [
'<b>썸네일/사진이 안 보임</b>: 참조 사진 원본 파일이 이동/삭제되지 않았는지 확인하세요.',
'<b>모두 Unsorted로 감</b>: 프로필에 얼굴(참조 사진)이 등록되었는지 확인하세요.',
'설정(언어/테마)은 메뉴 <b>보기</b>에서 언제든 바꿀 수 있습니다.'
]
}
]
},
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: [
'<b>Source folder</b>: the folder containing the photos/videos to organize (scanned recursively).',
'<b>Output folder</b>: where results are saved. Per-person and per-date folders are created automatically.',
'<b>Profile</b>: 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 <b>1. Person Profiles</b> panel, type a name (e.g. <code>Alex</code>) and click [Add]. Up to 3 people.',
'Click the <b>+ Add face</b> tile on a card to register several <b>clear, front-facing reference photos</b> of that person. More photos = better accuracy.',
'Hover a thumbnail and click the <b>×</b> to delete it individually. Photos with no detectable face are skipped automatically.',
'<b>Profile order matters.</b> If a photo contains multiple people, it is <b>moved</b> into the #1 (top) persons folder and <b>copied</b> into the others.'
]
},
{
h: '2. Select folders',
body: [
'Pick the <b>source folder</b> and the <b>output folder</b> with [Browse].',
'Keep source and output separate. (Results created inside the output folder are skipped on re-runs.)'
]
},
{
h: '3. Run options',
body: [
'<b>Match threshold</b>: how strict face matching is. <b>Lower = stricter</b> (fewer false matches, more misses). Start at 0.5; lower it if strangers leak in, raise it if the person is missed.',
'<b>Concurrency</b>: how many files are processed at once. Higher is faster but uses more memory.',
'<b>Detection engine</b>: "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:',
'<pre>output/\n &lt;person&gt;/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</pre>',
'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 <b>moved</b> into that persons folder.',
'If several are present, the <b>#1 person gets a move and the rest get copies</b>, so it appears in every persons folder.',
'Video files (.mp4 .mov .avi .mkv .webm .m4v) are moved into <b>Movie/YYYY/MM</b> without face recognition.',
'If a file with the same name already exists at the destination, it is auto-renamed like <code>name_1.jpg</code> instead of overwriting (no data loss).'
]
},
{
h: '6. Data safety',
body: [
'A "move" is done as <b>copy → verify integrity → delete original</b>. 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 <b>5+ reference photos</b> per person, with varied angles and lighting.',
'If strangers leak into a persons folder, lower the threshold to ~0.450.4.',
'If the person is often missed, raise it to ~0.550.6 or add more reference photos.'
]
},
{
h: '8. Troubleshooting',
body: [
'<b>Thumbnails/photos not showing</b>: make sure the original reference files were not moved or deleted.',
'<b>Everything goes to Unsorted</b>: check that the profile actually has reference faces registered.',
'Language/theme can be changed anytime from the <b>View</b> 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) => `
<section>
<h2>${s.h}</h2>
${s.body.map((b) => (b.startsWith('<pre>') ? b : `<p>${b}</p>`)).join('\n')}
</section>`
)
.join('\n')
return `<!doctype html>
<html lang="${lang}">
<head>
<meta charset="UTF-8" />
<title>${g.title}</title>
<style>
* { box-sizing: border-box; }
body { margin:0; background:${bg}; color:${fg};
font-family:'Segoe UI', system-ui, -apple-system, sans-serif; line-height:1.65; }
.wrap { max-width: 820px; margin: 0 auto; padding: 40px 28px 80px; }
h1 { color:${accent}; font-size: 26px; margin: 0 0 6px; }
.intro { color:${muted}; margin: 0 0 28px; }
section { background:${surface}; border:1px solid ${border}; border-radius:12px;
padding: 18px 22px; margin-bottom: 16px; }
h2 { font-size: 17px; margin: 0 0 10px; }
p { margin: 8px 0; }
code { background:${dark ? '#0c0e14' : '#eef1f7'}; padding: 1px 6px; border-radius:5px;
font-family:'Cascadia Code','Consolas',monospace; font-size: 13px; }
pre { background:${dark ? '#0c0e14' : '#eef1f7'}; padding: 14px 16px; border-radius:8px;
overflow:auto; font-family:'Cascadia Code','Consolas',monospace; font-size: 13px;
color:${fg}; }
b { color:${dark ? '#fff' : '#11151f'}; }
</style>
</head>
<body>
<div class="wrap">
<h1>${g.title}</h1>
<p class="intro">${g.intro}</p>
${sectionsHtml}
</div>
</body>
</html>`
}
+12 -1
View File
@@ -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()
+63 -2
View File
@@ -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/<id>/ 에 저장 후 등록
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<Settings>) => applySettings(patch))
}
+69
View File
@@ -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<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp'
}
/**
* 로컬 참조 이미지를 UI 렌더러에 안전하게 표시하기 위한 커스텀 프로토콜.
*
* URL 형식: photoai-media://img/?p=<encodeURIComponent(절대경로)>
*
* 보안: 렌더러가 임의 파일을 읽지 못하도록, **현재 등록된 참조 이미지 경로**에
* 한해서만 파일을 제공한다. (그 외 경로는 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=<encodeURIComponent(절대경로)>
// 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 })
}
})
}
+140
View File
@@ -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<Settings>) => void
}
/** 로컬라이즈된 애플리케이션 메뉴를 빌드/적용 */
export function buildAppMenu({ settings, onChange }: BuildOpts): void {
const t = (k: string, p?: Record<string, string | number>) =>
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))
}
+15 -4
View File
@@ -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<FileProcessed> {
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),
+94
View File
@@ -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<Preset[]> {
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<Preset[]> {
await this.load()
return [...this.presets].sort((a, b) => a.createdAt - b.createdAt)
}
private async persist(): Promise<void> {
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<Preset[]> {
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<Preset[]> {
await this.load()
this.presets = this.presets.filter((p) => p.id !== id)
await this.persist()
return this.list()
}
async get(id: string): Promise<Preset | undefined> {
await this.load()
return this.presets.find((p) => p.id === id)
}
/** media 프로토콜 보안 검사: 프리셋 참조 이미지인지 */
async isReferenceImage(path: string): Promise<boolean> {
await this.load()
return this.presets.some((p) => p.referenceImages.includes(path))
}
}
export const presetStore = new PresetStore()
+42 -1
View File
@@ -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<Profile> {
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<boolean> {
await this.load()
return this.profiles.some((p) => p.referenceImages.includes(path))
}
/** 참조 이미지 1장 제거 (해당 descriptor도 같은 인덱스에서 함께 제거) */
async removeReference(id: string, imagePath: string): Promise<Profile> {
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,
+5
View File
@@ -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,
+24 -7
View File
@@ -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<string>(SUPPORTED_EXTENSIONS)
const IMAGE_EXT_SET = new Set<string>(SUPPORTED_EXTENSIONS)
const VIDEO_EXT_SET = new Set<string>(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<string> = new Set()
): Promise<number> {
@@ -56,5 +73,5 @@ export async function countImages(
/** 출력물 재처리 방지를 위한 기본 제외 디렉터리 집합 */
export function defaultSkipDirs(profileNames: string[]): Set<string> {
return new Set<string>([LOG_FOLDER, UNMATCHED_FOLDER, ...profileNames])
return new Set<string>([LOG_FOLDER, UNMATCHED_FOLDER, MOVIE_FOLDER, ...profileNames])
}
+50
View File
@@ -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<Settings> {
if (this.loaded) return this.settings
try {
const raw = await readFile(this.filePath(), 'utf-8')
const parsed = JSON.parse(raw) as Partial<Settings>
this.settings = { ...DEFAULTS, ...parsed }
} catch {
this.settings = { ...DEFAULTS }
}
this.loaded = true
return this.settings
}
/** 동기 접근(load 이후). 메뉴 빌드 등에서 사용 */
current(): Settings {
return this.settings
}
async set(patch: Partial<Settings>): Promise<Settings> {
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()
+23 -3
View File
@@ -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<RendererEventName, string> = {
'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<Settings>) => ipcRenderer.invoke(IPC.SETTINGS_SET, patch)
},
// Electron 33: File.path 제거됨 → webUtils로 드롭된 파일의 실제 경로 획득
getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File),
on<E extends RendererEventName>(event: E, cb: (payload: RendererEvents[E]) => void) {
const channel = EVENT_CHANNELS[event]
const listener = (_e: unknown, payload: RendererEvents[E]) => cb(payload)
+25 -14
View File
@@ -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 <div className="h-full" />
if (!onboarded) return <Onboarding />
return (
<div className="min-h-screen flex flex-col">
<header className="px-6 py-4 bg-white border-b border-slate-200 shadow-sm">
<h1 className="text-xl font-bold text-brand-dark">AI Photo Organizer</h1>
<p className="text-sm text-slate-500">
+ ·
</p>
<div className="h-full flex flex-col">
<header className="px-6 py-4 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-sm shrink-0">
<h1 className="text-xl font-bold text-brand-dark dark:text-brand">{t('app.title')}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{t('app.subtitle')}</p>
</header>
<main className="flex-1 grid grid-cols-12 gap-4 p-6 overflow-hidden">
{/* 좌측: 설정 패널 */}
<section className="col-span-5 flex flex-col gap-4 overflow-y-auto pr-2">
<main className="flex-1 min-h-0 grid grid-cols-12 gap-4 p-6">
{/* 좌측: 설정 패널 (자체 스크롤) */}
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
<ProfileManager />
<FolderPicker />
<RunControl />
</section>
{/* 우측: 진행/결과 */}
<section className="col-span-7 flex flex-col gap-4 overflow-hidden">
{phase === 'done' ? <ReportView /> : <ProgressView />}
{/* 우측: 진행/결과 — FileList만 내부 스크롤 */}
<section className="col-span-7 min-h-0 flex flex-col gap-4">
<div className="shrink-0">
{phase === 'done' ? <ReportView /> : <ProgressView />}
</div>
<FileList />
</section>
</main>
+41 -40
View File
@@ -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<FileDecisionKind, { label: string; cls: string }> = {
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<FileDecisionKind, string> = {
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 (
<div className="bg-white rounded-xl border border-slate-200 p-4 flex-1 overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold"> </h2>
<span className="text-xs text-slate-400"> {processed.length}</span>
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex-1 min-h-0 flex flex-col">
<div className="flex items-center justify-between mb-2 shrink-0">
<h2 className="font-semibold dark:text-slate-100">{t('history.section')}</h2>
<span className="text-xs text-slate-400">
{t('history.recent', { n: processed.length })}
</span>
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 min-h-0 overflow-y-auto">
{processed.length === 0 ? (
<p className="text-sm text-slate-400 py-4"> .</p>
<p className="text-sm text-slate-400 py-4">{t('history.empty')}</p>
) : (
<ul className="flex flex-col divide-y divide-slate-100">
{processed.map((f, i) => {
const style = KIND_STYLE[f.kind]
return (
<li key={`${f.file}-${i}`} className="py-2 flex items-center gap-3">
<span
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${style.cls}`}
>
{style.label}
</span>
<span className="mono text-xs truncate flex-1" title={f.file}>
{baseName(f.file)}
</span>
<span className="text-xs text-slate-400">
{f.matchedNames.length > 0
? f.matchedNames.join(', ')
: f.error
? f.error.slice(0, 40)
: '—'}
</span>
</li>
)
})}
<ul className="flex flex-col divide-y divide-slate-100 dark:divide-slate-700">
{processed.map((f, i) => (
<li key={`${f.file}-${i}`} className="py-2 flex items-center gap-3">
<span
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${KIND_CLS[f.kind]}`}
>
{t(`kind.${f.kind}`)}
</span>
<span
className="mono text-xs truncate flex-1 dark:text-slate-300"
title={f.file}
>
{baseName(f.file)}
</span>
<span className="text-xs text-slate-400">
{f.matchedNames.length > 0
? f.matchedNames.join(', ')
: f.error
? f.error.slice(0, 40)
: '—'}
</span>
</li>
))}
</ul>
)}
</div>
+13 -9
View File
@@ -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 (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h2 className="font-semibold mb-3">2. </h2>
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h2 className="font-semibold mb-3 dark:text-slate-100">{t('folder.section')}</h2>
<Row label="정리할 폴더 (소스)" value={source} onPick={pickSource} />
<Row label="결과 저장 폴더 (출력)" value={outputRoot} onPick={pickOutput} />
<Row label={t('folder.source')} value={source} placeholder={t('folder.unselected')} browse={t('folder.browse')} onPick={pickSource} />
<Row label={t('folder.output')} value={outputRoot} placeholder={t('folder.unselected')} browse={t('folder.browse')} onPick={pickOutput} />
</div>
)
}
@@ -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 (
<div className="mb-3 last:mb-0">
<div className="text-xs text-slate-500 mb-1">{props.label}</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mb-1">{props.label}</div>
<div className="flex gap-2">
<div className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm mono truncate bg-slate-50">
{props.value ?? '미선택'}
<div className="flex-1 border border-slate-300 dark:border-slate-600 rounded-lg px-3 py-2 text-sm mono truncate bg-slate-50 dark:bg-slate-700 dark:text-slate-200">
{props.value ?? props.placeholder}
</div>
<button
className="border border-brand text-brand rounded-lg px-3 text-sm font-medium"
className="border border-brand text-brand rounded-lg px-3 text-sm font-medium hover:bg-brand hover:text-white"
onClick={props.onPick}
>
{props.browse}
</button>
</div>
</div>
+79
View File
@@ -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 (
<div className="h-full flex items-center justify-center bg-slate-100 dark:bg-slate-900 p-6">
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-lg p-8">
<h1 className="text-2xl font-bold text-brand-dark dark:text-brand mb-1">
{t('app.title')}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">{t('onboard.subtitle')}</p>
{/* 언어 */}
<div className="mb-5">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
{t('onboard.language')}
</div>
<div className="grid grid-cols-2 gap-2">
{LANGS.map((l) => (
<button
key={l}
onClick={() => pickLang(l)}
className={`py-2.5 rounded-lg border text-sm font-medium transition-colors ${
language === l
? 'border-brand bg-brand text-white'
: 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200 hover:border-brand'
}`}
>
{LANG_LABEL[l]}
</button>
))}
</div>
</div>
{/* 테마 */}
<div className="mb-8">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
{t('onboard.theme')}
</div>
<div className="grid grid-cols-2 gap-2">
{(['dark', 'light'] as Theme[]).map((th) => (
<button
key={th}
onClick={() => pickTheme(th)}
className={`py-2.5 rounded-lg border text-sm font-medium transition-colors ${
theme === th
? 'border-brand bg-brand text-white'
: 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200 hover:border-brand'
}`}
>
{th === 'dark' ? t('onboard.dark') : t('onboard.light')}
</button>
))}
</div>
</div>
<button
onClick={finish}
className="w-full py-3 rounded-lg bg-brand hover:bg-brand-dark text-white font-semibold"
>
{t('onboard.start')}
</button>
</div>
</div>
)
}
+316 -60
View File
@@ -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<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busyId, setBusyId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const [presets, setPresets] = useState<Preset[]>([])
const activeIdRef = useRef<string | null>(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<File>) => {
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 (
<div className="bg-white rounded-xl border border-slate-200 p-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-3">
<h2 className="font-semibold">1. </h2>
<h2 className="font-semibold dark:text-slate-100">{t('profile.section')}</h2>
<span className="text-xs text-slate-400">
{profiles.length}/{MAX_PROFILES} · =
{t('profile.count', { n: profiles.length, max: MAX_PROFILES })}
</span>
</div>
<div className="flex gap-2 mb-3">
<input
className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm"
placeholder="인물 이름 (예: seunghyun)"
className="flex-1 border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-3 py-2 text-sm"
placeholder={t('profile.namePlaceholder')}
value={name}
onChange={(e) => 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')}
</button>
</div>
{error && <p className="text-sm text-red-600 mb-2">{error}</p>}
{error && <p className="text-sm text-red-600 dark:text-red-400 mb-2">{error}</p>}
<ul className="flex flex-col gap-2">
<ul className="flex flex-col gap-3">
{profiles.map((p, i) => (
<li
<ProfileCard
key={p.id}
className="flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2"
>
<div>
<span className="text-xs font-bold text-brand mr-2">#{i + 1}</span>
<span className="font-medium">{p.name}</span>
<span className="text-xs text-slate-400 ml-2">
{p.descriptors.length}
</span>
</div>
<div className="flex gap-2">
<button
className="text-xs border border-brand text-brand rounded px-2 py-1 disabled:opacity-40"
onClick={() => addReference(p.id)}
disabled={busy === p.id}
>
{busy === p.id ? '분석 중…' : '얼굴 추가'}
</button>
<button
className="text-xs border border-red-300 text-red-500 rounded px-2 py-1"
onClick={() => remove(p.id)}
>
</button>
</div>
</li>
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 && (
<li className="text-sm text-slate-400 py-2">
. .
</li>
<li className="text-sm text-slate-400 py-2">{t('profile.empty')}</li>
)}
</ul>
{/* 프리셋(인물 라이브러리) */}
<div className="mt-4 pt-3 border-t border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-semibold dark:text-slate-100">{t('profile.presets')}</h3>
<span className="text-xs text-slate-400">
{presets.length}/{MAX_PRESETS}
</span>
</div>
<p className="text-[11px] text-slate-400 mb-2">{t('profile.presetsHint')}</p>
{presets.length === 0 ? (
<p className="text-xs text-slate-400">{t('profile.presetsEmpty')}</p>
) : (
<div className="flex flex-wrap gap-2">
{presets.map((ps) => (
<div
key={ps.id}
className="relative group flex items-center gap-2 pl-1 pr-2 py-1 rounded-full border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/50 hover:border-brand cursor-pointer"
title={t('profile.applyPresetTitle')}
onClick={() => applyPreset(ps.id)}
>
{ps.referenceImages[0] ? (
<img
src={mediaUrl(ps.referenceImages[0])}
alt={ps.name}
className="w-6 h-6 rounded-full object-cover"
/>
) : (
<span className="w-6 h-6 rounded-full bg-brand/20 text-brand text-[10px] flex items-center justify-center">
{ps.name.slice(0, 1).toUpperCase()}
</span>
)}
<span className="text-xs font-medium dark:text-slate-200">{ps.name}</span>
<button
className="text-slate-400 hover:text-red-500 text-xs leading-none ml-0.5"
title={t('profile.deletePreset')}
onClick={(e) => {
e.stopPropagation()
void removePreset(ps.id)
}}
>
×
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}
function ProfileCard(props: {
profile: Profile
index: number
busy: boolean
active: boolean
onActivate: () => void
onPick: () => void
onDropFiles: (files: FileList) => void
onSavePreset: () => void
onRefresh: () => Promise<void>
}): 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 (
<li
className={`bg-slate-50 dark:bg-slate-700/40 rounded-lg p-3 border-2 transition-colors ${
dragging
? 'border-brand border-dashed'
: active
? 'border-brand/40'
: 'border-transparent'
}`}
onMouseEnter={props.onActivate}
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
props.onDropFiles(e.dataTransfer.files)
}}
>
<div className="flex items-center justify-between mb-2">
<div>
<span className="text-xs font-bold text-brand mr-2">#{index + 1}</span>
<span className="font-medium dark:text-slate-100">{p.name}</span>
<span className="text-xs text-slate-400 ml-2">
{t('profile.refCount', { n: p.referenceImages.length })}
</span>
</div>
<div className="flex gap-2">
<button
className="text-xs border border-brand/60 text-brand rounded px-2 py-1 hover:bg-brand/10 disabled:opacity-40"
onClick={props.onSavePreset}
disabled={!canSavePreset}
title={t('profile.savePreset')}
>
{t('profile.savePreset')}
</button>
<button
className="text-xs border border-red-300 dark:border-red-500/60 text-red-500 dark:text-red-400 rounded px-2 py-1 hover:bg-red-50 dark:hover:bg-red-500/10"
onClick={removeProfile}
>
{t('profile.delete')}
</button>
</div>
</div>
{/* 참조 이미지 썸네일 그리드 */}
<div className="grid grid-cols-5 gap-2">
{p.referenceImages.map((img) => (
<div
key={img}
className="relative aspect-square rounded-md overflow-hidden border border-slate-200 dark:border-slate-600 group bg-white dark:bg-slate-800"
title={baseName(img)}
>
<img
src={mediaUrl(img)}
alt={baseName(img)}
className="w-full h-full object-cover"
loading="lazy"
/>
<button
className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-black/60 text-white text-xs leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
onClick={() => removeReference(img)}
title={t('profile.deletePhoto')}
>
×
</button>
</div>
))}
{/* 추가 타일 (클릭) */}
<button
className="aspect-square rounded-md border-2 border-dashed border-slate-300 dark:border-slate-600 text-slate-400 hover:border-brand hover:text-brand flex flex-col items-center justify-center disabled:opacity-40"
onClick={props.onPick}
disabled={busy}
>
<span className="text-xl leading-none">{busy ? '…' : '+'}</span>
<span className="text-[10px] mt-0.5">
{busy ? t('profile.analyzing') : t('profile.addFace')}
</span>
</button>
</div>
<p className="text-[11px] text-slate-400 mt-2">{t('profile.dndHint')}</p>
</li>
)
}
+8 -6
View File
@@ -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 (
<div className="bg-white rounded-xl border border-slate-200 p-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"> </h2>
<span className="text-sm text-slate-500">
{phase === 'running' ? `${done} / ${total}` : '대기 중'}
<h2 className="font-semibold dark:text-slate-100">{t('progress.section')}</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{phase === 'running' ? `${done} / ${total}` : t('progress.waiting')}
</span>
</div>
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div className="h-3 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-brand transition-[width] duration-200"
style={{ width: `${pct}%` }}
@@ -26,7 +28,7 @@ export function ProgressView(): JSX.Element {
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-slate-400 mono truncate max-w-[80%]">
{progress?.current ?? (phase === 'running' ? '스캔 중…' : '실행 대기')}
{progress?.current ?? (phase === 'running' ? t('progress.scanning') : t('progress.idle'))}
</span>
<span className="text-xs font-medium text-brand">{pct}%</span>
</div>
+26 -21
View File
@@ -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 (
<div className="bg-white rounded-xl border border-slate-200 p-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-3">
<h2 className="font-semibold"> </h2>
<span className="text-sm text-slate-500"> {fmtDuration(report.elapsedMs)}</span>
<h2 className="font-semibold dark:text-slate-100">{t('report.done')}</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{t('report.elapsed', { d: fmtDuration(report.elapsedMs) })}
</span>
</div>
<div className="grid grid-cols-5 gap-2 mb-3">
<div className="grid grid-cols-6 gap-2 mb-3">
{stats.map((s) => (
<div key={s.label} className="bg-slate-50 rounded-lg p-2 text-center">
<div key={s.label} className="bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2 text-center">
<div className={`text-lg font-bold ${s.cls}`}>{s.value}</div>
<div className="text-[11px] text-slate-400">{s.label}</div>
</div>
@@ -38,15 +43,15 @@ export function ReportView(): JSX.Element {
</div>
<div className="text-xs text-slate-400 mono truncate" title={report.logPath}>
: {report.logPath}
{t('report.log', { path: report.logPath })}
</div>
{errors.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-red-600 cursor-pointer">
{errors.length}
<summary className="text-xs text-red-600 dark:text-red-400 cursor-pointer">
{t('report.errors', { n: errors.length })}
</summary>
<ul className="mt-1 max-h-32 overflow-y-auto text-[11px] text-red-500 mono">
<ul className="mt-1 max-h-32 overflow-y-auto text-[11px] text-red-500 dark:text-red-400 mono">
{errors.map((e, i) => (
<li key={i} className="truncate">
{e.file}: {e.message}
+26 -22
View File
@@ -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 (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h2 className="font-semibold mb-3">3. </h2>
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h2 className="font-semibold mb-3 dark:text-slate-100">{t('run.section')}</h2>
<div className="grid grid-cols-2 gap-3 mb-4">
<label className="text-sm">
<span className="block text-xs text-slate-500 mb-1">
({options.matchThreshold.toFixed(2)})
<label className="text-sm cursor-help" title={t('run.thresholdTip')}>
<span className="block text-xs text-slate-500 dark:text-slate-400 mb-1">
{t('run.threshold', { v: options.matchThreshold.toFixed(2) })}
<span className="ml-1 text-slate-400" aria-hidden></span>
</span>
<input
type="range"
@@ -37,12 +40,13 @@ export function RunControl(): JSX.Element {
disabled={running}
className="w-full"
/>
<span className="text-[11px] text-slate-400"> </span>
<span className="text-[11px] text-slate-400">{t('run.thresholdHint')}</span>
</label>
<label className="text-sm">
<span className="block text-xs text-slate-500 mb-1">
({options.concurrency})
<label className="text-sm cursor-help" title={t('run.concurrencyTip')}>
<span className="block text-xs text-slate-500 dark:text-slate-400 mb-1">
{t('run.concurrency', { v: options.concurrency })}
<span className="ml-1 text-slate-400" aria-hidden></span>
</span>
<input
type="range"
@@ -57,48 +61,48 @@ export function RunControl(): JSX.Element {
</label>
<label className="text-sm col-span-2">
<span className="block text-xs text-slate-500 mb-1"> </span>
<span className="block text-xs text-slate-500 dark:text-slate-400 mb-1">
{t('run.detector')}
</span>
<select
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm"
className="w-full border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-2 py-1.5 text-sm"
value={options.detector}
onChange={(e) => setOptions({ detector: e.target.value as 'ssd' | 'tiny' })}
disabled={running}
>
<option value="ssd"> (SSD MobileNet)</option>
<option value="tiny"> (Tiny Face)</option>
<option value="ssd">{t('run.detectorSsd')}</option>
<option value="tiny">{t('run.detectorTiny')}</option>
</select>
</label>
</div>
{!hasDescriptors && (
<p className="text-xs text-amber-600 mb-2">
. [] .
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mb-2">{t('run.noFaceWarn')}</p>
)}
<div className="flex gap-2">
{!running ? (
<button
className="flex-1 bg-brand text-white rounded-lg py-2.5 font-semibold disabled:opacity-40"
className="flex-1 bg-brand hover:bg-brand-dark text-white rounded-lg py-2.5 font-semibold disabled:opacity-40"
onClick={startJob}
disabled={!canRun}
>
{phase === 'done' ? '다시 실행' : '정리 시작'}
{phase === 'done' ? t('run.rerun') : t('run.start')}
</button>
) : (
<button
className="flex-1 bg-red-500 text-white rounded-lg py-2.5 font-semibold"
className="flex-1 bg-red-500 hover:bg-red-600 text-white rounded-lg py-2.5 font-semibold"
onClick={cancelJob}
>
{t('run.cancel')}
</button>
)}
{phase === 'done' && (
<button
className="border border-slate-300 rounded-lg px-4 text-sm"
className="border border-slate-300 dark:border-slate-600 dark:text-slate-200 rounded-lg px-4 text-sm"
onClick={resetJob}
>
{t('run.reset')}
</button>
)}
</div>
+11
View File
@@ -0,0 +1,11 @@
import { useStore } from './store'
import { translate } from '@shared/i18n'
/**
* 현재 선택된 언어로 바인딩된 번역 함수를 반환하는 훅.
* 언어가 바뀌면 컴포넌트가 자동 리렌더된다.
*/
export function useT(): (key: string, params?: Record<string, string | number>) => string {
const language = useStore((s) => s.language)
return (key, params) => translate(language, key, params)
}
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data: file:; style-src 'self' 'unsafe-inline';"
content="default-src 'self'; img-src 'self' data: file: photoai-media:; style-src 'self' 'unsafe-inline';"
/>
<title>AI Photo Organizer</title>
</head>
+15
View File
@@ -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
}
+40 -3
View File
@@ -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'
/** 테마를 <html> 클래스에 반영 (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<void>
resetJob: () => void
// 설정(언어/테마/온보딩)
language: Lang
theme: Theme
onboarded: boolean
initSettings: () => Promise<void>
updateSettings: (patch: Partial<Settings>) => Promise<void>
// 이벤트 핸들러(내부)
_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<AppState>((set, get) => ({
@@ -74,6 +91,21 @@ export const useStore = create<AppState>((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<AppState>((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())
}
+10 -2
View File
@@ -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 class="dark"> 토글에 반응 */
html.dark body {
background: #0f1117;
color: #e6e8ee;
}
/* 파일 목록 가독성용 모노 폰트 */
.mono {
font-family: 'Cascadia Code', 'Consolas', monospace;
+38 -2
View File
@@ -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',
+187
View File
@@ -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<Lang, string> = {
ko: '한국어',
en: 'English'
}
/** 키 → 언어별 문자열. {token} 형태의 치환자를 지원. */
type Table = Record<string, Record<Lang, string>>
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.450.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, 23 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, string | number>
): 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
}
+51 -1
View File
@@ -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<Profile>
remove(id: string): Promise<void>
addReference(id: string, imagePaths: string[]): Promise<Profile>
/** 경로 없는 이미지(붙여넣기/드롭된 메모리 이미지)를 저장 후 참조로 등록 */
addReferenceData(id: string, items: ReferenceData[]): Promise<Profile>
removeReference(id: string, imagePath: string): Promise<Profile>
/** 프리셋을 활성 프로필로 불러와 새 프로필 생성 */
applyPreset(presetId: string): Promise<Profile>
}
presets: {
list(): Promise<Preset[]>
/** 현재 활성 프로필을 프리셋으로 저장 */
saveFrom(profileId: string): Promise<Preset[]>
remove(id: string): Promise<Preset[]>
}
dialog: {
pickSource(): Promise<string | null>
@@ -137,6 +181,12 @@ export interface ExposedApi {
run(req: JobRequest): Promise<void>
cancel(): Promise<void>
}
settings: {
get(): Promise<Settings>
set(patch: Partial<Settings>): Promise<Settings>
}
/** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */
getPathForFile(file: unknown): string
on<E extends RendererEventName>(
event: E,
cb: (payload: RendererEvents[E]) => void
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./src/renderer/**/*.{html,ts,tsx}'],
theme: {
extend: {
+7 -2
View File
@@ -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')
})
})
+24
View File
@@ -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()
})
})