Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램

Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼
사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드,
인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포.

- archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram
- archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트)
- archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수
- archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡
- 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널

Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-16 22:04:06 +09:00
co-authored by Claude Opus 4.8
commit 394d1b9805
132 changed files with 23468 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
// 유일한 RPC 경계 — Rust 커맨드 호출은 전부 이 모듈을 통해서만 한다.
import { invoke, Channel } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type { UnlistenFn };
export interface AppInfo {
version: string;
dataDir: string;
portable: boolean;
mediaPort: number;
mediaToken: string;
}
export interface Source {
id: number;
kind: string;
name: string;
root: string;
fileCount: number;
}
export interface FolderNode {
id: number;
parentId: number | null;
name: string;
fileCount: number;
}
export interface FileDetails {
id: number;
name: string;
dir: string;
size: number;
mtimeMs: number;
width: number | null;
height: number | null;
durationMs: number | null;
kind: number;
takenAt: number | null;
}
export interface ScanProgress {
scanId: number;
phase: "enumerate" | "metadata" | "done";
seen: number;
currentDir: string;
done: boolean;
}
export const appInfo = () => invoke<AppInfo>("app_info");
export const listSources = () => invoke<Source[]>("list_sources");
export const addLocalSource = (path: string) =>
invoke<Source>("add_local_source", { path });
export const cancelScan = () => invoke<void>("cancel_scan");
export const folderTree = (sourceId: number) =>
invoke<FolderNode[]>("folder_tree", { sourceId });
export const fileDetails = (ids: number[]) =>
invoke<FileDetails[]>("file_details", { ids });
export const setThumbPriority = (ids: number[]) =>
invoke<void>("set_thumb_priority", { ids });
export function startScan(
sourceId: number,
onProgress: (p: ScanProgress) => void,
): Promise<void> {
const ch = new Channel<ScanProgress>();
ch.onmessage = onProgress;
return invoke<void>("start_scan", { sourceId, onProgress: ch });
}
/** 스냅샷: 항목당 10바이트 packed 바이너리 (id u32, w u16, h u16, kind u8, flags u8) */
export const ITEM_BYTES = 10;
export class Snapshot {
private view: DataView;
readonly count: number;
constructor(buf: ArrayBuffer) {
this.view = new DataView(buf);
this.count = Math.floor(buf.byteLength / ITEM_BYTES);
}
id(i: number): number {
return this.view.getUint32(i * ITEM_BYTES, true);
}
width(i: number): number {
return this.view.getUint16(i * ITEM_BYTES + 4, true);
}
height(i: number): number {
return this.view.getUint16(i * ITEM_BYTES + 6, true);
}
kind(i: number): number {
return this.view.getUint8(i * ITEM_BYTES + 8);
}
hasThumb(i: number): boolean {
return (this.view.getUint8(i * ITEM_BYTES + 9) & 1) !== 0;
}
/** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
aspectRatios(): Float32Array {
const out = new Float32Array(this.count);
for (let i = 0; i < this.count; i++) {
const w = this.width(i);
const h = this.height(i);
out[i] = w > 0 && h > 0 ? w / h : 1;
}
return out;
}
}
export async function folderSnapshot(args: {
sourceId?: number;
folderId?: number;
recursive?: boolean;
}): Promise<Snapshot> {
const buf = await invoke<ArrayBuffer>("folder_snapshot", {
sourceId: args.sourceId ?? null,
folderId: args.folderId ?? null,
recursive: args.recursive ?? true,
});
return new Snapshot(buf);
}
export async function searchSnapshot(query: string): Promise<Snapshot> {
const buf = await invoke<ArrayBuffer>("search_snapshot", { query });
return new Snapshot(buf);
}
// ── 태그 ────────────────────────────────────────────────
export interface Tag {
id: number;
name: string;
color: string | null;
fileCount: number;
}
export interface TagOpResult {
batchId: string;
count: number;
}
export const listTags = () => invoke<Tag[]>("list_tags");
export const createTag = (name: string, color?: string) =>
invoke<number>("create_tag", { name, color: color ?? null });
export const renameTag = (tagId: number, name: string) =>
invoke<void>("rename_tag", { tagId, name });
export const deleteTag = (tagId: number) => invoke<void>("delete_tag", { tagId });
export const assignTags = (fileIds: number[], tagId: number) =>
invoke<TagOpResult>("assign_tags", { fileIds, tagId });
export const unassignTags = (fileIds: number[], tagId: number) =>
invoke<TagOpResult>("unassign_tags", { fileIds, tagId });
export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fileId });
export const exportTags = (path?: string) =>
invoke<string>("export_tags", { path: path ?? null });
export async function tagSnapshot(tagId: number): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId }));
}
// ── 파일 작업 ───────────────────────────────────────────
export interface OpSummary {
done: number;
skipped: number;
failed: number;
batchId: string;
}
export interface OpProgress {
done: number;
total: number;
}
export function moveFiles(
fileIds: number[],
destFolderId: number,
onProgress?: (p: OpProgress) => void,
): Promise<OpSummary> {
const ch = new Channel<OpProgress>();
ch.onmessage = onProgress ?? (() => {});
return invoke<OpSummary>("move_files", { fileIds, destFolderId, onProgress: ch });
}
export const renameFile = (fileId: number, newName: string) =>
invoke<void>("rename_file", { fileId, newName });
export function trashFiles(
fileIds: number[],
onProgress?: (p: OpProgress) => void,
): Promise<OpSummary> {
const ch = new Channel<OpProgress>();
ch.onmessage = onProgress ?? (() => {});
return invoke<OpSummary>("trash_files", { fileIds, onProgress: ch });
}
export const undoLast = () => invoke<OpSummary | null>("undo_last");
// ── 스마트 폴더 ─────────────────────────────────────────
export interface SmartQuery {
kind?: number | null;
tagIds?: number[];
untagged?: boolean;
}
export interface SmartFolder {
id: number;
name: string;
query: SmartQuery;
}
export const listSmartFolders = () => invoke<SmartFolder[]>("list_smart_folders");
export const createSmartFolder = (name: string, query: SmartQuery) =>
invoke<number>("create_smart_folder", { name, query });
export const deleteSmartFolder = (id: number) =>
invoke<void>("delete_smart_folder", { id });
export async function smartSnapshot(id: number): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("smart_snapshot", { id }));
}
// ── 원격 소스 ───────────────────────────────────────────
export interface RemoteInput {
kind: "sftp" | "ftp" | "webdav";
name: string;
host: string;
port?: number;
username: string;
password: string;
basePath?: string;
url?: string;
}
export const addRemoteSource = (input: RemoteInput) =>
invoke<number>("add_remote_source", { input });
export const connectRemoteSource = (sourceId: number) =>
invoke<boolean>("connect_remote_source", { sourceId });
export const scanRemoteSource = (sourceId: number) =>
invoke<number>("scan_remote_source", { sourceId });
export const trashRemoteFiles = (sourceId: number, fileIds: number[]) =>
invoke<OpSummary>("trash_remote_files", { sourceId, fileIds });
export const unlockVault = (passphrase: string) =>
invoke<void>("unlock_vault", { passphrase });
// ── 중복 탐지 ───────────────────────────────────────────
export interface DedupeProgress {
phase: "exact" | "image" | "video" | "done";
current: number;
total: number;
}
export interface DupeMember {
fileId: number;
name: string;
dir: string;
size: number;
width: number | null;
height: number | null;
durationMs: number | null;
kind: number;
isKeeper: boolean;
distance: number | null;
}
export interface DupeGroup {
id: number;
kind: number; // 0 exact, 1 image, 2 video
members: DupeMember[];
}
export function startDedupe(
args: { sourceId?: number; imageThreshold?: number; includeVideo?: boolean },
onProgress: (p: DedupeProgress) => void,
): Promise<void> {
const ch = new Channel<DedupeProgress>();
ch.onmessage = onProgress;
return invoke<void>("start_dedupe", {
sourceId: args.sourceId ?? null,
imageThreshold: args.imageThreshold ?? 5,
includeVideo: args.includeVideo ?? false,
onProgress: ch,
});
}
export const cancelDedupe = () => invoke<void>("cancel_dedupe");
export const listDupeGroups = () => invoke<DupeGroup[]>("list_dupe_groups");
export const resolveDupeGroup = (groupId: number) =>
invoke<void>("resolve_dupe_group", { groupId });
export const dismissDupePair = (fileA: number, fileB: number) =>
invoke<void>("dismiss_dupe_pair", { fileA, fileB });
export const onDedupeDone = (cb: () => void): Promise<UnlistenFn> =>
listen("dedupe-done", () => cb());
// ── 재생 ────────────────────────────────────────────────
export interface PlaybackInfo {
ext: string | null;
vcodec: string | null;
acodec: string | null;
durationMs: number | null;
width: number | null;
height: number | null;
streamAvailable: boolean;
}
export type VideoMode = "copy" | "copyhvc1" | "transcode";
export type AudioMode = "copy" | "aac";
export interface StreamStart {
jobId: number;
url: string;
}
export const playbackInfo = (fileId: number) =>
invoke<PlaybackInfo>("playback_info", { fileId });
export const startStream = (
fileId: number,
startSeconds: number,
video: VideoMode,
audio: AudioMode,
) => invoke<StreamStart>("start_stream", { fileId, startSeconds, video, audio });
export const stopStream = (jobId: number) => invoke<void>("stop_stream", { jobId });
// ── 재생 결정 로직 ──────────────────────────────────────
const V_MIME: Record<string, string> = {
h264: 'video/mp4; codecs="avc1.640028"',
hevc: 'video/mp4; codecs="hvc1.1.6.L120.90"',
vp9: 'video/webm; codecs="vp09.00.40.08"',
vp8: 'video/webm; codecs="vp8"',
av1: 'video/mp4; codecs="av01.0.08M.08"',
};
const A_MIME: Record<string, string> = {
aac: 'audio/mp4; codecs="mp4a.40.2"',
mp3: 'audio/mpeg',
opus: 'audio/webm; codecs="opus"',
vorbis: 'audio/webm; codecs="vorbis"',
flac: 'audio/mp4; codecs="flac"',
};
const probe = document.createElement("video");
const canPlay = (mime: string | undefined): boolean =>
!!mime && probe.canPlayType(mime) !== "";
const DIRECT_CONTAINERS = new Set(["mp4", "m4v", "mov", "webm"]);
/** fMP4 컨테이너에 카피해도 안전한 오디오 */
const MP4_SAFE_AUDIO = new Set(["aac", "mp3"]);
export type PlaybackPlan =
| { mode: "direct" }
| { mode: "stream"; video: VideoMode; audio: AudioMode }
| { mode: "unsupported"; reason: string };
export function decidePlayback(info: PlaybackInfo): PlaybackPlan {
const v = info.vcodec ?? "";
const a = info.acodec;
const vOk = canPlay(V_MIME[v]);
const aOk = !a || canPlay(A_MIME[a]);
const containerOk = DIRECT_CONTAINERS.has(info.ext ?? "");
if (containerOk && vOk && aOk) return { mode: "direct" };
if (!info.streamAvailable) {
return {
mode: "unsupported",
reason: `이 형식(${info.ext}/${v || "?"})은 ffmpeg 사이드카 없이 재생할 수 없습니다`,
};
}
// 영상: H.264(및 재생 가능한 HEVC)만 카피, 그 외 트랜스코딩
const videoMode: VideoMode =
v === "h264" && canPlay(V_MIME.h264)
? "copy"
: v === "hevc" && canPlay(V_MIME.hevc)
? "copyhvc1"
: "transcode";
const audioMode: AudioMode = !a || MP4_SAFE_AUDIO.has(a) ? "copy" : "aac";
return { mode: "stream", video: videoMode, audio: audioMode };
}
// ── 이벤트 ──────────────────────────────────────────────
export const onThumbsReady = (cb: (ids: number[]) => void): Promise<UnlistenFn> =>
listen<number[]>("thumbs-ready", (e) => cb(e.payload));
export const onLibraryChanged = (cb: (sourceId: number) => void): Promise<UnlistenFn> =>
listen<number>("library-changed", (e) => cb(e.payload));
// ── 미디어 URL ──────────────────────────────────────────
export function thumbUrl(info: AppInfo, fileId: number, sizeClass: 0 | 1 = 0): string {
return `http://127.0.0.1:${info.mediaPort}/thumb/${info.mediaToken}/${fileId}?s=${sizeClass}`;
}
export function mediaUrl(info: AppInfo, fileId: number): string {
return `http://127.0.0.1:${info.mediaPort}/media/${info.mediaToken}/${fileId}`;
}