// 유일한 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("app_info"); export const listSources = () => invoke("list_sources"); export const addLocalSource = (path: string) => invoke("add_local_source", { path }); export const cancelScan = () => invoke("cancel_scan"); export const folderTree = (sourceId: number) => invoke("folder_tree", { sourceId }); export const fileDetails = (ids: number[]) => invoke("file_details", { ids }); export const setThumbPriority = (ids: number[]) => invoke("set_thumb_priority", { ids }); export function startScan( sourceId: number, onProgress: (p: ScanProgress) => void, ): Promise { const ch = new Channel(); ch.onmessage = onProgress; return invoke("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 { const buf = await invoke("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 { const buf = await invoke("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("list_tags"); export const createTag = (name: string, color?: string) => invoke("create_tag", { name, color: color ?? null }); export const renameTag = (tagId: number, name: string) => invoke("rename_tag", { tagId, name }); export const deleteTag = (tagId: number) => invoke("delete_tag", { tagId }); export const assignTags = (fileIds: number[], tagId: number) => invoke("assign_tags", { fileIds, tagId }); export const unassignTags = (fileIds: number[], tagId: number) => invoke("unassign_tags", { fileIds, tagId }); export const fileTagsOf = (fileId: number) => invoke("file_tags_of", { fileId }); export const exportTags = (path?: string) => invoke("export_tags", { path: path ?? null }); export async function tagSnapshot(tagId: number): Promise { return new Snapshot(await invoke("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 { const ch = new Channel(); ch.onmessage = onProgress ?? (() => {}); return invoke("move_files", { fileIds, destFolderId, onProgress: ch }); } export const renameFile = (fileId: number, newName: string) => invoke("rename_file", { fileId, newName }); export function trashFiles( fileIds: number[], onProgress?: (p: OpProgress) => void, ): Promise { const ch = new Channel(); ch.onmessage = onProgress ?? (() => {}); return invoke("trash_files", { fileIds, onProgress: ch }); } export const undoLast = () => invoke("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("list_smart_folders"); export const createSmartFolder = (name: string, query: SmartQuery) => invoke("create_smart_folder", { name, query }); export const deleteSmartFolder = (id: number) => invoke("delete_smart_folder", { id }); export async function smartSnapshot(id: number): Promise { return new Snapshot(await invoke("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("add_remote_source", { input }); export const connectRemoteSource = (sourceId: number) => invoke("connect_remote_source", { sourceId }); export const scanRemoteSource = (sourceId: number) => invoke("scan_remote_source", { sourceId }); export const trashRemoteFiles = (sourceId: number, fileIds: number[]) => invoke("trash_remote_files", { sourceId, fileIds }); export const unlockVault = (passphrase: string) => invoke("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 { const ch = new Channel(); ch.onmessage = onProgress; return invoke("start_dedupe", { sourceId: args.sourceId ?? null, imageThreshold: args.imageThreshold ?? 5, includeVideo: args.includeVideo ?? false, onProgress: ch, }); } export const cancelDedupe = () => invoke("cancel_dedupe"); export const listDupeGroups = () => invoke("list_dupe_groups"); export const resolveDupeGroup = (groupId: number) => invoke("resolve_dupe_group", { groupId }); export const dismissDupePair = (fileA: number, fileB: number) => invoke("dismiss_dupe_pair", { fileA, fileB }); export const onDedupeDone = (cb: () => void): Promise => 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("playback_info", { fileId }); export const startStream = ( fileId: number, startSeconds: number, video: VideoMode, audio: AudioMode, ) => invoke("start_stream", { fileId, startSeconds, video, audio }); export const stopStream = (jobId: number) => invoke("stop_stream", { jobId }); // ── 재생 결정 로직 ────────────────────────────────────── const V_MIME: Record = { 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 = { 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 => listen("thumbs-ready", (e) => cb(e.payload)); export const onLibraryChanged = (cb: (sourceId: number) => void): Promise => listen("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}`; }