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
+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>
)
}