darktable-inspired reskin + metadata/collections, map, easy mode, select/export

UI overhaul to a darktable tone-and-manner and a set of features adapted from
darktable's proven patterns (reimplemented in our Electron/TS stack; no GPL code).

Design reskin:
- Dark neutral-gray palette + amber accent, flat/squared corners, no card shadows,
  compact darktable-style top bar (logo + pipe-separated view tabs), denser 15px base
- Done via design tokens (Tailwind slate/brand/radius/shadow remap) — minimal churn

Metadata & collections (Phase A/B):
- exifr now captures GPS + camera; asset table ALTER-migrated (gpsLat/gpsLon/camera,
  metaVersion backfill on re-index)
- Collection facet bar (year timeline / camera / color-label) filters the grid

Map & relation finder (Phase C):
- Leaflet + online OSM map tab; geotagged photos as markers
- relationService: related photos by place (GPS<1km) + time (+/-2d) + CLIP similarity

Easy mode (Phase D):
- easyMode setting (menu / onboarding); scales the whole UI (rem) + bigger thumbnails
  + large icon nav with plain labels (4050 accessibility)

Library usability:
- Video thumbnails (representative frame capture in the inference worker)
- Media filter (All / Photos / Videos) to separate them
- Clearer culling labels ("Good shots" / "To cull") + explanation tooltip
- Multi-select tiles -> Export selected to a folder (copy, best-cut extraction) and
  Delete to Recycle Bin (shell.trashItem) behind a confirm dialog
- ONNX Runtime wasm bundled locally (offline) via copy-ort-wasm + asarUnpack

