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
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest'
import { createLimiter } from '../src/main/concurrency'
describe('createLimiter', () => {
it('동시 실행 수가 limit을 넘지 않는다', async () => {
const limit = createLimiter(2)
let active = 0
let maxActive = 0
const task = () =>
limit(async () => {
active++
maxActive = Math.max(maxActive, active)
await new Promise((r) => setTimeout(r, 10))
active--
})
await Promise.all(Array.from({ length: 10 }, task))
expect(maxActive).toBeLessThanOrEqual(2)
})
it('모든 작업의 결과를 반환한다', async () => {
const limit = createLimiter(3)
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) => limit(async () => i * 2))
)
expect(results).toEqual([0, 2, 4, 6, 8])
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, writeFile, readFile, rm, mkdir, access } from 'node:fs/promises'
import { constants } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { safeMove, safeCopy, resolveCollisionFreePath } from '../src/main/fileOps'
let dir: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'photoai-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
async function exists(p: string): Promise<boolean> {
try {
await access(p, constants.F_OK)
return true
} catch {
return false
}
}
describe('safeMove', () => {
it('복사 후 원본을 삭제하고 내용이 보존된다', async () => {
const src = join(dir, 'a.txt')
await writeFile(src, 'hello')
const target = join(dir, 'out', 'a.txt')
const dest = await safeMove(src, target)
expect(await exists(src)).toBe(false) // 원본 삭제됨
expect(await readFile(dest, 'utf-8')).toBe('hello')
})
})
describe('safeCopy + 충돌 자동 리네임', () => {
it('대상이 존재하면 _1, _2 로 리네임한다 (덮어쓰기 금지)', async () => {
const target = join(dir, 'out', 'a.txt')
await mkdir(join(dir, 'out'), { recursive: true })
await writeFile(target, 'existing')
const src = join(dir, 'src.txt')
await writeFile(src, 'new')
const dest = await safeCopy(src, target)
expect(dest.endsWith('a_1.txt')).toBe(true)
expect(await readFile(target, 'utf-8')).toBe('existing') // 원본 보존
expect(await readFile(dest, 'utf-8')).toBe('new')
})
})
describe('resolveCollisionFreePath', () => {
it('충돌 없으면 그대로 반환한다', async () => {
const target = join(dir, 'fresh.txt')
expect(await resolveCollisionFreePath(target)).toBe(target)
})
})
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { buildTargetPath, withCollisionSuffix } from '../src/main/pathBuilder'
import type { CaptureDate } from '../src/shared/types'
const date: CaptureDate = { year: '2024', month: '03', source: 'exif' }
describe('buildTargetPath', () => {
it('인물 매칭 시 /프로필/YYYY/MM/파일명 경로를 만든다', () => {
const p = buildTargetPath('/out', 'seunghyun', date, '/src/a/photo.jpg')
expect(p.replace(/\\/g, '/')).toBe('/out/seunghyun/2024/03/photo.jpg')
})
it('미검출(who=null) 시 [미정] 폴더로 보낸다', () => {
const p = buildTargetPath('/out', null, date, '/src/photo.png')
expect(p.replace(/\\/g, '/')).toBe('/out/[미정]/2024/03/photo.png')
})
})
describe('withCollisionSuffix', () => {
it('확장자 앞에 _N을 붙인다', () => {
const p = withCollisionSuffix('/out/x/photo.jpg', 2)
expect(p.replace(/\\/g, '/')).toBe('/out/x/photo_2.jpg')
})
})