Initial commit: AI Photo Organizer (Electron + face-api)

Local-first photo organizer that auto-sorts images by face recognition
and EXIF capture date.

- Electron app with 3-process split: Main (Node) / UI Renderer (React) /
  hidden Inference Renderer (face-api + WebGL)
- Core pipeline: scan -> face match + EXIF -> path build -> atomic move/copy
- Move = copy -> verify -> delete; auto-rename on filename collision
- 1st-registered profile = move, others = copy; unmatched -> [미정]/YYYY/MM
- EXIF date with mtime fallback
- Vitest unit tests (pathBuilder / fileOps / concurrency) all green
- electron-builder config for Windows (nsis) + macOS (dmg)
- Docs: PRD / DECISIONS / ARCHITECTURE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 13:36:40 +09:00
commit 8a8c10248c
54 changed files with 11507 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
out/
release/
dist/
*.log
.DS_Store
# TypeScript 증분 빌드 정보 (생성물)
*.tsbuildinfo
# ASTRA 도구 로컬 캐시 (프로젝트 산출물 아님)
.astra/
# 모델 가중치는 용량이 커서 git에 올리지 않음 — npm run models:download 로 받음
models/*.bin
models/*-weights_manifest.json
+67
View File
@@ -0,0 +1,67 @@
# AI Photo Organizer
얼굴 인식 + 촬영일(EXIF) 기준으로 사진을 **로컬에서** 자동 정리하는 Electron 데스크톱 앱.
클라우드 업로드 없이 내 PC 안에서만 동작한다.
> 기획·설계 문서: [docs/PRD.md](docs/PRD.md) · [docs/DECISIONS.md](docs/DECISIONS.md) · [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
## 동작 방식
1. 인물 프로필(최대 3명)과 참조 얼굴 사진을 등록한다.
2. 정리할 폴더(소스)와 결과 폴더(출력)를 고른다.
3. [정리 시작] → 각 사진을 스캔해
- 얼굴이 매칭되면 `출력/<인물>/YYYY/MM/`**이동**(2·3순위 인물에게는 **복사**)
- 매칭 인물이 없으면 `출력/[미정]/YYYY/MM/` 로 이동
- EXIF 촬영일이 없으면 파일 수정일로 대체
데이터 안전: 이동은 **복사 → 무결성 검증 → 원본 삭제** 순서로 수행하고, 파일명 충돌 시 `_1`, `_2` 로 자동 리네임한다(덮어쓰기 없음).
## 기술 스택
Electron 33 · electron-vite · React 18 + TypeScript · Zustand · Tailwind ·
[@vladmandic/face-api](https://github.com/vladmandic/face-api) (WebGL) · exifr · electron-builder
3개 프로세스로 분리된 구조:
- **Main** (Node): 스캔 / EXIF / 파일 이동·복사 / 오케스트레이션
- **UI Renderer** (React): 화면 — 무거운 연산 없음
- **Inference Renderer** (숨김 창): face-api 얼굴 인식 전담
## 개발
```bash
npm install
npm run models:download # 최초 1회 — 모델 가중치 받기
npm run dev # 개발 실행 (HMR)
```
## 검증 / 빌드
```bash
npm run typecheck # 타입체크 (node + web)
npm test # 순수 로직 단위 테스트 (Vitest)
npm run dist:all # Windows(nsis) + macOS(dmg) 인스톨러 빌드
```
> macOS 타깃은 macOS 호스트에서 빌드해야 코드사이닝/공증이 가능하다(미서명 dmg는 어디서나 생성 가능).
## 트러블슈팅
- **`TypeError: Cannot read properties of undefined (reading 'whenReady')`** 로 부팅 즉시 크래시하면,
환경에 `ELECTRON_RUN_AS_NODE=1` 이 설정된 것이다. 이 변수가 있으면 Electron 바이너리가 일반 Node로 동작해
`electron.app``undefined` 가 된다. 실행 전 변수를 해제하라:
- PowerShell: `Remove-Item Env:\ELECTRON_RUN_AS_NODE`
- bash: `unset ELECTRON_RUN_AS_NODE`
## 폴더 구조
```
src/
main/ Main 프로세스 (scanner, exif, fileOps, orchestrator, ...)
preload/ contextBridge (UI용 index, 추론창용 inference)
renderer/ React UI
inference/ 숨김 추론 창 (faceEngine, imageLoader)
shared/ 공유 타입/상수
models/ face-api 가중치 (download 스크립트로 채움)
tests/ Vitest 단위 테스트
docs/ 기획·설계 문서
```
+303
View File
@@ -0,0 +1,303 @@
# AI Photo Organizer — 시스템 아키텍처 설계
> 상태: **확정 (Final)** · 최종 갱신: 2026-06-01
> 선행 문서: [PRD](./PRD.md) · [DECISIONS.md](./DECISIONS.md)
> 승인 완료 — 본 설계로 스캐폴딩/구현 착수.
---
## 0. 한눈에 보기 (TL;DR)
- **플랫폼**: Electron 데스크톱 앱 (Windows/macOS).
- **3-프로세스 구조**: `Main`(파일·오케스트레이션) + `UI Renderer`(React 화면) + `Inference Renderer`(숨김 창, 얼굴인식 전담).
- **왜 분리?** face-api.js는 브라우저(WebGL/Canvas) 환경이 필요하고, 파일 I/O는 Node 환경이 필요하다. 둘을 다른 프로세스로 나눠 **UI 프리징 0**, **빌드 안정성**을 동시에 확보한다.
- **데이터 흐름**: 스캔(Main) → 얼굴인식(Inference) → 날짜추출(Main) → 경로생성/이동·복사(Main) → 진행률 이벤트(UI).
---
## 1. 기술 스택 확정 (Tech Stack & Versions)
| 영역 | 라이브러리 | 버전(기준) | 비고 |
|------|-----------|-----------|------|
| 런타임/셸 | **Electron** | `^33.0.0` | Chromium + Node 번들. WebGL 가속 사용. |
| 빌드 도구 | **electron-vite** | `^2.3.0` | Main/Preload/Renderer 멀티 엔트리를 Vite로 통합 빌드. |
| 번들러 | **Vite** | `^5.4` | electron-vite 내부. |
| UI | **React** | `^18.3` | 함수형 + Hooks. |
| 언어 | **TypeScript** | `^5.5` | 전 프로세스 공통. `strict: true`. |
| 상태관리 | **Zustand** | `^4.5` | 경량 글로벌 스토어 (Redux 과잉 회피). |
| 스타일 | **Tailwind CSS** | `^3.4` | 빠른 UI 구성. (선호 시 교체 가능) |
| **얼굴 인식** | **@vladmandic/face-api** | `^1.7` | 원본 `face-api.js`(유지보수 중단)의 API 호환 포크. **tfjs(WebGL 백엔드)를 내부 번들로 포함** → 별도 tfjs 의존 불필요. Inference Renderer에서만 사용. |
| **EXIF 파싱** | **exifr** | `^7.1` | 빠르고 HEIC/주요 포맷 지원. Main(Node)에서 실행. |
| 파일 작업 | **node:fs/promises** | 내장 | 비동기 I/O. |
| 동시성 제어 | **(자체 구현 세마포어)** | - | 외부 `p-limit` 대신 경량 직접 구현. |
| 패키징 | **electron-builder** | `^25` | 배포용 인스톨러(.exe/.dmg) 생성. |
| 테스트 | **Vitest** | `^2` | 순수 로직(스캐너/EXIF/경로/충돌) 단위 테스트. |
> Node는 Electron 번들 버전(Electron 33 → Node 20 계열)을 사용하므로 별도 Node 버전 고정 불필요.
---
## 2. 프로세스 아키텍처 (Process Topology)
```
┌──────────────────────────────────────────────────────────────────┐
│ Electron App │
│ │
│ ┌─────────────────┐ IPC ┌──────────────────────┐ │
│ │ UI Renderer │◀──────────────────▶│ Main Process │ │
│ │ (visible window)│ progress/control │ (orchestrator/Node) │ │
│ │ React + Zustand │ │ │ │
│ └─────────────────┘ │ scanner / exif / │ │
│ ▲ preload(contextBridge) │ fileOps / pathBuilder│ │
│ │ │ profileStore /reporter│ │
│ │ └──────────┬───────────┘ │
│ │ │ IPC (descriptor│
│ │ │ 요청/응답) │
│ │ ┌──────────▼───────────┐ │
│ │ │ Inference Renderer │ │
│ └─────────(미표시)────────────────▶│ (hidden window) │ │
│ │ face-api + WebGL │ │
│ │ faceEngine/imageLoader│ │
│ └──────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
### 2.1 각 프로세스 책임
| 프로세스 | 환경 | 책임 |
|----------|------|------|
| **Main** | Node | 폴더 스캔, EXIF 추출, 파일 이동/복사(원자성), 프로필 영속화, 작업 오케스트레이션, 리포트/로그, IPC 라우팅. |
| **UI Renderer** | Chromium(보임) | 프로필 등록 UI, 폴더 선택, 실행 제어, 실시간 진행률/파일목록, 결과 리포트 표시. **무거운 연산 없음 → 프리징 없음.** |
| **Inference Renderer** | Chromium(숨김) | face-api 모델 로드, 이미지 디코딩, 얼굴 검출·descriptor 계산, 프로필과 매칭. UI 스레드와 분리되어 연산이 화면을 막지 않음. |
> **대안 검토**: Inference를 별도 숨김 창 대신 *Web Worker + OffscreenCanvas*로 둘 수도 있으나, face-api는 `document`/`canvas` 의존이 있어 worker 환경 패치가 까다롭다. v1은 **숨김 BrowserWindow**가 가장 안정적. (성능 이슈 시 worker로 이전 검토.)
---
## 3. 모듈 구조 (Module Breakdown)
### 3.1 Main Process (`src/main/`)
| 모듈 | 파일 | 역할 | 핵심 인터페이스(개념) |
|------|------|------|----------------------|
| **Scanner** | `scanner.ts` | 소스 폴더 재귀 순회, 확장자 필터, 이미지 경로 스트림 산출. | `async *scan(root): AsyncIterable<string>` |
| **ExifReader** | `exif.ts` | `DateTimeOriginal` 추출, 없으면 `fs.stat().mtime` 폴백 → `{year, month}`. | `getCaptureDate(path): Promise<{year,month}>` |
| **FileOps** | `fileOps.ts` | 이동(복사→검증→삭제)/복사, 충돌 시 자동 리네임, 원자성·오류처리. | `move(src,dest)`, `copy(src,dest)` |
| **PathBuilder** | `pathBuilder.ts` | `/<프로필>/YYYY/MM/` · `[미정]/YYYY/MM/` 경로 생성, 파일명 충돌 해소. | `build(outRoot, who, date, filename)` |
| **ProfileStore** | `profileStore.ts` | 프로필(이름·순서·참조이미지·descriptor) JSON 영속화/로드. | `load()`, `save()`, `upsert()` |
| **Orchestrator** | `orchestrator.ts` | 전체 잡 파이프라인 구동, 동시성 제어, 진행률 이벤트 발행, 중단/취소. | `run(job)`, `cancel()` |
| **InferenceBridge** | `inferenceWindow.ts` | 숨김 Inference 창 생성/관리, descriptor 요청-응답 RPC. | `detect(imagePath): Promise<MatchResult>` |
| **Reporter** | `reporter.ts` | 통계 집계(총/성공/실패/소요), 결과 리포트 + 로그파일 출력. | `summarize()`, `writeLog()` |
| **IpcMain** | `ipc.ts` | UI/Inference 채널 핸들러 등록·라우팅. | - |
| **Logger** | `logger.ts` | 구조적 로그(파일+콘솔), 오류 추적. | - |
### 3.2 Inference Renderer (`src/inference/`)
| 모듈 | 파일 | 역할 |
|------|------|------|
| **FaceEngine** | `faceEngine.ts` | 모델 1회 로드(SSD MobileNet v1 + Landmark68 + Recognition), 이미지당 얼굴검출+descriptor, `FaceMatcher`로 프로필 매칭(거리 `<0.5`). |
| **ImageLoader** | `imageLoader.ts` | 파일→`HTMLImageElement`/`canvas` 디코딩, 대형 이미지 다운스케일(예: 장변 1024px), 처리 후 canvas/objectURL 해제로 메모리 회수. |
| **bootstrap** | `main.ts` | 모델 로드 완료 시 Main에 ready 통지, descriptor 요청 수신 루프. |
### 3.3 UI Renderer (`src/renderer/`)
| 컴포넌트 | 파일 | 역할 |
|----------|------|------|
| App 셸 | `App.tsx` | 단계별 화면 라우팅(프로필→폴더→실행→리포트). |
| ProfileManager | `components/ProfileManager.tsx` | 최대 3인 프로필 등록/수정, 참조 이미지 추가, descriptor 생성 트리거. |
| FolderPicker | `components/FolderPicker.tsx` | 소스 폴더·출력 루트 선택(다이얼로그). |
| RunControl | `components/RunControl.tsx` | 실행/취소, 임계값 등 옵션. |
| ProgressView | `components/ProgressView.tsx` | 진행률 바, 처리 중 파일명, 카운터. |
| FileList | `components/FileList.tsx` | 처리 결과 스트림(파일→대상폴더/판정) 가상 스크롤. |
| ReportView | `components/ReportView.tsx` | 총처리/성공/실패/소요시간 + 로그 열기. |
| Store | `store.ts` | Zustand: 프로필·잡상태·진행률·결과. |
### 3.4 Preload (`src/preload/`)
`contextBridge`로 안전한 API만 노출 (`nodeIntegration: false`, `contextIsolation: true`).
```ts
window.api = {
profiles: { list, upsert, remove, addReference },
dialog: { pickSource, pickOutput },
job: { run, cancel },
on: (event, cb) => unsubscribe, // progress / fileProcessed / done / error
}
```
### 3.5 Shared (`src/shared/`)
`types.ts`(Profile, Job, MatchResult, ProgressEvent, Report 등), `constants.ts`(확장자, 기본 임계값, IPC 채널명).
---
## 4. 데이터 흐름 (End-to-End Sequence)
### 4.1 프로필 등록
```
UI: 이름 + 참조이미지 선택
→ IPC profiles.addReference(path[])
→ Main: 이미지 경로를 InferenceBridge.detect()로 전달
→ Inference: 참조이미지의 descriptor 계산 → 반환
→ Main ProfileStore: {name, order, refImages, descriptors[]} 저장 (다수 → 평균 descriptor)
→ UI: 등록 완료 표시
```
### 4.2 정리 잡 실행 (핵심 파이프라인)
```
UI: [실행] (source, outputRoot, options)
→ IPC job.run
→ Main Orchestrator 시작
1) Scanner.scan(source) ──▶ 이미지 경로 큐 (스트리밍)
2) 동시성 N(기본 3)개 워커로 각 경로 처리:
a. InferenceBridge.detect(path)
Inference: 이미지 로드 → 얼굴검출 → descriptor
→ FaceMatcher로 프로필 매칭(거리<0.5)
→ MatchResult { matched: [{name, order, distance}], faceFound: bool }
b. ExifReader.getCaptureDate(path) → {year, month} (없으면 mtime)
c. 분기:
- matched.length ≥ 1:
정렬(등록순서 asc) → first=이동, rest=복사
PathBuilder로 각 대상경로 생성 → FileOps.move/copy
- matched.length == 0 (미검출/실패):
PathBuilder([미정], date) → FileOps.move
d. 진행률/결과 이벤트 발행 → UI 갱신
3) 큐 소진 시 Reporter.summarize() → 로그 작성
→ IPC done(report) → UI ReportView
```
### 4.3 이동(Move)의 원자성 (데이터 무결성 0 Error)
```
move(src, dest):
1. dest 충돌 검사 → 있으면 자동 리네임(dest_1, dest_2 …)
2. fs.copyFile(src, tmp) # 임시로 복사
3. 무결성 검증 (size 일치 + 필요 시 해시)
4. rename(tmp → dest) # 원자적 노출
5. unlink(src) # 원본 삭제 (검증 통과 후에만)
실패 시: 원본 보존, tmp 정리, 에러 로깅 → 잡 계속 (해당 파일만 실패 처리)
```
> 동일 볼륨이면 `fs.rename` 최적화 가능하나, 안전성 우선으로 copy-verify-delete를 기본으로 한다. (옵션화 가능)
---
## 5. 프로젝트 폴더 트리
```
PhotoAI/
├─ docs/
│ ├─ PRD.md
│ ├─ DECISIONS.md
│ └─ ARCHITECTURE.md ← 본 문서
├─ models/ # face-api 가중치 (ssd_mobilenetv1, landmark68, recognition)
├─ src/
│ ├─ main/ # Main 프로세스
│ │ ├─ index.ts # 앱 진입, 창 생성
│ │ ├─ orchestrator.ts
│ │ ├─ scanner.ts
│ │ ├─ exif.ts
│ │ ├─ fileOps.ts
│ │ ├─ pathBuilder.ts
│ │ ├─ profileStore.ts
│ │ ├─ reporter.ts
│ │ ├─ inferenceWindow.ts
│ │ ├─ ipc.ts
│ │ └─ logger.ts
│ ├─ preload/
│ │ └─ index.ts # contextBridge API
│ ├─ renderer/ # UI 창
│ │ ├─ index.html
│ │ ├─ main.tsx
│ │ ├─ App.tsx
│ │ ├─ store.ts
│ │ ├─ components/
│ │ │ ├─ ProfileManager.tsx
│ │ │ ├─ FolderPicker.tsx
│ │ │ ├─ RunControl.tsx
│ │ │ ├─ ProgressView.tsx
│ │ │ ├─ FileList.tsx
│ │ │ └─ ReportView.tsx
│ │ └─ styles/index.css
│ ├─ inference/ # 숨김 추론 창
│ │ ├─ index.html
│ │ ├─ main.ts
│ │ ├─ faceEngine.ts
│ │ └─ imageLoader.ts
│ └─ shared/
│ ├─ types.ts
│ └─ constants.ts
├─ tests/ # Vitest 단위 테스트 (scanner/exif/pathBuilder/fileOps)
├─ electron.vite.config.ts
├─ electron-builder.yml
├─ tsconfig.json
├─ tailwind.config.js
├─ package.json
└─ README.md
```
### 5.1 런타임 데이터 위치
- **프로필 저장**: `app.getPath('userData')/profiles.json` (OS 표준 사용자 데이터 경로). 내보내기 기능으로 백업 가능.
- **작업 로그**: 출력 루트 하위 `_PhotoAI_logs/run-<타임스탬프>.log` (사용자가 결과와 함께 확인).
- **모델**: 앱 번들 내 `models/` (오프라인 동작, 클라우드 미사용).
---
## 6. IPC 채널 명세
| 채널 | 방향 | 페이로드 | 설명 |
|------|------|---------|------|
| `profiles:list` | UI→Main(invoke) | - | 프로필 목록 |
| `profiles:upsert` | UI→Main | `{id?, name, order}` | 등록/수정 |
| `profiles:addReference` | UI→Main | `{id, imagePaths[]}` | 참조이미지+descriptor |
| `profiles:remove` | UI→Main | `{id}` | 삭제 |
| `dialog:pickSource` / `dialog:pickOutput` | UI→Main | - | 폴더 선택 |
| `job:run` | UI→Main | `{source, outputRoot, options}` | 잡 시작 |
| `job:cancel` | UI→Main | - | 취소 |
| `job:progress` | Main→UI(send) | `{done, total, current}` | 진행률 |
| `job:fileProcessed` | Main→UI | `{file, decision, target}` | 파일 결과 |
| `job:done` | Main→UI | `Report` | 완료 |
| `job:error` | Main→UI | `{file, message}` | 파일 단위 오류 |
| `infer:detect` | Main→Inference(invoke) | `{imagePath}` | descriptor/매칭 |
| `infer:ready` | Inference→Main | - | 모델 로드 완료 |
---
## 7. 얼굴 인식 상세 (Accuracy ≥ 98% 전략)
- **모델 조합**: `SsdMobilenetv1`(검출, 정확도 우선) + `faceLandmark68Net` + `faceRecognitionNet`(128-d descriptor).
- 속도 우선 옵션으로 `TinyFaceDetector` 토글 제공.
- **매칭**: `faceapi.FaceMatcher`, Euclidean distance 임계값 **0.5**(노출형 파라미터). 낮출수록 엄격(오탐↓/미탐↑).
- **정확도 향상**: 인물당 참조이미지 다수 등록 → 라벨별 다중 descriptor 또는 평균값 사용.
- **메모리 관리**: 이미지 장변 다운스케일, 처리 후 `canvas`/`ObjectURL`/텐서 즉시 해제, 동시성 상한으로 피크 메모리 제어. 모델은 1회 로드·앱 생애 유지.
---
## 8. 비기능 요구 매핑 (PRD §5 대응)
| 요구 | 설계 반영 |
|------|----------|
| Atomic Operation | §4.3 copy-verify-delete, 파일 단위 try/catch, 실패 격리 |
| No Data Loss | 자동 리네임(덮어쓰기 금지), 검증 후에만 원본 삭제 |
| Asynchronous Processing | 모든 fs I/O 비동기 + 스트리밍 스캔 + 별도 추론 프로세스 → UI 프리징 0 |
| Memory Management | 다운스케일·즉시 해제·동시성 상한·모델 1회 로드 |
| Extension Auto-detection | `constants.ts`의 확장자 화이트리스트(`.jpg/.jpeg/.png/.webp`) |
---
## 9. 빌드 · 실행 · 테스트
- **개발**: `electron-vite dev` (HMR, 3개 엔트리 동시).
- **배포**: `electron-vite build``electron-builder` 인스톨러.
- **테스트**: Vitest로 순수 로직(스캐너/EXIF 폴백/경로생성/충돌 리네임/이동 원자성) 검증. 얼굴인식은 고정 샘플로 통합 스모크.
---
## 10. 열린 결정 — 확정됨 (Resolved)
| # | 항목 | 확정 |
|---|------|------|
| 1 | 얼굴인식 라이브러리 | **`@vladmandic/face-api`** (원본 face-api.js 유지보수 포크, API 호환) |
| 2 | Inference 격리 방식 | **숨김 BrowserWindow** (v1) |
| 3 | UI 프레임워크 | **React + TypeScript + Vite + Tailwind** |
| 4 | 프로필 저장 위치 | **OS userData** 경로 |
| 5 | 패키징 범위 | **Windows + macOS 동시** (electron-builder: `nsis` + `dmg`) |
> 전 항목 확정. 본 설계로 구현 착수.
```
+42
View File
@@ -0,0 +1,42 @@
# AI Photo Organizer — 확정 결정 사항 (Decisions Log)
> PRD([PRD.md] 별도 보관) 기반. 본 문서는 개발 착수 전 확정된 의사결정을 기록한다.
> 최종 갱신: 2026-06-01
## 1. 핵심 결정 (사용자 확정)
| # | 항목 | 결정 | 근거 |
|---|------|------|------|
| 1 | 앱 형태 | **Electron 데스크톱 앱** | face-api.js를 렌더러(브라우저 환경)에서 네이티브 실행 → 빌드 안정. PRD의 실시간 진행률/파일목록 UI 요구 충족. Windows/Mac 크로스플랫폼. |
| 2 | 중복 파일명 정책 | **자동 리네임** (`photo.jpg``photo_1.jpg`) | No Data Loss 원칙. 데이터 유실 0. |
| 3 | EXIF 촬영날짜 누락 시 | **파일 수정일(mtime)로 대체**하여 YYYY/MM 추출 | 대부분의 사진을 연/월 구조에 편입 가능. |
| 4 | 다중 인물 사진의 Move 1순위 | **프로필 등록 순서** (1번 인물=이동, 2·3번=복사) | PRD 명세 "첫 번째 프로필 기준 이동 후 복사"와 일치. |
## 2. 기본 가정 (Claude 기본값, 이의 없으면 채택)
- **이동(Move) 구현 방식**: `복사 → 무결성 검증 → 원본 삭제` 순서로 수행 (Atomic 보장, 데이터 무결성 0 Error).
- **유사도 임계값**: face-api Euclidean distance `< 0.5`를 매칭으로 간주 (튜닝 가능 파라미터로 노출).
- **Reference 등록**: 인물당 다수 사진 등록 허용 → 평균 descriptor 사용으로 정확도 향상 (KPI ≥98% 대응).
- **미검출/인식 실패 처리**: `[미정]/YYYY/MM/` 으로 이동 (삭제 금지, Scenario 03).
- **대상 확장자**: `.jpg .jpeg .png .webp` 자동 감지. (`.heic`는 향후 확장 검토.)
- **프로필 최대 인원**: 3명 (PRD 명세).
## 3. 폴더 출력 구조
```
<출력루트>/
<프로필명>/YYYY/MM/... # 인물 매칭 사진
[미정]/YYYY/MM/... # 미검출·인식실패 사진
```
## 4. KPI / 비기능 요구
- 분류 정확도 ≥ 98%
- 데이터 무결성 0 Error (유실·경로오류 0건)
- 모든 파일 I/O 비동기 처리 (UI 프리징 방지)
- face-api.js 모델 로드/해제 최적화 (메모리 누수 방지)
## 5. 범위 외 (Out of Scope)
- 클라우드(Google Photos 등) 실시간 동기화
- (미래) 모바일/태블릿 확장 — descriptor 표준 저장 구조는 설계 시 고려
+51
View File
@@ -0,0 +1,51 @@
# [PRD] AI Photo Organizer (가칭)
> 상태: 확정(Final) · 대상: 개발/디자인/QA
> (원문 보관 — 일부 오타는 정리, 내용은 원안 유지)
## 1. 배경
사용자는 스마트폰·DSLR·클라우드 등 다양한 경로로 방대한 사진을 보유한다. 날짜·인물 정보가 파편화되어 특정 인물의 추억을 모으거나 연도별 정리에 막대한 수동 작업이 필요하다. 본 프로젝트는 **얼굴 인식**과 **메타데이터 추출**을 결합해 사용자 개입을 최소화한 자동 정리 환경을 구축한다.
## 2. 목표
- **Local-first Automation**: 클라우드 업로드 없이 로컬 PC 내에서 보안 유지하며 자동 분류.
- **Intelligent Archiving**: 인물(Profile) + 시간(EXIF)을 결합한 폴더 구조 생성.
- **Zero-Effort Management**: 사용자는 '정리할 폴더'와 '대상 인물'만 지정.
## 3. 핵심 사용자 시나리오
- **S01 — 특정 인물 자동 분류**: 'seunghyun' 프로필 등록 후 폴더 지정 → 해당 얼굴 포함 사진만 찾아 `/seunghyun/YYYY/MM/`**이동(Move)**.
- **S02 — 다중 프로필 / 복사 로직**: 'yejin'·'seunghyun' 등록. 두 인물 모두 포함된 사진은 첫 프로필(seunghyun)로 **이동**, 두 번째(yejin)로 **복사(Copy)**. 원칙: 첫 번째 프로필 기준 이동 후 나머지는 복사.
- **S03 — 미검출 예외 처리**: 등록 인물이 없거나 인식 실패 사진은 삭제하지 않고 `[미정]/YYYY/MM/`로 이동.
## 4. 주요 기능
| 구분 | 기능 | 설명 |
|------|------|------|
| 인물 관리 | 프로필 등록/수정 | 최대 3명 이름 + 얼굴 참조사진 등록 |
| AI 엔진 | Face Recognition | face-api.js로 사진 인물 ↔ 프로필 유사도 측정 |
| 파일 관리 | Metadata Extraction | EXIF로 촬영 년/월 추출 |
| 자동 정리 | Path Auto-Generation | `/프로필/YYYY/MM/` 또는 `[미정]/YYYY/MM/` 생성 및 이동/복사 |
| UI | 작업 현황 모니터링 | 실시간 진행률 + 처리 파일 목록 |
| 리포팅 | 결과 리포트 | 총 처리 수, 성공/실패, 소요시간 로그 |
## 5. 비기능 요구사항
**5.1 데이터 안전성·무결성**
- Atomic Operation: 이동 중 오류 시 원본 무손상 예외 처리.
- No Data Loss: 동일 파일명 존재 시 덮어쓰기 전 백업/스킵 정책 정의.
**5.2 성능·확장성**
- Asynchronous Processing: 대량 스캔 시 UI 프리징 방지 위해 모든 파일 I/O 비동기.
- Memory Management: 고해상도 분석 시 누수 방지 위해 모델 로드/해제 최적화.
**5.3 확장성**
- Extension Auto-detection: `.jpg/.png/.webp` 등 주요 확장자 자동 감지.
## 6. KPI
- 분류 정확도 ≥ 98%
- 데이터 무결성 0 Error
- 작업 처리 효율성: 전체 스캔~완료 시간 측정
## 7. 미래/비목표
- **비목표**: 클라우드(Google Photos 등) 실시간 동기화는 범위 외.
- **미래확장**: 모바일/태블릿 확장 대비 descriptor 표준 저장 구조 설계.
---
> 개발 단계 확정 결정은 [DECISIONS.md](./DECISIONS.md), 설계는 [ARCHITECTURE.md](./ARCHITECTURE.md) 참조.
+30
View File
@@ -0,0 +1,30 @@
appId: com.photoai.organizer
productName: AI Photo Organizer
directories:
output: release/${version}
buildResources: build
files:
- out/**/*
- package.json
# face-api 모델 가중치를 앱 리소스로 동봉 (오프라인 동작)
extraResources:
- from: models
to: models
filter:
- "**/*"
asarUnpack:
- "**/*.node"
win:
target:
- nsis
artifactName: ${productName}-${version}-win-${arch}.${ext}
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
mac:
target:
- dmg
category: public.app-category.photography
artifactName: ${productName}-${version}-mac-${arch}.${ext}
dmg:
artifactName: ${productName}-${version}-mac-${arch}.${ext}
+47
View File
@@ -0,0 +1,47 @@
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: { '@shared': resolve('src/shared') }
},
build: {
rollupOptions: {
input: { index: resolve('src/main/index.ts') }
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve('src/preload/index.ts'),
inference: resolve('src/preload/inference.ts')
}
}
}
},
renderer: {
root: '.',
resolve: {
alias: {
'@shared': resolve('src/shared'),
'@renderer': resolve('src/renderer')
}
},
plugins: [react()],
build: {
rollupOptions: {
input: {
// UI 창 + 숨김 추론 창, 두 개의 HTML 엔트리
main_window: resolve('src/renderer/index.html'),
inference_window: resolve('src/inference/index.html')
}
}
}
}
})
+15
View File
@@ -0,0 +1,15 @@
# 모델 가중치 폴더
이 폴더에는 face-api 모델 가중치가 들어갑니다 (용량이 커서 git에는 포함하지 않음).
아래 명령으로 내려받으세요:
```bash
npm run models:download
```
필요 파일:
- `ssd_mobilenetv1_model-*` (정확도 우선 검출)
- `tiny_face_detector_model-*` (속도 우선 검출)
- `face_landmark_68_model-*` (랜드마크)
- `face_recognition_model-*` (128-d descriptor)
+8609
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "ai-photo-organizer",
"version": "0.1.0",
"description": "Local-first AI photo organizer — face recognition + EXIF based auto archiving",
"author": "PhotoAI",
"license": "MIT",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"start": "electron-vite preview",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test": "vitest run",
"test:watch": "vitest",
"models:download": "node scripts/download-models.mjs",
"pack": "electron-vite build && electron-builder --dir",
"dist": "electron-vite build && electron-builder",
"dist:win": "electron-vite build && electron-builder --win",
"dist:mac": "electron-vite build && electron-builder --mac",
"dist:all": "electron-vite build && electron-builder --win --mac"
},
"dependencies": {
"@vladmandic/face-api": "^1.7.13",
"exifr": "^7.1.3",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^20.16.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"electron": "^33.0.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"postcss": "^8.4.47",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.4",
"vite": "^5.4.8",
"vitest": "^2.1.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
+62
View File
@@ -0,0 +1,62 @@
// face-api 모델 가중치를 ./models 로 내려받는 스크립트.
// 출처: @vladmandic/face-api 모델 저장소 (오프라인 동작을 위해 앱에 동봉).
// node scripts/download-models.mjs
import { mkdir, writeFile, access } from 'node:fs/promises'
import { constants } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const MODELS_DIR = join(__dirname, '..', 'models')
const BASE = 'https://raw.githubusercontent.com/vladmandic/face-api/master/model'
// 필요한 모델: SSD MobileNet v1, Tiny Face Detector, Landmark68, Recognition
const FILES = [
'ssd_mobilenetv1_model-weights_manifest.json',
'ssd_mobilenetv1_model.bin',
'tiny_face_detector_model-weights_manifest.json',
'tiny_face_detector_model.bin',
'face_landmark_68_model-weights_manifest.json',
'face_landmark_68_model.bin',
'face_recognition_model-weights_manifest.json',
'face_recognition_model.bin'
]
async function exists(p) {
try {
await access(p, constants.F_OK)
return true
} catch {
return false
}
}
async function download(file) {
const dest = join(MODELS_DIR, file)
if (await exists(dest)) {
console.log(` skip ${file} (이미 존재)`)
return
}
const url = `${BASE}/${file}`
const res = await fetch(url)
if (!res.ok) throw new Error(`다운로드 실패 ${res.status}: ${url}`)
const buf = Buffer.from(await res.arrayBuffer())
await writeFile(dest, buf)
console.log(` ok ${file} (${(buf.length / 1024).toFixed(0)} KB)`)
}
async function main() {
await mkdir(MODELS_DIR, { recursive: true })
console.log(`모델 다운로드 → ${MODELS_DIR}`)
for (const f of FILES) {
await download(f)
}
console.log('완료. 모델 준비됨.')
}
main().catch((err) => {
console.error('오류:', err.message)
process.exit(1)
})
+106
View File
@@ -0,0 +1,106 @@
import * as faceapi from '@vladmandic/face-api'
import type { Profile, JobOptions, MatchResult, ProfileMatch } from '@shared/types'
import { loadImageToCanvas, releaseCanvas } from './imageLoader'
/**
* face-api 기반 얼굴 인식 엔진 (숨김 추론 창에서만 동작).
* - 모델 1회 로드 후 앱 생애 유지
* - 프로필 descriptor로 FaceMatcher 구성
* - 이미지당 얼굴 검출 + 매칭
*/
class FaceEngine {
private modelsLoaded = false
private detector: JobOptions['detector'] = 'ssd'
private matcher: faceapi.FaceMatcher | null = null
/** label(=profileId) → {name, order} */
private labelMeta = new Map<string, { name: string; order: number }>()
async loadModels(baseUrl: string): Promise<void> {
if (this.modelsLoaded) return
// face-api는 브라우저(렌더러) 환경에서 첫 추론 시 WebGL 백엔드를 자동 초기화한다.
// 별도의 setBackend 호출 없이 모델만 로드하면 된다.
await Promise.all([
faceapi.nets.ssdMobilenetv1.loadFromUri(baseUrl),
faceapi.nets.tinyFaceDetector.loadFromUri(baseUrl),
faceapi.nets.faceLandmark68Net.loadFromUri(baseUrl),
faceapi.nets.faceRecognitionNet.loadFromUri(baseUrl)
])
this.modelsLoaded = true
}
/** 잡 시작 전 호출: 매처 + 옵션 구성 */
configure(profiles: Profile[], options: JobOptions): void {
this.detector = options.detector
this.labelMeta.clear()
const labeled: faceapi.LabeledFaceDescriptors[] = []
for (const p of profiles) {
if (!p.descriptors || p.descriptors.length === 0) continue
this.labelMeta.set(p.id, { name: p.name, order: p.order })
const descs = p.descriptors.map((d) => new Float32Array(d))
labeled.push(new faceapi.LabeledFaceDescriptors(p.id, descs))
}
this.matcher = labeled.length
? new faceapi.FaceMatcher(labeled, options.matchThreshold)
: null
}
private detectorOptions(): faceapi.SsdMobilenetv1Options | faceapi.TinyFaceDetectorOptions {
return this.detector === 'tiny'
? new faceapi.TinyFaceDetectorOptions({ inputSize: 512, scoreThreshold: 0.5 })
: new faceapi.SsdMobilenetv1Options({ minConfidence: 0.5 })
}
/** 참조 이미지 1장 → 대표 descriptor (가장 큰 얼굴 1개) */
async describeImage(imagePath: string): Promise<number[] | null> {
const canvas = await loadImageToCanvas(imagePath)
try {
const det = await faceapi
.detectSingleFace(canvas, this.detectorOptions())
.withFaceLandmarks()
.withFaceDescriptor()
return det ? Array.from(det.descriptor) : null
} finally {
releaseCanvas(canvas)
}
}
/** 사진 1장 → 얼굴 검출 + 프로필 매칭 결과 */
async detectImage(imagePath: string): Promise<MatchResult> {
const canvas = await loadImageToCanvas(imagePath)
try {
const results = await faceapi
.detectAllFaces(canvas, this.detectorOptions())
.withFaceLandmarks()
.withFaceDescriptors()
const faceCount = results.length
if (faceCount === 0 || !this.matcher) {
return { faceFound: faceCount > 0, matched: [], faceCount }
}
// 프로필별 최소 거리(최적 매칭) 집계
const best = new Map<string, number>()
for (const r of results) {
const m = this.matcher.findBestMatch(r.descriptor)
if (m.label === 'unknown') continue
const prev = best.get(m.label)
if (prev === undefined || m.distance < prev) best.set(m.label, m.distance)
}
const matched: ProfileMatch[] = []
for (const [profileId, distance] of best) {
const meta = this.labelMeta.get(profileId)
if (!meta) continue
matched.push({ profileId, name: meta.name, order: meta.order, distance })
}
return { faceFound: true, matched, faceCount }
} finally {
releaseCanvas(canvas)
}
}
}
export const faceEngine = new FaceEngine()
+9
View File
@@ -0,0 +1,9 @@
import type { InferBridge } from '../preload/inference'
declare global {
interface Window {
inferBridge: InferBridge
}
}
export {}
+43
View File
@@ -0,0 +1,43 @@
import { pathToFileUrl } from './pathToFileUrl'
import { MAX_IMAGE_DIMENSION } from '@shared/constants'
/**
* 파일 경로의 이미지를 HTMLCanvasElement로 디코딩한다.
* 장변이 MAX_IMAGE_DIMENSION을 넘으면 비율 유지하며 다운스케일 → 메모리/속도 최적화.
* webSecurity:false 환경이므로 file:// URL을 직접 로드할 수 있다.
*/
export async function loadImageToCanvas(imagePath: string): Promise<HTMLCanvasElement> {
const img = await loadImageElement(pathToFileUrl(imagePath))
const { width, height } = img
const longSide = Math.max(width, height)
const scale = longSide > MAX_IMAGE_DIMENSION ? MAX_IMAGE_DIMENSION / longSide : 1
const w = Math.max(1, Math.round(width * scale))
const h = Math.max(1, Math.round(height * scale))
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D 컨텍스트 생성 실패')
ctx.drawImage(img, 0, 0, w, h)
// 원본 img 참조 해제 (디코딩 버퍼 회수 유도)
img.src = ''
return canvas
}
function loadImageElement(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`이미지 로드 실패: ${url}`))
img.src = url
})
}
/** 처리 후 캔버스 크기를 0으로 줄여 메모리 회수를 유도 */
export function releaseCanvas(canvas: HTMLCanvasElement): void {
canvas.width = 0
canvas.height = 0
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>PhotoAI Inference Worker</title>
</head>
<body>
<!-- 숨김 추론 창: UI 없음. face-api 연산 전용. -->
<script type="module" src="./main.ts"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
import { faceEngine } from './faceEngine'
import type { Profile, JobOptions, DescriptorResult } from '@shared/types'
/**
* 숨김 추론 창 부트스트랩.
* 1) URL 쿼리에서 모델 경로 읽어 모델 로드 → ready 통지
* 2) Main의 요청(infer:init/describe/detect) 처리 후 reply
*/
async function bootstrap(): Promise<void> {
const params = new URLSearchParams(location.search)
const modelsUrl = params.get('models')
if (!modelsUrl) throw new Error('models 경로 쿼리가 없습니다.')
console.log('models loading from:', decodeURIComponent(modelsUrl))
await faceEngine.loadModels(decodeURIComponent(modelsUrl))
console.log('models loaded OK')
window.inferBridge.ready()
window.inferBridge.onRequest(async (channel, payload) => {
const { requestId } = payload
try {
if (channel === 'infer:init') {
const { profiles, options } = payload as unknown as {
profiles: Profile[]
options: JobOptions
}
faceEngine.configure(profiles, options)
window.inferBridge.reply(requestId, true, { ok: true })
} else if (channel === 'infer:describe') {
const { imagePaths } = payload as unknown as { imagePaths: string[] }
const out: DescriptorResult[] = []
for (const imagePath of imagePaths) {
const descriptor = await faceEngine.describeImage(imagePath)
out.push({ imagePath, descriptor })
}
window.inferBridge.reply(requestId, true, out)
} else if (channel === 'infer:detect') {
const { imagePath } = payload as unknown as { imagePath: string }
const result = await faceEngine.detectImage(imagePath)
window.inferBridge.reply(requestId, true, result)
}
} catch (err) {
window.inferBridge.reply(requestId, false, undefined, (err as Error).message)
}
})
}
bootstrap().catch((err) => {
// 부트스트랩 실패 시 콘솔에 남김 — Main은 whenReady에서 영구 대기하므로
// 개발 중 콘솔로 원인 확인
console.error('[inference] 부트스트랩 실패:', err)
})
+17
View File
@@ -0,0 +1,17 @@
/**
* 렌더러(브라우저)에는 node:url이 없으므로 경로 → file:// URL 변환을 직접 수행.
* Windows(드라이브 문자, 백슬래시)와 POSIX 경로를 모두 처리한다.
*/
export function pathToFileUrl(p: string): string {
let normalized = p.replace(/\\/g, '/')
// Windows 드라이브 경로(C:/...)는 슬래시 3개 + 그대로
if (/^[a-zA-Z]:\//.test(normalized)) {
normalized = '/' + normalized
}
// 각 세그먼트를 인코딩 (공백/한글/특수문자 대응), 슬래시는 보존
const encoded = normalized
.split('/')
.map((seg) => encodeURIComponent(seg))
.join('/')
return 'file://' + encoded
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 경량 동시성 제한 세마포어. 외부 p-limit 대신 직접 구현.
* limit 개수만큼만 동시에 실행되도록 작업을 게이팅한다.
*/
export function createLimiter(limit: number) {
let active = 0
const queue: Array<() => void> = []
const next = () => {
if (active >= limit) return
const run = queue.shift()
if (run) {
active++
run()
}
}
return function schedule<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const run = () => {
task()
.then(resolve, reject)
.finally(() => {
active--
next()
})
}
queue.push(run)
next()
})
}
}
+41
View File
@@ -0,0 +1,41 @@
import exifr from 'exifr'
import { stat } from 'node:fs/promises'
import type { CaptureDate } from '@shared/types'
function toYearMonth(d: Date, source: CaptureDate['source']): CaptureDate {
const year = String(d.getFullYear())
const month = String(d.getMonth() + 1).padStart(2, '0')
return { year, month, source }
}
/**
* 촬영 날짜 추출.
* 1) EXIF DateTimeOriginal (없으면 CreateDate/ModifyDate) 시도
* 2) 실패 시 파일 시스템 mtime 폴백
*
* 어떤 경우에도 throw 하지 않고 항상 유효한 CaptureDate를 반환한다.
*/
export async function getCaptureDate(path: string): Promise<CaptureDate> {
// 1) EXIF 시도
try {
const exif = await exifr.parse(path, {
pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate']
})
const raw: unknown =
exif?.DateTimeOriginal ?? exif?.CreateDate ?? exif?.ModifyDate
if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
return toYearMonth(raw, 'exif')
}
} catch {
// EXIF 파싱 실패 → 폴백으로 진행
}
// 2) mtime 폴백
try {
const s = await stat(path)
return toYearMonth(s.mtime, 'mtime')
} catch {
// stat 마저 실패하면 현재 시각으로 최후 폴백 (파일 분류는 계속되어야 함)
return toYearMonth(new Date(), 'mtime')
}
}
+67
View File
@@ -0,0 +1,67 @@
import { copyFile, mkdir, unlink, stat, access } from 'node:fs/promises'
import { constants as FS } from 'node:fs'
import { dirname } from 'node:path'
import { withCollisionSuffix } from './pathBuilder'
async function exists(p: string): Promise<boolean> {
try {
await access(p, FS.F_OK)
return true
} catch {
return false
}
}
/**
* 대상 경로에 충돌이 없는 최종 경로를 구한다.
* 이미 존재하면 name_1, name_2 ... 로 자동 리네임 (No Data Loss 정책).
*/
export async function resolveCollisionFreePath(target: string): Promise<string> {
if (!(await exists(target))) return target
for (let i = 1; i < 100000; i++) {
const candidate = withCollisionSuffix(target, i)
if (!(await exists(candidate))) return candidate
}
throw new Error(`충돌 회피 경로 생성 실패(시도 초과): ${target}`)
}
/**
* 안전 복사: 대상 디렉터리 생성 → copyFile → 크기 검증.
* 반환값은 실제로 기록된 (충돌 회피된) 경로.
*/
export async function safeCopy(src: string, target: string): Promise<string> {
const dest = await resolveCollisionFreePath(target)
await mkdir(dirname(dest), { recursive: true })
await copyFile(src, dest)
// 무결성 검증: 원본/사본 크기 일치
const [s, d] = await Promise.all([stat(src), stat(dest)])
if (s.size !== d.size) {
// 검증 실패 → 깨진 사본 제거 후 오류
await unlink(dest).catch(() => {})
throw new Error(`복사 무결성 검증 실패(size ${s.size} != ${d.size}): ${src}`)
}
return dest
}
/**
* 안전 이동: 복사 → 검증 → 원본 삭제 (Atomic 정책, 데이터 무결성 0 Error).
* 동일 볼륨 여부와 무관하게 copy-verify-delete 로 동작해 부분 손상 방지.
* 검증 통과 후에만 원본을 삭제하므로 어느 단계에서 실패해도 원본은 보존된다.
*
* @returns 실제로 기록된 (충돌 회피된) 대상 경로
*/
export async function safeMove(src: string, target: string): Promise<string> {
const dest = await safeCopy(src, target) // 복사 + 검증 완료
try {
await unlink(src) // 검증 통과 후에만 원본 삭제
} catch (err) {
// 사본은 정상. 원본 삭제만 실패한 경우 → 사본 유지하되 경고로 남김(데이터 유실 없음)
throw new Error(
`이동 완료(사본 생성됨) 후 원본 삭제 실패: ${src}${dest} :: ${(err as Error).message}`
)
}
return dest
}
export { exists }
+57
View File
@@ -0,0 +1,57 @@
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'node:path'
import { registerIpc } from './ipc'
import { inferenceBridge } from './inferenceBridge'
import { logger } from './logger'
let mainWindow: BrowserWindow | null = null
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1100,
height: 760,
minWidth: 900,
minHeight: 600,
show: false,
title: 'AI Photo Organizer',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
mainWindow.on('ready-to-show', () => mainWindow?.show())
// 외부 링크는 기본 브라우저로
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
mainWindow.loadURL(`${devUrl}/src/renderer/index.html`)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/src/renderer/index.html'))
}
}
app.whenReady().then(() => {
registerIpc()
// 숨김 추론 창을 먼저 띄워 모델 로드를 선행
inferenceBridge.init()
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
logger.info('앱 시작 완료')
})
app.on('window-all-closed', () => {
inferenceBridge.dispose()
if (process.platform !== 'darwin') app.quit()
})
+129
View File
@@ -0,0 +1,129 @@
import { BrowserWindow, ipcMain, app } from 'electron'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { MatchResult, Profile, DescriptorResult, JobOptions } from '@shared/types'
import { logger } from './logger'
/** 모델 가중치 디렉터리의 file:// URL (dev: 프로젝트 루트, prod: resources) */
function modelsBaseUrl(): string {
const dir = app.isPackaged
? join(process.resourcesPath, 'models')
: join(app.getAppPath(), 'models')
// 끝에 슬래시 없이 — 렌더러에서 `${base}/파일명` 형태로 사용
return pathToFileURL(dir).toString()
}
interface Pending {
resolve: (v: unknown) => void
reject: (e: Error) => void
}
/**
* 숨김 BrowserWindow(Inference Renderer)와의 RPC 브릿지.
* - Main → Renderer: webContents.send(channel, {requestId, ...})
* - Renderer → Main: ipcRenderer.send('infer:reply', {requestId, ok, data, error})
* requestId 상관관계로 요청/응답을 매칭한다.
*/
class InferenceBridge {
private win: BrowserWindow | null = null
private readyPromise: Promise<void> | null = null
private readyResolve: (() => void) | null = null
private pending = new Map<string, Pending>()
private seq = 0
/** 숨김 추론 창 생성 및 모델 로드 대기 준비 */
init(): void {
if (this.win) return
this.readyPromise = new Promise((res) => {
this.readyResolve = res
})
// 추론 창이 ready를 알리면 resolve
ipcMain.on('infer:ready', () => {
logger.info('Inference 창 모델 로드 완료')
this.readyResolve?.()
})
// 추론 창의 모든 응답 수신
ipcMain.on('infer:reply', (_e, payload: { requestId: string; ok: boolean; data?: unknown; error?: string }) => {
const p = this.pending.get(payload.requestId)
if (!p) return
this.pending.delete(payload.requestId)
if (payload.ok) p.resolve(payload.data)
else p.reject(new Error(payload.error ?? '추론 오류'))
})
this.win = new BrowserWindow({
show: false,
webPreferences: {
preload: join(__dirname, '../preload/inference.js'),
contextIsolation: true,
nodeIntegration: false,
// 숨김 창이 백그라운드에서도 연산을 멈추지 않도록
backgroundThrottling: false,
// 내부 전용 창: 로컬 파일(모델 가중치/사진)을 file://로 fetch 하기 위해 완화.
// 원격 콘텐츠를 절대 로드하지 않으므로 위험은 격리됨.
webSecurity: false
}
})
// 추론창 콘솔을 Main 로그로 포워딩 (모델 로드 성공/실패 가시화)
this.win.webContents.on('console-message', (_e, level, message) => {
const tag = level >= 2 ? 'ERROR' : 'INFO'
logger.info(`[inference console:${tag}] ${message}`)
})
const models = encodeURIComponent(modelsBaseUrl())
// electron-vite: 개발 시 dev 서버, 배포 시 빌드된 html 로드.
// 모델 경로는 쿼리스트링으로 전달 → 렌더러가 즉시 모델 로드 시작.
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
this.win.loadURL(`${devUrl}/src/inference/index.html?models=${models}`)
} else {
this.win.loadFile(join(__dirname, '../renderer/src/inference/index.html'), {
search: `models=${models}`
})
}
}
/** 모델 로드 완료까지 대기 */
async whenReady(): Promise<void> {
if (!this.readyPromise) throw new Error('InferenceBridge.init()가 호출되지 않음')
await this.readyPromise
}
private call<T>(channel: string, payload: Record<string, unknown>): Promise<T> {
if (!this.win) throw new Error('Inference 창이 없음')
const requestId = `req_${++this.seq}`
return new Promise<T>((resolve, reject) => {
this.pending.set(requestId, { resolve: resolve as (v: unknown) => void, reject })
this.win!.webContents.send(channel, { requestId, ...payload })
})
}
/** 잡 시작 전: 프로필 descriptor로 FaceMatcher 구성 + 옵션 적용 */
async initMatcher(profiles: Profile[], options: JobOptions): Promise<void> {
await this.call('infer:init', { profiles, options })
}
/** 참조 이미지들의 descriptor 계산 (프로필 등록용) */
async describe(imagePaths: string[], detector: JobOptions['detector']): Promise<DescriptorResult[]> {
return this.call<DescriptorResult[]>('infer:describe', { imagePaths, detector })
}
/** 사진 1장 얼굴 검출 + 프로필 매칭 */
async detect(imagePath: string): Promise<MatchResult> {
return this.call<MatchResult>('infer:detect', { imagePath })
}
dispose(): void {
this.pending.forEach((p) => p.reject(new Error('브릿지 종료')))
this.pending.clear()
this.win?.destroy()
this.win = null
}
}
export const inferenceBridge = new InferenceBridge()
+69
View File
@@ -0,0 +1,69 @@
import { ipcMain, dialog, BrowserWindow } from 'electron'
import type { ProfileInput, JobRequest } from '@shared/types'
import { IPC } from '@shared/constants'
import { profileStore } from './profileStore'
import { inferenceBridge } from './inferenceBridge'
import { orchestrator } from './orchestrator'
import { logger } from './logger'
/** UI/다이얼로그/잡 관련 IPC 핸들러 등록 */
export function registerIpc(): void {
// ---- 프로필 ----
ipcMain.handle(IPC.PROFILES_LIST, () => profileStore.list())
ipcMain.handle(IPC.PROFILES_UPSERT, (_e, input: ProfileInput) =>
profileStore.upsert(input)
)
ipcMain.handle(IPC.PROFILES_REMOVE, (_e, id: string) => profileStore.remove(id))
ipcMain.handle(
IPC.PROFILES_ADD_REFERENCE,
async (_e, id: string, imagePaths: string[]) => {
await inferenceBridge.whenReady()
// 참조 이미지 descriptor 계산 (기본 정확도 우선 detector)
const results = await inferenceBridge.describe(imagePaths, 'ssd')
const valid = results.filter((r) => r.descriptor !== null)
const usedPaths = valid.map((r) => r.imagePath)
const descriptors = valid.map((r) => r.descriptor as number[])
if (descriptors.length === 0) {
throw new Error('선택한 이미지에서 얼굴을 찾지 못했습니다.')
}
return profileStore.addReference(id, usedPaths, descriptors)
}
)
// ---- 다이얼로그 ----
ipcMain.handle(IPC.DIALOG_PICK_SOURCE, async () => {
const r = await dialog.showOpenDialog({ properties: ['openDirectory'] })
return r.canceled ? null : r.filePaths[0]
})
ipcMain.handle(IPC.DIALOG_PICK_OUTPUT, async () => {
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
return r.canceled ? null : r.filePaths[0]
})
ipcMain.handle(IPC.DIALOG_PICK_IMAGES, async () => {
const r = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }]
})
return r.canceled ? [] : r.filePaths
})
// ---- 잡 ----
ipcMain.handle(IPC.JOB_RUN, async (e, req: JobRequest) => {
const win = BrowserWindow.fromWebContents(e.sender)
if (!win) throw new Error('요청 창을 찾을 수 없음')
// 비동기로 실행하되 완료는 이벤트(JOB_DONE)로 통지 → 호출 즉시 반환
orchestrator.run(req, win).catch((err) => {
logger.error('잡 실행 실패', { message: (err as Error).message })
if (!win.isDestroyed()) {
win.webContents.send(IPC.JOB_ERROR, { file: '', message: (err as Error).message })
}
})
})
ipcMain.handle(IPC.JOB_CANCEL, () => orchestrator.cancel())
}
+64
View File
@@ -0,0 +1,64 @@
import { appendFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
type Level = 'INFO' | 'WARN' | 'ERROR'
/**
* 구조적 로거. 콘솔 + (옵션) 파일에 기록.
* 잡 실행 시 setLogFile()로 출력 루트 하위 로그 파일 경로를 지정한다.
*/
class Logger {
private logFile: string | null = null
private buffer: string[] = []
async setLogFile(path: string): Promise<void> {
this.logFile = path
await mkdir(dirname(path), { recursive: true })
}
private async write(level: Level, msg: string, meta?: unknown): Promise<void> {
// new Date() 사용 (Main 프로세스는 일반 Node — 제약 없음)
const ts = new Date().toISOString()
const metaStr = meta === undefined ? '' : ` ${safeJson(meta)}`
const line = `[${ts}] [${level}] ${msg}${metaStr}`
if (level === 'ERROR') console.error(line)
else if (level === 'WARN') console.warn(line)
else console.log(line)
this.buffer.push(line)
if (this.logFile) {
try {
await appendFile(this.logFile, line + '\n', 'utf-8')
} catch {
// 로그 파일 기록 실패는 치명적이지 않음 — 콘솔 출력은 이미 됨
}
}
}
info(msg: string, meta?: unknown) {
return this.write('INFO', msg, meta)
}
warn(msg: string, meta?: unknown) {
return this.write('WARN', msg, meta)
}
error(msg: string, meta?: unknown) {
return this.write('ERROR', msg, meta)
}
/** 잡 종료 시 버퍼 비우기 */
reset(): void {
this.buffer = []
this.logFile = null
}
}
function safeJson(v: unknown): string {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
export const logger = new Logger()
+167
View File
@@ -0,0 +1,167 @@
import { BrowserWindow } from 'electron'
import type {
JobRequest,
FileProcessed,
ProgressEvent,
Report,
ProfileMatch
} from '@shared/types'
import { IPC } from '@shared/constants'
import { scan, countImages, defaultSkipDirs } from './scanner'
import { getCaptureDate } from './exif'
import { buildTargetPath } from './pathBuilder'
import { safeMove, safeCopy } from './fileOps'
import { profileStore } from './profileStore'
import { inferenceBridge } from './inferenceBridge'
import { Reporter } from './reporter'
import { createLimiter } from './concurrency'
import { logger } from './logger'
/**
* 정리 잡 파이프라인 오케스트레이터.
* 스캔 → (얼굴인식 + EXIF) → 경로생성 → 이동/복사 → 진행률/리포트.
*/
class Orchestrator {
private cancelled = false
private running = false
cancel(): void {
if (this.running) {
this.cancelled = true
logger.warn('잡 취소 요청됨')
}
}
async run(req: JobRequest, sender: BrowserWindow): Promise<Report> {
if (this.running) throw new Error('이미 실행 중인 잡이 있습니다.')
this.running = true
this.cancelled = false
const send = <T>(channel: string, payload: T) => {
if (!sender.isDestroyed()) sender.webContents.send(channel, payload)
}
const reporter = new Reporter()
const startTs = Date.now()
const logPath = Reporter.logPathFor(req.outputRoot, startTs)
await logger.setLogFile(logPath)
await logger.info('잡 시작', req)
try {
const profiles = await profileStore.list() // order asc 정렬됨
// 추론 엔진 준비 + 매처 구성
await inferenceBridge.whenReady()
await inferenceBridge.initMatcher(profiles, req.options)
// 출력물 재처리 방지 위해 우리가 만든 폴더는 스캔 제외
const skip = defaultSkipDirs(profiles.map((p) => p.name))
// 진행률 total 산출
const total = await countImages(req.source, skip)
logger.info('스캔 대상 이미지 수', { total })
let done = 0
const limit = createLimiter(Math.max(1, req.options.concurrency))
const tasks: Promise<void>[] = []
for await (const file of scan(req.source, skip)) {
if (this.cancelled) break
const task = limit(async () => {
if (this.cancelled) return
const progress: ProgressEvent = { done, total, current: file }
send(IPC.JOB_PROGRESS, progress)
const result = await this.processFile(req, file, profiles)
reporter.record(result)
done++
send(IPC.JOB_FILE_PROCESSED, result)
send<ProgressEvent>(IPC.JOB_PROGRESS, { done, total, current: file })
if (result.kind === 'failed' && result.error) {
send(IPC.JOB_ERROR, { file, message: result.error })
}
})
tasks.push(task)
}
await Promise.all(tasks)
const report = await reporter.summarize(logPath)
send(IPC.JOB_DONE, report)
return report
} finally {
this.running = false
logger.reset()
}
}
/** 파일 1건 처리: 인식 → 날짜 → 이동/복사 */
private async processFile(
req: JobRequest,
file: string,
profilesOrdered: { id: string; name: string; order: number }[]
): Promise<FileProcessed> {
void profilesOrdered
try {
// 얼굴 인식 + 날짜 추출 병렬
const [match, date] = await Promise.all([
inferenceBridge.detect(file),
getCaptureDate(file)
])
// 매칭 인물 없음 → [미정]
if (!match.matched || match.matched.length === 0) {
const dest = await safeMove(file, buildTargetPath(req.outputRoot, null, date, file))
return {
file,
kind: 'unmatched',
targets: [dest],
matchedNames: [],
date
}
}
// 등록 순서(order asc) 정렬 → 1순위 이동, 나머지 복사
const ordered: ProfileMatch[] = [...match.matched].sort((a, b) => a.order - b.order)
const targets: string[] = []
// 1순위: 이동
const first = ordered[0]
const movedDest = await safeMove(
file,
buildTargetPath(req.outputRoot, first.name, date, file)
)
targets.push(movedDest)
// 나머지: 이동된 파일을 소스로 복사
for (let i = 1; i < ordered.length; i++) {
const copyDest = await safeCopy(
movedDest,
buildTargetPath(req.outputRoot, ordered[i].name, date, file)
)
targets.push(copyDest)
}
return {
file,
kind: 'moved',
targets,
matchedNames: ordered.map((m) => m.name),
date
}
} catch (err) {
const message = (err as Error).message
await logger.error('파일 처리 실패', { file, message })
return {
file,
kind: 'failed',
targets: [],
matchedNames: [],
date: null,
error: message
}
}
}
}
export const orchestrator = new Orchestrator()
+31
View File
@@ -0,0 +1,31 @@
import { join, extname, basename } from 'node:path'
import type { CaptureDate } from '@shared/types'
import { UNMATCHED_FOLDER } from '@shared/constants'
/**
* 인물/미정 + 연/월 기준의 대상 디렉터리 경로를 생성한다.
* 실제 파일명 충돌 해소는 fileOps에서 수행 (여기서는 디렉터리 + 원본 파일명까지).
*
* @param who 인물 폴더명, 또는 미검출이면 null → [미정]
*/
export function buildTargetPath(
outputRoot: string,
who: string | null,
date: CaptureDate,
sourceFile: string
): string {
const folder = who ?? UNMATCHED_FOLDER
const filename = basename(sourceFile)
return join(outputRoot, folder, date.year, date.month, filename)
}
/**
* 파일명 충돌 시 사용할 후보 경로를 생성 (name_1.ext, name_2.ext ...).
* @param index 1부터 시작하는 충돌 회피 인덱스
*/
export function withCollisionSuffix(targetPath: string, index: number): string {
const dir = targetPath.slice(0, targetPath.length - basename(targetPath).length)
const ext = extname(targetPath)
const stem = basename(targetPath, ext)
return join(dir, `${stem}_${index}${ext}`)
}
+102
View File
@@ -0,0 +1,102 @@
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 { PROFILE_STORE_FILE, MAX_PROFILES } from '@shared/constants'
import { logger } from './logger'
/**
* 프로필 영속화. OS userData 경로의 profiles.json 에 저장.
* descriptor(Float32Array)는 number[][] 직렬화 형태로 보관 → 모바일 확장 시 표준 구조 호환.
*/
class ProfileStore {
private profiles: Profile[] = []
private loaded = false
private filePath(): string {
return join(app.getPath('userData'), PROFILE_STORE_FILE)
}
async load(): Promise<Profile[]> {
if (this.loaded) return this.profiles
try {
const raw = await readFile(this.filePath(), 'utf-8')
const parsed = JSON.parse(raw) as { profiles?: Profile[] }
this.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : []
} catch {
this.profiles = [] // 최초 실행 등 → 빈 목록
}
this.loaded = true
return this.profiles
}
async list(): Promise<Profile[]> {
await this.load()
return [...this.profiles].sort((a, b) => a.order - b.order)
}
private async persist(): Promise<void> {
await mkdir(app.getPath('userData'), { recursive: true })
await writeFile(
this.filePath(),
JSON.stringify({ profiles: this.profiles }, null, 2),
'utf-8'
)
}
/** 생성/수정. id 없으면 신규(최대 인원 검사). */
async upsert(input: ProfileInput): Promise<Profile> {
await this.load()
if (input.id) {
const existing = this.profiles.find((p) => p.id === input.id)
if (!existing) throw new Error(`프로필을 찾을 수 없음: ${input.id}`)
existing.name = input.name
existing.order = input.order
await this.persist()
return existing
}
if (this.profiles.length >= MAX_PROFILES) {
throw new Error(`프로필은 최대 ${MAX_PROFILES}명까지 등록 가능합니다.`)
}
const profile: Profile = {
id: cryptoRandomId(),
name: input.name,
order: input.order,
referenceImages: [],
descriptors: []
}
this.profiles.push(profile)
await this.persist()
logger.info('프로필 생성', { id: profile.id, name: profile.name })
return profile
}
async remove(id: string): Promise<void> {
await this.load()
this.profiles = this.profiles.filter((p) => p.id !== id)
await this.persist()
}
/** 참조 이미지 + 계산된 descriptor 추가 */
async addReference(
id: string,
imagePaths: string[],
descriptors: number[][]
): Promise<Profile> {
await this.load()
const p = this.profiles.find((x) => x.id === id)
if (!p) throw new Error(`프로필을 찾을 수 없음: ${id}`)
p.referenceImages.push(...imagePaths)
p.descriptors.push(...descriptors)
await this.persist()
logger.info('참조 이미지 추가', { id, added: descriptors.length })
return p
}
}
function cryptoRandomId(): string {
// Electron Main(Node)에서 무작위 ID — globalThis.crypto.randomUUID 사용
return globalThis.crypto.randomUUID()
}
export const profileStore = new ProfileStore()
+63
View File
@@ -0,0 +1,63 @@
import { join } from 'node:path'
import type { FileProcessed, Report } from '@shared/types'
import { LOG_FOLDER } from '@shared/constants'
import { logger } from './logger'
/**
* 잡 통계 집계 + 결과 로그 파일 생성.
*/
export class Reporter {
private moved = 0
private copied = 0
private unmatched = 0
private failed = 0
private total = 0
private readonly startedAt: number
constructor() {
this.startedAt = Date.now()
}
record(result: FileProcessed): void {
this.total++
switch (result.kind) {
case 'moved':
this.moved++
// 복사 대상은 targets에서 첫(이동) 제외한 나머지
this.copied += Math.max(0, result.targets.length - 1)
break
case 'unmatched':
this.unmatched++
break
case 'failed':
this.failed++
break
case 'copied':
this.copied++
break
}
}
/** 로그 파일 경로 (출력 루트 하위 _PhotoAI_logs/run-<ts>.log) */
static logPathFor(outputRoot: string, ts: number): string {
const stamp = new Date(ts).toISOString().replace(/[:.]/g, '-')
return join(outputRoot, LOG_FOLDER, `run-${stamp}.log`)
}
async summarize(logPath: string): Promise<Report> {
const finishedAt = Date.now()
const report: Report = {
total: this.total,
moved: this.moved,
copied: this.copied,
unmatched: this.unmatched,
failed: this.failed,
elapsedMs: finishedAt - this.startedAt,
logPath,
startedAt: this.startedAt,
finishedAt
}
await logger.info('==== 작업 결과 리포트 ====', report)
return report
}
}
+60
View File
@@ -0,0 +1,60 @@
import { readdir } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { SUPPORTED_EXTENSIONS, LOG_FOLDER, UNMATCHED_FOLDER } from '@shared/constants'
const EXT_SET = new Set<string>(SUPPORTED_EXTENSIONS)
function isSupportedImage(filename: string): boolean {
return EXT_SET.has(extname(filename).toLowerCase())
}
/**
* 소스 폴더를 재귀 순회하며 지원 확장자 이미지의 절대 경로를 스트리밍 산출.
* 비동기 제너레이터 → 대량 폴더에서도 메모리에 전체 목록을 적재하지 않음.
*
* @param skipDirs 순회에서 제외할 디렉터리명 (출력 루트가 소스 내부일 때 자기 출력물 재처리 방지)
*/
export async function* scan(
root: string,
skipDirs: ReadonlySet<string> = new Set()
): AsyncGenerator<string> {
let entries
try {
entries = await readdir(root, { withFileTypes: true })
} catch {
// 읽을 수 없는 디렉터리는 건너뜀
return
}
for (const entry of entries) {
const full = join(root, entry.name)
if (entry.isDirectory()) {
// 우리 자신이 만든 폴더(프로필/[미정]/로그)는 재귀 제외
if (skipDirs.has(entry.name)) continue
yield* scan(full, skipDirs)
} else if (entry.isFile() && isSupportedImage(entry.name)) {
yield full
}
}
}
/**
* 전체 개수를 먼저 세는 헬퍼 (진행률 total 표시용).
* 스캔을 한 번 더 도는 비용이 있으나, 정확한 진행률을 위해 사용.
*/
export async function countImages(
root: string,
skipDirs: ReadonlySet<string> = new Set()
): Promise<number> {
let count = 0
for await (const _ of scan(root, skipDirs)) {
void _
count++
}
return count
}
/** 출력물 재처리 방지를 위한 기본 제외 디렉터리 집합 */
export function defaultSkipDirs(profileNames: string[]): Set<string> {
return new Set<string>([LOG_FOLDER, UNMATCHED_FOLDER, ...profileNames])
}
+44
View File
@@ -0,0 +1,44 @@
import { contextBridge, ipcRenderer } from 'electron'
import { IPC } from '../shared/constants'
import type {
ExposedApi,
ProfileInput,
JobRequest,
RendererEventName,
RendererEvents
} from '../shared/types'
// Main→UI 이벤트 채널 화이트리스트
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
}
const api: ExposedApi = {
profiles: {
list: () => ipcRenderer.invoke(IPC.PROFILES_LIST),
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)
},
dialog: {
pickSource: () => ipcRenderer.invoke(IPC.DIALOG_PICK_SOURCE),
pickOutput: () => ipcRenderer.invoke(IPC.DIALOG_PICK_OUTPUT),
pickImages: () => ipcRenderer.invoke(IPC.DIALOG_PICK_IMAGES)
},
job: {
run: (req: JobRequest) => ipcRenderer.invoke(IPC.JOB_RUN, req),
cancel: () => ipcRenderer.invoke(IPC.JOB_CANCEL)
},
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)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
}
contextBridge.exposeInMainWorld('api', api)
+33
View File
@@ -0,0 +1,33 @@
import { contextBridge, ipcRenderer } from 'electron'
// 숨김 추론 창 전용 브릿지.
// Main이 보내는 요청 채널만 수신하고, 응답은 'infer:reply'로만 전송한다.
const REQUEST_CHANNELS = ['infer:init', 'infer:describe', 'infer:detect'] as const
type RequestChannel = (typeof REQUEST_CHANNELS)[number]
export interface InferBridge {
/** Main의 요청 수신 */
onRequest(
cb: (channel: RequestChannel, payload: Record<string, unknown> & { requestId: string }) => void
): void
/** 요청 처리 결과 회신 */
reply(requestId: string, ok: boolean, data?: unknown, error?: string): void
/** 모델 로드 완료 통지 */
ready(): void
}
const bridge: InferBridge = {
onRequest(cb) {
for (const channel of REQUEST_CHANNELS) {
ipcRenderer.on(channel, (_e, payload) => cb(channel, payload))
}
},
reply(requestId, ok, data, error) {
ipcRenderer.send('infer:reply', { requestId, ok, data, error })
},
ready() {
ipcRenderer.send('infer:ready')
}
}
contextBridge.exposeInMainWorld('inferBridge', bridge)
+45
View File
@@ -0,0 +1,45 @@
import { useEffect } from 'react'
import { useStore, wireEvents } from './store'
import { ProfileManager } from './components/ProfileManager'
import { FolderPicker } from './components/FolderPicker'
import { RunControl } from './components/RunControl'
import { ProgressView } from './components/ProgressView'
import { FileList } from './components/FileList'
import { ReportView } from './components/ReportView'
export default function App(): JSX.Element {
const phase = useStore((s) => s.phase)
const refreshProfiles = useStore((s) => s.refreshProfiles)
useEffect(() => {
const unwire = wireEvents()
void refreshProfiles()
return unwire
}, [refreshProfiles])
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>
</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">
<ProfileManager />
<FolderPicker />
<RunControl />
</section>
{/* 우측: 진행/결과 */}
<section className="col-span-7 flex flex-col gap-4 overflow-hidden">
{phase === 'done' ? <ReportView /> : <ProgressView />}
<FileList />
</section>
</main>
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { useStore } from '../store'
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' }
}
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 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>
<div className="flex-1 overflow-y-auto">
{processed.length === 0 ? (
<p className="text-sm text-slate-400 py-4"> .</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>
)}
</div>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { useStore } from '../store'
/** 소스 폴더 + 출력 루트 선택 */
export function FolderPicker(): JSX.Element {
const source = useStore((s) => s.source)
const outputRoot = useStore((s) => s.outputRoot)
const setSource = useStore((s) => s.setSource)
const setOutput = useStore((s) => s.setOutput)
const pickSource = async () => {
const p = await window.api.dialog.pickSource()
if (p) setSource(p)
}
const pickOutput = async () => {
const p = await window.api.dialog.pickOutput()
if (p) setOutput(p)
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h2 className="font-semibold mb-3">2. </h2>
<Row label="정리할 폴더 (소스)" value={source} onPick={pickSource} />
<Row label="결과 저장 폴더 (출력)" value={outputRoot} onPick={pickOutput} />
</div>
)
}
function Row(props: {
label: string
value: string | null
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="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>
<button
className="border border-brand text-brand rounded-lg px-3 text-sm font-medium"
onClick={props.onPick}
>
</button>
</div>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { useState } from 'react'
import { useStore } from '../store'
import { MAX_PROFILES } from '@shared/constants'
/** 최대 3인 프로필 등록/수정 + 참조 이미지 추가 */
export function ProfileManager(): JSX.Element {
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 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()
} catch (e) {
setError((e as Error).message)
}
}
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)
}
}
const remove = async (id: string) => {
await window.api.profiles.remove(id)
await refreshProfiles()
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">1. </h2>
<span className="text-xs text-slate-400">
{profiles.length}/{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)"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addProfile()}
disabled={profiles.length >= MAX_PROFILES}
/>
<button
className="bg-brand text-white rounded-lg px-4 text-sm font-medium disabled:opacity-40"
onClick={addProfile}
disabled={profiles.length >= MAX_PROFILES || !name.trim()}
>
</button>
</div>
{error && <p className="text-sm text-red-600 mb-2">{error}</p>}
<ul className="flex flex-col gap-2">
{profiles.map((p, i) => (
<li
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>
))}
{profiles.length === 0 && (
<li className="text-sm text-slate-400 py-2">
. .
</li>
)}
</ul>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { useStore } from '../store'
/** 실시간 진행률 바 + 현재 처리 파일 */
export function ProgressView(): JSX.Element {
const phase = useStore((s) => s.phase)
const progress = useStore((s) => s.progress)
const total = progress?.total ?? 0
const done = progress?.done ?? 0
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="flex items-center justify-between mb-2">
<h2 className="font-semibold"> </h2>
<span className="text-sm text-slate-500">
{phase === 'running' ? `${done} / ${total}` : '대기 중'}
</span>
</div>
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-brand transition-[width] duration-200"
style={{ width: `${pct}%` }}
/>
</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' ? '스캔 중…' : '실행 대기')}
</span>
<span className="text-xs font-medium text-brand">{pct}%</span>
</div>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
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}`
}
/** 잡 완료 후 결과 리포트 */
export function ReportView(): JSX.Element {
const report = useStore((s) => s.report)
const errors = useStore((s) => s.errors)
if (!report) return <></>
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' }
]
return (
<div className="bg-white rounded-xl border border-slate-200 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>
</div>
<div className="grid grid-cols-5 gap-2 mb-3">
{stats.map((s) => (
<div key={s.label} className="bg-slate-50 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>
))}
</div>
<div className="text-xs text-slate-400 mono truncate" title={report.logPath}>
: {report.logPath}
</div>
{errors.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-red-600 cursor-pointer">
{errors.length}
</summary>
<ul className="mt-1 max-h-32 overflow-y-auto text-[11px] text-red-500 mono">
{errors.map((e, i) => (
<li key={i} className="truncate">
{e.file}: {e.message}
</li>
))}
</ul>
</details>
)}
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { useStore } from '../store'
/** 실행/취소 + 옵션(임계값, 동시성, 검출기) */
export function RunControl(): JSX.Element {
const { source, outputRoot, profiles, options, phase } = useStore((s) => ({
source: s.source,
outputRoot: s.outputRoot,
profiles: s.profiles,
options: s.options,
phase: s.phase
}))
const setOptions = useStore((s) => s.setOptions)
const startJob = useStore((s) => s.startJob)
const cancelJob = useStore((s) => s.cancelJob)
const resetJob = useStore((s) => s.resetJob)
const hasDescriptors = profiles.some((p) => p.descriptors.length > 0)
const canRun = !!source && !!outputRoot && phase !== 'running'
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="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)})
</span>
<input
type="range"
min={0.3}
max={0.7}
step={0.01}
value={options.matchThreshold}
onChange={(e) => setOptions({ matchThreshold: Number(e.target.value) })}
disabled={running}
className="w-full"
/>
<span className="text-[11px] text-slate-400"> </span>
</label>
<label className="text-sm">
<span className="block text-xs text-slate-500 mb-1">
({options.concurrency})
</span>
<input
type="range"
min={1}
max={8}
step={1}
value={options.concurrency}
onChange={(e) => setOptions({ concurrency: Number(e.target.value) })}
disabled={running}
className="w-full"
/>
</label>
<label className="text-sm col-span-2">
<span className="block text-xs text-slate-500 mb-1"> </span>
<select
className="w-full border border-slate-300 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>
</select>
</label>
</div>
{!hasDescriptors && (
<p className="text-xs text-amber-600 mb-2">
. [] .
</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"
onClick={startJob}
disabled={!canRun}
>
{phase === 'done' ? '다시 실행' : '정리 시작'}
</button>
) : (
<button
className="flex-1 bg-red-500 text-white rounded-lg py-2.5 font-semibold"
onClick={cancelJob}
>
</button>
)}
{phase === 'done' && (
<button
className="border border-slate-300 rounded-lg px-4 text-sm"
onClick={resetJob}
>
</button>
)}
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import type { ExposedApi } from '@shared/types'
declare global {
interface Window {
api: ExposedApi
}
}
export {}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<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';"
/>
<title>AI Photo Organizer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles/index.css'
const container = document.getElementById('root')
if (!container) throw new Error('#root 요소를 찾을 수 없음')
createRoot(container).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+97
View File
@@ -0,0 +1,97 @@
import { create } from 'zustand'
import type {
Profile,
JobOptions,
FileProcessed,
ProgressEvent,
Report
} from '@shared/types'
import { DEFAULT_JOB_OPTIONS } from '@shared/constants'
export type JobPhase = 'idle' | 'running' | 'done'
interface AppState {
// 프로필
profiles: Profile[]
setProfiles: (p: Profile[]) => void
refreshProfiles: () => Promise<void>
// 폴더/옵션
source: string | null
outputRoot: string | null
options: JobOptions
setSource: (s: string | null) => void
setOutput: (s: string | null) => void
setOptions: (o: Partial<JobOptions>) => void
// 잡 상태
phase: JobPhase
progress: ProgressEvent | null
processed: FileProcessed[]
report: Report | null
errors: { file: string; message: string }[]
startJob: () => Promise<void>
cancelJob: () => Promise<void>
resetJob: () => void
// 이벤트 핸들러(내부)
_onProgress: (p: ProgressEvent) => void
_onFile: (f: FileProcessed) => void
_onDone: (r: Report) => void
_onError: (e: { file: string; message: string }) => void
}
export const useStore = create<AppState>((set, get) => ({
profiles: [],
setProfiles: (profiles) => set({ profiles }),
refreshProfiles: async () => {
const profiles = await window.api.profiles.list()
set({ profiles })
},
source: null,
outputRoot: null,
options: { ...DEFAULT_JOB_OPTIONS },
setSource: (source) => set({ source }),
setOutput: (outputRoot) => set({ outputRoot }),
setOptions: (o) => set({ options: { ...get().options, ...o } }),
phase: 'idle',
progress: null,
processed: [],
report: null,
errors: [],
startJob: async () => {
const { source, outputRoot, options } = get()
if (!source || !outputRoot) return
set({ phase: 'running', progress: null, processed: [], report: null, errors: [] })
await window.api.job.run({ source, outputRoot, options })
},
cancelJob: async () => {
await window.api.job.cancel()
},
resetJob: () => set({ phase: 'idle', progress: null, processed: [], report: null, errors: [] }),
_onProgress: (progress) => set({ progress }),
_onFile: (f) =>
set((s) => ({
// 메모리 보호: 최근 500건만 UI에 유지 (리포트는 Main이 집계)
processed: [f, ...s.processed].slice(0, 500)
})),
_onDone: (report) => set({ report, phase: 'done' }),
_onError: (e) => set((s) => ({ errors: [e, ...s.errors].slice(0, 200) }))
}))
/** 앱 시작 시 1회: Main→UI 이벤트 구독 */
export function wireEvents(): () => void {
const s = useStore.getState()
const offs = [
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)
]
return () => offs.forEach((off) => off())
}
+19
View File
@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
body {
margin: 0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #f5f6fa;
color: #1f2330;
}
/* 파일 목록 가독성용 모노 폰트 */
.mono {
font-family: 'Cascadia Code', 'Consolas', monospace;
}
+50
View File
@@ -0,0 +1,50 @@
// 전 프로세스 공유 상수
/** 처리 대상 이미지 확장자 (소문자, 점 포함) */
export const SUPPORTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const
/** 미검출/인식실패 사진이 들어가는 폴더명 */
export const UNMATCHED_FOLDER = '[미정]'
/** 로그 폴더명 (출력 루트 하위) */
export const LOG_FOLDER = '_PhotoAI_logs'
/** 프로필 영속화 파일명 (userData 하위) */
export const PROFILE_STORE_FILE = 'profiles.json'
/** 최대 프로필 인원 (PRD) */
export const MAX_PROFILES = 3
/** 기본 잡 옵션 */
export const DEFAULT_JOB_OPTIONS = {
matchThreshold: 0.5,
concurrency: 3,
detector: 'ssd' as const
}
/** 추론 시 이미지 장변 최대 픽셀 (다운스케일 기준) */
export const MAX_IMAGE_DIMENSION = 1024
/** IPC 채널명 */
export const IPC = {
// UI → Main (invoke)
PROFILES_LIST: 'profiles:list',
PROFILES_UPSERT: 'profiles:upsert',
PROFILES_REMOVE: 'profiles:remove',
PROFILES_ADD_REFERENCE: 'profiles:addReference',
DIALOG_PICK_SOURCE: 'dialog:pickSource',
DIALOG_PICK_OUTPUT: 'dialog:pickOutput',
DIALOG_PICK_IMAGES: 'dialog:pickImages',
JOB_RUN: 'job:run',
JOB_CANCEL: 'job:cancel',
// Main → UI (send)
JOB_PROGRESS: 'job:progress',
JOB_FILE_PROCESSED: 'job:fileProcessed',
JOB_DONE: 'job:done',
JOB_ERROR: 'job:error',
// Main ↔ Inference
INFER_READY: 'infer:ready',
INFER_DETECT: 'infer:detect',
INFER_DESCRIBE: 'infer:describe',
INFER_INIT: 'infer:init'
} as const
+144
View File
@@ -0,0 +1,144 @@
// 전 프로세스(Main/Preload/Renderer/Inference)가 공유하는 타입 정의
/** 등록된 인물 프로필 */
export interface Profile {
id: string
/** 폴더명으로 사용되는 인물 이름 (예: "seunghyun") */
name: string
/** 이동/복사 우선순위. 작을수록 1순위(=이동 대상). PRD: 첫 프로필 기준 이동 */
order: number
/** 참조 이미지 절대 경로 목록 */
referenceImages: string[]
/** 참조 이미지로부터 계산된 128-d descriptor 들 (number[] 직렬화 형태) */
descriptors: number[][]
}
/** 프로필 등록/수정 입력 */
export interface ProfileInput {
id?: string
name: string
order: number
}
/** 한 사진에 대한 단일 인물 매칭 결과 */
export interface ProfileMatch {
profileId: string
name: string
order: number
/** Euclidean distance (작을수록 유사) */
distance: number
}
/** Inference 창이 반환하는 사진 1장 분석 결과 */
export interface MatchResult {
/** 얼굴이 하나라도 검출되었는지 */
faceFound: boolean
/** 등록 프로필과 매칭된 결과 (없으면 빈 배열) */
matched: ProfileMatch[]
/** 검출된 총 얼굴 수 (디버깅/리포트용) */
faceCount: number
}
/** 참조 이미지 1장에 대한 descriptor 계산 결과 */
export interface DescriptorResult {
imagePath: string
/** 얼굴 미검출 시 null */
descriptor: number[] | null
}
/** 촬영 날짜 (EXIF 또는 mtime 폴백) */
export interface CaptureDate {
year: string // "2024"
month: string // "03"
/** EXIF에서 왔는지 mtime 폴백인지 */
source: 'exif' | 'mtime'
}
/** 정리 잡 실행 옵션 */
export interface JobOptions {
/** 얼굴 매칭 거리 임계값 (기본 0.5) */
matchThreshold: number
/** 동시 처리 워커 수 (기본 3) */
concurrency: number
/** 정확도 우선(ssd) vs 속도 우선(tiny) */
detector: 'ssd' | 'tiny'
}
/** 정리 잡 정의 */
export interface JobRequest {
source: string
outputRoot: string
options: JobOptions
}
/** 파일 1건 처리 후 결정 종류 */
export type FileDecisionKind = 'moved' | 'copied' | 'unmatched' | 'failed'
/** 파일 1건 처리 결과 (UI 스트림 + 리포트용) */
export interface FileProcessed {
file: string
/** 주된 결정 (이동/미정/실패) */
kind: FileDecisionKind
/** 실제 기록된 대상 경로들 (이동 1 + 복사 N) */
targets: string[]
/** 매칭된 인물 이름들 */
matchedNames: string[]
date: CaptureDate | null
error?: string
}
/** 진행률 이벤트 */
export interface ProgressEvent {
done: number
total: number
/** 현재 처리 중인 파일 경로 */
current: string
}
/** 잡 완료 리포트 */
export interface Report {
total: number
moved: number
copied: number
unmatched: number
failed: number
/** 소요 시간(ms) */
elapsedMs: number
/** 작성된 로그 파일 경로 */
logPath: string
startedAt: number
finishedAt: number
}
/** IPC 이벤트(Main→UI) 페이로드 매핑 */
export interface RendererEvents {
'job:progress': ProgressEvent
'job:fileProcessed': FileProcessed
'job:done': Report
'job:error': { file: string; message: string }
}
export type RendererEventName = keyof RendererEvents
/** preload가 노출하는 window.api 형태 */
export interface ExposedApi {
profiles: {
list(): Promise<Profile[]>
upsert(input: ProfileInput): Promise<Profile>
remove(id: string): Promise<void>
addReference(id: string, imagePaths: string[]): Promise<Profile>
}
dialog: {
pickSource(): Promise<string | null>
pickOutput(): Promise<string | null>
pickImages(): Promise<string[]>
}
job: {
run(req: JobRequest): Promise<void>
cancel(): Promise<void>
}
on<E extends RendererEventName>(
event: E,
cb: (payload: RendererEvents[E]) => void
): () => void
}
+15
View File
@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/renderer/**/*.{html,ts,tsx}'],
theme: {
extend: {
colors: {
brand: {
DEFAULT: '#5b7cfa',
dark: '#3f5ad6'
}
}
}
},
plugins: []
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { createLimiter } from '../src/main/concurrency'
describe('createLimiter', () => {
it('동시 실행 수가 limit을 넘지 않는다', async () => {
const limit = createLimiter(2)
let active = 0
let maxActive = 0
const task = () =>
limit(async () => {
active++
maxActive = Math.max(maxActive, active)
await new Promise((r) => setTimeout(r, 10))
active--
})
await Promise.all(Array.from({ length: 10 }, task))
expect(maxActive).toBeLessThanOrEqual(2)
})
it('모든 작업의 결과를 반환한다', async () => {
const limit = createLimiter(3)
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) => limit(async () => i * 2))
)
expect(results).toEqual([0, 2, 4, 6, 8])
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, writeFile, readFile, rm, mkdir, access } from 'node:fs/promises'
import { constants } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { safeMove, safeCopy, resolveCollisionFreePath } from '../src/main/fileOps'
let dir: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'photoai-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
async function exists(p: string): Promise<boolean> {
try {
await access(p, constants.F_OK)
return true
} catch {
return false
}
}
describe('safeMove', () => {
it('복사 후 원본을 삭제하고 내용이 보존된다', async () => {
const src = join(dir, 'a.txt')
await writeFile(src, 'hello')
const target = join(dir, 'out', 'a.txt')
const dest = await safeMove(src, target)
expect(await exists(src)).toBe(false) // 원본 삭제됨
expect(await readFile(dest, 'utf-8')).toBe('hello')
})
})
describe('safeCopy + 충돌 자동 리네임', () => {
it('대상이 존재하면 _1, _2 로 리네임한다 (덮어쓰기 금지)', async () => {
const target = join(dir, 'out', 'a.txt')
await mkdir(join(dir, 'out'), { recursive: true })
await writeFile(target, 'existing')
const src = join(dir, 'src.txt')
await writeFile(src, 'new')
const dest = await safeCopy(src, target)
expect(dest.endsWith('a_1.txt')).toBe(true)
expect(await readFile(target, 'utf-8')).toBe('existing') // 원본 보존
expect(await readFile(dest, 'utf-8')).toBe('new')
})
})
describe('resolveCollisionFreePath', () => {
it('충돌 없으면 그대로 반환한다', async () => {
const target = join(dir, 'fresh.txt')
expect(await resolveCollisionFreePath(target)).toBe(target)
})
})
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { buildTargetPath, withCollisionSuffix } from '../src/main/pathBuilder'
import type { CaptureDate } from '../src/shared/types'
const date: CaptureDate = { year: '2024', month: '03', source: 'exif' }
describe('buildTargetPath', () => {
it('인물 매칭 시 /프로필/YYYY/MM/파일명 경로를 만든다', () => {
const p = buildTargetPath('/out', 'seunghyun', date, '/src/a/photo.jpg')
expect(p.replace(/\\/g, '/')).toBe('/out/seunghyun/2024/03/photo.jpg')
})
it('미검출(who=null) 시 [미정] 폴더로 보낸다', () => {
const p = buildTargetPath('/out', null, date, '/src/photo.png')
expect(p.replace(/\\/g, '/')).toBe('/out/[미정]/2024/03/photo.png')
})
})
describe('withCollisionSuffix', () => {
it('확장자 앞에 _N을 붙인다', () => {
const p = withCollisionSuffix('/out/x/photo.jpg', 2)
expect(p.replace(/\\/g, '/')).toBe('/out/x/photo_2.jpg')
})
})
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"],
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"]
}
},
"include": [
"src/main/**/*.ts",
"src/preload/**/*.ts",
"src/shared/**/*.ts",
"scripts/**/*.mjs",
"electron.vite.config.ts"
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"],
"@renderer/*": ["src/renderer/*"]
}
},
"include": [
"src/renderer/**/*.ts",
"src/renderer/**/*.tsx",
"src/inference/**/*.ts",
"src/shared/**/*.ts"
]
}
+12
View File
@@ -0,0 +1,12 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: { '@shared': resolve(__dirname, 'src/shared') }
},
test: {
environment: 'node',
include: ['tests/**/*.test.ts']
}
})