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:
@@ -0,0 +1,18 @@
|
||||
# PhotoAI Chronicle Records
|
||||
|
||||
## Project
|
||||
- ID: photoai
|
||||
- Root: E:\Wiki\PhotoAI
|
||||
- Record root: E:\Wiki\PhotoAI\docs\records\PhotoAI
|
||||
- Detail level: standard
|
||||
|
||||
## Purpose
|
||||
Auto-created by Project Architecture activation.
|
||||
|
||||
## Folders
|
||||
- `planning/`
|
||||
- `discussions/`
|
||||
- `decisions/`
|
||||
- `development/`
|
||||
- `bugs/`
|
||||
- `retrospectives/`
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"projectId": "photoai",
|
||||
"projectName": "PhotoAI",
|
||||
"projectRoot": "E:\\Wiki\\PhotoAI",
|
||||
"recordRoot": "E:\\Wiki\\PhotoAI\\docs\\records\\PhotoAI",
|
||||
"description": "Auto-created by Project Architecture activation.",
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-06-01T04:16:09.722Z",
|
||||
"updatedAt": "2026-06-02T05:39:25.175Z"
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# ADR: 기업모드로 다시 너가 준 내용을 한번더 다같이 논의 하고 최종 결과물을 줄 수 있어?
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
기업모드로 다시 너가 준 내용을 한번더 다같이 논의 하고 최종 결과물을 줄 수 있어?
|
||||
|
||||
## Decision
|
||||
결론부터 말씀씀: 네, 가능합니다. 제가 제안한 기능들을 단순한 아이디어 나열이 아니라, '기술적 타당성', '구현 난이도', '기존 아키텍처와의 정합성'을 기준으로 재검토하여, 팀원들과 즉시 논의하고 결정할 수 있는 '의사결정용 검토 안건(Decision Memo)' 형태로 정리해 드릴 수 있습니다. 이렇게 하면 단순한 브레인스토밍이 아니라, 실제 프로젝트의 로드맵에 반영할 수 있는 '확정된 계획'을 만드는 과정이 됩니다. --- 🛠️ 기업 모드 기반: 기능 확장 안건 검토 프로세스 제가 다음과 같은 구조로 내용을 재구성하여 드릴 테니, 이를 바탕으로 팀원들과 논의를 시작하시면 됩니다. 1. [검토 단계] 기술적/비용적 타당성 분석 (Feasibility Study) - 기존 로직과의 충돌 확인: 예를 들어 '자연어 검색'을 도입할 때, 현재의 `inference` 모듈이 CLIP 모델을 감당할 수 있는지, 메모리 점유율은 어떻게 변할지 분석합니다. - 리소스 제약 검토: 로컬 ...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Development Log: E:\Wiki\PhotoAI 이 프로젝트를 지금 개발 중이야. 사진 관리, 정리하는 기능성 유틸리티인데, 사진 정리할때 필요한 기능을 추가 할 ...
|
||||
|
||||
## Purpose
|
||||
Record the implementation or verification outcome from the current conversation.
|
||||
|
||||
## Implementation Summary
|
||||
결론부터 말씀씀: 현재 PhotoAI의 핵심 가치인 '로컬 기반 자동화(Face/EXIF)'를 유지하면서도, 사용자의 개입을 최소화하고 정리된 결과물의 품질을 높이는 '선별 보조(Culling Support)'와 (Post-processing) 메타데이터 관리 기능에 집중하는 것이 좋습니다. 현재 프로젝트의 아키텍처와 목적성을 고려할 때, 단순한 기능 확장이 아닌 '정리 효율'을 극대화할 수 있는 방향으로 제안합니다. --- 1. 지능형 선별 및 품질 관리 (Smart Culling & Quality Control) 사진 정리의 핵심은 '좋은 사진을 남기는 것'입니다. - 이미지 품질 점수 자동 부여 (Aesthetic Scoring): 단순한 초점(Blur) 체크를 넘어, AI 모델을 통해 사진의 미적 점수를 매깁니다. 점수가 낮은 사진은 `Unsorted` 폴러로 자동 분류하거나 삭제 대상 후보로 지정합니다. - 중복 및 유사 이미지 클러스터링 (Near-duplicate Det...
|
||||
|
||||
## Architecture
|
||||
Captured automatically from the assistant response and active project context.
|
||||
|
||||
## Changed Files
|
||||
- docs/PRD.md
|
||||
- docs/NEXTGEN_REVIEW.md
|
||||
|
||||
## Dependency Notes
|
||||
No new dependency note was captured automatically.
|
||||
|
||||
## Bugs
|
||||
No bugs recorded.
|
||||
|
||||
## Lessons
|
||||
- Automatic project records should be generated in the background when the turn contains durable project knowledge.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Development Log: E:\Wiki\PhotoAI 코딩 리뷰하고 설계적으로 더 최적화 할 수 있는 부분이 있는지 분석해줘.
|
||||
|
||||
## Purpose
|
||||
Record the implementation or verification outcome from the current conversation.
|
||||
|
||||
## Implementation Summary
|
||||
현재 정보만으로는 전체 코드의 로직을 모두 파악할 수 없으므로, 제공된 아키텍처 컨텍스트와 파일 구조를 바탕으로 구조적 안정성과 확장성 관점에서 리뷰하겠습니다. 결론부터 말씀씀: 현재 PhotoAI는 3-Tier(Main/Renderer/Inference) 분리 구조를 통해 Electron의 고질적인 문제인 'UI 프리징'을 잘 피하고 있으나, 데이터 흐름의 단일화(Single Source of Truth)와 추론 프로세스의 동기화 관리 측면에서 최적화가 필요합니다. --- 🔍 아키텍처 리뷰 및 최적화 제안 1. 데이터 정합성: Single Source of Truth (SSOT) 강화 현재 `src/main/indexDb.ts`, `src/renderer/store.ts`, `src/main/libraryStore.ts` 등 여러 곳에서 상태를 관리하고 있습니다. 파일 이동(Move)이나 삭제(Delete) 발생 시, DB와 Renderer Store 간의 동기화가 깨질 위험이 ...
|
||||
|
||||
## Architecture
|
||||
Captured automatically from the assistant response and active project context.
|
||||
|
||||
## Changed Files
|
||||
- src/main/indexDb.ts
|
||||
- src/renderer/store.ts
|
||||
- src/main/libraryStore.ts
|
||||
- src/main/fileOps.ts
|
||||
- src/main/inferenceBridge.ts
|
||||
- src/main/thumbnails.ts
|
||||
- src/inference/pathToFileUrl.ts
|
||||
- src/renderer/components/LibraryView.tsx
|
||||
|
||||
## Dependency Notes
|
||||
No new dependency note was captured automatically.
|
||||
|
||||
## Bugs
|
||||
No bugs recorded.
|
||||
|
||||
## Lessons
|
||||
- Automatic project records should be generated in the background when the turn contains durable project knowledge.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Project Profile
|
||||
|
||||
## Project Name
|
||||
PhotoAI
|
||||
|
||||
## Description
|
||||
Auto-created by Project Architecture activation.
|
||||
|
||||
## Project Root
|
||||
E:\Wiki\PhotoAI
|
||||
|
||||
## Record Root
|
||||
E:\Wiki\PhotoAI\docs\records\PhotoAI
|
||||
|
||||
## Core Purpose
|
||||
Not captured yet.
|
||||
|
||||
## Target Users
|
||||
Not captured yet.
|
||||
|
||||
## Avoid Directions
|
||||
Not captured yet.
|
||||
|
||||
## Record Detail Level
|
||||
standard
|
||||
|
||||
## Created
|
||||
2026-06-01T04:16:09.722Z
|
||||
|
||||
## Updated
|
||||
2026-06-02T02:04:14.970Z
|
||||
@@ -0,0 +1,13 @@
|
||||
# Project Timeline
|
||||
|
||||
## 2026-06-01
|
||||
- Project Chronicle record folder initialized for PhotoAI.
|
||||
|
||||
## 2026-06-02
|
||||
- Auto development record created: development\2026-06-02_e-wiki-photoai-이-프로젝트를-지금-개발-중이야-사진-관리-정리하는-기능성-유틸리티인데-사진-정리_implementation.md
|
||||
|
||||
## 2026-06-02
|
||||
- Auto decision record created: decisions\ADR-0001-기업모드로-다시-너가-준-내용을-한번더-다같이-논의-하고-최종-결과물을-줄-수-있어.md
|
||||
|
||||
## 2026-06-02
|
||||
- Auto development record created: development\2026-06-02_e-wiki-photoai-코딩-리뷰하고-설계적으로-더-최적화-할-수-있는-부분이-있는지-분석해줘_implementation.md
|
||||
@@ -96,6 +96,10 @@ export async function videoThumbnail(
|
||||
const video = document.createElement('video')
|
||||
video.muted = true
|
||||
video.preload = 'auto'
|
||||
video.setAttribute('playsinline', '')
|
||||
// 일부 환경에서 off-DOM 비디오는 프레임 디코딩이 안 되므로 화면 밖에 부착
|
||||
video.style.cssText = 'position:fixed;left:-10000px;top:0;width:2px;height:2px;opacity:0;'
|
||||
document.body.appendChild(video)
|
||||
video.src = pathToFileUrl(path)
|
||||
|
||||
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
|
||||
@@ -107,19 +111,21 @@ export async function videoThumbnail(
|
||||
try {
|
||||
await withTimeout(
|
||||
new Promise<void>((res, rej) => {
|
||||
video.onloadedmetadata = () => res()
|
||||
video.onerror = () => rej(new Error('영상 로드 실패'))
|
||||
video.onloadeddata = () => res()
|
||||
video.onerror = () => rej(new Error('영상 로드/디코딩 실패'))
|
||||
}),
|
||||
20000
|
||||
)
|
||||
|
||||
// 너무 앞(검은 화면) 회피 위해 1초 또는 절반 지점으로 seek
|
||||
const seekTo = Math.min(1, (video.duration || 2) / 2)
|
||||
// 요청: 약 5초 지점 프레임 사용 (영상이 짧으면 끝부분/절반)
|
||||
const dur = Number.isFinite(video.duration) ? video.duration : 0
|
||||
const seekTo = dur > 5 ? 5 : dur > 0.2 ? Math.max(0, dur - 0.1) : 0
|
||||
await withTimeout(
|
||||
new Promise<void>((res, rej) => {
|
||||
video.onseeked = () => res()
|
||||
video.onerror = () => rej(new Error('영상 seek 실패'))
|
||||
video.currentTime = seekTo
|
||||
if (Math.abs(video.currentTime - seekTo) < 0.001) res()
|
||||
else video.currentTime = seekTo
|
||||
}),
|
||||
20000
|
||||
)
|
||||
@@ -145,6 +151,7 @@ export async function videoThumbnail(
|
||||
video.src = ''
|
||||
video.removeAttribute('src')
|
||||
video.load()
|
||||
video.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+59
-1
@@ -1,6 +1,64 @@
|
||||
import exifr from 'exifr'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { CaptureDate } from '@shared/types'
|
||||
import type { CaptureDate, ExifInfo } from '@shared/types'
|
||||
|
||||
function formatExposure(t: number): string {
|
||||
if (!t || t <= 0) return ''
|
||||
return t < 1 ? `1/${Math.round(1 / t)}s` : `${t}s`
|
||||
}
|
||||
|
||||
/** 상세 EXIF를 온디맨드로 읽어 이미지 정보 패널에 표시 (실패 시 빈 값) */
|
||||
export async function readFullExif(path: string): Promise<ExifInfo> {
|
||||
let d: Record<string, unknown> = {}
|
||||
try {
|
||||
d =
|
||||
(await exifr.parse(path, {
|
||||
pick: [
|
||||
'DateTimeOriginal',
|
||||
'CreateDate',
|
||||
'Make',
|
||||
'Model',
|
||||
'LensModel',
|
||||
'FNumber',
|
||||
'ExposureTime',
|
||||
'ISO',
|
||||
'ISOSpeedRatings',
|
||||
'FocalLength',
|
||||
'ExifImageWidth',
|
||||
'ExifImageHeight',
|
||||
'PixelXDimension',
|
||||
'PixelYDimension'
|
||||
]
|
||||
})) || {}
|
||||
} catch {
|
||||
/* no exif */
|
||||
}
|
||||
let gps: ExifInfo['gps'] = null
|
||||
try {
|
||||
const g = await exifr.gps(path)
|
||||
if (g && typeof g.latitude === 'number' && typeof g.longitude === 'number') {
|
||||
gps = { lat: g.latitude, lon: g.longitude }
|
||||
}
|
||||
} catch {
|
||||
/* no gps */
|
||||
}
|
||||
const dt = (d.DateTimeOriginal ?? d.CreateDate) as Date | string | undefined
|
||||
const num = (v: unknown): number | null => (typeof v === 'number' ? v : null)
|
||||
const str = (v: unknown): string | null => (v ? String(v).trim() : null)
|
||||
return {
|
||||
dateTime: dt instanceof Date ? dt.toLocaleString() : dt ? String(dt) : null,
|
||||
make: str(d.Make),
|
||||
model: str(d.Model),
|
||||
lens: str(d.LensModel),
|
||||
fNumber: num(d.FNumber),
|
||||
exposureTime: typeof d.ExposureTime === 'number' ? formatExposure(d.ExposureTime) : null,
|
||||
iso: num(d.ISO) ?? num(d.ISOSpeedRatings),
|
||||
focalLength: num(d.FocalLength),
|
||||
width: num(d.ExifImageWidth) ?? num(d.PixelXDimension),
|
||||
height: num(d.ExifImageHeight) ?? num(d.PixelYDimension),
|
||||
gps
|
||||
}
|
||||
}
|
||||
|
||||
function toYearMonth(d: Date, source: CaptureDate['source']): CaptureDate {
|
||||
const year = String(d.getFullYear())
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { readdir, access, mkdir, rename, cp } from 'node:fs/promises'
|
||||
import { constants as FS } from 'node:fs'
|
||||
import { join, basename, dirname } from 'node:path'
|
||||
import { shell } from 'electron'
|
||||
import type { FsEntry } from '@shared/types'
|
||||
|
||||
/** 느린(미디어 없는 카드리더/끊긴 네트워크) 드라이브가 전체를 막지 않도록 타임아웃 */
|
||||
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms))
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 탐색기용 디렉터리 나열.
|
||||
* - path 없음 → 드라이브 목록(Windows: 접근 가능한 A~Z, 병렬 탐색)
|
||||
* - path 있음 → 해당 폴더의 하위 디렉터리만 (이름순)
|
||||
* 권한/오류는 빈 배열로 처리.
|
||||
*/
|
||||
export async function listDir(path: string | null): Promise<FsEntry[]> {
|
||||
if (!path) {
|
||||
if (process.platform === 'win32') {
|
||||
// A~Z를 병렬로 검사 → 총 소요시간이 '가장 느린 드라이브 하나'로 수렴 (순차 합산 X)
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
const results = await Promise.all(
|
||||
letters.map(async (letter) => {
|
||||
const root = `${letter}:\\`
|
||||
try {
|
||||
await withTimeout(access(root, FS.R_OK), 700)
|
||||
return { name: `${letter}:`, path: root } as FsEntry
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
return results.filter((d): d is FsEntry => d !== null)
|
||||
}
|
||||
return [{ name: '/', path: '/' }]
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await readdir(path, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('$'))
|
||||
.map((e) => ({ name: e.name, path: join(path, e.name) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 폴더 생성. 이미 있으면 throw. 생성된 항목 반환 */
|
||||
export async function makeDir(parent: string, name: string): Promise<FsEntry> {
|
||||
const clean = name.trim()
|
||||
if (!clean || /[\\/:*?"<>|]/.test(clean)) throw new Error('잘못된 폴더 이름')
|
||||
const target = join(parent, clean)
|
||||
await mkdir(target) // recursive 아님 → 이미 존재하면 EEXIST throw
|
||||
return { name: clean, path: target }
|
||||
}
|
||||
|
||||
/** 폴더를 휴지통으로 이동 (영구 삭제 아님 → 복구 가능) */
|
||||
export async function trashDir(path: string): Promise<void> {
|
||||
await shell.trashItem(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더 이동: src 를 destDir 안으로. 같은 드라이브면 rename(즉시),
|
||||
* 다른 드라이브면 복사 후 원본 휴지통 이동.
|
||||
* 자기 자신/하위로의 이동은 거부.
|
||||
*/
|
||||
export async function moveDir(src: string, destDir: string): Promise<FsEntry> {
|
||||
const name = basename(src)
|
||||
const target = join(destDir, name)
|
||||
const norm = (p: string): string => p.replace(/[\\/]+$/, '').toLowerCase()
|
||||
if (norm(src) === norm(destDir)) throw new Error('같은 위치')
|
||||
if (norm(dirname(src)) === norm(destDir)) throw new Error('이미 해당 폴더에 있음')
|
||||
// destDir 가 src 자신이거나 그 하위면 거부 (무한/자기참조 방지)
|
||||
if (norm(destDir) === norm(src) || norm(destDir).startsWith(norm(src) + '\\')) {
|
||||
throw new Error('자기 자신 또는 하위 폴더로는 이동할 수 없습니다')
|
||||
}
|
||||
try {
|
||||
await rename(src, target)
|
||||
} catch (err) {
|
||||
// 다른 드라이브(EXDEV) → 재귀 복사 후 원본 삭제(휴지통)
|
||||
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
await cp(src, target, { recursive: true, errorOnExist: true, force: false })
|
||||
await shell.trashItem(src)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return { name, path: target }
|
||||
}
|
||||
+192
-6
@@ -77,6 +77,19 @@ class IndexDb {
|
||||
assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE,
|
||||
vec BLOB
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tag (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS asset_tag (
|
||||
assetId INTEGER NOT NULL REFERENCES asset(id) ON DELETE CASCADE,
|
||||
tagId INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (assetId, tagId)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS metadata (
|
||||
assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE,
|
||||
title TEXT, description TEXT, creator TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_hash ON asset(contentHash);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_path ON asset(path);
|
||||
`)
|
||||
@@ -85,7 +98,8 @@ class IndexDb {
|
||||
this.ensureColumn('asset', 'gpsLat', 'REAL')
|
||||
this.ensureColumn('asset', 'gpsLon', 'REAL')
|
||||
this.ensureColumn('asset', 'camera', 'TEXT')
|
||||
// metaVersion: 확장 메타(GPS/카메라) 적재 버전. 구버전 행(0/NULL)은 재색인 시 backfill
|
||||
this.ensureColumn('asset', 'folder', 'TEXT')
|
||||
// metaVersion: 확장 메타(GPS/카메라/folder) 적재 버전. 구버전 행은 재색인 시 backfill
|
||||
this.ensureColumn('asset', 'metaVersion', 'INTEGER')
|
||||
}
|
||||
|
||||
@@ -133,6 +147,17 @@ class IndexDb {
|
||||
}
|
||||
}
|
||||
|
||||
/** 해당 경로가 라이브러리 인덱스에 등록되어 있는지 (미디어 프로토콜 원본 서빙 허용 판정용) */
|
||||
hasPath(path: string): boolean {
|
||||
const stmt = this.db!.prepare('SELECT 1 FROM asset WHERE path = ? LIMIT 1')
|
||||
try {
|
||||
stmt.bind([path])
|
||||
return stmt.step()
|
||||
} finally {
|
||||
stmt.free()
|
||||
}
|
||||
}
|
||||
|
||||
/** 이미 색인되었고 mtime이 동일하면 재색인 불필요 */
|
||||
needsIndex(contentHash: string, mtime: number): boolean {
|
||||
const stmt = this.db!.prepare('SELECT mtime FROM asset WHERE contentHash = ?')
|
||||
@@ -198,9 +223,25 @@ class IndexDb {
|
||||
conds.push('label = ?')
|
||||
params.push(query.label)
|
||||
}
|
||||
if (query.folder) {
|
||||
conds.push('folder = ?')
|
||||
params.push(query.folder)
|
||||
}
|
||||
if (query.tag) {
|
||||
conds.push(
|
||||
'id IN (SELECT at.assetId FROM asset_tag at JOIN tag t ON t.id = at.tagId WHERE t.name = ?)'
|
||||
)
|
||||
params.push(query.tag)
|
||||
}
|
||||
return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
/** 정렬: 사진 우선 → 영상 나중, 각 그룹 내에서는 최근 색인순 */
|
||||
private orderClause(): string {
|
||||
const v = SUPPORTED_VIDEO_EXTENSIONS.map((e) => `'${e}'`).join(',')
|
||||
return `ORDER BY (CASE WHEN ext IN (${v}) THEN 1 ELSE 0 END), indexedAt DESC, id DESC`
|
||||
}
|
||||
|
||||
listAssets(
|
||||
offset: number,
|
||||
limit: number,
|
||||
@@ -210,7 +251,7 @@ class IndexDb {
|
||||
const inner = this.innerSelect(th)
|
||||
const { where, params } = this.buildWhere(query)
|
||||
const stmt = this.db!.prepare(
|
||||
`SELECT * FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC LIMIT ? OFFSET ?`
|
||||
`SELECT * FROM (${inner}) ${where} ${this.orderClause()} LIMIT ? OFFSET ?`
|
||||
)
|
||||
const out: IndexedAsset[] = []
|
||||
try {
|
||||
@@ -227,7 +268,7 @@ class IndexDb {
|
||||
const inner = this.innerSelect(th)
|
||||
const { where, params } = this.buildWhere(query)
|
||||
const stmt = this.db!.prepare(
|
||||
`SELECT id FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC`
|
||||
`SELECT id FROM (${inner}) ${where} ${this.orderClause()}`
|
||||
)
|
||||
const out: number[] = []
|
||||
try {
|
||||
@@ -255,10 +296,87 @@ class IndexDb {
|
||||
),
|
||||
labels: q(
|
||||
'SELECT label, COUNT(*) FROM usermeta WHERE label IS NOT NULL GROUP BY label ORDER BY COUNT(*) DESC'
|
||||
),
|
||||
folders: q(
|
||||
"SELECT folder, COUNT(*) FROM asset WHERE folder IS NOT NULL AND folder <> '' GROUP BY folder ORDER BY folder"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 태깅 / 메타데이터 (darktable) ----
|
||||
|
||||
listTags(): { name: string; count: number }[] {
|
||||
const res = this.db!.exec(
|
||||
`SELECT t.name, COUNT(at.assetId) FROM tag t
|
||||
LEFT JOIN asset_tag at ON at.tagId = t.id
|
||||
GROUP BY t.id ORDER BY t.name`
|
||||
)
|
||||
if (!res.length) return []
|
||||
return res[0].values.map((r) => ({ name: String(r[0]), count: Number(r[1]) }))
|
||||
}
|
||||
|
||||
getAssetTags(assetId: number): string[] {
|
||||
const stmt = this.db!.prepare(
|
||||
'SELECT t.name FROM asset_tag at JOIN tag t ON t.id = at.tagId WHERE at.assetId = ? ORDER BY t.name'
|
||||
)
|
||||
const out: string[] = []
|
||||
try {
|
||||
stmt.bind([assetId])
|
||||
while (stmt.step()) out.push(String((stmt.getAsObject() as { name: string }).name))
|
||||
} finally {
|
||||
stmt.free()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
attachTag(assetIds: number[], name: string): void {
|
||||
const tag = name.trim()
|
||||
if (!tag) return
|
||||
this.db!.run('INSERT OR IGNORE INTO tag (name) VALUES (?)', [tag])
|
||||
const res = this.db!.exec('SELECT id FROM tag WHERE name = ?', [tag])
|
||||
if (!res.length) return
|
||||
const tagId = Number(res[0].values[0][0])
|
||||
for (const assetId of assetIds) {
|
||||
this.db!.run('INSERT OR IGNORE INTO asset_tag (assetId, tagId) VALUES (?, ?)', [
|
||||
assetId,
|
||||
tagId
|
||||
])
|
||||
}
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
detachTag(assetId: number, name: string): void {
|
||||
this.db!.run(
|
||||
'DELETE FROM asset_tag WHERE assetId = ? AND tagId = (SELECT id FROM tag WHERE name = ?)',
|
||||
[assetId, name]
|
||||
)
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
getMetadata(assetId: number): { title: string; description: string; creator: string } {
|
||||
const stmt = this.db!.prepare('SELECT title, description, creator FROM metadata WHERE assetId = ?')
|
||||
try {
|
||||
stmt.bind([assetId])
|
||||
if (!stmt.step()) return { title: '', description: '', creator: '' }
|
||||
const r = stmt.getAsObject() as { title?: string; description?: string; creator?: string }
|
||||
return { title: r.title ?? '', description: r.description ?? '', creator: r.creator ?? '' }
|
||||
} finally {
|
||||
stmt.free()
|
||||
}
|
||||
}
|
||||
|
||||
setMetadata(
|
||||
assetId: number,
|
||||
m: { title: string; description: string; creator: string }
|
||||
): void {
|
||||
this.db!.run(
|
||||
`INSERT INTO metadata (assetId, title, description, creator) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(assetId) DO UPDATE SET title=excluded.title, description=excluded.description, creator=excluded.creator`,
|
||||
[assetId, m.title, m.description, m.creator]
|
||||
)
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
setRating(assetId: number, rating: number): void {
|
||||
const r = Math.max(0, Math.min(5, Math.round(rating)))
|
||||
this.db!.run(
|
||||
@@ -333,6 +451,30 @@ class IndexDb {
|
||||
return out
|
||||
}
|
||||
|
||||
/** GPS가 없는 자산(지오태깅 드래그 소스용). 이미지 한정, 최근순 */
|
||||
assetsWithoutGps(th: QualityThresholds, limit = 200): IndexedAsset[] {
|
||||
const inner = this.innerSelect(th)
|
||||
const exts = SUPPORTED_EXTENSIONS.map((e) => `'${e}'`).join(',')
|
||||
const stmt = this.db!.prepare(
|
||||
`SELECT * FROM (${inner}) WHERE (gpsLat IS NULL OR gpsLon IS NULL) AND ext IN (${exts})
|
||||
ORDER BY indexedAt DESC, id DESC LIMIT ?`
|
||||
)
|
||||
const out: IndexedAsset[] = []
|
||||
try {
|
||||
stmt.bind([limit])
|
||||
while (stmt.step()) out.push(stmt.getAsObject() as unknown as IndexedAsset)
|
||||
} finally {
|
||||
stmt.free()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 자산에 GPS 좌표 부여/갱신 (지오태깅). 인덱스 DB에만 저장 — 원본 파일 EXIF는 변경하지 않음 */
|
||||
setGps(id: number, lat: number, lon: number): void {
|
||||
this.db!.run('UPDATE asset SET gpsLat = ?, gpsLon = ? WHERE id = ?', [lat, lon, id])
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
/** 특정 자산의 임베딩 (연관 탐색용) */
|
||||
getEmbedding(assetId: number): Float32Array | null {
|
||||
const stmt = this.db!.prepare('SELECT vec FROM embedding WHERE assetId = ?')
|
||||
@@ -423,6 +565,49 @@ class IndexDb {
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
/** path가 prefix 폴더 하위인 자산들 (폴더 이동/삭제 시 인덱스 정합용). win은 대소문자 무시 */
|
||||
private idsUnderPrefix(prefix: string): { id: number; path: string }[] {
|
||||
const base = prefix.replace(/[\\/]+$/, '')
|
||||
const win = process.platform === 'win32'
|
||||
const lower = (s: string): string => (win ? s.toLowerCase() : s)
|
||||
const t1 = lower(base + '\\')
|
||||
const t2 = lower(base + '/')
|
||||
const stmt = this.db!.prepare('SELECT id, path FROM asset')
|
||||
const out: { id: number; path: string }[] = []
|
||||
try {
|
||||
while (stmt.step()) {
|
||||
const r = stmt.getAsObject() as { id: number; path: string }
|
||||
const lp = lower(r.path)
|
||||
if (lp.startsWith(t1) || lp.startsWith(t2)) out.push(r)
|
||||
}
|
||||
} finally {
|
||||
stmt.free()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 폴더 삭제 시: 하위 인덱스 자산 제거 → DB가 사라진 파일을 가리키지 않도록. 제거 수 반환 */
|
||||
removeAssetsUnder(prefix: string): number {
|
||||
const rows = this.idsUnderPrefix(prefix)
|
||||
for (const r of rows) this.deleteAsset(r.id)
|
||||
return rows.length
|
||||
}
|
||||
|
||||
/** 폴더 이동 시: 하위 자산 path/folder 를 새 위치로 갱신. 갱신 수 반환 */
|
||||
reparentAssets(oldPrefix: string, newPrefix: string): number {
|
||||
const rows = this.idsUnderPrefix(oldPrefix)
|
||||
const oldBase = oldPrefix.replace(/[\\/]+$/, '')
|
||||
const newBase = newPrefix.replace(/[\\/]+$/, '')
|
||||
for (const r of rows) {
|
||||
const np = newBase + r.path.slice(oldBase.length) // 접미부(구분자 포함) 보존
|
||||
const sep = Math.max(np.lastIndexOf('\\'), np.lastIndexOf('/'))
|
||||
const folder = sep > 0 ? np.slice(0, sep) : np
|
||||
this.db!.run('UPDATE asset SET path = ?, folder = ? WHERE id = ?', [np, folder, r.id])
|
||||
}
|
||||
if (rows.length) this.dirty = true
|
||||
return rows.length
|
||||
}
|
||||
|
||||
getByHash(contentHash: string): AssetRecord | null {
|
||||
const stmt = this.db!.prepare('SELECT * FROM asset WHERE contentHash = ?')
|
||||
try {
|
||||
@@ -439,14 +624,14 @@ class IndexDb {
|
||||
this.db!.run(
|
||||
`INSERT INTO asset
|
||||
(contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth,
|
||||
gpsLat, gpsLon, camera, metaVersion, indexedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
gpsLat, gpsLon, camera, folder, metaVersion, indexedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
ON CONFLICT(contentHash) DO UPDATE SET
|
||||
path=excluded.path, ext=excluded.ext, sizeBytes=excluded.sizeBytes,
|
||||
mtime=excluded.mtime, width=excluded.width, height=excluded.height,
|
||||
exifYear=excluded.exifYear, exifMonth=excluded.exifMonth,
|
||||
gpsLat=excluded.gpsLat, gpsLon=excluded.gpsLon, camera=excluded.camera,
|
||||
metaVersion=1, indexedAt=excluded.indexedAt`,
|
||||
folder=excluded.folder, metaVersion=1, indexedAt=excluded.indexedAt`,
|
||||
[
|
||||
r.contentHash,
|
||||
r.path,
|
||||
@@ -460,6 +645,7 @@ class IndexDb {
|
||||
r.gpsLat,
|
||||
r.gpsLon,
|
||||
r.camera,
|
||||
dirname(r.path),
|
||||
r.indexedAt
|
||||
]
|
||||
)
|
||||
|
||||
+8
-1
@@ -71,8 +71,15 @@ class Indexer {
|
||||
try {
|
||||
const st = await stat(file)
|
||||
const mtime = Math.floor(st.mtimeMs)
|
||||
// 빠른 스킵: 같은 경로·mtime이면 해시 없이 건너뜀
|
||||
const isImageFast = mediaKind(file) === 'image'
|
||||
// 이미지: 같은 경로·mtime이면 해시 없이 빠른 스킵.
|
||||
// 영상: 썸네일이 없을 수 있어, 색인됐어도 통과시켜 backfill (해시로 썸네일 존재 확인).
|
||||
let fastSkip = false
|
||||
if (indexDb.isIndexedPath(file, mtime)) {
|
||||
if (isImageFast) fastSkip = true
|
||||
else if (await hasThumb(await contentHash(file))) fastSkip = true
|
||||
}
|
||||
if (fastSkip) {
|
||||
skipped++
|
||||
} else {
|
||||
const hash = await contentHash(file)
|
||||
|
||||
+50
-1
@@ -2,12 +2,15 @@ import { ipcMain, dialog, BrowserWindow, app, shell } from 'electron'
|
||||
import { writeFile, mkdir } from 'node:fs/promises'
|
||||
import { join, extname, basename } from 'node:path'
|
||||
import { safeCopy } from './fileOps'
|
||||
import { readFullExif } from './exif'
|
||||
import { listDir, makeDir, trashDir, moveDir } from './fsExplorer'
|
||||
import type {
|
||||
ProfileInput,
|
||||
JobRequest,
|
||||
ReferenceData,
|
||||
AssetQuery,
|
||||
ColorLabel
|
||||
ColorLabel,
|
||||
AssetMetadata
|
||||
} from '@shared/types'
|
||||
import { IPC, SUPPORTED_EXTENSIONS } from '@shared/constants'
|
||||
import { profileStore } from './profileStore'
|
||||
@@ -175,6 +178,46 @@ export function registerIpc(): void {
|
||||
|
||||
ipcMain.handle(IPC.INDEX_FACETS, () => indexDb.facets())
|
||||
|
||||
ipcMain.handle(IPC.INDEX_EXIF, async (_e, assetId: number) => {
|
||||
const a = indexDb.getById(assetId)
|
||||
if (!a) return await readFullExif('')
|
||||
return readFullExif(a.path)
|
||||
})
|
||||
|
||||
// ---- 태깅 / 메타데이터 ----
|
||||
ipcMain.handle(IPC.TAGS_LIST, () => indexDb.listTags())
|
||||
ipcMain.handle(IPC.TAGS_FOR_ASSET, (_e, id: number) => indexDb.getAssetTags(id))
|
||||
ipcMain.handle(IPC.TAGS_ATTACH, async (_e, ids: number[], name: string) => {
|
||||
indexDb.attachTag(ids, name)
|
||||
await indexDb.save()
|
||||
})
|
||||
ipcMain.handle(IPC.TAGS_DETACH, async (_e, id: number, name: string) => {
|
||||
indexDb.detachTag(id, name)
|
||||
await indexDb.save()
|
||||
})
|
||||
ipcMain.handle(IPC.META_GET, (_e, id: number) => indexDb.getMetadata(id))
|
||||
ipcMain.handle(IPC.META_SET, async (_e, id: number, data: AssetMetadata) => {
|
||||
indexDb.setMetadata(id, data)
|
||||
await indexDb.save()
|
||||
})
|
||||
|
||||
// ---- 파일 탐색기 ----
|
||||
ipcMain.handle(IPC.FS_LIST, (_e, path: string | null) => listDir(path))
|
||||
ipcMain.handle(IPC.FS_MKDIR, (_e, parent: string, name: string) => makeDir(parent, name))
|
||||
ipcMain.handle(IPC.FS_TRASH, async (_e, path: string) => {
|
||||
await trashDir(path)
|
||||
// 인덱스 정합: 삭제된 폴더 하위의 색인 자산 제거 (SSOT — DB가 사라진 파일을 가리키지 않게)
|
||||
const removed = indexDb.removeAssetsUnder(path)
|
||||
if (removed > 0) logger.info('폴더 삭제 → 인덱스 자산 제거', { path, removed })
|
||||
})
|
||||
ipcMain.handle(IPC.FS_MOVE, async (_e, src: string, destDir: string) => {
|
||||
const res = await moveDir(src, destDir)
|
||||
// 인덱스 정합: 이동된 폴더 하위 자산의 경로를 새 위치로 갱신
|
||||
const moved = indexDb.reparentAssets(src, res.path)
|
||||
if (moved > 0) logger.info('폴더 이동 → 인덱스 경로 갱신', { from: src, to: res.path, moved })
|
||||
return res
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.INDEX_EXPORT, async (e, assetIds: number[]) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
const r = await dialog.showOpenDialog(win!, {
|
||||
@@ -225,6 +268,12 @@ export function registerIpc(): void {
|
||||
// ---- 지도 / 연관 탐색 (Phase C) ----
|
||||
ipcMain.handle(IPC.MAP_ASSETS, () => indexDb.assetsWithGps())
|
||||
ipcMain.handle(IPC.MAP_RELATED, (_e, assetId: number) => relatedAssets(assetId))
|
||||
ipcMain.handle(IPC.MAP_UNTAGGED, () =>
|
||||
indexDb.assetsWithoutGps(settingsStore.current().qualityThresholds)
|
||||
)
|
||||
ipcMain.handle(IPC.MAP_SET_GPS, (_e, id: number, lat: number, lon: number) =>
|
||||
indexDb.setGps(id, lat, lon)
|
||||
)
|
||||
|
||||
// ---- 그룹화 / 자가정화 (Phase 3) ----
|
||||
ipcMain.handle(IPC.GROUPS_BUILD, (_e, threshold: number) => buildGroups(threshold))
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { protocol } from 'electron'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile, stat, open } from 'node:fs/promises'
|
||||
import { extname } from 'node:path'
|
||||
import { MEDIA_SCHEME } from '@shared/constants'
|
||||
import { profileStore } from './profileStore'
|
||||
import { presetStore } from './presetStore'
|
||||
import { indexDb } from './indexDb'
|
||||
import { thumbPath } from './thumbnails'
|
||||
import { logger } from './logger'
|
||||
|
||||
@@ -11,7 +12,16 @@ const MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp'
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
// 라이브러리 뷰어 영상 재생용 (코덱 미지원 시 렌더러가 placeholder로 폴백)
|
||||
'.mp4': 'video/mp4',
|
||||
'.m4v': 'video/mp4',
|
||||
'.mov': 'video/quicktime',
|
||||
'.webm': 'video/webm',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.avi': 'video/x-msvideo'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +64,12 @@ export function handleMediaProtocol(): void {
|
||||
const data = await readFile(thumbPath(hash))
|
||||
return new Response(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/webp', 'cache-control': 'no-cache' }
|
||||
headers: {
|
||||
'content-type': 'image/webp',
|
||||
'cache-control': 'no-cache',
|
||||
// 캔버스에서 픽셀 읽기(포토모자이크 색 분석)를 위해 CORS 허용
|
||||
'access-control-allow-origin': '*'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,18 +80,60 @@ export function handleMediaProtocol(): void {
|
||||
if (i < 0) return new Response('missing path', { status: 400 })
|
||||
const filePath = decodeURIComponent(url.slice(i + marker.length))
|
||||
|
||||
// 등록된 참조 이미지(활성 프로필 또는 프리셋)에 한해 제공 — 임의 파일 읽기 차단
|
||||
// 등록된 참조 이미지(활성 프로필/프리셋) 또는 라이브러리 인덱스 등록 경로에 한해 제공
|
||||
// → 임의 파일 읽기 차단
|
||||
const allowed =
|
||||
(await profileStore.isReferenceImage(filePath)) ||
|
||||
(await presetStore.isReferenceImage(filePath))
|
||||
(await presetStore.isReferenceImage(filePath)) ||
|
||||
indexDb.hasPath(filePath)
|
||||
if (!allowed) return new Response('forbidden', { status: 403 })
|
||||
|
||||
const mime = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream'
|
||||
|
||||
// 영상은 Range 요청을 지원해 탐색(seek)·스트리밍이 매끄럽게 동작하도록 부분 응답
|
||||
const rangeHeader = request.headers.get('range')
|
||||
if (rangeHeader && mime.startsWith('video/')) {
|
||||
const { size } = await stat(filePath)
|
||||
const m = /bytes=(\d*)-(\d*)/.exec(rangeHeader)
|
||||
const start = m && m[1] ? parseInt(m[1], 10) : 0
|
||||
const end = m && m[2] ? Math.min(parseInt(m[2], 10), size - 1) : size - 1
|
||||
if (start >= size || start > end) {
|
||||
return new Response('range not satisfiable', {
|
||||
status: 416,
|
||||
headers: { 'content-range': `bytes */${size}` }
|
||||
})
|
||||
}
|
||||
const fh = await open(filePath, 'r')
|
||||
try {
|
||||
const len = end - start + 1
|
||||
const buf = Buffer.alloc(len)
|
||||
await fh.read(buf, 0, len, start)
|
||||
return new Response(new Uint8Array(buf), {
|
||||
status: 206,
|
||||
headers: {
|
||||
'content-type': mime,
|
||||
'content-range': `bytes ${start}-${end}/${size}`,
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(len),
|
||||
'cache-control': 'no-cache',
|
||||
'access-control-allow-origin': '*'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await fh.close()
|
||||
}
|
||||
}
|
||||
|
||||
// net.fetch(file://) 대신 fs로 직접 읽어 바이트 반환 → 한글/공백 경로에도 안전
|
||||
const data = await readFile(filePath)
|
||||
const mime = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream'
|
||||
return new Response(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: { 'content-type': mime, 'cache-control': 'no-cache' }
|
||||
headers: {
|
||||
'content-type': mime,
|
||||
'accept-ranges': mime.startsWith('video/') ? 'bytes' : 'none',
|
||||
'cache-control': 'no-cache',
|
||||
'access-control-allow-origin': '*'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('media 프로토콜 처리 실패', { message: (err as Error).message })
|
||||
|
||||
+22
-1
@@ -8,6 +8,7 @@ import type {
|
||||
ReferenceData,
|
||||
AssetQuery,
|
||||
ColorLabel,
|
||||
AssetMetadata,
|
||||
RendererEventName,
|
||||
RendererEvents
|
||||
} from '../shared/types'
|
||||
@@ -70,6 +71,7 @@ const api: ExposedApi = {
|
||||
assetIds: (query: AssetQuery) => ipcRenderer.invoke(IPC.INDEX_ASSET_IDS, query),
|
||||
facets: () => ipcRenderer.invoke(IPC.INDEX_FACETS),
|
||||
export: (assetIds: number[]) => ipcRenderer.invoke(IPC.INDEX_EXPORT, assetIds),
|
||||
exif: (assetId: number) => ipcRenderer.invoke(IPC.INDEX_EXIF, assetId),
|
||||
setRating: (assetId: number, rating: number) =>
|
||||
ipcRenderer.invoke(IPC.INDEX_SET_RATING, assetId, rating),
|
||||
setLabel: (assetId: number, label: ColorLabel) =>
|
||||
@@ -81,14 +83,33 @@ const api: ExposedApi = {
|
||||
status: () => ipcRenderer.invoke(IPC.SEARCH_STATUS),
|
||||
query: (text: string) => ipcRenderer.invoke(IPC.SEARCH_QUERY, text)
|
||||
},
|
||||
tags: {
|
||||
list: () => ipcRenderer.invoke(IPC.TAGS_LIST),
|
||||
forAsset: (assetId: number) => ipcRenderer.invoke(IPC.TAGS_FOR_ASSET, assetId),
|
||||
attach: (assetIds: number[], name: string) => ipcRenderer.invoke(IPC.TAGS_ATTACH, assetIds, name),
|
||||
detach: (assetId: number, name: string) => ipcRenderer.invoke(IPC.TAGS_DETACH, assetId, name)
|
||||
},
|
||||
meta: {
|
||||
get: (assetId: number) => ipcRenderer.invoke(IPC.META_GET, assetId),
|
||||
set: (assetId: number, data: AssetMetadata) => ipcRenderer.invoke(IPC.META_SET, assetId, data)
|
||||
},
|
||||
map: {
|
||||
assets: () => ipcRenderer.invoke(IPC.MAP_ASSETS),
|
||||
related: (assetId: number) => ipcRenderer.invoke(IPC.MAP_RELATED, assetId)
|
||||
related: (assetId: number) => ipcRenderer.invoke(IPC.MAP_RELATED, assetId),
|
||||
untagged: () => ipcRenderer.invoke(IPC.MAP_UNTAGGED),
|
||||
setGps: (id: number, lat: number, lon: number) =>
|
||||
ipcRenderer.invoke(IPC.MAP_SET_GPS, id, lat, lon)
|
||||
},
|
||||
groups: {
|
||||
build: (threshold: number) => ipcRenderer.invoke(IPC.GROUPS_BUILD, threshold),
|
||||
trash: (assetIds: number[]) => ipcRenderer.invoke(IPC.GROUPS_TRASH, assetIds)
|
||||
},
|
||||
fs: {
|
||||
list: (path: string | null) => ipcRenderer.invoke(IPC.FS_LIST, path),
|
||||
mkdir: (parent: string, name: string) => ipcRenderer.invoke(IPC.FS_MKDIR, parent, name),
|
||||
trash: (path: string) => ipcRenderer.invoke(IPC.FS_TRASH, path),
|
||||
move: (src: string, destDir: string) => ipcRenderer.invoke(IPC.FS_MOVE, src, destDir)
|
||||
},
|
||||
// Electron 33: File.path 제거됨 → webUtils로 드롭된 파일의 실제 경로 획득
|
||||
getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File),
|
||||
on<E extends RendererEventName>(event: E, cb: (payload: RendererEvents[E]) => void) {
|
||||
|
||||
+23
-15
@@ -12,6 +12,7 @@ import { LibraryView } from './components/LibraryView'
|
||||
import { SearchView } from './components/SearchView'
|
||||
import { GroupsView } from './components/GroupsView'
|
||||
import { MapView } from './components/MapView'
|
||||
import { FileExplorer } from './components/FileExplorer'
|
||||
import type { AppView } from './store'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
@@ -96,24 +97,31 @@ export default function App(): JSX.Element {
|
||||
)}
|
||||
|
||||
{view === 'organize' ? (
|
||||
<main className="flex-1 min-h-0 grid grid-cols-12 gap-3 p-4">
|
||||
{/* 좌측: 설정 패널 (자체 스크롤) */}
|
||||
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
|
||||
<ProfileManager />
|
||||
<FolderPicker />
|
||||
<RunControl />
|
||||
</section>
|
||||
<main className="flex-1 min-h-0 flex">
|
||||
{/* 좌측: 파일 탐색기 사이드바 */}
|
||||
<aside className="w-56 shrink-0 bg-slate-100 dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700">
|
||||
<FileExplorer />
|
||||
</aside>
|
||||
|
||||
{/* 우측: 진행/결과 — FileList만 내부 스크롤 */}
|
||||
<section className="col-span-7 min-h-0 flex flex-col gap-4">
|
||||
<div className="shrink-0">
|
||||
{phase === 'done' ? <ReportView /> : <ProgressView />}
|
||||
</div>
|
||||
<FileList />
|
||||
</section>
|
||||
<div className="flex-1 min-w-0 min-h-0 grid grid-cols-12 gap-3 p-4">
|
||||
{/* 설정 패널 (자체 스크롤) */}
|
||||
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
|
||||
<ProfileManager />
|
||||
<FolderPicker />
|
||||
<RunControl />
|
||||
</section>
|
||||
|
||||
{/* 진행/결과 — FileList만 내부 스크롤 */}
|
||||
<section className="col-span-7 min-h-0 flex flex-col gap-4">
|
||||
<div className="shrink-0">
|
||||
{phase === 'done' ? <ReportView /> : <ProgressView />}
|
||||
</div>
|
||||
<FileList />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
) : view === 'library' ? (
|
||||
<main className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
<main className="flex-1 min-h-0">
|
||||
<LibraryView />
|
||||
</main>
|
||||
) : view === 'search' ? (
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useT } from '../i18n'
|
||||
import type { FsEntry } from '@shared/types'
|
||||
|
||||
/** 경로의 부모 디렉터리 (Windows/POSIX 겸용) */
|
||||
function parentOf(p: string): string {
|
||||
const idx = Math.max(p.replace(/[\\/]+$/, '').lastIndexOf('\\'), p.replace(/[\\/]+$/, '').lastIndexOf('/'))
|
||||
return idx > 0 ? p.slice(0, idx) : p
|
||||
}
|
||||
function baseOf(p: string): string {
|
||||
const t = p.replace(/[\\/]+$/, '')
|
||||
const idx = Math.max(t.lastIndexOf('\\'), t.lastIndexOf('/'))
|
||||
return idx >= 0 ? t.slice(idx + 1) : t
|
||||
}
|
||||
|
||||
/** 정리 탭 좌측 파일 탐색기 — 폴더를 드래그/복사/버튼으로 소스·출력 지정, 생성·삭제·이동 */
|
||||
export function FileExplorer(): JSX.Element {
|
||||
const t = useT()
|
||||
const setSource = useStore((s) => s.setSource)
|
||||
const setOutput = useStore((s) => s.setOutput)
|
||||
const [roots, setRoots] = useState<FsEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [children, setChildren] = useState<Record<string, FsEntry[]>>({})
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
void window.api.fs
|
||||
.list(null)
|
||||
.then(setRoots)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
/** 해당 폴더의 하위를 다시 읽어 캐시 갱신 */
|
||||
const reload = useCallback(async (path: string) => {
|
||||
const c = await window.api.fs.list(path)
|
||||
setChildren((prev) => ({ ...prev, [path]: c }))
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(
|
||||
async (path: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(path)) next.delete(path)
|
||||
else next.add(path)
|
||||
return next
|
||||
})
|
||||
if (!children[path]) await reload(path)
|
||||
},
|
||||
[children, reload]
|
||||
)
|
||||
|
||||
const copyPath = (p: string) => void navigator.clipboard?.writeText(p)
|
||||
|
||||
const newFolder = async (parent: string) => {
|
||||
const name = window.prompt(t('explorer.newFolderPrompt'))
|
||||
if (!name) return
|
||||
try {
|
||||
await window.api.fs.mkdir(parent, name)
|
||||
await reload(parent)
|
||||
setExpanded((prev) => new Set(prev).add(parent))
|
||||
} catch (e) {
|
||||
window.alert(t('explorer.opFailed', { msg: (e as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFolder = async (path: string) => {
|
||||
if (!window.confirm(t('explorer.confirmDelete', { name: baseOf(path) }))) return
|
||||
try {
|
||||
await window.api.fs.trash(path)
|
||||
if (selected === path) setSelected(null)
|
||||
await reload(parentOf(path))
|
||||
} catch (e) {
|
||||
window.alert(t('explorer.opFailed', { msg: (e as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
const moveFolder = async (src: string, destDir: string) => {
|
||||
if (!window.confirm(t('explorer.confirmMove', { src: baseOf(src), dest: baseOf(destDir) }))) return
|
||||
try {
|
||||
await window.api.fs.move(src, destDir)
|
||||
if (selected === src) setSelected(null)
|
||||
await reload(parentOf(src))
|
||||
await reload(destDir)
|
||||
setExpanded((prev) => new Set(prev).add(destDir))
|
||||
} catch (e) {
|
||||
window.alert(t('explorer.opFailed', { msg: (e as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
const renderNode = (entry: FsEntry, depth: number): JSX.Element => {
|
||||
const isOpen = expanded.has(entry.path)
|
||||
const kids = children[entry.path]
|
||||
const isDrop = dropTarget === entry.path
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<div
|
||||
className={`flex items-center gap-1 py-0.5 pr-1 text-xs rounded cursor-pointer select-none ${
|
||||
isDrop
|
||||
? 'bg-brand/30 ring-1 ring-brand'
|
||||
: selected === entry.path
|
||||
? 'bg-brand text-white'
|
||||
: 'hover:bg-slate-200 dark:hover:bg-slate-700 dark:text-slate-200'
|
||||
}`}
|
||||
style={{ paddingLeft: depth * 12 + 4 }}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', entry.path)
|
||||
e.dataTransfer.effectAllowed = 'copyMove'
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// 트리 내부 폴더 위로 드래그 → 이동 대상 하이라이트
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dropTarget !== entry.path) setDropTarget(entry.path)
|
||||
}}
|
||||
onDragLeave={() => setDropTarget((d) => (d === entry.path ? null : d))}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDropTarget(null)
|
||||
const src = e.dataTransfer.getData('text/plain')
|
||||
if (src && src !== entry.path) void moveFolder(src, entry.path)
|
||||
}}
|
||||
onClick={() => setSelected(entry.path)}
|
||||
onDoubleClick={() => void toggle(entry.path)}
|
||||
title={entry.path}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void toggle(entry.path)
|
||||
}}
|
||||
className="w-3 text-[8px] shrink-0 text-slate-400"
|
||||
>
|
||||
{isOpen ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="truncate">
|
||||
{depth === 0 ? '💽' : '📁'} {entry.name}
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && kids && kids.map((k) => renderNode(k, depth + 1))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
|
||||
<span>{t('explorer.title')}</span>
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => void newFolder(selected)}
|
||||
className="normal-case text-[11px] text-brand hover:underline"
|
||||
title={t('explorer.newFolder')}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-1 py-1">
|
||||
{loading ? (
|
||||
<p className="text-[11px] text-slate-400 px-2 py-1">{t('explorer.loading')}</p>
|
||||
) : (
|
||||
roots.map((r) => renderNode(r, 0))
|
||||
)}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 p-2">
|
||||
<div className="mono text-[10px] text-slate-400 break-all mb-1" title={selected}>
|
||||
{selected}
|
||||
</div>
|
||||
<div className="flex gap-1 mb-1">
|
||||
<button
|
||||
onClick={() => setSource(selected)}
|
||||
className="flex-1 whitespace-nowrap text-[11px] border border-brand text-brand rounded px-1 py-1 hover:bg-brand hover:text-white"
|
||||
>
|
||||
{t('explorer.asSource')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOutput(selected)}
|
||||
className="flex-1 whitespace-nowrap text-[11px] border border-brand text-brand rounded px-1 py-1 hover:bg-brand hover:text-white"
|
||||
>
|
||||
{t('explorer.asOutput')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyPath(selected)}
|
||||
className="whitespace-nowrap text-[11px] border border-slate-300 dark:border-slate-600 dark:text-slate-200 rounded px-2 py-1"
|
||||
>
|
||||
{t('explorer.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1 mb-1">
|
||||
<button
|
||||
onClick={() => void newFolder(selected)}
|
||||
className="flex-1 whitespace-nowrap text-[11px] border border-slate-300 dark:border-slate-600 dark:text-slate-200 rounded px-1 py-1 hover:border-brand hover:text-brand"
|
||||
>
|
||||
+ {t('explorer.newFolder')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void deleteFolder(selected)}
|
||||
className="flex-1 whitespace-nowrap text-[11px] border border-red-400 text-red-500 dark:text-red-400 rounded px-1 py-1 hover:bg-red-500 hover:text-white"
|
||||
>
|
||||
🗑 {t('explorer.delete')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400">{t('explorer.hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useT } from '../i18n'
|
||||
|
||||
/** 소스 폴더 + 출력 루트 선택 */
|
||||
/** 소스 폴더 + 출력 루트 선택 (찾기 / 드래그&드롭 / 탐색기 버튼) */
|
||||
export function FolderPicker(): JSX.Element {
|
||||
const t = useT()
|
||||
const source = useStore((s) => s.source)
|
||||
@@ -22,8 +23,22 @@ export function FolderPicker(): JSX.Element {
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h2 className="font-semibold mb-3 dark:text-slate-100">{t('folder.section')}</h2>
|
||||
|
||||
<Row label={t('folder.source')} value={source} placeholder={t('folder.unselected')} browse={t('folder.browse')} onPick={pickSource} />
|
||||
<Row label={t('folder.output')} value={outputRoot} placeholder={t('folder.unselected')} browse={t('folder.browse')} onPick={pickOutput} />
|
||||
<Row
|
||||
label={t('folder.source')}
|
||||
value={source}
|
||||
placeholder={t('folder.unselected')}
|
||||
browse={t('folder.browse')}
|
||||
onPick={pickSource}
|
||||
onDropPath={setSource}
|
||||
/>
|
||||
<Row
|
||||
label={t('folder.output')}
|
||||
value={outputRoot}
|
||||
placeholder={t('folder.unselected')}
|
||||
browse={t('folder.browse')}
|
||||
onPick={pickOutput}
|
||||
onDropPath={setOutput}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,12 +49,30 @@ function Row(props: {
|
||||
placeholder: string
|
||||
browse: string
|
||||
onPick: () => void
|
||||
onDropPath: (path: string) => void
|
||||
}): JSX.Element {
|
||||
const [over, setOver] = useState(false)
|
||||
return (
|
||||
<div className="mb-3 last:mb-0">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mb-1">{props.label}</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 border border-slate-300 dark:border-slate-600 rounded-lg px-3 py-2 text-sm mono truncate bg-slate-50 dark:bg-slate-700 dark:text-slate-200">
|
||||
<div
|
||||
className={`flex-1 border rounded-lg px-3 py-2 text-sm mono truncate bg-slate-50 dark:bg-slate-700 dark:text-slate-200 ${
|
||||
over ? 'border-brand border-dashed' : 'border-slate-300 dark:border-slate-600'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setOver(true)
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setOver(false)
|
||||
const p = e.dataTransfer.getData('text/plain')
|
||||
if (p) props.onDropPath(p)
|
||||
}}
|
||||
title={props.value ?? ''}
|
||||
>
|
||||
{props.value ?? props.placeholder}
|
||||
</div>
|
||||
<button
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,29 @@ import type { GpsAsset, IndexedAsset } from '@shared/types'
|
||||
|
||||
const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']
|
||||
|
||||
/** GPS 지도 + 연관 탐색 (Phase C, 온라인 OSM 타일) */
|
||||
/** GPS 마커 추가 (지도 초기 로드 + 드롭 지오태깅 공용) */
|
||||
function addGpsMarker(
|
||||
map: L.Map,
|
||||
a: { id: number; contentHash: string },
|
||||
lat: number,
|
||||
lon: number,
|
||||
onPick: (id: number) => void
|
||||
): L.CircleMarker {
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 6,
|
||||
color: '#3f5ad6',
|
||||
fillColor: '#5b7cfa',
|
||||
fillOpacity: 0.85,
|
||||
weight: 1
|
||||
}).addTo(map)
|
||||
marker.bindPopup(
|
||||
`<img src="${thumbUrl(a.contentHash)}" style="width:120px;height:120px;object-fit:cover;border-radius:6px" />`
|
||||
)
|
||||
marker.on('click', () => onPick(a.id))
|
||||
return marker
|
||||
}
|
||||
|
||||
/** GPS 지도 + 연관 탐색 + 드래그&드롭 지오태깅 (Phase C, 온라인 OSM 타일) */
|
||||
export function MapView(): JSX.Element {
|
||||
const t = useT()
|
||||
const mapEl = useRef<HTMLDivElement | null>(null)
|
||||
@@ -15,6 +37,8 @@ export function MapView(): JSX.Element {
|
||||
const [count, setCount] = useState<number | null>(null)
|
||||
const [related, setRelated] = useState<IndexedAsset[]>([])
|
||||
const [loadingRel, setLoadingRel] = useState(false)
|
||||
const [untagged, setUntagged] = useState<IndexedAsset[]>([])
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const showRelated = async (assetId: number) => {
|
||||
setLoadingRel(true)
|
||||
@@ -40,24 +64,14 @@ export function MapView(): JSX.Element {
|
||||
setCount(assets.length)
|
||||
const pts: L.LatLngExpression[] = []
|
||||
for (const a of assets) {
|
||||
const ll: L.LatLngExpression = [a.gpsLat, a.gpsLon]
|
||||
pts.push(ll)
|
||||
const marker = L.circleMarker(ll, {
|
||||
radius: 6,
|
||||
color: '#3f5ad6',
|
||||
fillColor: '#5b7cfa',
|
||||
fillOpacity: 0.85,
|
||||
weight: 1
|
||||
}).addTo(map)
|
||||
marker.bindPopup(
|
||||
`<img src="${thumbUrl(a.contentHash)}" style="width:120px;height:120px;object-fit:cover;border-radius:6px" />`
|
||||
)
|
||||
marker.on('click', () => {
|
||||
void showRelated(a.id)
|
||||
})
|
||||
pts.push([a.gpsLat, a.gpsLon])
|
||||
addGpsMarker(map, a, a.gpsLat, a.gpsLon, (id) => void showRelated(id))
|
||||
}
|
||||
if (pts.length > 0) map.fitBounds(L.latLngBounds(pts).pad(0.2))
|
||||
})
|
||||
void window.api.map.untagged().then((u) => {
|
||||
if (!cancelled) setUntagged(u)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -66,6 +80,25 @@ export function MapView(): JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 사진을 지도 위로 드롭 → 그 지점의 좌표를 GPS로 부여
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const id = Number(
|
||||
e.dataTransfer.getData('application/x-photoai-id') || e.dataTransfer.getData('text/plain')
|
||||
)
|
||||
const asset = untagged.find((u) => u.id === id)
|
||||
if (!id || !asset) return
|
||||
const ll = map.mouseEventToLatLng(e.nativeEvent)
|
||||
void window.api.map.setGps(id, ll.lat, ll.lng).then(() => {
|
||||
addGpsMarker(map, { id, contentHash: asset.contentHash }, ll.lat, ll.lng, (aid) => void showRelated(aid))
|
||||
setUntagged((prev) => prev.filter((u) => u.id !== id))
|
||||
setCount((c) => (c ?? 0) + 1)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full flex flex-col gap-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
@@ -76,11 +109,65 @@ export function MapView(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
{count === 0 && <p className="text-[11px] text-amber-600 dark:text-amber-400 mb-2">{t('map.empty')}</p>}
|
||||
<div
|
||||
ref={mapEl}
|
||||
className="w-full rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700"
|
||||
style={{ height: '60vh' }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={mapEl}
|
||||
className={`w-full rounded-lg overflow-hidden border ${
|
||||
dragOver ? 'border-brand ring-2 ring-brand' : 'border-slate-200 dark:border-slate-700'
|
||||
}`}
|
||||
style={{ height: '60vh' }}
|
||||
onDragOver={(e) => {
|
||||
if (untagged.length === 0) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
if (!dragOver) setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
{dragOver && (
|
||||
<div className="pointer-events-none absolute inset-0 z-[500] flex items-center justify-center">
|
||||
<span className="bg-brand/90 text-white text-sm rounded px-3 py-1.5 shadow-lg">
|
||||
📍 {t('map.dropHere')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지오태깅 드래그 소스: GPS 없는 사진 */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="font-semibold dark:text-slate-100">{t('map.geotag')}</h2>
|
||||
<span className="text-xs text-slate-400">{t('map.untaggedCount', { n: untagged.length })}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mb-3">{t('map.geotagHint')}</p>
|
||||
{untagged.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">{t('map.allTagged')}</p>
|
||||
) : (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{untagged.map((a) => (
|
||||
<div
|
||||
key={a.contentHash}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-photoai-id', String(a.id))
|
||||
e.dataTransfer.setData('text/plain', String(a.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
className="h-20 w-20 shrink-0 rounded-md overflow-hidden border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 cursor-grab active:cursor-grabbing"
|
||||
title={baseName(a.path)}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(a.contentHash)}
|
||||
alt={baseName(a.path)}
|
||||
className="w-full h-full object-cover pointer-events-none"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연관 사진 패널 */}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { thumbUrl, baseName } from '../media'
|
||||
import type { IndexedAsset } from '@shared/types'
|
||||
|
||||
type Tile = { img: HTMLImageElement; r: number; g: number; b: number }
|
||||
|
||||
/** crossOrigin 이미지를 로드 (캔버스 픽셀 읽기용). 실패 시 null */
|
||||
function loadImage(src: string): Promise<HTMLImageElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
/** 이미지의 평균 RGB (drawImage로 1x1 축소 후 픽셀 판독) */
|
||||
function averageColor(img: HTMLImageElement): { r: number; g: number; b: number } {
|
||||
const c = document.createElement('canvas')
|
||||
c.width = 1
|
||||
c.height = 1
|
||||
const ctx = c.getContext('2d', { willReadFrequently: true })!
|
||||
ctx.drawImage(img, 0, 0, 1, 1)
|
||||
const d = ctx.getImageData(0, 0, 1, 1).data
|
||||
return { r: d[0], g: d[1], b: d[2] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 포토모자이크 뷰어 — 대표(대상) 사진을 라이브러리 사진(타일)들로 재구성한다.
|
||||
* 대상 이미지를 격자로 나눠 각 칸의 평균색과 가장 가까운 타일을 배치.
|
||||
*/
|
||||
export function MosaicView(props: {
|
||||
target: IndexedAsset
|
||||
tiles: IndexedAsset[]
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const { target, tiles, t } = props
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const poolRef = useRef<Tile[]>([])
|
||||
const targetImgRef = useRef<HTMLImageElement | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [cols, setCols] = useState(48)
|
||||
const [blend, setBlend] = useState(0.35)
|
||||
const [unique, setUnique] = useState(true)
|
||||
|
||||
// 타일/대상 이미지 로드 + 평균색 사전 계산 (1회)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setReady(false)
|
||||
void (async () => {
|
||||
const pool: Tile[] = []
|
||||
const imgs = await Promise.all(tiles.map((a) => loadImage(thumbUrl(a.contentHash))))
|
||||
for (const img of imgs) {
|
||||
if (!img) continue
|
||||
try {
|
||||
pool.push({ img, ...averageColor(img) })
|
||||
} catch {
|
||||
/* 손상 타일 무시 */
|
||||
}
|
||||
}
|
||||
const tImg = await loadImage(thumbUrl(target.contentHash))
|
||||
if (cancelled) return
|
||||
poolRef.current = pool
|
||||
targetImgRef.current = tImg
|
||||
setReady(true)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [target, tiles])
|
||||
|
||||
const generate = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const tImg = targetImgRef.current
|
||||
const pool = poolRef.current
|
||||
if (!canvas || !tImg || pool.length === 0) return
|
||||
|
||||
const aspect = tImg.naturalHeight / tImg.naturalWidth || 1
|
||||
const gridCols = cols
|
||||
const gridRows = Math.max(1, Math.round(gridCols * aspect))
|
||||
|
||||
// 대상 이미지를 격자 크기로 축소 → 각 픽셀 = 해당 칸 평균색
|
||||
const sample = document.createElement('canvas')
|
||||
sample.width = gridCols
|
||||
sample.height = gridRows
|
||||
const sctx = sample.getContext('2d', { willReadFrequently: true })!
|
||||
sctx.drawImage(tImg, 0, 0, gridCols, gridRows)
|
||||
const cells = sctx.getImageData(0, 0, gridCols, gridRows).data
|
||||
|
||||
const cellPx = Math.max(8, Math.round(1800 / gridCols))
|
||||
canvas.width = gridCols * cellPx
|
||||
canvas.height = gridRows * cellPx
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
// 최근 사용 타일에 페널티를 주어 같은 타일이 뭉치는 것을 줄임
|
||||
const recent = new Map<Tile, number>()
|
||||
let step = 0
|
||||
|
||||
for (let y = 0; y < gridRows; y++) {
|
||||
for (let x = 0; x < gridCols; x++) {
|
||||
step++
|
||||
const i = (y * gridCols + x) * 4
|
||||
const r = cells[i]
|
||||
const g = cells[i + 1]
|
||||
const b = cells[i + 2]
|
||||
|
||||
let best: Tile | null = null
|
||||
let bestScore = Infinity
|
||||
for (const tile of pool) {
|
||||
const dr = tile.r - r
|
||||
const dg = tile.g - g
|
||||
const db = tile.b - b
|
||||
let score = dr * dr + dg * dg + db * db
|
||||
if (unique) {
|
||||
const last = recent.get(tile)
|
||||
if (last !== undefined && step - last < pool.length) score += (pool.length - (step - last)) * 24
|
||||
}
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
best = tile
|
||||
}
|
||||
}
|
||||
|
||||
const dx = x * cellPx
|
||||
const dy = y * cellPx
|
||||
if (best) {
|
||||
ctx.drawImage(best.img, dx, dy, cellPx, cellPx)
|
||||
recent.set(best, step)
|
||||
} else {
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`
|
||||
ctx.fillRect(dx, dy, cellPx, cellPx)
|
||||
}
|
||||
// 색 보정: 대상 칸 색을 반투명으로 덮어 닮음새를 강화
|
||||
if (blend > 0) {
|
||||
ctx.globalAlpha = blend
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`
|
||||
ctx.fillRect(dx, dy, cellPx, cellPx)
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [cols, blend, unique])
|
||||
|
||||
// 준비 완료/설정 변경 시 재생성
|
||||
useEffect(() => {
|
||||
if (ready) generate()
|
||||
}, [ready, generate])
|
||||
|
||||
const save = () => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mosaic_${baseName(target.path).replace(/\.[^.]+$/, '')}.png`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
const tileCount = poolRef.current.length
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/95 flex flex-col select-none">
|
||||
{/* 상단 바 */}
|
||||
<div className="h-11 shrink-0 flex items-center gap-3 px-4 text-slate-300 text-sm border-b border-slate-700">
|
||||
<button onClick={props.onClose} className="flex items-center gap-1 hover:text-white" title="Esc">
|
||||
← {t('viewer.back')}
|
||||
</button>
|
||||
<span className="font-semibold text-slate-200">{t('panel.mosaic')}</span>
|
||||
<span className="truncate mono text-xs text-slate-400 flex-1" title={target.path}>
|
||||
{t('mosaic.target')}: {baseName(target.path)}
|
||||
</span>
|
||||
{ready && <span className="text-xs text-slate-500 shrink-0">{t('mosaic.tiles', { n: tileCount })}</span>}
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 바 */}
|
||||
<div className="shrink-0 flex flex-wrap items-center gap-4 px-4 py-2 text-xs text-slate-300 border-b border-slate-800">
|
||||
<label className="flex items-center gap-2">
|
||||
<span>{t('mosaic.resolution')}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={16}
|
||||
max={96}
|
||||
step={2}
|
||||
value={cols}
|
||||
onChange={(e) => setCols(Number(e.target.value))}
|
||||
className="w-40"
|
||||
/>
|
||||
<span className="w-7 tabular-nums text-slate-400">{cols}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>{t('mosaic.blend')}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.8}
|
||||
step={0.05}
|
||||
value={blend}
|
||||
onChange={(e) => setBlend(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="w-9 tabular-nums text-slate-400">{Math.round(blend * 100)}%</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={unique} onChange={(e) => setUnique(e.target.checked)} />
|
||||
<span>{t('mosaic.unique')}</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!ready}
|
||||
className="ml-auto bg-brand hover:bg-brand-dark text-white rounded px-3 py-1 font-medium disabled:opacity-40"
|
||||
>
|
||||
⬇ {t('mosaic.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 캔버스 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto flex items-center justify-center p-4" onClick={props.onClose}>
|
||||
{!ready ? (
|
||||
<p className="text-slate-400 text-sm">{t('mosaic.building')}</p>
|
||||
) : tileCount < 4 ? (
|
||||
<p className="text-slate-400 text-sm max-w-md text-center">{t('mosaic.tooFew')}</p>
|
||||
) : (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-w-full max-h-full object-contain shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,11 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* darktable 톤: 밀도 높은 프로 도구 느낌 (기본 15px, 쉬운 모드에서 20px) */
|
||||
/* darktable 톤: 밀도 높은 프로 도구 느낌.
|
||||
창 너비에 따라 rem 기준값을 자동 보간 → 폰트·아이콘·사이드바(rem 기반)가 함께 스케일되어
|
||||
해상도가 달라져도 버튼 라벨이 줄바꿈 없이 들어맞는다. (쉬운 모드는 고정 20px) */
|
||||
html {
|
||||
font-size: 15px;
|
||||
font-size: clamp(13px, 0.45vw + 10px, 17px);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -63,6 +65,13 @@ button:focus-visible {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 슬라이더/체크박스 강조색을 앰버로 (제너릭 파랑 제거) */
|
||||
input[type='range'],
|
||||
input[type='checkbox'],
|
||||
input[type='radio'] {
|
||||
accent-color: #d98c3f;
|
||||
}
|
||||
|
||||
/* 4050 쉬운 모드: <html class="easy"> — rem 기준 전체 확대 + 큰 썸네일 */
|
||||
html.easy {
|
||||
font-size: 20px; /* 기본 16px → rem 기반 Tailwind 유틸 전반 확대 */
|
||||
|
||||
@@ -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