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,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()
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { InferBridge } from '../preload/inference'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
inferBridge: InferBridge
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { ExposedApi } from '@shared/types'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: ExposedApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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