Docs: DARKTABLE_REVIEW (feasibility + roadmap A->D). All typecheck/tests/build green;
boot smoke verified each phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:22:19 +09:00
parent 72c41ae834
commit 3e73967c7b
33 changed files with 1670 additions and 96 deletions
+9
View File
@@ -98,6 +98,9 @@ export const IPC = {
INDEX_PROGRESS: 'index:progress',
INDEX_DONE: 'index:done',
INDEX_ASSETS: 'index:assets',
INDEX_ASSET_IDS: 'index:assetIds',
INDEX_FACETS: 'index:facets',
INDEX_EXPORT: 'index:export',
INDEX_SET_RATING: 'index:setRating',
INDEX_SET_LABEL: 'index:setLabel',
// 검색 (Phase 2)
@@ -107,6 +110,12 @@ export const IPC = {
SEARCH_QUERY: 'search:query',
SEARCH_PROGRESS: 'search:progress',
SEARCH_DONE: 'search:done',
// 그룹화 / 자가정화 (Phase 3)
GROUPS_BUILD: 'groups:build',
GROUPS_TRASH: 'groups:trash',
// 지도 / 연관 탐색 (Phase C)
MAP_ASSETS: 'map:assets',
MAP_RELATED: 'map:related',
// Main → UI (send)
JOB_PROGRESS: 'job:progress',
JOB_FILE_PROCESSED: 'job:fileProcessed',
+84 -4
View File
@@ -33,6 +33,9 @@ export const MESSAGES: Table = {
'onboard.theme': { ko: '테마', en: 'Theme' },
'onboard.dark': { ko: '다크', en: 'Dark' },
'onboard.light': { ko: '라이트', en: 'Light' },
'onboard.easy': { ko: '화면 크기', en: 'Display size' },
'onboard.easyOn': { ko: '크게 (쉬운 모드)', en: 'Large (Easy)' },
'onboard.easyOff': { ko: '보통', en: 'Normal' },
'onboard.start': { ko: '시작하기', en: 'Get Started' },
// 1. 프로필
@@ -145,6 +148,52 @@ export const MESSAGES: Table = {
'nav.organize': { ko: '정리', en: 'Organize' },
'nav.library': { ko: '라이브러리', en: 'Library' },
'nav.search': { ko: '검색', en: 'Search' },
'nav.groups': { ko: '그룹·정화', en: 'Groups' },
'nav.map': { ko: '지도', en: 'Map' },
// 쉬운 모드 대형 네비(구어체)
'easynav.organize': { ko: '사진 정리', en: 'Organize' },
'easynav.library': { ko: '내 사진', en: 'My Photos' },
'easynav.search': { ko: '사진 찾기', en: 'Find' },
'easynav.map': { ko: '지도로 보기', en: 'Map' },
'easynav.groups': { ko: '중복 정리', en: 'Cleanup' },
// 지도 / 연관 탐색 (Phase C)
'map.section': { ko: '지도 (촬영 장소)', en: 'Map (photo locations)' },
'map.count': { ko: 'GPS 사진 {n}장', en: '{n} geotagged' },
'map.empty': {
ko: 'GPS 정보가 있는 사진이 없습니다. (라이브러리에서 재색인하면 GPS가 채워집니다)',
en: 'No geotagged photos yet. (Re-index in Library to populate GPS)'
},
'map.related': { ko: '연관 사진', en: 'Related photos' },
'map.relatedHint': {
ko: '지도의 사진을 클릭하면 같은 장소·시기·비슷한 장면의 사진을 모아 보여줍니다.',
en: 'Click a photo on the map to see ones from the same place, time, and similar scenes.'
},
'map.selectHint': { ko: '지도에서 사진을 클릭하세요.', en: 'Click a photo on the map.' },
'map.loading': { ko: '불러오는 중…', en: 'Loading…' },
// 그룹화 / 자가정화 (Phase 3)
'groups.section': { ko: '유사 사진 그룹 · 자가정화', en: 'Similar groups · Cleanup' },
'groups.hint': {
ko: '검색 색인(임베딩)을 기반으로 비슷한 사진을 묶습니다. 각 그룹의 보관 추천(가장 선명)만 남기고 나머지를 휴지통으로 보낼 수 있습니다.',
en: 'Groups similar photos using the search embeddings. Keep the recommended (sharpest) one per group and send the rest to the trash.'
},
'groups.threshold': { ko: '유사도 ({v})', en: 'Similarity ({v})' },
'groups.find': { ko: '그룹 찾기', en: 'Find groups' },
'groups.finding': { ko: '찾는 중…', en: 'Finding…' },
'groups.empty': {
ko: '유사 그룹이 없습니다. (먼저 검색 탭에서 임베딩 색인을 생성하세요)',
en: 'No similar groups. (Build the embedding index in the Search tab first)'
},
'groups.count': { ko: '{n}개 그룹', en: '{n} groups' },
'groups.keep': { ko: '추천 보관', en: 'Keep' },
'groups.groupSize': { ko: '유사 {n}장', en: '{n} similar' },
'groups.trashSelected': { ko: '선택 {n}개 휴지통으로', en: 'Trash {n} selected' },
'groups.confirmTrash': {
ko: '선택한 {n}개 사진을 휴지통(복구 가능)으로 보냅니다. 계속할까요?',
en: 'Move {n} selected photos to the trash (recoverable)? Continue?'
},
'groups.trashed': { ko: '{n}개를 휴지통으로 이동했습니다.', en: 'Moved {n} to trash.' },
// 검색 (Phase 2)
'search.section': { ko: '검색 색인', en: 'Search index' },
@@ -191,11 +240,37 @@ export const MESSAGES: Table = {
},
'lib.loadMore': { ko: '더 보기', en: 'Load more' },
// 선택 / 내보내기 / 삭제 (Library)
'sel.count': { ko: '{n}개 선택', en: '{n} selected' },
'sel.selectAll': { ko: '전체 선택', en: 'Select all' },
'sel.clear': { ko: '선택 해제', en: 'Clear' },
'sel.export': { ko: '폴더로 내보내기', en: 'Export to folder' },
'sel.delete': { ko: '삭제', en: 'Delete' },
'sel.hint': { ko: '사진을 클릭해 선택하세요.', en: 'Click photos to select.' },
'sel.confirmDelete': {
ko: '선택한 {n}개를 휴지통으로 보냅니다.\n(원본 파일이 휴지통으로 이동되며 복구할 수 있습니다)\n계속할까요?',
en: 'Move {n} selected items to the Recycle Bin?\n(Originals are moved to the trash and can be restored.)\nContinue?'
},
'sel.deleted': { ko: '{n}개를 휴지통으로 이동했습니다.', en: 'Moved {n} to the trash.' },
'sel.exported': {
ko: '{n}개를 내보냈습니다.\n{dest}',
en: 'Exported {n} items.\n{dest}'
},
// 미디어 종류 (사진/영상 분리)
'media.all': { ko: '전체', en: 'All' },
'media.image': { ko: '사진', en: 'Photos' },
'media.video': { ko: '영상', en: 'Videos' },
// 컬링 필터 / 품질 플래그 (Phase 1)
'cull.all': { ko: '전체', en: 'All' },
'cull.candidate': { ko: '고품질 후보', en: 'Candidates' },
'cull.rejected': { ko: '제외 후보', en: 'Rejected' },
'flag.candidate': { ko: '후보', en: 'Keep' },
'cull.all': { ko: '품질 전체', en: 'All' },
'cull.candidate': { ko: '잘 나온 사진', en: 'Good shots' },
'cull.rejected': { ko: '걸러낼 사진', en: 'To cull' },
'cull.help': {
ko: '흐리거나 눈 감은·노출 나쁜 사진을 자동으로 표시해, 잘 나온 사진만 빠르게 고를 수 있게 도와줍니다.',
en: 'Auto-flags blurry / closed-eye / badly-exposed shots so you can quickly keep the good ones.'
},
'flag.candidate': { ko: '좋음', en: 'Good' },
'flag.blurry': { ko: '흐림', en: 'Blurry' },
'flag.eyesClosed': { ko: '눈감음', en: 'Eyes closed' },
'flag.badExposure': { ko: '노출', en: 'Exposure' },
@@ -209,6 +284,10 @@ export const MESSAGES: Table = {
},
'cull.reset': { ko: '기본값', en: 'Reset' },
'cull.ratingMin': { ko: '별점', en: 'Rating' },
// 컬렉션 패싯 (Phase B)
'col.year': { ko: '연도', en: 'Year' },
'col.camera': { ko: '카메라', en: 'Camera' },
'col.label': { ko: '색라벨', en: 'Label' },
// 메뉴
'menu.file': { ko: '파일', en: 'File' },
@@ -235,6 +314,7 @@ export const MESSAGES: Table = {
'menu.theme.dark': { ko: '다크 모드', en: 'Dark' },
'menu.theme.light': { ko: '라이트 모드', en: 'Light' },
'menu.language': { ko: '언어', en: 'Language' },
'menu.easyMode': { ko: '쉬운 모드 (큰 화면)', en: 'Easy mode (large)' },
'menu.guide': { ko: '사용 방법 가이드', en: 'User Guide' },
'menu.about': { ko: '정보', en: 'About' }
}
+68 -1
View File
@@ -20,6 +20,8 @@ export interface Settings {
onboarded: boolean
/** 컬링 품질 임계값 */
qualityThresholds: QualityThresholds
/** 4050 쉬운 모드(대형 UI/구어체) */
easyMode: boolean
}
/** 등록된 인물 프로필 */
@@ -98,6 +100,11 @@ export interface AssetRecord {
height: number | null
exifYear: string | null
exifMonth: string | null
/** GPS 좌표 (없으면 null) */
gpsLat: number | null
gpsLon: number | null
/** 카메라 모델 (없으면 null) */
camera: string | null
indexedAt: number
}
@@ -159,11 +166,35 @@ export type QualityFilter =
| 'eyesClosed'
| 'badExposure'
/** 미디어 종류 필터 (사진/영상 분리) */
export type MediaFilter = 'all' | 'image' | 'video'
/** 자산 조회 옵션 */
export interface AssetQuery {
filter: QualityFilter
/** 미디어 종류 */
kind: MediaFilter
/** 최소 별점 (0이면 무시) */
ratingMin: number
/** 연도 필터 (예: "2024") */
year?: string | null
/** 카메라 모델 필터 */
camera?: string | null
/** 색라벨 필터 */
label?: ColorLabel
}
/** 컬렉션 패싯 한 항목 */
export interface FacetItem {
value: string
count: number
}
/** 컬렉션 필터용 패싯 집계 */
export interface Facets {
years: FacetItem[]
cameras: FacetItem[]
labels: FacetItem[]
}
/** 검색 색인(임베딩) 생성 진행률 */
@@ -182,6 +213,22 @@ export interface SearchSummary {
count: number
}
/** 지도 마커용 GPS 자산 */
export interface GpsAsset {
id: number
contentHash: string
path: string
gpsLat: number
gpsLon: number
}
/** 유사 이미지 그룹 (스마트 그룹화 / 근접 중복 정화) */
export interface AssetGroup {
/** 보관 추천(품질 최고) 자산 id */
bestId: number
members: IndexedAsset[]
}
/** 검색 색인 상태 */
export interface SearchStatus {
/** 임베딩 보유 수 */
@@ -314,8 +361,14 @@ export interface ExposedApi {
index: {
run(): Promise<void>
cancel(): Promise<void>
/** 색인된 자산 목록 (최근순, 페이지네이션, 품질/별점 필터) */
/** 색인된 자산 목록 (최근순, 페이지네이션, 품질/별점/연도/카메라/라벨 필터) */
assets(offset: number, limit: number, query: AssetQuery): Promise<IndexedAsset[]>
/** 쿼리에 매칭되는 전체 자산 id (전체 선택용) */
assetIds(query: AssetQuery): Promise<number[]>
/** 컬렉션 패싯(연도/카메라/색라벨 집계) */
facets(): Promise<Facets>
/** 선택 자산을 폴더로 내보내기(복사). 폴더 선택 다이얼로그 후 복사. 취소 시 null */
export(assetIds: number[]): Promise<{ count: number; dest: string } | null>
/** 별점(0~5) 설정 */
setRating(assetId: number, rating: number): Promise<void>
/** 색라벨 설정 */
@@ -330,6 +383,20 @@ export interface ExposedApi {
/** 자연어 쿼리 → 유사도 상위 결과 */
query(text: string): Promise<IndexedAsset[]>
}
/** 지도 / 연관 탐색 (Phase C) */
map: {
/** GPS 좌표가 있는 자산 목록(지도 마커) */
assets(): Promise<GpsAsset[]>
/** 특정 사진과 관련된 사진(장소+시간+유사도) */
related(assetId: number): Promise<IndexedAsset[]>
}
/** 스마트 그룹화 / 자가정화 (Phase 3) */
groups: {
/** 임베딩 유사도(threshold) 이상으로 묶인 그룹(크기 2+) 반환 */
build(threshold: number): Promise<AssetGroup[]>
/** 선택 자산을 OS 휴지통으로 이동하고 인덱스에서 제거. 처리 수 반환 */
trash(assetIds: number[]): Promise<number>
}
/** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */
getPathForFile(file: unknown): string
on<E extends RendererEventName>(