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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user