8a8c10248c
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>
108 lines
3.5 KiB
TypeScript
108 lines
3.5 KiB
TypeScript
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>
|
||
)
|
||
}
|