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
+18
View File
@@ -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"
}
@@ -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.
@@ -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.
@@ -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.
+31
View File
@@ -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
+13
View File
@@ -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
+12 -5
View File
@@ -96,6 +96,10 @@ export async function videoThumbnail(
const video = document.createElement('video') const video = document.createElement('video')
video.muted = true video.muted = true
video.preload = 'auto' 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) video.src = pathToFileUrl(path)
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> => const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
@@ -107,19 +111,21 @@ export async function videoThumbnail(
try { try {
await withTimeout( await withTimeout(
new Promise<void>((res, rej) => { new Promise<void>((res, rej) => {
video.onloadedmetadata = () => res() video.onloadeddata = () => res()
video.onerror = () => rej(new Error('영상 로드 실패')) video.onerror = () => rej(new Error('영상 로드/디코딩 실패'))
}), }),
20000 20000
) )
// 너무 앞(검은 화면) 회피 위해 1초 또는 절반 지점으로 seek // 요청: 약 5초 지점 프레임 사용 (영상이 짧으면 끝부분/절반)
const seekTo = Math.min(1, (video.duration || 2) / 2) 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( await withTimeout(
new Promise<void>((res, rej) => { new Promise<void>((res, rej) => {
video.onseeked = () => res() video.onseeked = () => res()
video.onerror = () => rej(new Error('영상 seek 실패')) video.onerror = () => rej(new Error('영상 seek 실패'))
video.currentTime = seekTo if (Math.abs(video.currentTime - seekTo) < 0.001) res()
else video.currentTime = seekTo
}), }),
20000 20000
) )
@@ -145,6 +151,7 @@ export async function videoThumbnail(
video.src = '' video.src = ''
video.removeAttribute('src') video.removeAttribute('src')
video.load() video.load()
video.remove()
} }
} }
+59 -1
View File
@@ -1,6 +1,64 @@
import exifr from 'exifr' import exifr from 'exifr'
import { stat } from 'node:fs/promises' 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 { function toYearMonth(d: Date, source: CaptureDate['source']): CaptureDate {
const year = String(d.getFullYear()) const year = String(d.getFullYear())
+94
View File
@@ -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
View File
@@ -77,6 +77,19 @@ class IndexDb {
assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE, assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE,
vec BLOB 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_hash ON asset(contentHash);
CREATE INDEX IF NOT EXISTS idx_asset_path ON asset(path); 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', 'gpsLat', 'REAL')
this.ensureColumn('asset', 'gpsLon', 'REAL') this.ensureColumn('asset', 'gpsLon', 'REAL')
this.ensureColumn('asset', 'camera', 'TEXT') this.ensureColumn('asset', 'camera', 'TEXT')
// metaVersion: 확장 메타(GPS/카메라) 적재 버전. 구버전 행(0/NULL)은 재색인 시 backfill this.ensureColumn('asset', 'folder', 'TEXT')
// metaVersion: 확장 메타(GPS/카메라/folder) 적재 버전. 구버전 행은 재색인 시 backfill
this.ensureColumn('asset', 'metaVersion', 'INTEGER') 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이 동일하면 재색인 불필요 */ /** 이미 색인되었고 mtime이 동일하면 재색인 불필요 */
needsIndex(contentHash: string, mtime: number): boolean { needsIndex(contentHash: string, mtime: number): boolean {
const stmt = this.db!.prepare('SELECT mtime FROM asset WHERE contentHash = ?') const stmt = this.db!.prepare('SELECT mtime FROM asset WHERE contentHash = ?')
@@ -198,9 +223,25 @@ class IndexDb {
conds.push('label = ?') conds.push('label = ?')
params.push(query.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 } 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( listAssets(
offset: number, offset: number,
limit: number, limit: number,
@@ -210,7 +251,7 @@ class IndexDb {
const inner = this.innerSelect(th) const inner = this.innerSelect(th)
const { where, params } = this.buildWhere(query) const { where, params } = this.buildWhere(query)
const stmt = this.db!.prepare( 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[] = [] const out: IndexedAsset[] = []
try { try {
@@ -227,7 +268,7 @@ class IndexDb {
const inner = this.innerSelect(th) const inner = this.innerSelect(th)
const { where, params } = this.buildWhere(query) const { where, params } = this.buildWhere(query)
const stmt = this.db!.prepare( 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[] = [] const out: number[] = []
try { try {
@@ -255,10 +296,87 @@ class IndexDb {
), ),
labels: q( labels: q(
'SELECT label, COUNT(*) FROM usermeta WHERE label IS NOT NULL GROUP BY label ORDER BY COUNT(*) DESC' '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 { setRating(assetId: number, rating: number): void {
const r = Math.max(0, Math.min(5, Math.round(rating))) const r = Math.max(0, Math.min(5, Math.round(rating)))
this.db!.run( this.db!.run(
@@ -333,6 +451,30 @@ class IndexDb {
return out 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 { getEmbedding(assetId: number): Float32Array | null {
const stmt = this.db!.prepare('SELECT vec FROM embedding WHERE assetId = ?') const stmt = this.db!.prepare('SELECT vec FROM embedding WHERE assetId = ?')
@@ -423,6 +565,49 @@ class IndexDb {
this.dirty = true 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 { getByHash(contentHash: string): AssetRecord | null {
const stmt = this.db!.prepare('SELECT * FROM asset WHERE contentHash = ?') const stmt = this.db!.prepare('SELECT * FROM asset WHERE contentHash = ?')
try { try {
@@ -439,14 +624,14 @@ class IndexDb {
this.db!.run( this.db!.run(
`INSERT INTO asset `INSERT INTO asset
(contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth, (contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth,
gpsLat, gpsLon, camera, metaVersion, indexedAt) gpsLat, gpsLon, camera, folder, metaVersion, indexedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
ON CONFLICT(contentHash) DO UPDATE SET ON CONFLICT(contentHash) DO UPDATE SET
path=excluded.path, ext=excluded.ext, sizeBytes=excluded.sizeBytes, path=excluded.path, ext=excluded.ext, sizeBytes=excluded.sizeBytes,
mtime=excluded.mtime, width=excluded.width, height=excluded.height, mtime=excluded.mtime, width=excluded.width, height=excluded.height,
exifYear=excluded.exifYear, exifMonth=excluded.exifMonth, exifYear=excluded.exifYear, exifMonth=excluded.exifMonth,
gpsLat=excluded.gpsLat, gpsLon=excluded.gpsLon, camera=excluded.camera, 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.contentHash,
r.path, r.path,
@@ -460,6 +645,7 @@ class IndexDb {
r.gpsLat, r.gpsLat,
r.gpsLon, r.gpsLon,
r.camera, r.camera,
dirname(r.path),
r.indexedAt r.indexedAt
] ]
) )
+8 -1
View File
@@ -71,8 +71,15 @@ class Indexer {
try { try {
const st = await stat(file) const st = await stat(file)
const mtime = Math.floor(st.mtimeMs) const mtime = Math.floor(st.mtimeMs)
// 빠른 스킵: 같은 경로·mtime이면 해시 없이 건너뜀 const isImageFast = mediaKind(file) === 'image'
// 이미지: 같은 경로·mtime이면 해시 없이 빠른 스킵.
// 영상: 썸네일이 없을 수 있어, 색인됐어도 통과시켜 backfill (해시로 썸네일 존재 확인).
let fastSkip = false
if (indexDb.isIndexedPath(file, mtime)) { if (indexDb.isIndexedPath(file, mtime)) {
if (isImageFast) fastSkip = true
else if (await hasThumb(await contentHash(file))) fastSkip = true
}
if (fastSkip) {
skipped++ skipped++
} else { } else {
const hash = await contentHash(file) const hash = await contentHash(file)
+50 -1
View File
@@ -2,12 +2,15 @@ import { ipcMain, dialog, BrowserWindow, app, shell } from 'electron'
import { writeFile, mkdir } from 'node:fs/promises' import { writeFile, mkdir } from 'node:fs/promises'
import { join, extname, basename } from 'node:path' import { join, extname, basename } from 'node:path'
import { safeCopy } from './fileOps' import { safeCopy } from './fileOps'
import { readFullExif } from './exif'
import { listDir, makeDir, trashDir, moveDir } from './fsExplorer'
import type { import type {
ProfileInput, ProfileInput,
JobRequest, JobRequest,
ReferenceData, ReferenceData,
AssetQuery, AssetQuery,
ColorLabel ColorLabel,
AssetMetadata
} from '@shared/types' } from '@shared/types'
import { IPC, SUPPORTED_EXTENSIONS } from '@shared/constants' import { IPC, SUPPORTED_EXTENSIONS } from '@shared/constants'
import { profileStore } from './profileStore' import { profileStore } from './profileStore'
@@ -175,6 +178,46 @@ export function registerIpc(): void {
ipcMain.handle(IPC.INDEX_FACETS, () => indexDb.facets()) 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[]) => { ipcMain.handle(IPC.INDEX_EXPORT, async (e, assetIds: number[]) => {
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
const r = await dialog.showOpenDialog(win!, { const r = await dialog.showOpenDialog(win!, {
@@ -225,6 +268,12 @@ export function registerIpc(): void {
// ---- 지도 / 연관 탐색 (Phase C) ---- // ---- 지도 / 연관 탐색 (Phase C) ----
ipcMain.handle(IPC.MAP_ASSETS, () => indexDb.assetsWithGps()) ipcMain.handle(IPC.MAP_ASSETS, () => indexDb.assetsWithGps())
ipcMain.handle(IPC.MAP_RELATED, (_e, assetId: number) => relatedAssets(assetId)) 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) ---- // ---- 그룹화 / 자가정화 (Phase 3) ----
ipcMain.handle(IPC.GROUPS_BUILD, (_e, threshold: number) => buildGroups(threshold)) ipcMain.handle(IPC.GROUPS_BUILD, (_e, threshold: number) => buildGroups(threshold))
+64 -7
View File
@@ -1,9 +1,10 @@
import { protocol } from 'electron' import { protocol } from 'electron'
import { readFile } from 'node:fs/promises' import { readFile, stat, open } from 'node:fs/promises'
import { extname } from 'node:path' import { extname } from 'node:path'
import { MEDIA_SCHEME } from '@shared/constants' import { MEDIA_SCHEME } from '@shared/constants'
import { profileStore } from './profileStore' import { profileStore } from './profileStore'
import { presetStore } from './presetStore' import { presetStore } from './presetStore'
import { indexDb } from './indexDb'
import { thumbPath } from './thumbnails' import { thumbPath } from './thumbnails'
import { logger } from './logger' import { logger } from './logger'
@@ -11,7 +12,16 @@ const MIME: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.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)) const data = await readFile(thumbPath(hash))
return new Response(new Uint8Array(data), { return new Response(new Uint8Array(data), {
status: 200, 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 }) if (i < 0) return new Response('missing path', { status: 400 })
const filePath = decodeURIComponent(url.slice(i + marker.length)) const filePath = decodeURIComponent(url.slice(i + marker.length))
// 등록된 참조 이미지(활성 프로필 또는 프리셋)에 한해 제공 — 임의 파일 읽기 차단 // 등록된 참조 이미지(활성 프로필/프리셋) 또는 라이브러리 인덱스 등록 경로에 한해 제공
// → 임의 파일 읽기 차단
const allowed = const allowed =
(await profileStore.isReferenceImage(filePath)) || (await profileStore.isReferenceImage(filePath)) ||
(await presetStore.isReferenceImage(filePath)) (await presetStore.isReferenceImage(filePath)) ||
indexDb.hasPath(filePath)
if (!allowed) return new Response('forbidden', { status: 403 }) 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로 직접 읽어 바이트 반환 → 한글/공백 경로에도 안전 // net.fetch(file://) 대신 fs로 직접 읽어 바이트 반환 → 한글/공백 경로에도 안전
const data = await readFile(filePath) const data = await readFile(filePath)
const mime = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream'
return new Response(new Uint8Array(data), { return new Response(new Uint8Array(data), {
status: 200, 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) { } catch (err) {
logger.error('media 프로토콜 처리 실패', { message: (err as Error).message }) logger.error('media 프로토콜 처리 실패', { message: (err as Error).message })
+22 -1
View File
@@ -8,6 +8,7 @@ import type {
ReferenceData, ReferenceData,
AssetQuery, AssetQuery,
ColorLabel, ColorLabel,
AssetMetadata,
RendererEventName, RendererEventName,
RendererEvents RendererEvents
} from '../shared/types' } from '../shared/types'
@@ -70,6 +71,7 @@ const api: ExposedApi = {
assetIds: (query: AssetQuery) => ipcRenderer.invoke(IPC.INDEX_ASSET_IDS, query), assetIds: (query: AssetQuery) => ipcRenderer.invoke(IPC.INDEX_ASSET_IDS, query),
facets: () => ipcRenderer.invoke(IPC.INDEX_FACETS), facets: () => ipcRenderer.invoke(IPC.INDEX_FACETS),
export: (assetIds: number[]) => ipcRenderer.invoke(IPC.INDEX_EXPORT, assetIds), export: (assetIds: number[]) => ipcRenderer.invoke(IPC.INDEX_EXPORT, assetIds),
exif: (assetId: number) => ipcRenderer.invoke(IPC.INDEX_EXIF, assetId),
setRating: (assetId: number, rating: number) => setRating: (assetId: number, rating: number) =>
ipcRenderer.invoke(IPC.INDEX_SET_RATING, assetId, rating), ipcRenderer.invoke(IPC.INDEX_SET_RATING, assetId, rating),
setLabel: (assetId: number, label: ColorLabel) => setLabel: (assetId: number, label: ColorLabel) =>
@@ -81,14 +83,33 @@ const api: ExposedApi = {
status: () => ipcRenderer.invoke(IPC.SEARCH_STATUS), status: () => ipcRenderer.invoke(IPC.SEARCH_STATUS),
query: (text: string) => ipcRenderer.invoke(IPC.SEARCH_QUERY, text) 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: { map: {
assets: () => ipcRenderer.invoke(IPC.MAP_ASSETS), 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: { groups: {
build: (threshold: number) => ipcRenderer.invoke(IPC.GROUPS_BUILD, threshold), build: (threshold: number) => ipcRenderer.invoke(IPC.GROUPS_BUILD, threshold),
trash: (assetIds: number[]) => ipcRenderer.invoke(IPC.GROUPS_TRASH, assetIds) 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로 드롭된 파일의 실제 경로 획득 // Electron 33: File.path 제거됨 → webUtils로 드롭된 파일의 실제 경로 획득
getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File), getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File),
on<E extends RendererEventName>(event: E, cb: (payload: RendererEvents[E]) => void) { on<E extends RendererEventName>(event: E, cb: (payload: RendererEvents[E]) => void) {
+23 -15
View File
@@ -12,6 +12,7 @@ import { LibraryView } from './components/LibraryView'
import { SearchView } from './components/SearchView' import { SearchView } from './components/SearchView'
import { GroupsView } from './components/GroupsView' import { GroupsView } from './components/GroupsView'
import { MapView } from './components/MapView' import { MapView } from './components/MapView'
import { FileExplorer } from './components/FileExplorer'
import type { AppView } from './store' import type { AppView } from './store'
export default function App(): JSX.Element { export default function App(): JSX.Element {
@@ -96,24 +97,31 @@ export default function App(): JSX.Element {
)} )}
{view === 'organize' ? ( {view === 'organize' ? (
<main className="flex-1 min-h-0 grid grid-cols-12 gap-3 p-4"> <main className="flex-1 min-h-0 flex">
{/* 좌측: 설정 패널 (자체 스크롤) */} {/* 좌측: 파일 탐색기 사이드바 */}
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2"> <aside className="w-56 shrink-0 bg-slate-100 dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700">
<ProfileManager /> <FileExplorer />
<FolderPicker /> </aside>
<RunControl />
</section>
{/* 우측: 진행/결과 — FileList만 내부 스크롤 */} <div className="flex-1 min-w-0 min-h-0 grid grid-cols-12 gap-3 p-4">
<section className="col-span-7 min-h-0 flex flex-col gap-4"> {/* 설정 패널 (자체 스크롤) */}
<div className="shrink-0"> <section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
{phase === 'done' ? <ReportView /> : <ProgressView />} <ProfileManager />
</div> <FolderPicker />
<FileList /> <RunControl />
</section> </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> </main>
) : view === 'library' ? ( ) : view === 'library' ? (
<main className="flex-1 min-h-0 overflow-y-auto p-4"> <main className="flex-1 min-h-0">
<LibraryView /> <LibraryView />
</main> </main>
) : view === 'search' ? ( ) : view === 'search' ? (
+215
View File
@@ -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>
)
}
+37 -4
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useStore } from '../store' import { useStore } from '../store'
import { useT } from '../i18n' import { useT } from '../i18n'
/** 소스 폴더 + 출력 루트 선택 */ /** 소스 폴더 + 출력 루트 선택 (찾기 / 드래그&드롭 / 탐색기 버튼) */
export function FolderPicker(): JSX.Element { export function FolderPicker(): JSX.Element {
const t = useT() const t = useT()
const source = useStore((s) => s.source) 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"> <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> <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
<Row label={t('folder.output')} value={outputRoot} placeholder={t('folder.unselected')} browse={t('folder.browse')} onPick={pickOutput} /> 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> </div>
) )
} }
@@ -34,12 +49,30 @@ function Row(props: {
placeholder: string placeholder: string
browse: string browse: string
onPick: () => void onPick: () => void
onDropPath: (path: string) => void
}): JSX.Element { }): JSX.Element {
const [over, setOver] = useState(false)
return ( return (
<div className="mb-3 last:mb-0"> <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="text-xs text-slate-500 dark:text-slate-400 mb-1">{props.label}</div>
<div className="flex gap-2"> <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} {props.value ?? props.placeholder}
</div> </div>
<button <button
File diff suppressed because it is too large Load Diff
+108 -21
View File
@@ -7,7 +7,29 @@ import type { GpsAsset, IndexedAsset } from '@shared/types'
const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'] 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 { export function MapView(): JSX.Element {
const t = useT() const t = useT()
const mapEl = useRef<HTMLDivElement | null>(null) const mapEl = useRef<HTMLDivElement | null>(null)
@@ -15,6 +37,8 @@ export function MapView(): JSX.Element {
const [count, setCount] = useState<number | null>(null) const [count, setCount] = useState<number | null>(null)
const [related, setRelated] = useState<IndexedAsset[]>([]) const [related, setRelated] = useState<IndexedAsset[]>([])
const [loadingRel, setLoadingRel] = useState(false) const [loadingRel, setLoadingRel] = useState(false)
const [untagged, setUntagged] = useState<IndexedAsset[]>([])
const [dragOver, setDragOver] = useState(false)
const showRelated = async (assetId: number) => { const showRelated = async (assetId: number) => {
setLoadingRel(true) setLoadingRel(true)
@@ -40,24 +64,14 @@ export function MapView(): JSX.Element {
setCount(assets.length) setCount(assets.length)
const pts: L.LatLngExpression[] = [] const pts: L.LatLngExpression[] = []
for (const a of assets) { for (const a of assets) {
const ll: L.LatLngExpression = [a.gpsLat, a.gpsLon] pts.push([a.gpsLat, a.gpsLon])
pts.push(ll) addGpsMarker(map, a, a.gpsLat, a.gpsLon, (id) => void showRelated(id))
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)
})
} }
if (pts.length > 0) map.fitBounds(L.latLngBounds(pts).pad(0.2)) if (pts.length > 0) map.fitBounds(L.latLngBounds(pts).pad(0.2))
}) })
void window.api.map.untagged().then((u) => {
if (!cancelled) setUntagged(u)
})
return () => { return () => {
cancelled = true 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 ( return (
<div className="max-w-5xl mx-auto w-full flex flex-col gap-4"> <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"> <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> </div>
{count === 0 && <p className="text-[11px] text-amber-600 dark:text-amber-400 mb-2">{t('map.empty')}</p>} {count === 0 && <p className="text-[11px] text-amber-600 dark:text-amber-400 mb-2">{t('map.empty')}</p>}
<div <div className="relative">
ref={mapEl} <div
className="w-full rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700" ref={mapEl}
style={{ height: '60vh' }} 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> </div>
{/* 연관 사진 패널 */} {/* 연관 사진 패널 */}
+238
View File
@@ -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>
)
}
+11 -2
View File
@@ -8,9 +8,11 @@ body,
height: 100%; height: 100%;
} }
/* darktable 톤: 밀도 높은 프로 도구 느낌 (기본 15px, 쉬운 모드에서 20px) */ /* darktable 톤: 밀도 높은 프로 도구 느낌.
창 너비에 따라 rem 기준값을 자동 보간 → 폰트·아이콘·사이드바(rem 기반)가 함께 스케일되어
해상도가 달라져도 버튼 라벨이 줄바꿈 없이 들어맞는다. (쉬운 모드는 고정 20px) */
html { html {
font-size: 15px; font-size: clamp(13px, 0.45vw + 10px, 17px);
} }
body { body {
@@ -63,6 +65,13 @@ button:focus-visible {
outline-offset: 1px; outline-offset: 1px;
} }
/* 슬라이더/체크박스 강조색을 앰버로 (제너릭 파랑 제거) */
input[type='range'],
input[type='checkbox'],
input[type='radio'] {
accent-color: #d98c3f;
}
/* 4050 쉬운 모드: <html class="easy"> — rem 기준 전체 확대 + 큰 썸네일 */ /* 4050 쉬운 모드: <html class="easy"> — rem 기준 전체 확대 + 큰 썸네일 */
html.easy { html.easy {
font-size: 20px; /* 기본 16px → rem 기반 Tailwind 유틸 전반 확대 */ font-size: 20px; /* 기본 16px → rem 기반 Tailwind 유틸 전반 확대 */
+14
View File
@@ -101,6 +101,18 @@ export const IPC = {
INDEX_ASSET_IDS: 'index:assetIds', INDEX_ASSET_IDS: 'index:assetIds',
INDEX_FACETS: 'index:facets', INDEX_FACETS: 'index:facets',
INDEX_EXPORT: 'index:export', 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_RATING: 'index:setRating',
INDEX_SET_LABEL: 'index:setLabel', INDEX_SET_LABEL: 'index:setLabel',
// 검색 (Phase 2) // 검색 (Phase 2)
@@ -116,6 +128,8 @@ export const IPC = {
// 지도 / 연관 탐색 (Phase C) // 지도 / 연관 탐색 (Phase C)
MAP_ASSETS: 'map:assets', MAP_ASSETS: 'map:assets',
MAP_RELATED: 'map:related', MAP_RELATED: 'map:related',
MAP_UNTAGGED: 'map:untagged',
MAP_SET_GPS: 'map:setGps',
// Main → UI (send) // Main → UI (send)
JOB_PROGRESS: 'job:progress', JOB_PROGRESS: 'job:progress',
JOB_FILE_PROCESSED: 'job:fileProcessed', JOB_FILE_PROCESSED: 'job:fileProcessed',
+104 -3
View File
@@ -85,6 +85,30 @@ export const MESSAGES: Table = {
'profile.analyzing': { ko: '분석 중', en: 'Analyzing' }, 'profile.analyzing': { ko: '분석 중', en: 'Analyzing' },
'profile.deletePhoto': { ko: '이 사진 삭제', en: 'Delete this photo' }, '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. 폴더 // 2. 폴더
'folder.section': { ko: '2. 폴더 선택', en: '2. Select Folders' }, 'folder.section': { ko: '2. 폴더 선택', en: '2. Select Folders' },
'folder.source': { ko: '정리할 폴더 (소스)', en: 'Folder to organize (source)' }, '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.selectHint': { ko: '지도에서 사진을 클릭하세요.', en: 'Click a photo on the map.' },
'map.loading': { ko: '불러오는 중…', en: 'Loading…' }, '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) // 그룹화 / 자가정화 (Phase 3)
'groups.section': { ko: '유사 사진 그룹 · 자가정화', en: 'Similar groups · Cleanup' }, '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.' en: 'No indexed photos. Add a folder and run indexing.'
}, },
'lib.loadMore': { ko: '더 보기', en: 'Load more' }, '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) // 선택 / 내보내기 / 삭제 (Library)
'sel.count': { ko: '{n}개 선택', en: '{n} selected' }, 'sel.count': { ko: '{n}개 선택', en: '{n} selected' },
@@ -263,9 +329,11 @@ export const MESSAGES: Table = {
'media.video': { ko: '영상', en: 'Videos' }, 'media.video': { ko: '영상', en: 'Videos' },
// 컬링 필터 / 품질 플래그 (Phase 1) // 컬링 필터 / 품질 플래그 (Phase 1)
'cull.all': { ko: '품질 전체', en: 'All' }, 'filter.kind': { ko: '종류', en: 'Type' },
'cull.candidate': { ko: '잘 나온 사진', en: 'Good shots' }, 'filter.quality': { ko: '품질', en: 'Quality' },
'cull.rejected': { ko: '걸러낼 사진', en: 'To cull' }, 'cull.all': { ko: '전체', en: 'All' },
'cull.candidate': { ko: '잘나온', en: 'Good' },
'cull.rejected': { ko: '걸러낼', en: 'Cull' },
'cull.help': { 'cull.help': {
ko: '흐리거나 눈 감은·노출 나쁜 사진을 자동으로 표시해, 잘 나온 사진만 빠르게 고를 수 있게 도와줍니다.', ko: '흐리거나 눈 감은·노출 나쁜 사진을 자동으로 표시해, 잘 나온 사진만 빠르게 고를 수 있게 도와줍니다.',
en: 'Auto-flags blurry / closed-eye / badly-exposed shots so you can quickly keep the good ones.' 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.year': { ko: '연도', en: 'Year' },
'col.camera': { ko: '카메라', en: 'Camera' }, 'col.camera': { ko: '카메라', en: 'Camera' },
'col.label': { ko: '색라벨', en: 'Label' }, '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' }, 'menu.file': { ko: '파일', en: 'File' },
+68
View File
@@ -169,6 +169,40 @@ export type QualityFilter =
/** 미디어 종류 필터 (사진/영상 분리) */ /** 미디어 종류 필터 (사진/영상 분리) */
export type MediaFilter = 'all' | 'image' | 'video' 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 { export interface AssetQuery {
filter: QualityFilter filter: QualityFilter
@@ -182,6 +216,10 @@ export interface AssetQuery {
camera?: string | null camera?: string | null
/** 색라벨 필터 */ /** 색라벨 필터 */
label?: ColorLabel label?: ColorLabel
/** 폴더 필터 (파일 탐색기) */
folder?: string | null
/** 태그 필터 */
tag?: string | null
} }
/** 컬렉션 패싯 한 항목 */ /** 컬렉션 패싯 한 항목 */
@@ -195,6 +233,7 @@ export interface Facets {
years: FacetItem[] years: FacetItem[]
cameras: FacetItem[] cameras: FacetItem[]
labels: FacetItem[] labels: FacetItem[]
folders: FacetItem[]
} }
/** 검색 색인(임베딩) 생성 진행률 */ /** 검색 색인(임베딩) 생성 진행률 */
@@ -369,11 +408,26 @@ export interface ExposedApi {
facets(): Promise<Facets> facets(): Promise<Facets>
/** 선택 자산을 폴더로 내보내기(복사). 폴더 선택 다이얼로그 후 복사. 취소 시 null */ /** 선택 자산을 폴더로 내보내기(복사). 폴더 선택 다이얼로그 후 복사. 취소 시 null */
export(assetIds: number[]): Promise<{ count: number; dest: string } | null> export(assetIds: number[]): Promise<{ count: number; dest: string } | null>
/** 자산의 상세 EXIF 정보(온디맨드) */
exif(assetId: number): Promise<ExifInfo>
/** 별점(0~5) 설정 */ /** 별점(0~5) 설정 */
setRating(assetId: number, rating: number): Promise<void> setRating(assetId: number, rating: number): Promise<void>
/** 색라벨 설정 */ /** 색라벨 설정 */
setLabel(assetId: number, label: ColorLabel): 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) */ /** 자연어/유사 검색 (Phase 2) */
search: { search: {
/** 검색 색인(CLIP 임베딩) 생성 시작 */ /** 검색 색인(CLIP 임베딩) 생성 시작 */
@@ -389,6 +443,10 @@ export interface ExposedApi {
assets(): Promise<GpsAsset[]> assets(): Promise<GpsAsset[]>
/** 특정 사진과 관련된 사진(장소+시간+유사도) */ /** 특정 사진과 관련된 사진(장소+시간+유사도) */
related(assetId: number): Promise<IndexedAsset[]> related(assetId: number): Promise<IndexedAsset[]>
/** GPS가 없는 자산(지오태깅 드래그 소스) */
untagged(): Promise<IndexedAsset[]>
/** 자산에 GPS 좌표 부여(지오태깅) */
setGps(id: number, lat: number, lon: number): Promise<void>
} }
/** 스마트 그룹화 / 자가정화 (Phase 3) */ /** 스마트 그룹화 / 자가정화 (Phase 3) */
groups: { groups: {
@@ -397,6 +455,16 @@ export interface ExposedApi {
/** 선택 자산을 OS 휴지통으로 이동하고 인덱스에서 제거. 처리 수 반환 */ /** 선택 자산을 OS 휴지통으로 이동하고 인덱스에서 제거. 처리 수 반환 */
trash(assetIds: number[]): Promise<number> 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). 경로가 없으면 빈 문자열. */ /** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */
getPathForFile(file: unknown): string getPathForFile(file: unknown): string
on<E extends RendererEventName>( on<E extends RendererEventName>(