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:
+13
@@ -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
|
||||
@@ -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/ 기획·설계 문서
|
||||
```
|
||||
@@ -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`) |
|
||||
|
||||
> 전 항목 확정. 본 설계로 구현 착수.
|
||||
```
|
||||
@@ -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
@@ -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) 참조.
|
||||
@@ -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}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
Generated
+8609
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { InferBridge } from '../preload/inference'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
inferBridge: InferBridge
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { ExposedApi } from '@shared/types'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: ExposedApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: []
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user