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:
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user