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:
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user