Library workspace upgrades: darkroom viewer, mosaic, geotagging, file explorer

- darkroom-style fullscreen viewer: left info panel, rating/color-label toolbar,
  bottom filmstrip, keyboard nav (Esc / arrows), placeholder on load failure
- thumbnail density slider (contact-sheet) + photo mosaic generator (target -> tiles)
- lighttable-style hover info preview (no click needed)
- map drag & drop geotagging (saved to index only; originals untouched)
- file explorer: parallel drive scan + timeout, create/delete(trash)/move folders;
  index reparent on move and cleanup on delete (single source of truth)
- library: photos-before-videos ordering; drag range select/deselect;
  native image drag disabled so sweep-select works
- responsive sidebar font scaling; no-wrap filter labels; media protocol CORS + video Range

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:47:26 +09:00
parent 3e73967c7b
commit d2546f9cbf
25 changed files with 2358 additions and 434 deletions
+14
View File
@@ -101,6 +101,18 @@ export const IPC = {
INDEX_ASSET_IDS: 'index:assetIds',
INDEX_FACETS: 'index:facets',
INDEX_EXPORT: 'index:export',
INDEX_EXIF: 'index:exif',
// 태깅 / 메타데이터 (darktable)
TAGS_LIST: 'tags:list',
TAGS_FOR_ASSET: 'tags:forAsset',
TAGS_ATTACH: 'tags:attach',
TAGS_DETACH: 'tags:detach',
META_GET: 'meta:get',
META_SET: 'meta:set',
FS_LIST: 'fs:list',
FS_MKDIR: 'fs:mkdir',
FS_TRASH: 'fs:trash',
FS_MOVE: 'fs:move',
INDEX_SET_RATING: 'index:setRating',
INDEX_SET_LABEL: 'index:setLabel',
// 검색 (Phase 2)
@@ -116,6 +128,8 @@ export const IPC = {
// 지도 / 연관 탐색 (Phase C)
MAP_ASSETS: 'map:assets',
MAP_RELATED: 'map:related',
MAP_UNTAGGED: 'map:untagged',
MAP_SET_GPS: 'map:setGps',
// Main → UI (send)
JOB_PROGRESS: 'job:progress',
JOB_FILE_PROCESSED: 'job:fileProcessed',
+104 -3
View File
@@ -85,6 +85,30 @@ export const MESSAGES: Table = {
'profile.analyzing': { ko: '분석 중', en: 'Analyzing' },
'profile.deletePhoto': { ko: '이 사진 삭제', en: 'Delete this photo' },
// 파일 탐색기 (정리 탭 사이드바)
'explorer.title': { ko: '파일 탐색기', en: 'File Explorer' },
'explorer.asSource': { ko: '소스로', en: 'As source' },
'explorer.asOutput': { ko: '출력으로', en: 'As output' },
'explorer.copy': { ko: '복사', en: 'Copy' },
'explorer.copied': { ko: '경로 복사됨', en: 'Path copied' },
'explorer.hint': {
ko: '폴더를 끌어서 경로 칸에 놓거나, 다른 폴더 위에 놓아 이동할 수 있어요.',
en: 'Drag a folder onto a path field, or onto another folder to move it.'
},
'explorer.newFolder': { ko: '새 폴더', en: 'New folder' },
'explorer.delete': { ko: '삭제', en: 'Delete' },
'explorer.newFolderPrompt': { ko: '새 폴더 이름:', en: 'New folder name:' },
'explorer.confirmDelete': {
ko: "'{name}' 폴더를 휴지통으로 보낼까요?",
en: "Move folder '{name}' to the Recycle Bin?"
},
'explorer.confirmMove': {
ko: "'{src}' 을(를) '{dest}' 안으로 이동할까요?",
en: "Move '{src}' into '{dest}'?"
},
'explorer.opFailed': { ko: '작업 실패: {msg}', en: 'Operation failed: {msg}' },
'explorer.loading': { ko: '드라이브 검색 중…', en: 'Scanning drives…' },
// 2. 폴더
'folder.section': { ko: '2. 폴더 선택', en: '2. Select Folders' },
'folder.source': { ko: '정리할 폴더 (소스)', en: 'Folder to organize (source)' },
@@ -171,6 +195,15 @@ export const MESSAGES: Table = {
},
'map.selectHint': { ko: '지도에서 사진을 클릭하세요.', en: 'Click a photo on the map.' },
'map.loading': { ko: '불러오는 중…', en: 'Loading…' },
'map.geotag': { ko: '지오태깅', en: 'Geotagging' },
'map.geotagHint': {
ko: 'GPS가 없는 사진을 지도 위로 끌어다 놓으면 위치가 지정됩니다. (원본 파일은 그대로, 색인에만 저장)',
en: 'Drag a photo without GPS onto the map to set its location. (Saved to the index only; original files unchanged.)'
},
'map.untaggedCount': { ko: '위치 없는 사진 {n}장', en: '{n} without location' },
'map.allTagged': { ko: '모든 사진에 위치가 있습니다.', en: 'All photos already have a location.' },
'map.tagged': { ko: '위치를 지정했습니다.', en: 'Location set.' },
'map.dropHere': { ko: '여기에 놓아 위치 지정', en: 'Drop here to set location' },
// 그룹화 / 자가정화 (Phase 3)
'groups.section': { ko: '유사 사진 그룹 · 자가정화', en: 'Similar groups · Cleanup' },
@@ -239,6 +272,39 @@ export const MESSAGES: Table = {
en: 'No indexed photos. Add a folder and run indexing.'
},
'lib.loadMore': { ko: '더 보기', en: 'Load more' },
'lib.density': { ko: '크기', en: 'Size' },
'lib.densityHint': {
ko: '썸네일 크기 조절 — 작게 하면 폴더 전체를 한눈에(컨택트시트), 크게 하면 자세히 봅니다.',
en: 'Thumbnail size — smaller shows the whole folder at a glance, larger shows detail.'
},
'viewer.back': { ko: '뒤로', en: 'Back' },
'viewer.hint': {
ko: '← → 이동 · Esc 닫기',
en: '← → navigate · Esc to close'
},
'viewer.videoUnsupported': {
ko: '이 영상 형식은 미리보기를 지원하지 않습니다.',
en: 'Preview is not available for this video format.'
},
// 포토모자이크 (대표 사진을 라이브러리 사진들로 재구성)
'panel.mosaic': { ko: '포토모자이크', en: 'Photo mosaic' },
'mosaic.make': { ko: '🧩 이 사진으로 모자이크', en: '🧩 Make mosaic' },
'mosaic.hint': {
ko: '대표 사진을 한 번 클릭(선택)한 뒤 버튼을 누르세요. 라이브러리 사진들이 작은 타일이 되어 그 사진을 재현합니다.',
en: 'Click a target photo to select it, then press the button. Library photos become tiles that recreate it.'
},
'mosaic.needTarget': { ko: '대표 사진을 먼저 선택하세요.', en: 'Select a target photo first.' },
'mosaic.target': { ko: '대상', en: 'Target' },
'mosaic.tiles': { ko: '타일 {n}장', en: '{n} tiles' },
'mosaic.resolution': { ko: '해상도', en: 'Resolution' },
'mosaic.blend': { ko: '색 보정', en: 'Color blend' },
'mosaic.unique': { ko: '중복 줄이기', en: 'Reduce repeats' },
'mosaic.save': { ko: 'PNG 저장', en: 'Save PNG' },
'mosaic.building': { ko: '타일 색상 분석 중…', en: 'Analyzing tile colors…' },
'mosaic.tooFew': {
ko: '타일이 너무 적습니다. 색인된 사진이 많을수록 모자이크가 정교해집니다.',
en: 'Too few tiles. The more indexed photos, the finer the mosaic.'
},
// 선택 / 내보내기 / 삭제 (Library)
'sel.count': { ko: '{n}개 선택', en: '{n} selected' },
@@ -263,9 +329,11 @@ export const MESSAGES: Table = {
'media.video': { ko: '영상', en: 'Videos' },
// 컬링 필터 / 품질 플래그 (Phase 1)
'cull.all': { ko: '품질 전체', en: 'All' },
'cull.candidate': { ko: '잘 나온 사진', en: 'Good shots' },
'cull.rejected': { ko: '걸러낼 사진', en: 'To cull' },
'filter.kind': { ko: '종류', en: 'Type' },
'filter.quality': { ko: '품질', en: 'Quality' },
'cull.all': { ko: '전체', en: 'All' },
'cull.candidate': { ko: '잘나온', en: 'Good' },
'cull.rejected': { ko: '걸러낼', en: 'Cull' },
'cull.help': {
ko: '흐리거나 눈 감은·노출 나쁜 사진을 자동으로 표시해, 잘 나온 사진만 빠르게 고를 수 있게 도와줍니다.',
en: 'Auto-flags blurry / closed-eye / badly-exposed shots so you can quickly keep the good ones.'
@@ -288,6 +356,39 @@ export const MESSAGES: Table = {
'col.year': { ko: '연도', en: 'Year' },
'col.camera': { ko: '카메라', en: 'Camera' },
'col.label': { ko: '색라벨', en: 'Label' },
'col.folder': { ko: '폴더', en: 'Folder' },
// darktable식 3-패널 (라이브러리 워크스페이스)
'panel.library': { ko: '라이브러리', en: 'Library' },
'panel.explorer': { ko: '파일 탐색기', en: 'Folders' },
'panel.collections': { ko: '컬렉션', en: 'Collections' },
'panel.filters': { ko: '필터', en: 'Filters' },
'panel.thresholds': { ko: '품질 임계값', en: 'Quality thresholds' },
'panel.info': { ko: '이미지 정보', en: 'Image information' },
'panel.selection': { ko: '선택', en: 'Selection' },
'panel.actions': { ko: '작업', en: 'Actions' },
'panel.rate': { ko: '평가', en: 'Rating' },
'panel.tagging': { ko: '태깅', en: 'Tags' },
'panel.metadata': { ko: '메타데이터', en: 'Metadata' },
'info.none': { ko: '사진을 클릭하면 정보가 표시됩니다.', en: 'Click a photo to see info.' },
'info.file': { ko: '파일', en: 'File' },
'info.date': { ko: '촬영', en: 'Date' },
'info.camera': { ko: '카메라', en: 'Camera' },
'info.lens': { ko: '렌즈', en: 'Lens' },
'info.exposure': { ko: '노출', en: 'Exposure' },
'info.iso': { ko: 'ISO', en: 'ISO' },
'info.size': { ko: '크기', en: 'Size' },
'info.gps': { ko: '위치', en: 'GPS' },
'info.folder': { ko: '폴더', en: 'Folder' },
'tag.placeholder': { ko: '태그 입력 후 Enter', en: 'Type a tag, press Enter' },
'tag.attachHint': { ko: '선택한 사진(없으면 클릭한 사진)에 부착됩니다.', en: 'Attached to the selection (or the focused photo).' },
'tag.assetTags': { ko: '이 사진의 태그', en: "This photo's tags" },
'meta.title': { ko: '제목', en: 'Title' },
'meta.description': { ko: '설명', en: 'Description' },
'meta.creator': { ko: '작성자', en: 'Creator' },
'meta.save': { ko: '저장', en: 'Save' },
'meta.saved': { ko: '저장됨', en: 'Saved' },
'meta.selectFirst': { ko: '사진을 먼저 클릭하세요.', en: 'Click a photo first.' },
// 메뉴
'menu.file': { ko: '파일', en: 'File' },
+68
View File
@@ -169,6 +169,40 @@ export type QualityFilter =
/** 미디어 종류 필터 (사진/영상 분리) */
export type MediaFilter = 'all' | 'image' | 'video'
/** 이미지 상세 정보(온디맨드 EXIF 조회) */
export interface ExifInfo {
dateTime: string | null
make: string | null
model: string | null
lens: string | null
fNumber: number | null
exposureTime: string | null
iso: number | null
focalLength: number | null
width: number | null
height: number | null
gps: { lat: number; lon: number } | null
}
/** 사용자 메타데이터(편집 가능) */
export interface AssetMetadata {
title: string
description: string
creator: string
}
/** 태그 집계 항목 */
export interface TagItem {
name: string
count: number
}
/** 파일 탐색기 디렉터리 항목 */
export interface FsEntry {
name: string
path: string
}
/** 자산 조회 옵션 */
export interface AssetQuery {
filter: QualityFilter
@@ -182,6 +216,10 @@ export interface AssetQuery {
camera?: string | null
/** 색라벨 필터 */
label?: ColorLabel
/** 폴더 필터 (파일 탐색기) */
folder?: string | null
/** 태그 필터 */
tag?: string | null
}
/** 컬렉션 패싯 한 항목 */
@@ -195,6 +233,7 @@ export interface Facets {
years: FacetItem[]
cameras: FacetItem[]
labels: FacetItem[]
folders: FacetItem[]
}
/** 검색 색인(임베딩) 생성 진행률 */
@@ -369,11 +408,26 @@ export interface ExposedApi {
facets(): Promise<Facets>
/** 선택 자산을 폴더로 내보내기(복사). 폴더 선택 다이얼로그 후 복사. 취소 시 null */
export(assetIds: number[]): Promise<{ count: number; dest: string } | null>
/** 자산의 상세 EXIF 정보(온디맨드) */
exif(assetId: number): Promise<ExifInfo>
/** 별점(0~5) 설정 */
setRating(assetId: number, rating: number): Promise<void>
/** 색라벨 설정 */
setLabel(assetId: number, label: ColorLabel): Promise<void>
}
/** 태깅 (darktable tagging) */
tags: {
list(): Promise<TagItem[]>
forAsset(assetId: number): Promise<string[]>
/** 선택 자산들에 태그 부착 */
attach(assetIds: number[], name: string): Promise<void>
detach(assetId: number, name: string): Promise<void>
}
/** 메타데이터 편집기 (제목/설명/작성자) */
meta: {
get(assetId: number): Promise<AssetMetadata>
set(assetId: number, data: AssetMetadata): Promise<void>
}
/** 자연어/유사 검색 (Phase 2) */
search: {
/** 검색 색인(CLIP 임베딩) 생성 시작 */
@@ -389,6 +443,10 @@ export interface ExposedApi {
assets(): Promise<GpsAsset[]>
/** 특정 사진과 관련된 사진(장소+시간+유사도) */
related(assetId: number): Promise<IndexedAsset[]>
/** GPS가 없는 자산(지오태깅 드래그 소스) */
untagged(): Promise<IndexedAsset[]>
/** 자산에 GPS 좌표 부여(지오태깅) */
setGps(id: number, lat: number, lon: number): Promise<void>
}
/** 스마트 그룹화 / 자가정화 (Phase 3) */
groups: {
@@ -397,6 +455,16 @@ export interface ExposedApi {
/** 선택 자산을 OS 휴지통으로 이동하고 인덱스에서 제거. 처리 수 반환 */
trash(assetIds: number[]): Promise<number>
}
/** 파일 탐색기: 디렉터리 나열 (path 없으면 드라이브 목록) */
fs: {
list(path: string | null): Promise<FsEntry[]>
/** 새 폴더 생성 (parent 안에 name) */
mkdir(parent: string, name: string): Promise<FsEntry>
/** 폴더를 휴지통으로 이동 */
trash(path: string): Promise<void>
/** 폴더 이동 (src → destDir 안으로) */
move(src: string, destDir: string): Promise<FsEntry>
}
/** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */
getPathForFile(file: unknown): string
on<E extends RendererEventName>(