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
+106
View File
@@ -0,0 +1,106 @@
import * as faceapi from '@vladmandic/face-api'
import type { Profile, JobOptions, MatchResult, ProfileMatch } from '@shared/types'
import { loadImageToCanvas, releaseCanvas } from './imageLoader'
/**
* face-api 기반 얼굴 인식 엔진 (숨김 추론 창에서만 동작).
* - 모델 1회 로드 후 앱 생애 유지
* - 프로필 descriptor로 FaceMatcher 구성
* - 이미지당 얼굴 검출 + 매칭
*/
class FaceEngine {
private modelsLoaded = false
private detector: JobOptions['detector'] = 'ssd'
private matcher: faceapi.FaceMatcher | null = null
/** label(=profileId) → {name, order} */
private labelMeta = new Map<string, { name: string; order: number }>()
async loadModels(baseUrl: string): Promise<void> {
if (this.modelsLoaded) return
// face-api는 브라우저(렌더러) 환경에서 첫 추론 시 WebGL 백엔드를 자동 초기화한다.
// 별도의 setBackend 호출 없이 모델만 로드하면 된다.
await Promise.all([
faceapi.nets.ssdMobilenetv1.loadFromUri(baseUrl),
faceapi.nets.tinyFaceDetector.loadFromUri(baseUrl),
faceapi.nets.faceLandmark68Net.loadFromUri(baseUrl),
faceapi.nets.faceRecognitionNet.loadFromUri(baseUrl)
])
this.modelsLoaded = true
}
/** 잡 시작 전 호출: 매처 + 옵션 구성 */
configure(profiles: Profile[], options: JobOptions): void {
this.detector = options.detector
this.labelMeta.clear()
const labeled: faceapi.LabeledFaceDescriptors[] = []
for (const p of profiles) {
if (!p.descriptors || p.descriptors.length === 0) continue
this.labelMeta.set(p.id, { name: p.name, order: p.order })
const descs = p.descriptors.map((d) => new Float32Array(d))
labeled.push(new faceapi.LabeledFaceDescriptors(p.id, descs))
}
this.matcher = labeled.length
? new faceapi.FaceMatcher(labeled, options.matchThreshold)
: null
}
private detectorOptions(): faceapi.SsdMobilenetv1Options | faceapi.TinyFaceDetectorOptions {
return this.detector === 'tiny'
? new faceapi.TinyFaceDetectorOptions({ inputSize: 512, scoreThreshold: 0.5 })
: new faceapi.SsdMobilenetv1Options({ minConfidence: 0.5 })
}
/** 참조 이미지 1장 → 대표 descriptor (가장 큰 얼굴 1개) */
async describeImage(imagePath: string): Promise<number[] | null> {
const canvas = await loadImageToCanvas(imagePath)
try {
const det = await faceapi
.detectSingleFace(canvas, this.detectorOptions())
.withFaceLandmarks()
.withFaceDescriptor()
return det ? Array.from(det.descriptor) : null
} finally {
releaseCanvas(canvas)
}
}
/** 사진 1장 → 얼굴 검출 + 프로필 매칭 결과 */
async detectImage(imagePath: string): Promise<MatchResult> {
const canvas = await loadImageToCanvas(imagePath)
try {
const results = await faceapi
.detectAllFaces(canvas, this.detectorOptions())
.withFaceLandmarks()
.withFaceDescriptors()
const faceCount = results.length
if (faceCount === 0 || !this.matcher) {
return { faceFound: faceCount > 0, matched: [], faceCount }
}
// 프로필별 최소 거리(최적 매칭) 집계
const best = new Map<string, number>()
for (const r of results) {
const m = this.matcher.findBestMatch(r.descriptor)
if (m.label === 'unknown') continue
const prev = best.get(m.label)
if (prev === undefined || m.distance < prev) best.set(m.label, m.distance)
}
const matched: ProfileMatch[] = []
for (const [profileId, distance] of best) {
const meta = this.labelMeta.get(profileId)
if (!meta) continue
matched.push({ profileId, name: meta.name, order: meta.order, distance })
}
return { faceFound: true, matched, faceCount }
} finally {
releaseCanvas(canvas)
}
}
}
export const faceEngine = new FaceEngine()
+9
View File
@@ -0,0 +1,9 @@
import type { InferBridge } from '../preload/inference'
declare global {
interface Window {
inferBridge: InferBridge
}
}
export {}
+43
View File
@@ -0,0 +1,43 @@
import { pathToFileUrl } from './pathToFileUrl'
import { MAX_IMAGE_DIMENSION } from '@shared/constants'
/**
* 파일 경로의 이미지를 HTMLCanvasElement로 디코딩한다.
* 장변이 MAX_IMAGE_DIMENSION을 넘으면 비율 유지하며 다운스케일 → 메모리/속도 최적화.
* webSecurity:false 환경이므로 file:// URL을 직접 로드할 수 있다.
*/
export async function loadImageToCanvas(imagePath: string): Promise<HTMLCanvasElement> {
const img = await loadImageElement(pathToFileUrl(imagePath))
const { width, height } = img
const longSide = Math.max(width, height)
const scale = longSide > MAX_IMAGE_DIMENSION ? MAX_IMAGE_DIMENSION / longSide : 1
const w = Math.max(1, Math.round(width * scale))
const h = Math.max(1, Math.round(height * scale))
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D 컨텍스트 생성 실패')
ctx.drawImage(img, 0, 0, w, h)
// 원본 img 참조 해제 (디코딩 버퍼 회수 유도)
img.src = ''
return canvas
}
function loadImageElement(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`이미지 로드 실패: ${url}`))
img.src = url
})
}
/** 처리 후 캔버스 크기를 0으로 줄여 메모리 회수를 유도 */
export function releaseCanvas(canvas: HTMLCanvasElement): void {
canvas.width = 0
canvas.height = 0
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>PhotoAI Inference Worker</title>
</head>
<body>
<!-- 숨김 추론 창: UI 없음. face-api 연산 전용. -->
<script type="module" src="./main.ts"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
import { faceEngine } from './faceEngine'
import type { Profile, JobOptions, DescriptorResult } from '@shared/types'
/**
* 숨김 추론 창 부트스트랩.
* 1) URL 쿼리에서 모델 경로 읽어 모델 로드 → ready 통지
* 2) Main의 요청(infer:init/describe/detect) 처리 후 reply
*/
async function bootstrap(): Promise<void> {
const params = new URLSearchParams(location.search)
const modelsUrl = params.get('models')
if (!modelsUrl) throw new Error('models 경로 쿼리가 없습니다.')
console.log('models loading from:', decodeURIComponent(modelsUrl))
await faceEngine.loadModels(decodeURIComponent(modelsUrl))
console.log('models loaded OK')
window.inferBridge.ready()
window.inferBridge.onRequest(async (channel, payload) => {
const { requestId } = payload
try {
if (channel === 'infer:init') {
const { profiles, options } = payload as unknown as {
profiles: Profile[]
options: JobOptions
}
faceEngine.configure(profiles, options)
window.inferBridge.reply(requestId, true, { ok: true })
} else if (channel === 'infer:describe') {
const { imagePaths } = payload as unknown as { imagePaths: string[] }
const out: DescriptorResult[] = []
for (const imagePath of imagePaths) {
const descriptor = await faceEngine.describeImage(imagePath)
out.push({ imagePath, descriptor })
}
window.inferBridge.reply(requestId, true, out)
} else if (channel === 'infer:detect') {
const { imagePath } = payload as unknown as { imagePath: string }
const result = await faceEngine.detectImage(imagePath)
window.inferBridge.reply(requestId, true, result)
}
} catch (err) {
window.inferBridge.reply(requestId, false, undefined, (err as Error).message)
}
})
}
bootstrap().catch((err) => {
// 부트스트랩 실패 시 콘솔에 남김 — Main은 whenReady에서 영구 대기하므로
// 개발 중 콘솔로 원인 확인
console.error('[inference] 부트스트랩 실패:', err)
})
+17
View File
@@ -0,0 +1,17 @@
/**
* 렌더러(브라우저)에는 node:url이 없으므로 경로 → file:// URL 변환을 직접 수행.
* Windows(드라이브 문자, 백슬래시)와 POSIX 경로를 모두 처리한다.
*/
export function pathToFileUrl(p: string): string {
let normalized = p.replace(/\\/g, '/')
// Windows 드라이브 경로(C:/...)는 슬래시 3개 + 그대로
if (/^[a-zA-Z]:\//.test(normalized)) {
normalized = '/' + normalized
}
// 각 세그먼트를 인코딩 (공백/한글/특수문자 대응), 슬래시는 보존
const encoded = normalized
.split('/')
.map((seg) => encodeURIComponent(seg))
.join('/')
return 'file://' + encoded
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 경량 동시성 제한 세마포어. 외부 p-limit 대신 직접 구현.
* limit 개수만큼만 동시에 실행되도록 작업을 게이팅한다.
*/
export function createLimiter(limit: number) {
let active = 0
const queue: Array<() => void> = []
const next = () => {
if (active >= limit) return
const run = queue.shift()
if (run) {
active++
run()
}
}
return function schedule<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const run = () => {
task()
.then(resolve, reject)
.finally(() => {
active--
next()
})
}
queue.push(run)
next()
})
}
}
+41
View File
@@ -0,0 +1,41 @@
import exifr from 'exifr'
import { stat } from 'node:fs/promises'
import type { CaptureDate } from '@shared/types'
function toYearMonth(d: Date, source: CaptureDate['source']): CaptureDate {
const year = String(d.getFullYear())
const month = String(d.getMonth() + 1).padStart(2, '0')
return { year, month, source }
}
/**
* 촬영 날짜 추출.
* 1) EXIF DateTimeOriginal (없으면 CreateDate/ModifyDate) 시도
* 2) 실패 시 파일 시스템 mtime 폴백
*
* 어떤 경우에도 throw 하지 않고 항상 유효한 CaptureDate를 반환한다.
*/
export async function getCaptureDate(path: string): Promise<CaptureDate> {
// 1) EXIF 시도
try {
const exif = await exifr.parse(path, {
pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate']
})
const raw: unknown =
exif?.DateTimeOriginal ?? exif?.CreateDate ?? exif?.ModifyDate
if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
return toYearMonth(raw, 'exif')
}
} catch {
// EXIF 파싱 실패 → 폴백으로 진행
}
// 2) mtime 폴백
try {
const s = await stat(path)
return toYearMonth(s.mtime, 'mtime')
} catch {
// stat 마저 실패하면 현재 시각으로 최후 폴백 (파일 분류는 계속되어야 함)
return toYearMonth(new Date(), 'mtime')
}
}
+67
View File
@@ -0,0 +1,67 @@
import { copyFile, mkdir, unlink, stat, access } from 'node:fs/promises'
import { constants as FS } from 'node:fs'
import { dirname } from 'node:path'
import { withCollisionSuffix } from './pathBuilder'
async function exists(p: string): Promise<boolean> {
try {
await access(p, FS.F_OK)
return true
} catch {
return false
}
}
/**
* 대상 경로에 충돌이 없는 최종 경로를 구한다.
* 이미 존재하면 name_1, name_2 ... 로 자동 리네임 (No Data Loss 정책).
*/
export async function resolveCollisionFreePath(target: string): Promise<string> {
if (!(await exists(target))) return target
for (let i = 1; i < 100000; i++) {
const candidate = withCollisionSuffix(target, i)
if (!(await exists(candidate))) return candidate
}
throw new Error(`충돌 회피 경로 생성 실패(시도 초과): ${target}`)
}
/**
* 안전 복사: 대상 디렉터리 생성 → copyFile → 크기 검증.
* 반환값은 실제로 기록된 (충돌 회피된) 경로.
*/
export async function safeCopy(src: string, target: string): Promise<string> {
const dest = await resolveCollisionFreePath(target)
await mkdir(dirname(dest), { recursive: true })
await copyFile(src, dest)
// 무결성 검증: 원본/사본 크기 일치
const [s, d] = await Promise.all([stat(src), stat(dest)])
if (s.size !== d.size) {
// 검증 실패 → 깨진 사본 제거 후 오류
await unlink(dest).catch(() => {})
throw new Error(`복사 무결성 검증 실패(size ${s.size} != ${d.size}): ${src}`)
}
return dest
}
/**
* 안전 이동: 복사 → 검증 → 원본 삭제 (Atomic 정책, 데이터 무결성 0 Error).
* 동일 볼륨 여부와 무관하게 copy-verify-delete 로 동작해 부분 손상 방지.
* 검증 통과 후에만 원본을 삭제하므로 어느 단계에서 실패해도 원본은 보존된다.
*
* @returns 실제로 기록된 (충돌 회피된) 대상 경로
*/
export async function safeMove(src: string, target: string): Promise<string> {
const dest = await safeCopy(src, target) // 복사 + 검증 완료
try {
await unlink(src) // 검증 통과 후에만 원본 삭제
} catch (err) {
// 사본은 정상. 원본 삭제만 실패한 경우 → 사본 유지하되 경고로 남김(데이터 유실 없음)
throw new Error(
`이동 완료(사본 생성됨) 후 원본 삭제 실패: ${src}${dest} :: ${(err as Error).message}`
)
}
return dest
}
export { exists }
+57
View File
@@ -0,0 +1,57 @@
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'node:path'
import { registerIpc } from './ipc'
import { inferenceBridge } from './inferenceBridge'
import { logger } from './logger'
let mainWindow: BrowserWindow | null = null
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1100,
height: 760,
minWidth: 900,
minHeight: 600,
show: false,
title: 'AI Photo Organizer',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
mainWindow.on('ready-to-show', () => mainWindow?.show())
// 외부 링크는 기본 브라우저로
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
mainWindow.loadURL(`${devUrl}/src/renderer/index.html`)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/src/renderer/index.html'))
}
}
app.whenReady().then(() => {
registerIpc()
// 숨김 추론 창을 먼저 띄워 모델 로드를 선행
inferenceBridge.init()
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
logger.info('앱 시작 완료')
})
app.on('window-all-closed', () => {
inferenceBridge.dispose()
if (process.platform !== 'darwin') app.quit()
})
+129
View File
@@ -0,0 +1,129 @@
import { BrowserWindow, ipcMain, app } from 'electron'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { MatchResult, Profile, DescriptorResult, JobOptions } from '@shared/types'
import { logger } from './logger'
/** 모델 가중치 디렉터리의 file:// URL (dev: 프로젝트 루트, prod: resources) */
function modelsBaseUrl(): string {
const dir = app.isPackaged
? join(process.resourcesPath, 'models')
: join(app.getAppPath(), 'models')
// 끝에 슬래시 없이 — 렌더러에서 `${base}/파일명` 형태로 사용
return pathToFileURL(dir).toString()
}
interface Pending {
resolve: (v: unknown) => void
reject: (e: Error) => void
}
/**
* 숨김 BrowserWindow(Inference Renderer)와의 RPC 브릿지.
* - Main → Renderer: webContents.send(channel, {requestId, ...})
* - Renderer → Main: ipcRenderer.send('infer:reply', {requestId, ok, data, error})
* requestId 상관관계로 요청/응답을 매칭한다.
*/
class InferenceBridge {
private win: BrowserWindow | null = null
private readyPromise: Promise<void> | null = null
private readyResolve: (() => void) | null = null
private pending = new Map<string, Pending>()
private seq = 0
/** 숨김 추론 창 생성 및 모델 로드 대기 준비 */
init(): void {
if (this.win) return
this.readyPromise = new Promise((res) => {
this.readyResolve = res
})
// 추론 창이 ready를 알리면 resolve
ipcMain.on('infer:ready', () => {
logger.info('Inference 창 모델 로드 완료')
this.readyResolve?.()
})
// 추론 창의 모든 응답 수신
ipcMain.on('infer:reply', (_e, payload: { requestId: string; ok: boolean; data?: unknown; error?: string }) => {
const p = this.pending.get(payload.requestId)
if (!p) return
this.pending.delete(payload.requestId)
if (payload.ok) p.resolve(payload.data)
else p.reject(new Error(payload.error ?? '추론 오류'))
})
this.win = new BrowserWindow({
show: false,
webPreferences: {
preload: join(__dirname, '../preload/inference.js'),
contextIsolation: true,
nodeIntegration: false,
// 숨김 창이 백그라운드에서도 연산을 멈추지 않도록
backgroundThrottling: false,
// 내부 전용 창: 로컬 파일(모델 가중치/사진)을 file://로 fetch 하기 위해 완화.
// 원격 콘텐츠를 절대 로드하지 않으므로 위험은 격리됨.
webSecurity: false
}
})
// 추론창 콘솔을 Main 로그로 포워딩 (모델 로드 성공/실패 가시화)
this.win.webContents.on('console-message', (_e, level, message) => {
const tag = level >= 2 ? 'ERROR' : 'INFO'
logger.info(`[inference console:${tag}] ${message}`)
})
const models = encodeURIComponent(modelsBaseUrl())
// electron-vite: 개발 시 dev 서버, 배포 시 빌드된 html 로드.
// 모델 경로는 쿼리스트링으로 전달 → 렌더러가 즉시 모델 로드 시작.
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
this.win.loadURL(`${devUrl}/src/inference/index.html?models=${models}`)
} else {
this.win.loadFile(join(__dirname, '../renderer/src/inference/index.html'), {
search: `models=${models}`
})
}
}
/** 모델 로드 완료까지 대기 */
async whenReady(): Promise<void> {
if (!this.readyPromise) throw new Error('InferenceBridge.init()가 호출되지 않음')
await this.readyPromise
}
private call<T>(channel: string, payload: Record<string, unknown>): Promise<T> {
if (!this.win) throw new Error('Inference 창이 없음')
const requestId = `req_${++this.seq}`
return new Promise<T>((resolve, reject) => {
this.pending.set(requestId, { resolve: resolve as (v: unknown) => void, reject })
this.win!.webContents.send(channel, { requestId, ...payload })
})
}
/** 잡 시작 전: 프로필 descriptor로 FaceMatcher 구성 + 옵션 적용 */
async initMatcher(profiles: Profile[], options: JobOptions): Promise<void> {
await this.call('infer:init', { profiles, options })
}
/** 참조 이미지들의 descriptor 계산 (프로필 등록용) */
async describe(imagePaths: string[], detector: JobOptions['detector']): Promise<DescriptorResult[]> {
return this.call<DescriptorResult[]>('infer:describe', { imagePaths, detector })
}
/** 사진 1장 얼굴 검출 + 프로필 매칭 */
async detect(imagePath: string): Promise<MatchResult> {
return this.call<MatchResult>('infer:detect', { imagePath })
}
dispose(): void {
this.pending.forEach((p) => p.reject(new Error('브릿지 종료')))
this.pending.clear()
this.win?.destroy()
this.win = null
}
}
export const inferenceBridge = new InferenceBridge()
+69
View File
@@ -0,0 +1,69 @@
import { ipcMain, dialog, BrowserWindow } from 'electron'
import type { ProfileInput, JobRequest } from '@shared/types'
import { IPC } from '@shared/constants'
import { profileStore } from './profileStore'
import { inferenceBridge } from './inferenceBridge'
import { orchestrator } from './orchestrator'
import { logger } from './logger'
/** UI/다이얼로그/잡 관련 IPC 핸들러 등록 */
export function registerIpc(): void {
// ---- 프로필 ----
ipcMain.handle(IPC.PROFILES_LIST, () => profileStore.list())
ipcMain.handle(IPC.PROFILES_UPSERT, (_e, input: ProfileInput) =>
profileStore.upsert(input)
)
ipcMain.handle(IPC.PROFILES_REMOVE, (_e, id: string) => profileStore.remove(id))
ipcMain.handle(
IPC.PROFILES_ADD_REFERENCE,
async (_e, id: string, imagePaths: string[]) => {
await inferenceBridge.whenReady()
// 참조 이미지 descriptor 계산 (기본 정확도 우선 detector)
const results = await inferenceBridge.describe(imagePaths, 'ssd')
const valid = results.filter((r) => r.descriptor !== null)
const usedPaths = valid.map((r) => r.imagePath)
const descriptors = valid.map((r) => r.descriptor as number[])
if (descriptors.length === 0) {
throw new Error('선택한 이미지에서 얼굴을 찾지 못했습니다.')
}
return profileStore.addReference(id, usedPaths, descriptors)
}
)
// ---- 다이얼로그 ----
ipcMain.handle(IPC.DIALOG_PICK_SOURCE, async () => {
const r = await dialog.showOpenDialog({ properties: ['openDirectory'] })
return r.canceled ? null : r.filePaths[0]
})
ipcMain.handle(IPC.DIALOG_PICK_OUTPUT, async () => {
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
return r.canceled ? null : r.filePaths[0]
})
ipcMain.handle(IPC.DIALOG_PICK_IMAGES, async () => {
const r = await dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }]
})
return r.canceled ? [] : r.filePaths
})
// ---- 잡 ----
ipcMain.handle(IPC.JOB_RUN, async (e, req: JobRequest) => {
const win = BrowserWindow.fromWebContents(e.sender)
if (!win) throw new Error('요청 창을 찾을 수 없음')
// 비동기로 실행하되 완료는 이벤트(JOB_DONE)로 통지 → 호출 즉시 반환
orchestrator.run(req, win).catch((err) => {
logger.error('잡 실행 실패', { message: (err as Error).message })
if (!win.isDestroyed()) {
win.webContents.send(IPC.JOB_ERROR, { file: '', message: (err as Error).message })
}
})
})
ipcMain.handle(IPC.JOB_CANCEL, () => orchestrator.cancel())
}
+64
View File
@@ -0,0 +1,64 @@
import { appendFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
type Level = 'INFO' | 'WARN' | 'ERROR'
/**
* 구조적 로거. 콘솔 + (옵션) 파일에 기록.
* 잡 실행 시 setLogFile()로 출력 루트 하위 로그 파일 경로를 지정한다.
*/
class Logger {
private logFile: string | null = null
private buffer: string[] = []
async setLogFile(path: string): Promise<void> {
this.logFile = path
await mkdir(dirname(path), { recursive: true })
}
private async write(level: Level, msg: string, meta?: unknown): Promise<void> {
// new Date() 사용 (Main 프로세스는 일반 Node — 제약 없음)
const ts = new Date().toISOString()
const metaStr = meta === undefined ? '' : ` ${safeJson(meta)}`
const line = `[${ts}] [${level}] ${msg}${metaStr}`
if (level === 'ERROR') console.error(line)
else if (level === 'WARN') console.warn(line)
else console.log(line)
this.buffer.push(line)
if (this.logFile) {
try {
await appendFile(this.logFile, line + '\n', 'utf-8')
} catch {
// 로그 파일 기록 실패는 치명적이지 않음 — 콘솔 출력은 이미 됨
}
}
}
info(msg: string, meta?: unknown) {
return this.write('INFO', msg, meta)
}
warn(msg: string, meta?: unknown) {
return this.write('WARN', msg, meta)
}
error(msg: string, meta?: unknown) {
return this.write('ERROR', msg, meta)
}
/** 잡 종료 시 버퍼 비우기 */
reset(): void {
this.buffer = []
this.logFile = null
}
}
function safeJson(v: unknown): string {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
export const logger = new Logger()
+167
View File
@@ -0,0 +1,167 @@
import { BrowserWindow } from 'electron'
import type {
JobRequest,
FileProcessed,
ProgressEvent,
Report,
ProfileMatch
} from '@shared/types'
import { IPC } from '@shared/constants'
import { scan, countImages, defaultSkipDirs } from './scanner'
import { getCaptureDate } from './exif'
import { buildTargetPath } from './pathBuilder'
import { safeMove, safeCopy } from './fileOps'
import { profileStore } from './profileStore'
import { inferenceBridge } from './inferenceBridge'
import { Reporter } from './reporter'
import { createLimiter } from './concurrency'
import { logger } from './logger'
/**
* 정리 잡 파이프라인 오케스트레이터.
* 스캔 → (얼굴인식 + EXIF) → 경로생성 → 이동/복사 → 진행률/리포트.
*/
class Orchestrator {
private cancelled = false
private running = false
cancel(): void {
if (this.running) {
this.cancelled = true
logger.warn('잡 취소 요청됨')
}
}
async run(req: JobRequest, sender: BrowserWindow): Promise<Report> {
if (this.running) throw new Error('이미 실행 중인 잡이 있습니다.')
this.running = true
this.cancelled = false
const send = <T>(channel: string, payload: T) => {
if (!sender.isDestroyed()) sender.webContents.send(channel, payload)
}
const reporter = new Reporter()
const startTs = Date.now()
const logPath = Reporter.logPathFor(req.outputRoot, startTs)
await logger.setLogFile(logPath)
await logger.info('잡 시작', req)
try {
const profiles = await profileStore.list() // order asc 정렬됨
// 추론 엔진 준비 + 매처 구성
await inferenceBridge.whenReady()
await inferenceBridge.initMatcher(profiles, req.options)
// 출력물 재처리 방지 위해 우리가 만든 폴더는 스캔 제외
const skip = defaultSkipDirs(profiles.map((p) => p.name))
// 진행률 total 산출
const total = await countImages(req.source, skip)
logger.info('스캔 대상 이미지 수', { total })
let done = 0
const limit = createLimiter(Math.max(1, req.options.concurrency))
const tasks: Promise<void>[] = []
for await (const file of scan(req.source, skip)) {
if (this.cancelled) break
const task = limit(async () => {
if (this.cancelled) return
const progress: ProgressEvent = { done, total, current: file }
send(IPC.JOB_PROGRESS, progress)
const result = await this.processFile(req, file, profiles)
reporter.record(result)
done++
send(IPC.JOB_FILE_PROCESSED, result)
send<ProgressEvent>(IPC.JOB_PROGRESS, { done, total, current: file })
if (result.kind === 'failed' && result.error) {
send(IPC.JOB_ERROR, { file, message: result.error })
}
})
tasks.push(task)
}
await Promise.all(tasks)
const report = await reporter.summarize(logPath)
send(IPC.JOB_DONE, report)
return report
} finally {
this.running = false
logger.reset()
}
}
/** 파일 1건 처리: 인식 → 날짜 → 이동/복사 */
private async processFile(
req: JobRequest,
file: string,
profilesOrdered: { id: string; name: string; order: number }[]
): Promise<FileProcessed> {
void profilesOrdered
try {
// 얼굴 인식 + 날짜 추출 병렬
const [match, date] = await Promise.all([
inferenceBridge.detect(file),
getCaptureDate(file)
])
// 매칭 인물 없음 → [미정]
if (!match.matched || match.matched.length === 0) {
const dest = await safeMove(file, buildTargetPath(req.outputRoot, null, date, file))
return {
file,
kind: 'unmatched',
targets: [dest],
matchedNames: [],
date
}
}
// 등록 순서(order asc) 정렬 → 1순위 이동, 나머지 복사
const ordered: ProfileMatch[] = [...match.matched].sort((a, b) => a.order - b.order)
const targets: string[] = []
// 1순위: 이동
const first = ordered[0]
const movedDest = await safeMove(
file,
buildTargetPath(req.outputRoot, first.name, date, file)
)
targets.push(movedDest)
// 나머지: 이동된 파일을 소스로 복사
for (let i = 1; i < ordered.length; i++) {
const copyDest = await safeCopy(
movedDest,
buildTargetPath(req.outputRoot, ordered[i].name, date, file)
)
targets.push(copyDest)
}
return {
file,
kind: 'moved',
targets,
matchedNames: ordered.map((m) => m.name),
date
}
} catch (err) {
const message = (err as Error).message
await logger.error('파일 처리 실패', { file, message })
return {
file,
kind: 'failed',
targets: [],
matchedNames: [],
date: null,
error: message
}
}
}
}
export const orchestrator = new Orchestrator()
+31
View File
@@ -0,0 +1,31 @@
import { join, extname, basename } from 'node:path'
import type { CaptureDate } from '@shared/types'
import { UNMATCHED_FOLDER } from '@shared/constants'
/**
* 인물/미정 + 연/월 기준의 대상 디렉터리 경로를 생성한다.
* 실제 파일명 충돌 해소는 fileOps에서 수행 (여기서는 디렉터리 + 원본 파일명까지).
*
* @param who 인물 폴더명, 또는 미검출이면 null → [미정]
*/
export function buildTargetPath(
outputRoot: string,
who: string | null,
date: CaptureDate,
sourceFile: string
): string {
const folder = who ?? UNMATCHED_FOLDER
const filename = basename(sourceFile)
return join(outputRoot, folder, date.year, date.month, filename)
}
/**
* 파일명 충돌 시 사용할 후보 경로를 생성 (name_1.ext, name_2.ext ...).
* @param index 1부터 시작하는 충돌 회피 인덱스
*/
export function withCollisionSuffix(targetPath: string, index: number): string {
const dir = targetPath.slice(0, targetPath.length - basename(targetPath).length)
const ext = extname(targetPath)
const stem = basename(targetPath, ext)
return join(dir, `${stem}_${index}${ext}`)
}
+102
View File
@@ -0,0 +1,102 @@
import { app } from 'electron'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import type { Profile, ProfileInput } from '@shared/types'
import { PROFILE_STORE_FILE, MAX_PROFILES } from '@shared/constants'
import { logger } from './logger'
/**
* 프로필 영속화. OS userData 경로의 profiles.json 에 저장.
* descriptor(Float32Array)는 number[][] 직렬화 형태로 보관 → 모바일 확장 시 표준 구조 호환.
*/
class ProfileStore {
private profiles: Profile[] = []
private loaded = false
private filePath(): string {
return join(app.getPath('userData'), PROFILE_STORE_FILE)
}
async load(): Promise<Profile[]> {
if (this.loaded) return this.profiles
try {
const raw = await readFile(this.filePath(), 'utf-8')
const parsed = JSON.parse(raw) as { profiles?: Profile[] }
this.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : []
} catch {
this.profiles = [] // 최초 실행 등 → 빈 목록
}
this.loaded = true
return this.profiles
}
async list(): Promise<Profile[]> {
await this.load()
return [...this.profiles].sort((a, b) => a.order - b.order)
}
private async persist(): Promise<void> {
await mkdir(app.getPath('userData'), { recursive: true })
await writeFile(
this.filePath(),
JSON.stringify({ profiles: this.profiles }, null, 2),
'utf-8'
)
}
/** 생성/수정. id 없으면 신규(최대 인원 검사). */
async upsert(input: ProfileInput): Promise<Profile> {
await this.load()
if (input.id) {
const existing = this.profiles.find((p) => p.id === input.id)
if (!existing) throw new Error(`프로필을 찾을 수 없음: ${input.id}`)
existing.name = input.name
existing.order = input.order
await this.persist()
return existing
}
if (this.profiles.length >= MAX_PROFILES) {
throw new Error(`프로필은 최대 ${MAX_PROFILES}명까지 등록 가능합니다.`)
}
const profile: Profile = {
id: cryptoRandomId(),
name: input.name,
order: input.order,
referenceImages: [],
descriptors: []
}
this.profiles.push(profile)
await this.persist()
logger.info('프로필 생성', { id: profile.id, name: profile.name })
return profile
}
async remove(id: string): Promise<void> {
await this.load()
this.profiles = this.profiles.filter((p) => p.id !== id)
await this.persist()
}
/** 참조 이미지 + 계산된 descriptor 추가 */
async addReference(
id: string,
imagePaths: string[],
descriptors: number[][]
): Promise<Profile> {
await this.load()
const p = this.profiles.find((x) => x.id === id)
if (!p) throw new Error(`프로필을 찾을 수 없음: ${id}`)
p.referenceImages.push(...imagePaths)
p.descriptors.push(...descriptors)
await this.persist()
logger.info('참조 이미지 추가', { id, added: descriptors.length })
return p
}
}
function cryptoRandomId(): string {
// Electron Main(Node)에서 무작위 ID — globalThis.crypto.randomUUID 사용
return globalThis.crypto.randomUUID()
}
export const profileStore = new ProfileStore()
+63
View File
@@ -0,0 +1,63 @@
import { join } from 'node:path'
import type { FileProcessed, Report } from '@shared/types'
import { LOG_FOLDER } from '@shared/constants'
import { logger } from './logger'
/**
* 잡 통계 집계 + 결과 로그 파일 생성.
*/
export class Reporter {
private moved = 0
private copied = 0
private unmatched = 0
private failed = 0
private total = 0
private readonly startedAt: number
constructor() {
this.startedAt = Date.now()
}
record(result: FileProcessed): void {
this.total++
switch (result.kind) {
case 'moved':
this.moved++
// 복사 대상은 targets에서 첫(이동) 제외한 나머지
this.copied += Math.max(0, result.targets.length - 1)
break
case 'unmatched':
this.unmatched++
break
case 'failed':
this.failed++
break
case 'copied':
this.copied++
break
}
}
/** 로그 파일 경로 (출력 루트 하위 _PhotoAI_logs/run-<ts>.log) */
static logPathFor(outputRoot: string, ts: number): string {
const stamp = new Date(ts).toISOString().replace(/[:.]/g, '-')
return join(outputRoot, LOG_FOLDER, `run-${stamp}.log`)
}
async summarize(logPath: string): Promise<Report> {
const finishedAt = Date.now()
const report: Report = {
total: this.total,
moved: this.moved,
copied: this.copied,
unmatched: this.unmatched,
failed: this.failed,
elapsedMs: finishedAt - this.startedAt,
logPath,
startedAt: this.startedAt,
finishedAt
}
await logger.info('==== 작업 결과 리포트 ====', report)
return report
}
}
+60
View File
@@ -0,0 +1,60 @@
import { readdir } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { SUPPORTED_EXTENSIONS, LOG_FOLDER, UNMATCHED_FOLDER } from '@shared/constants'
const EXT_SET = new Set<string>(SUPPORTED_EXTENSIONS)
function isSupportedImage(filename: string): boolean {
return EXT_SET.has(extname(filename).toLowerCase())
}
/**
* 소스 폴더를 재귀 순회하며 지원 확장자 이미지의 절대 경로를 스트리밍 산출.
* 비동기 제너레이터 → 대량 폴더에서도 메모리에 전체 목록을 적재하지 않음.
*
* @param skipDirs 순회에서 제외할 디렉터리명 (출력 루트가 소스 내부일 때 자기 출력물 재처리 방지)
*/
export async function* scan(
root: string,
skipDirs: ReadonlySet<string> = new Set()
): AsyncGenerator<string> {
let entries
try {
entries = await readdir(root, { withFileTypes: true })
} catch {
// 읽을 수 없는 디렉터리는 건너뜀
return
}
for (const entry of entries) {
const full = join(root, entry.name)
if (entry.isDirectory()) {
// 우리 자신이 만든 폴더(프로필/[미정]/로그)는 재귀 제외
if (skipDirs.has(entry.name)) continue
yield* scan(full, skipDirs)
} else if (entry.isFile() && isSupportedImage(entry.name)) {
yield full
}
}
}
/**
* 전체 개수를 먼저 세는 헬퍼 (진행률 total 표시용).
* 스캔을 한 번 더 도는 비용이 있으나, 정확한 진행률을 위해 사용.
*/
export async function countImages(
root: string,
skipDirs: ReadonlySet<string> = new Set()
): Promise<number> {
let count = 0
for await (const _ of scan(root, skipDirs)) {
void _
count++
}
return count
}
/** 출력물 재처리 방지를 위한 기본 제외 디렉터리 집합 */
export function defaultSkipDirs(profileNames: string[]): Set<string> {
return new Set<string>([LOG_FOLDER, UNMATCHED_FOLDER, ...profileNames])
}
+44
View File
@@ -0,0 +1,44 @@
import { contextBridge, ipcRenderer } from 'electron'
import { IPC } from '../shared/constants'
import type {
ExposedApi,
ProfileInput,
JobRequest,
RendererEventName,
RendererEvents
} from '../shared/types'
// Main→UI 이벤트 채널 화이트리스트
const EVENT_CHANNELS: Record<RendererEventName, string> = {
'job:progress': IPC.JOB_PROGRESS,
'job:fileProcessed': IPC.JOB_FILE_PROCESSED,
'job:done': IPC.JOB_DONE,
'job:error': IPC.JOB_ERROR
}
const api: ExposedApi = {
profiles: {
list: () => ipcRenderer.invoke(IPC.PROFILES_LIST),
upsert: (input: ProfileInput) => ipcRenderer.invoke(IPC.PROFILES_UPSERT, input),
remove: (id: string) => ipcRenderer.invoke(IPC.PROFILES_REMOVE, id),
addReference: (id: string, imagePaths: string[]) =>
ipcRenderer.invoke(IPC.PROFILES_ADD_REFERENCE, id, imagePaths)
},
dialog: {
pickSource: () => ipcRenderer.invoke(IPC.DIALOG_PICK_SOURCE),
pickOutput: () => ipcRenderer.invoke(IPC.DIALOG_PICK_OUTPUT),
pickImages: () => ipcRenderer.invoke(IPC.DIALOG_PICK_IMAGES)
},
job: {
run: (req: JobRequest) => ipcRenderer.invoke(IPC.JOB_RUN, req),
cancel: () => ipcRenderer.invoke(IPC.JOB_CANCEL)
},
on<E extends RendererEventName>(event: E, cb: (payload: RendererEvents[E]) => void) {
const channel = EVENT_CHANNELS[event]
const listener = (_e: unknown, payload: RendererEvents[E]) => cb(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
}
contextBridge.exposeInMainWorld('api', api)
+33
View File
@@ -0,0 +1,33 @@
import { contextBridge, ipcRenderer } from 'electron'
// 숨김 추론 창 전용 브릿지.
// Main이 보내는 요청 채널만 수신하고, 응답은 'infer:reply'로만 전송한다.
const REQUEST_CHANNELS = ['infer:init', 'infer:describe', 'infer:detect'] as const
type RequestChannel = (typeof REQUEST_CHANNELS)[number]
export interface InferBridge {
/** Main의 요청 수신 */
onRequest(
cb: (channel: RequestChannel, payload: Record<string, unknown> & { requestId: string }) => void
): void
/** 요청 처리 결과 회신 */
reply(requestId: string, ok: boolean, data?: unknown, error?: string): void
/** 모델 로드 완료 통지 */
ready(): void
}
const bridge: InferBridge = {
onRequest(cb) {
for (const channel of REQUEST_CHANNELS) {
ipcRenderer.on(channel, (_e, payload) => cb(channel, payload))
}
},
reply(requestId, ok, data, error) {
ipcRenderer.send('infer:reply', { requestId, ok, data, error })
},
ready() {
ipcRenderer.send('infer:ready')
}
}
contextBridge.exposeInMainWorld('inferBridge', bridge)
+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;
}
+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
}