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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 13:36:40 +09:00
commit 8a8c10248c
54 changed files with 11507 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
// 전 프로세스 공유 상수
/** 처리 대상 이미지 확장자 (소문자, 점 포함) */
export const SUPPORTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const
/** 미검출/인식실패 사진이 들어가는 폴더명 */
export const UNMATCHED_FOLDER = '[미정]'
/** 로그 폴더명 (출력 루트 하위) */
export const LOG_FOLDER = '_PhotoAI_logs'
/** 프로필 영속화 파일명 (userData 하위) */
export const PROFILE_STORE_FILE = 'profiles.json'
/** 최대 프로필 인원 (PRD) */
export const MAX_PROFILES = 3
/** 기본 잡 옵션 */
export const DEFAULT_JOB_OPTIONS = {
matchThreshold: 0.5,
concurrency: 3,
detector: 'ssd' as const
}
/** 추론 시 이미지 장변 최대 픽셀 (다운스케일 기준) */
export const MAX_IMAGE_DIMENSION = 1024
/** IPC 채널명 */
export const IPC = {
// UI → Main (invoke)
PROFILES_LIST: 'profiles:list',
PROFILES_UPSERT: 'profiles:upsert',
PROFILES_REMOVE: 'profiles:remove',
PROFILES_ADD_REFERENCE: 'profiles:addReference',
DIALOG_PICK_SOURCE: 'dialog:pickSource',
DIALOG_PICK_OUTPUT: 'dialog:pickOutput',
DIALOG_PICK_IMAGES: 'dialog:pickImages',
JOB_RUN: 'job:run',
JOB_CANCEL: 'job:cancel',
// Main → UI (send)
JOB_PROGRESS: 'job:progress',
JOB_FILE_PROCESSED: 'job:fileProcessed',
JOB_DONE: 'job:done',
JOB_ERROR: 'job:error',
// Main ↔ Inference
INFER_READY: 'infer:ready',
INFER_DETECT: 'infer:detect',
INFER_DESCRIBE: 'infer:describe',
INFER_INIT: 'infer:init'
} as const
+144
View File
@@ -0,0 +1,144 @@
// 전 프로세스(Main/Preload/Renderer/Inference)가 공유하는 타입 정의
/** 등록된 인물 프로필 */
export interface Profile {
id: string
/** 폴더명으로 사용되는 인물 이름 (예: "seunghyun") */
name: string
/** 이동/복사 우선순위. 작을수록 1순위(=이동 대상). PRD: 첫 프로필 기준 이동 */
order: number
/** 참조 이미지 절대 경로 목록 */
referenceImages: string[]
/** 참조 이미지로부터 계산된 128-d descriptor 들 (number[] 직렬화 형태) */
descriptors: number[][]
}
/** 프로필 등록/수정 입력 */
export interface ProfileInput {
id?: string
name: string
order: number
}
/** 한 사진에 대한 단일 인물 매칭 결과 */
export interface ProfileMatch {
profileId: string
name: string
order: number
/** Euclidean distance (작을수록 유사) */
distance: number
}
/** Inference 창이 반환하는 사진 1장 분석 결과 */
export interface MatchResult {
/** 얼굴이 하나라도 검출되었는지 */
faceFound: boolean
/** 등록 프로필과 매칭된 결과 (없으면 빈 배열) */
matched: ProfileMatch[]
/** 검출된 총 얼굴 수 (디버깅/리포트용) */
faceCount: number
}
/** 참조 이미지 1장에 대한 descriptor 계산 결과 */
export interface DescriptorResult {
imagePath: string
/** 얼굴 미검출 시 null */
descriptor: number[] | null
}
/** 촬영 날짜 (EXIF 또는 mtime 폴백) */
export interface CaptureDate {
year: string // "2024"
month: string // "03"
/** EXIF에서 왔는지 mtime 폴백인지 */
source: 'exif' | 'mtime'
}
/** 정리 잡 실행 옵션 */
export interface JobOptions {
/** 얼굴 매칭 거리 임계값 (기본 0.5) */
matchThreshold: number
/** 동시 처리 워커 수 (기본 3) */
concurrency: number
/** 정확도 우선(ssd) vs 속도 우선(tiny) */
detector: 'ssd' | 'tiny'
}
/** 정리 잡 정의 */
export interface JobRequest {
source: string
outputRoot: string
options: JobOptions
}
/** 파일 1건 처리 후 결정 종류 */
export type FileDecisionKind = 'moved' | 'copied' | 'unmatched' | 'failed'
/** 파일 1건 처리 결과 (UI 스트림 + 리포트용) */
export interface FileProcessed {
file: string
/** 주된 결정 (이동/미정/실패) */
kind: FileDecisionKind
/** 실제 기록된 대상 경로들 (이동 1 + 복사 N) */
targets: string[]
/** 매칭된 인물 이름들 */
matchedNames: string[]
date: CaptureDate | null
error?: string
}
/** 진행률 이벤트 */
export interface ProgressEvent {
done: number
total: number
/** 현재 처리 중인 파일 경로 */
current: string
}
/** 잡 완료 리포트 */
export interface Report {
total: number
moved: number
copied: number
unmatched: number
failed: number
/** 소요 시간(ms) */
elapsedMs: number
/** 작성된 로그 파일 경로 */
logPath: string
startedAt: number
finishedAt: number
}
/** IPC 이벤트(Main→UI) 페이로드 매핑 */
export interface RendererEvents {
'job:progress': ProgressEvent
'job:fileProcessed': FileProcessed
'job:done': Report
'job:error': { file: string; message: string }
}
export type RendererEventName = keyof RendererEvents
/** preload가 노출하는 window.api 형태 */
export interface ExposedApi {
profiles: {
list(): Promise<Profile[]>
upsert(input: ProfileInput): Promise<Profile>
remove(id: string): Promise<void>
addReference(id: string, imagePaths: string[]): Promise<Profile>
}
dialog: {
pickSource(): Promise<string | null>
pickOutput(): Promise<string | null>
pickImages(): Promise<string[]>
}
job: {
run(req: JobRequest): Promise<void>
cancel(): Promise<void>
}
on<E extends RendererEventName>(
event: E,
cb: (payload: RendererEvents[E]) => void
): () => void
}