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
+45
View File
@@ -0,0 +1,45 @@
import { useEffect } from 'react'
import { useStore, wireEvents } from './store'
import { ProfileManager } from './components/ProfileManager'
import { FolderPicker } from './components/FolderPicker'
import { RunControl } from './components/RunControl'
import { ProgressView } from './components/ProgressView'
import { FileList } from './components/FileList'
import { ReportView } from './components/ReportView'
export default function App(): JSX.Element {
const phase = useStore((s) => s.phase)
const refreshProfiles = useStore((s) => s.refreshProfiles)
useEffect(() => {
const unwire = wireEvents()
void refreshProfiles()
return unwire
}, [refreshProfiles])
return (
<div className="min-h-screen flex flex-col">
<header className="px-6 py-4 bg-white border-b border-slate-200 shadow-sm">
<h1 className="text-xl font-bold text-brand-dark">AI Photo Organizer</h1>
<p className="text-sm text-slate-500">
+ ·
</p>
</header>
<main className="flex-1 grid grid-cols-12 gap-4 p-6 overflow-hidden">
{/* 좌측: 설정 패널 */}
<section className="col-span-5 flex flex-col gap-4 overflow-y-auto pr-2">
<ProfileManager />
<FolderPicker />
<RunControl />
</section>
{/* 우측: 진행/결과 */}
<section className="col-span-7 flex flex-col gap-4 overflow-hidden">
{phase === 'done' ? <ReportView /> : <ProgressView />}
<FileList />
</section>
</main>
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { useStore } from '../store'
import type { FileDecisionKind } from '@shared/types'
const KIND_STYLE: Record<FileDecisionKind, { label: string; cls: string }> = {
moved: { label: '이동', cls: 'bg-emerald-100 text-emerald-700' },
copied: { label: '복사', cls: 'bg-sky-100 text-sky-700' },
unmatched: { label: '미정', cls: 'bg-slate-200 text-slate-600' },
failed: { label: '실패', cls: 'bg-red-100 text-red-700' }
}
function baseName(p: string): string {
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
return idx >= 0 ? p.slice(idx + 1) : p
}
/** 처리 결과 스트림 (최근 건 상단) */
export function FileList(): JSX.Element {
const processed = useStore((s) => s.processed)
return (
<div className="bg-white rounded-xl border border-slate-200 p-4 flex-1 overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold"> </h2>
<span className="text-xs text-slate-400"> {processed.length}</span>
</div>
<div className="flex-1 overflow-y-auto">
{processed.length === 0 ? (
<p className="text-sm text-slate-400 py-4"> .</p>
) : (
<ul className="flex flex-col divide-y divide-slate-100">
{processed.map((f, i) => {
const style = KIND_STYLE[f.kind]
return (
<li key={`${f.file}-${i}`} className="py-2 flex items-center gap-3">
<span
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${style.cls}`}
>
{style.label}
</span>
<span className="mono text-xs truncate flex-1" title={f.file}>
{baseName(f.file)}
</span>
<span className="text-xs text-slate-400">
{f.matchedNames.length > 0
? f.matchedNames.join(', ')
: f.error
? f.error.slice(0, 40)
: '—'}
</span>
</li>
)
})}
</ul>
)}
</div>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { useStore } from '../store'
/** 소스 폴더 + 출력 루트 선택 */
export function FolderPicker(): JSX.Element {
const source = useStore((s) => s.source)
const outputRoot = useStore((s) => s.outputRoot)
const setSource = useStore((s) => s.setSource)
const setOutput = useStore((s) => s.setOutput)
const pickSource = async () => {
const p = await window.api.dialog.pickSource()
if (p) setSource(p)
}
const pickOutput = async () => {
const p = await window.api.dialog.pickOutput()
if (p) setOutput(p)
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h2 className="font-semibold mb-3">2. </h2>
<Row label="정리할 폴더 (소스)" value={source} onPick={pickSource} />
<Row label="결과 저장 폴더 (출력)" value={outputRoot} onPick={pickOutput} />
</div>
)
}
function Row(props: {
label: string
value: string | null
onPick: () => void
}): JSX.Element {
return (
<div className="mb-3 last:mb-0">
<div className="text-xs text-slate-500 mb-1">{props.label}</div>
<div className="flex gap-2">
<div className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm mono truncate bg-slate-50">
{props.value ?? '미선택'}
</div>
<button
className="border border-brand text-brand rounded-lg px-3 text-sm font-medium"
onClick={props.onPick}
>
</button>
</div>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { useState } from 'react'
import { useStore } from '../store'
import { MAX_PROFILES } from '@shared/constants'
/** 최대 3인 프로필 등록/수정 + 참조 이미지 추가 */
export function ProfileManager(): JSX.Element {
const profiles = useStore((s) => s.profiles)
const refreshProfiles = useStore((s) => s.refreshProfiles)
const [name, setName] = useState('')
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const addProfile = async () => {
const trimmed = name.trim()
if (!trimmed) return
setError(null)
try {
// 등록 순서 = 현재 인원 수 (뒤에 추가)
await window.api.profiles.upsert({ name: trimmed, order: profiles.length })
setName('')
await refreshProfiles()
} catch (e) {
setError((e as Error).message)
}
}
const addReference = async (id: string) => {
const paths = await window.api.dialog.pickImages()
if (paths.length === 0) return
setBusy(id)
setError(null)
try {
await window.api.profiles.addReference(id, paths)
await refreshProfiles()
} catch (e) {
setError((e as Error).message)
} finally {
setBusy(null)
}
}
const remove = async (id: string) => {
await window.api.profiles.remove(id)
await refreshProfiles()
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">1. </h2>
<span className="text-xs text-slate-400">
{profiles.length}/{MAX_PROFILES} · =
</span>
</div>
<div className="flex gap-2 mb-3">
<input
className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm"
placeholder="인물 이름 (예: seunghyun)"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addProfile()}
disabled={profiles.length >= MAX_PROFILES}
/>
<button
className="bg-brand text-white rounded-lg px-4 text-sm font-medium disabled:opacity-40"
onClick={addProfile}
disabled={profiles.length >= MAX_PROFILES || !name.trim()}
>
</button>
</div>
{error && <p className="text-sm text-red-600 mb-2">{error}</p>}
<ul className="flex flex-col gap-2">
{profiles.map((p, i) => (
<li
key={p.id}
className="flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2"
>
<div>
<span className="text-xs font-bold text-brand mr-2">#{i + 1}</span>
<span className="font-medium">{p.name}</span>
<span className="text-xs text-slate-400 ml-2">
{p.descriptors.length}
</span>
</div>
<div className="flex gap-2">
<button
className="text-xs border border-brand text-brand rounded px-2 py-1 disabled:opacity-40"
onClick={() => addReference(p.id)}
disabled={busy === p.id}
>
{busy === p.id ? '분석 중…' : '얼굴 추가'}
</button>
<button
className="text-xs border border-red-300 text-red-500 rounded px-2 py-1"
onClick={() => remove(p.id)}
>
</button>
</div>
</li>
))}
{profiles.length === 0 && (
<li className="text-sm text-slate-400 py-2">
. .
</li>
)}
</ul>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { useStore } from '../store'
/** 실시간 진행률 바 + 현재 처리 파일 */
export function ProgressView(): JSX.Element {
const phase = useStore((s) => s.phase)
const progress = useStore((s) => s.progress)
const total = progress?.total ?? 0
const done = progress?.done ?? 0
const pct = total > 0 ? Math.round((done / total) * 100) : 0
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold"> </h2>
<span className="text-sm text-slate-500">
{phase === 'running' ? `${done} / ${total}` : '대기 중'}
</span>
</div>
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-brand transition-[width] duration-200"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-slate-400 mono truncate max-w-[80%]">
{progress?.current ?? (phase === 'running' ? '스캔 중…' : '실행 대기')}
</span>
<span className="text-xs font-medium text-brand">{pct}%</span>
</div>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { useStore } from '../store'
function fmtDuration(ms: number): string {
const s = Math.round(ms / 1000)
const m = Math.floor(s / 60)
const rem = s % 60
return m > 0 ? `${m}${rem}` : `${rem}`
}
/** 잡 완료 후 결과 리포트 */
export function ReportView(): JSX.Element {
const report = useStore((s) => s.report)
const errors = useStore((s) => s.errors)
if (!report) return <></>
const stats = [
{ label: '총 처리', value: report.total, cls: 'text-slate-700' },
{ label: '이동', value: report.moved, cls: 'text-emerald-600' },
{ label: '복사', value: report.copied, cls: 'text-sky-600' },
{ label: '미정', value: report.unmatched, cls: 'text-slate-500' },
{ label: '실패', value: report.failed, cls: 'text-red-600' }
]
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold"> </h2>
<span className="text-sm text-slate-500"> {fmtDuration(report.elapsedMs)}</span>
</div>
<div className="grid grid-cols-5 gap-2 mb-3">
{stats.map((s) => (
<div key={s.label} className="bg-slate-50 rounded-lg p-2 text-center">
<div className={`text-lg font-bold ${s.cls}`}>{s.value}</div>
<div className="text-[11px] text-slate-400">{s.label}</div>
</div>
))}
</div>
<div className="text-xs text-slate-400 mono truncate" title={report.logPath}>
: {report.logPath}
</div>
{errors.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-red-600 cursor-pointer">
{errors.length}
</summary>
<ul className="mt-1 max-h-32 overflow-y-auto text-[11px] text-red-500 mono">
{errors.map((e, i) => (
<li key={i} className="truncate">
{e.file}: {e.message}
</li>
))}
</ul>
</details>
)}
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { useStore } from '../store'
/** 실행/취소 + 옵션(임계값, 동시성, 검출기) */
export function RunControl(): JSX.Element {
const { source, outputRoot, profiles, options, phase } = useStore((s) => ({
source: s.source,
outputRoot: s.outputRoot,
profiles: s.profiles,
options: s.options,
phase: s.phase
}))
const setOptions = useStore((s) => s.setOptions)
const startJob = useStore((s) => s.startJob)
const cancelJob = useStore((s) => s.cancelJob)
const resetJob = useStore((s) => s.resetJob)
const hasDescriptors = profiles.some((p) => p.descriptors.length > 0)
const canRun = !!source && !!outputRoot && phase !== 'running'
const running = phase === 'running'
return (
<div className="bg-white rounded-xl border border-slate-200 p-4">
<h2 className="font-semibold mb-3">3. </h2>
<div className="grid grid-cols-2 gap-3 mb-4">
<label className="text-sm">
<span className="block text-xs text-slate-500 mb-1">
({options.matchThreshold.toFixed(2)})
</span>
<input
type="range"
min={0.3}
max={0.7}
step={0.01}
value={options.matchThreshold}
onChange={(e) => setOptions({ matchThreshold: Number(e.target.value) })}
disabled={running}
className="w-full"
/>
<span className="text-[11px] text-slate-400"> </span>
</label>
<label className="text-sm">
<span className="block text-xs text-slate-500 mb-1">
({options.concurrency})
</span>
<input
type="range"
min={1}
max={8}
step={1}
value={options.concurrency}
onChange={(e) => setOptions({ concurrency: Number(e.target.value) })}
disabled={running}
className="w-full"
/>
</label>
<label className="text-sm col-span-2">
<span className="block text-xs text-slate-500 mb-1"> </span>
<select
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm"
value={options.detector}
onChange={(e) => setOptions({ detector: e.target.value as 'ssd' | 'tiny' })}
disabled={running}
>
<option value="ssd"> (SSD MobileNet)</option>
<option value="tiny"> (Tiny Face)</option>
</select>
</label>
</div>
{!hasDescriptors && (
<p className="text-xs text-amber-600 mb-2">
. [] .
</p>
)}
<div className="flex gap-2">
{!running ? (
<button
className="flex-1 bg-brand text-white rounded-lg py-2.5 font-semibold disabled:opacity-40"
onClick={startJob}
disabled={!canRun}
>
{phase === 'done' ? '다시 실행' : '정리 시작'}
</button>
) : (
<button
className="flex-1 bg-red-500 text-white rounded-lg py-2.5 font-semibold"
onClick={cancelJob}
>
</button>
)}
{phase === 'done' && (
<button
className="border border-slate-300 rounded-lg px-4 text-sm"
onClick={resetJob}
>
</button>
)}
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import type { ExposedApi } from '@shared/types'
declare global {
interface Window {
api: ExposedApi
}
}
export {}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data: file:; style-src 'self' 'unsafe-inline';"
/>
<title>AI Photo Organizer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles/index.css'
const container = document.getElementById('root')
if (!container) throw new Error('#root 요소를 찾을 수 없음')
createRoot(container).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+97
View File
@@ -0,0 +1,97 @@
import { create } from 'zustand'
import type {
Profile,
JobOptions,
FileProcessed,
ProgressEvent,
Report
} from '@shared/types'
import { DEFAULT_JOB_OPTIONS } from '@shared/constants'
export type JobPhase = 'idle' | 'running' | 'done'
interface AppState {
// 프로필
profiles: Profile[]
setProfiles: (p: Profile[]) => void
refreshProfiles: () => Promise<void>
// 폴더/옵션
source: string | null
outputRoot: string | null
options: JobOptions
setSource: (s: string | null) => void
setOutput: (s: string | null) => void
setOptions: (o: Partial<JobOptions>) => void
// 잡 상태
phase: JobPhase
progress: ProgressEvent | null
processed: FileProcessed[]
report: Report | null
errors: { file: string; message: string }[]
startJob: () => Promise<void>
cancelJob: () => Promise<void>
resetJob: () => void
// 이벤트 핸들러(내부)
_onProgress: (p: ProgressEvent) => void
_onFile: (f: FileProcessed) => void
_onDone: (r: Report) => void
_onError: (e: { file: string; message: string }) => void
}
export const useStore = create<AppState>((set, get) => ({
profiles: [],
setProfiles: (profiles) => set({ profiles }),
refreshProfiles: async () => {
const profiles = await window.api.profiles.list()
set({ profiles })
},
source: null,
outputRoot: null,
options: { ...DEFAULT_JOB_OPTIONS },
setSource: (source) => set({ source }),
setOutput: (outputRoot) => set({ outputRoot }),
setOptions: (o) => set({ options: { ...get().options, ...o } }),
phase: 'idle',
progress: null,
processed: [],
report: null,
errors: [],
startJob: async () => {
const { source, outputRoot, options } = get()
if (!source || !outputRoot) return
set({ phase: 'running', progress: null, processed: [], report: null, errors: [] })
await window.api.job.run({ source, outputRoot, options })
},
cancelJob: async () => {
await window.api.job.cancel()
},
resetJob: () => set({ phase: 'idle', progress: null, processed: [], report: null, errors: [] }),
_onProgress: (progress) => set({ progress }),
_onFile: (f) =>
set((s) => ({
// 메모리 보호: 최근 500건만 UI에 유지 (리포트는 Main이 집계)
processed: [f, ...s.processed].slice(0, 500)
})),
_onDone: (report) => set({ report, phase: 'done' }),
_onError: (e) => set((s) => ({ errors: [e, ...s.errors].slice(0, 200) }))
}))
/** 앱 시작 시 1회: Main→UI 이벤트 구독 */
export function wireEvents(): () => void {
const s = useStore.getState()
const offs = [
window.api.on('job:progress', s._onProgress),
window.api.on('job:fileProcessed', s._onFile),
window.api.on('job:done', s._onDone),
window.api.on('job:error', s._onError)
]
return () => offs.forEach((off) => off())
}
+19
View File
@@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
body {
margin: 0;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #f5f6fa;
color: #1f2330;
}
/* 파일 목록 가독성용 모노 폰트 */
.mono {
font-family: 'Cascadia Code', 'Consolas', monospace;
}