Add video sorting, reference thumbnails, theme/i18n, menu, DnD/paste, presets

Feature work on top of the initial organizer:

- Videos: .mp4/.mov/.avi/.mkv/.webm/.m4v sorted into output/Movie/YYYY/MM
- Profiles: reference-image thumbnail cards via a secure photoai-media:// protocol
  (serves only registered reference images); per-thumbnail delete; add via
  click, drag & drop, or paste (Ctrl+V) using webUtils.getPathForFile + a
  byte-based addReferenceData path for clipboard images
- Presets: local person library (max 5) saved to userData; one-click apply into
  active profiles; reusing stored descriptors (no recompute)
- Theme: dark/light with dark default (Tailwind class strategy)
- i18n: ko/en table-based localization; first-run onboarding to pick
  language + theme; English-neutral "Unsorted" folder (was [미정])
- App menu: File/Edit/View/Window/Help, localized; Help opens a detailed,
  theme-aware user guide window
- UI: History block scrolls internally (no whole-window scroll);
  threshold/concurrency tooltips; generic example name
- Settings persisted to userData/settings.json; menu + renderer kept in sync
- Docs: NextGen Photo AI feasibility review + Phase 0/1 roadmap

All typecheck/tests (12) /build green; boot smoke verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:40:44 +09:00
parent 8a8c10248c
commit 6dce580846
38 changed files with 1916 additions and 212 deletions
+38 -2
View File
@@ -3,8 +3,24 @@
/** 처리 대상 이미지 확장자 (소문자, 점 포함) */
export const SUPPORTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const
/** 미검출/인식실패 사진이 들어가는 폴더명 */
export const UNMATCHED_FOLDER = '[미정]'
/** 처리 대상 영상 확장자 (소문자, 점 포함) */
export const SUPPORTED_VIDEO_EXTENSIONS = [
'.mp4',
'.mov',
'.avi',
'.mkv',
'.webm',
'.m4v'
] as const
/** 미검출/인식실패 사진이 들어가는 폴더명 (언어 중립 — 영어 모드에서 한글 노출 방지) */
export const UNMATCHED_FOLDER = 'Unsorted'
/** 영상 파일이 들어가는 폴더명 (얼굴인식 없이 날짜 기준 이동) */
export const MOVIE_FOLDER = 'Movie'
/** 로컬 참조 이미지를 UI 창에 안전하게 표시하기 위한 커스텀 프로토콜 스킴 */
export const MEDIA_SCHEME = 'photoai-media'
/** 로그 폴더명 (출력 루트 하위) */
export const LOG_FOLDER = '_PhotoAI_logs'
@@ -12,9 +28,18 @@ export const LOG_FOLDER = '_PhotoAI_logs'
/** 프로필 영속화 파일명 (userData 하위) */
export const PROFILE_STORE_FILE = 'profiles.json'
/** 앱 설정 영속화 파일명 (userData 하위) */
export const SETTINGS_FILE = 'settings.json'
/** 프리셋(인물 라이브러리) 영속화 파일명 (userData 하위, 로컬 전용) */
export const PRESET_STORE_FILE = 'presets.json'
/** 최대 프로필 인원 (PRD) */
export const MAX_PROFILES = 3
/** 최대 프리셋(저장 인물) 수 */
export const MAX_PRESETS = 5
/** 기본 잡 옵션 */
export const DEFAULT_JOB_OPTIONS = {
matchThreshold: 0.5,
@@ -32,11 +57,22 @@ export const IPC = {
PROFILES_UPSERT: 'profiles:upsert',
PROFILES_REMOVE: 'profiles:remove',
PROFILES_ADD_REFERENCE: 'profiles:addReference',
PROFILES_ADD_REFERENCE_DATA: 'profiles:addReferenceData',
PROFILES_REMOVE_REFERENCE: 'profiles:removeReference',
PROFILES_APPLY_PRESET: 'profiles:applyPreset',
// 프리셋(인물 라이브러리)
PRESETS_LIST: 'presets:list',
PRESETS_SAVE_FROM: 'presets:saveFrom',
PRESETS_REMOVE: 'presets:remove',
DIALOG_PICK_SOURCE: 'dialog:pickSource',
DIALOG_PICK_OUTPUT: 'dialog:pickOutput',
DIALOG_PICK_IMAGES: 'dialog:pickImages',
JOB_RUN: 'job:run',
JOB_CANCEL: 'job:cancel',
// 설정
SETTINGS_GET: 'settings:get',
SETTINGS_SET: 'settings:set',
SETTINGS_CHANGED: 'settings:changed',
// Main → UI (send)
JOB_PROGRESS: 'job:progress',
JOB_FILE_PROCESSED: 'job:fileProcessed',
+187
View File
@@ -0,0 +1,187 @@
// 다국어(i18n) 문자열 테이블. ko/en 두 언어를 지원한다.
// 메인(메뉴/가이드)과 렌더러(UI)가 공용으로 사용한다.
export type Lang = 'ko' | 'en'
export const LANGS: Lang[] = ['ko', 'en']
export const DEFAULT_LANG: Lang = 'ko'
export const LANG_LABEL: Record<Lang, string> = {
ko: '한국어',
en: 'English'
}
/** 키 → 언어별 문자열. {token} 형태의 치환자를 지원. */
type Table = Record<string, Record<Lang, string>>
export const MESSAGES: Table = {
// 공통
'app.title': { ko: 'AI Photo Organizer', en: 'AI Photo Organizer' },
'app.subtitle': {
ko: '얼굴 인식 + 촬영일 기준 자동 사진 정리 · 로컬 전용',
en: 'Auto-organize photos by face recognition + capture date · Local only'
},
'common.add': { ko: '추가', en: 'Add' },
// 온보딩
'onboard.title': { ko: '시작하기 전에', en: 'Before you start' },
'onboard.subtitle': {
ko: '언어와 테마를 선택하세요. 나중에 메뉴에서 바꿀 수 있습니다.',
en: 'Choose your language and theme. You can change these later from the menu.'
},
'onboard.language': { ko: '언어', en: 'Language' },
'onboard.theme': { ko: '테마', en: 'Theme' },
'onboard.dark': { ko: '다크', en: 'Dark' },
'onboard.light': { ko: '라이트', en: 'Light' },
'onboard.start': { ko: '시작하기', en: 'Get Started' },
// 1. 프로필
'profile.section': { ko: '1. 인물 프로필', en: '1. Person Profiles' },
'profile.count': {
ko: '{n}/{max}명 · 순서 = 이동 우선순위',
en: '{n}/{max} people · order = move priority'
},
'profile.namePlaceholder': {
ko: '인물 이름 (예: Alex)',
en: 'Person name (e.g. Alex)'
},
'profile.dndHint': {
ko: '타일 클릭 · 드래그&드롭 · 붙여넣기(Ctrl+V)로 추가',
en: 'Add by click, drag & drop, or paste (Ctrl+V)'
},
'profile.empty': {
ko: '등록된 프로필이 없습니다. 이름을 추가하고 참조 얼굴 사진을 등록하세요.',
en: 'No profiles yet. Add a name and register reference face photos.'
},
'profile.refCount': { ko: '참조 {n}장', en: '{n} refs' },
'profile.delete': { ko: '프로필 삭제', en: 'Delete profile' },
'profile.savePreset': { ko: '프리셋 저장', en: 'Save preset' },
'profile.presets': { ko: '프리셋', en: 'Presets' },
'profile.presetsHint': {
ko: '자주 쓰는 인물을 저장해두고 클릭으로 불러옵니다 (로컬 전용)',
en: 'Save people you use often and load them with a click (local only)'
},
'profile.presetsEmpty': {
ko: "저장된 프리셋이 없습니다. 프로필의 '프리셋 저장'으로 추가하세요.",
en: "No presets yet. Use 'Save preset' on a profile."
},
'profile.applyPresetTitle': {
ko: '클릭해서 인물 프로필에 추가',
en: 'Click to add to profiles'
},
'profile.deletePreset': { ko: '프리셋 삭제', en: 'Delete preset' },
'profile.presetsFull': {
ko: '프리셋이 가득 찼습니다 (최대 {max}개).',
en: 'Presets are full (max {max}).'
},
'profile.profilesFull': {
ko: '프로필이 가득 찼습니다 (최대 {max}명).',
en: 'Profiles are full (max {max}).'
},
'profile.addFace': { ko: '얼굴 추가', en: 'Add face' },
'profile.analyzing': { ko: '분석 중', en: 'Analyzing' },
'profile.deletePhoto': { ko: '이 사진 삭제', en: 'Delete this photo' },
// 2. 폴더
'folder.section': { ko: '2. 폴더 선택', en: '2. Select Folders' },
'folder.source': { ko: '정리할 폴더 (소스)', en: 'Folder to organize (source)' },
'folder.output': { ko: '결과 저장 폴더 (출력)', en: 'Output folder' },
'folder.unselected': { ko: '미선택', en: 'Not selected' },
'folder.browse': { ko: '찾기', en: 'Browse' },
// 3. 실행 옵션
'run.section': { ko: '3. 실행 옵션', en: '3. Run Options' },
'run.threshold': { ko: '매칭 임계값 ({v})', en: 'Match threshold ({v})' },
'run.thresholdHint': { ko: '낮을수록 엄격', en: 'Lower = stricter' },
'run.thresholdTip': {
ko: '두 얼굴이 같은 사람인지 판단하는 거리 기준입니다.\n• 낮추면(예: 0.4) 더 엄격 → 다른 사람이 섞일 확률↓, 대신 본인을 놓칠 수 있음\n• 높이면(예: 0.6) 더 너그러움 → 본인을 잘 찾음, 대신 다른 사람이 섞일 수 있음\n권장: 0.45~0.55',
en: 'Distance threshold for deciding whether two faces are the same person.\n• Lower (e.g. 0.4) = stricter → fewer false matches, but may miss the person\n• Higher (e.g. 0.6) = looser → catches the person more, but may include others\nRecommended: 0.450.55'
},
'run.concurrency': { ko: '동시 처리 ({v})', en: 'Concurrency ({v})' },
'run.concurrencyTip': {
ko: '한 번에 동시에 처리하는 파일 수입니다.\n• 높이면 처리 속도↑, 대신 메모리(RAM)·CPU 사용량↑\n• 사양이 낮거나 고해상도 사진이 많으면 2~3 권장',
en: 'How many files are processed at the same time.\n• Higher = faster, but uses more memory (RAM) and CPU\n• On lower-end machines or with many high-resolution photos, 23 is recommended'
},
'run.detector': { ko: '검출 엔진', en: 'Detection engine' },
'run.detectorSsd': {
ko: '정확도 우선 (SSD MobileNet)',
en: 'Accuracy first (SSD MobileNet)'
},
'run.detectorTiny': { ko: '속도 우선 (Tiny Face)', en: 'Speed first (Tiny Face)' },
'run.noFaceWarn': {
ko: "⚠️ 등록된 얼굴이 없습니다. 매칭 인물 없이 모두 'Unsorted' 폴더로 분류됩니다.",
en: "⚠️ No registered faces. Everything will be sorted into the 'Unsorted' folder."
},
'run.start': { ko: '정리 시작', en: 'Start' },
'run.rerun': { ko: '다시 실행', en: 'Run again' },
'run.cancel': { ko: '취소', en: 'Cancel' },
'run.reset': { ko: '초기화', en: 'Reset' },
// 진행/결과
'progress.section': { ko: '진행 상황', en: 'Progress' },
'progress.waiting': { ko: '대기 중', en: 'Waiting' },
'progress.scanning': { ko: '스캔 중…', en: 'Scanning…' },
'progress.idle': { ko: '실행 대기', en: 'Idle' },
'history.section': { ko: '처리 내역', en: 'History' },
'history.recent': { ko: '최근 {n}건', en: 'Recent {n}' },
'history.empty': { ko: '아직 처리된 파일이 없습니다.', en: 'No files processed yet.' },
'kind.moved': { ko: '이동', en: 'Moved' },
'kind.copied': { ko: '복사', en: 'Copied' },
'kind.unmatched': { ko: '미정', en: 'Unsorted' },
'kind.movie': { ko: '영상', en: 'Video' },
'kind.failed': { ko: '실패', en: 'Failed' },
'report.done': { ko: '✅ 작업 완료', en: '✅ Done' },
'report.elapsed': { ko: '소요 {d}', en: 'Took {d}' },
'report.total': { ko: '총 처리', en: 'Total' },
'report.log': { ko: '로그: {path}', en: 'Log: {path}' },
'report.errors': { ko: '오류 {n}건 보기', en: 'View {n} errors' },
'dur.ms': { ko: '{m}분 {s}초', en: '{m}m {s}s' },
'dur.s': { ko: '{s}초', en: '{s}s' },
// 메뉴
'menu.file': { ko: '파일', en: 'File' },
'menu.edit': { ko: '편집', en: 'Edit' },
'menu.view': { ko: '보기', en: 'View' },
'menu.window': { ko: '창', en: 'Window' },
'menu.help': { ko: '도움말', en: 'Help' },
'menu.quit': { ko: '종료', en: 'Quit' },
'menu.undo': { ko: '실행 취소', en: 'Undo' },
'menu.redo': { ko: '다시 실행', en: 'Redo' },
'menu.cut': { ko: '잘라내기', en: 'Cut' },
'menu.copy': { ko: '복사', en: 'Copy' },
'menu.paste': { ko: '붙여넣기', en: 'Paste' },
'menu.selectall': { ko: '모두 선택', en: 'Select All' },
'menu.reload': { ko: '새로고침', en: 'Reload' },
'menu.devtools': { ko: '개발자 도구', en: 'Toggle DevTools' },
'menu.resetzoom': { ko: '실제 크기', en: 'Actual Size' },
'menu.zoomin': { ko: '확대', en: 'Zoom In' },
'menu.zoomout': { ko: '축소', en: 'Zoom Out' },
'menu.fullscreen': { ko: '전체 화면', en: 'Toggle Full Screen' },
'menu.minimize': { ko: '최소화', en: 'Minimize' },
'menu.close': { ko: '닫기', en: 'Close' },
'menu.appearance': { ko: '테마', en: 'Appearance' },
'menu.theme.dark': { ko: '다크 모드', en: 'Dark' },
'menu.theme.light': { ko: '라이트 모드', en: 'Light' },
'menu.language': { ko: '언어', en: 'Language' },
'menu.guide': { ko: '사용 방법 가이드', en: 'User Guide' },
'menu.about': { ko: '정보', en: 'About' }
}
/** 번역. 누락 키는 키 자체를 반환(개발 중 가시성). {token} 치환 지원. */
export function translate(
lang: Lang,
key: string,
params?: Record<string, string | number>
): string {
const entry = MESSAGES[key]
let text = entry ? entry[lang] : key
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
}
}
return text
}
+51 -1
View File
@@ -1,5 +1,18 @@
// 전 프로세스(Main/Preload/Renderer/Inference)가 공유하는 타입 정의
import type { Lang } from './i18n'
/** UI 테마 */
export type Theme = 'dark' | 'light'
/** 앱 설정 (userData/settings.json) */
export interface Settings {
language: Lang
theme: Theme
/** 첫 실행 온보딩(언어/테마 선택) 완료 여부 */
onboarded: boolean
}
/** 등록된 인물 프로필 */
export interface Profile {
id: string
@@ -20,6 +33,23 @@ export interface ProfileInput {
order: number
}
/** 프리셋: 저장된 인물(라이브러리). 클릭하면 활성 프로필로 불러온다. 로컬 전용. */
export interface Preset {
id: string
name: string
referenceImages: string[]
descriptors: number[][]
createdAt: number
}
/** 경로가 없는(붙여넣기/메모리) 이미지 데이터 — main이 파일로 저장 후 참조로 등록 */
export interface ReferenceData {
/** 표시/확장자 추출용 파일명 */
name: string
/** 이미지 바이트 (IPC 구조화 복제로 전달) */
data: ArrayBuffer
}
/** 한 사진에 대한 단일 인물 매칭 결과 */
export interface ProfileMatch {
profileId: string
@@ -72,7 +102,7 @@ export interface JobRequest {
}
/** 파일 1건 처리 후 결정 종류 */
export type FileDecisionKind = 'moved' | 'copied' | 'unmatched' | 'failed'
export type FileDecisionKind = 'moved' | 'copied' | 'unmatched' | 'movie' | 'failed'
/** 파일 1건 처리 결과 (UI 스트림 + 리포트용) */
export interface FileProcessed {
@@ -101,6 +131,8 @@ export interface Report {
moved: number
copied: number
unmatched: number
/** Movie 폴더로 이동된 영상 수 */
movies: number
failed: number
/** 소요 시간(ms) */
elapsedMs: number
@@ -116,6 +148,7 @@ export interface RendererEvents {
'job:fileProcessed': FileProcessed
'job:done': Report
'job:error': { file: string; message: string }
'settings:changed': Settings
}
export type RendererEventName = keyof RendererEvents
@@ -127,6 +160,17 @@ export interface ExposedApi {
upsert(input: ProfileInput): Promise<Profile>
remove(id: string): Promise<void>
addReference(id: string, imagePaths: string[]): Promise<Profile>
/** 경로 없는 이미지(붙여넣기/드롭된 메모리 이미지)를 저장 후 참조로 등록 */
addReferenceData(id: string, items: ReferenceData[]): Promise<Profile>
removeReference(id: string, imagePath: string): Promise<Profile>
/** 프리셋을 활성 프로필로 불러와 새 프로필 생성 */
applyPreset(presetId: string): Promise<Profile>
}
presets: {
list(): Promise<Preset[]>
/** 현재 활성 프로필을 프리셋으로 저장 */
saveFrom(profileId: string): Promise<Preset[]>
remove(id: string): Promise<Preset[]>
}
dialog: {
pickSource(): Promise<string | null>
@@ -137,6 +181,12 @@ export interface ExposedApi {
run(req: JobRequest): Promise<void>
cancel(): Promise<void>
}
settings: {
get(): Promise<Settings>
set(patch: Partial<Settings>): Promise<Settings>
}
/** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */
getPathForFile(file: unknown): string
on<E extends RendererEventName>(
event: E,
cb: (payload: RendererEvents[E]) => void