# 8-cut Client Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a Tauri + Svelte desktop client with full feature parity to the Qt app, connecting to the 8-cut server API. **Architecture:** Tauri (Rust) manages an mpv sidecar process via JSON IPC. Svelte renders the UI in a webview. All data comes from the server REST API. Export progress arrives over WebSocket. **Tech Stack:** Tauri v2, Svelte 5, TypeScript, Vite, Rust, mpv (sidecar via IPC) --- ### Task 1: Install Rust toolchain **Step 1: Install rustup + stable toolchain** ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source "$HOME/.cargo/env" rustc --version cargo --version ``` **Step 2: Install Tauri CLI and system dependencies** ```bash cargo install tauri-cli # Tauri v2 Linux dependencies sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl appmenu-gtk-module gtk3 libappindicator-gtk3 librsvg patchelf ``` **Step 3: Commit nothing** — toolchain install only. --- ### Task 2: Scaffold Tauri + Svelte project **Files:** - Create: `client/` (entire scaffold) **Step 1: Create the project** ```bash cd /media/p5/8-cut pnpm create tauri-app client --template svelte-ts --manager pnpm cd client pnpm install ``` **Step 2: Verify it builds and opens** ```bash cd /media/p5/8-cut/client pnpm tauri dev ``` Expected: A blank Tauri window opens with the default Svelte template. **Step 3: Clean up template** Replace `client/src/App.svelte`: ```svelte

8-cut

``` **Step 4: Commit** ```bash git add client/ git commit -m "feat: scaffold Tauri + Svelte client" ``` --- ### Task 3: API client module **Files:** - Create: `client/src/lib/api.ts` **Step 1: Create the API client** ```typescript const DEFAULT_SERVER = "http://192.168.1.51:8000"; let serverUrl = DEFAULT_SERVER; export function setServer(url: string) { serverUrl = url.replace(/\/+$/, ""); } export function getServer(): string { return serverUrl; } async function get(path: string): Promise { const res = await fetch(`${serverUrl}${path}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } async function post(path: string, body?: unknown): Promise { const res = await fetch(`${serverUrl}${path}`, { method: "POST", headers: body ? { "Content-Type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } async function del(path: string): Promise { const res = await fetch(`${serverUrl}${path}`, { method: "DELETE" }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } // --- Files --- export interface VideoFile { name: string; path: string; root: string; size: number; } export function getRoots(): Promise { return get("/api/roots"); } export function getFiles(root?: string): Promise { const q = root ? `?root=${encodeURIComponent(root)}` : ""; return get(`/api/files${q}`); } export function streamUrl(path: string, root: string, quality: string): string { return `${serverUrl}/api/stream/${encodeURIComponent(path)}?root=${encodeURIComponent(root)}&quality=${quality}`; } export function audioUrl(path: string, root: string): string { return `${serverUrl}/api/audio/${encodeURIComponent(path)}?root=${encodeURIComponent(root)}`; } export function cacheStatus(path: string, root: string): Promise> { return get(`/api/cache/status/${encodeURIComponent(path)}?root=${encodeURIComponent(root)}`); } // --- Markers & Profiles --- export interface Marker { start_time: number; marker_number: number; output_path: string; } export function getMarkers(filename: string, profile: string = "default"): Promise { return get(`/api/markers/${encodeURIComponent(filename)}?profile=${encodeURIComponent(profile)}`); } export function getProfiles(): Promise { return get("/api/profiles"); } export function getLabels(): Promise { return get("/api/labels"); } // --- Export --- export interface ExportRequest { input_path: string; cursor: number; name: string; clips?: number; spread?: number; short_side?: number | null; portrait_ratio?: string | null; crop_center?: number; format?: string; label?: string; category?: string; profile?: string; folder_suffix?: string; encoder?: string; } export function startExport(req: ExportRequest): Promise<{ job_id: string }> { return post("/api/export", req); } export function getExportStatus(jobId: string): Promise<{ status: string; total: number; completed: number; outputs: string[]; error?: string; }> { return get(`/api/export/${jobId}`); } export function deleteExport(outputPath: string): Promise<{ deleted: string }> { return del(`/api/export?output_path=${encodeURIComponent(outputPath)}`); } // --- Hidden --- export function hideFile(filename: string, profile: string = "default"): Promise { return post(`/api/hidden/${encodeURIComponent(filename)}?profile=${encodeURIComponent(profile)}`); } export function unhideFile(filename: string, profile: string = "default"): Promise { return del(`/api/hidden/${encodeURIComponent(filename)}?profile=${encodeURIComponent(profile)}`); } export function getHidden(profile: string = "default"): Promise { return get(`/api/hidden?profile=${encodeURIComponent(profile)}`); } ``` **Step 2: Commit** ```bash git add client/src/lib/api.ts git commit -m "feat: add server API client module" ``` --- ### Task 4: Svelte stores **Files:** - Create: `client/src/lib/stores.ts` **Step 1: Create reactive stores** ```typescript import { writable, derived } from "svelte/store"; import type { VideoFile, Marker } from "./api"; // --- Connection --- export const serverUrl = writable("http://192.168.1.51:8000"); // --- Files --- export const roots = writable([]); export const files = writable([]); export const hiddenFiles = writable>(new Set()); export const currentFile = writable(null); export const hideExported = writable(false); export const showHidden = writable(false); // --- Playback --- export const duration = writable(0); export const cursor = writable(0); export const playPos = writable(null); export const playing = writable(false); export const quality = writable("low"); // --- Timeline --- export const markers = writable([]); export const locked = writable(false); // --- Export settings --- export const clips = writable(3); export const spread = writable(3.0); export const shortSide = writable(512); export const portraitRatio = writable(null); export const cropCenter = writable(0.5); export const format = writable("MP4"); export const hwEncode = writable(false); export const label = writable(""); export const category = writable(""); export const clipName = writable(""); export const exportFolder = writable(""); export const encoder = writable("libx264"); export const trackSubject = writable(false); export const randPortrait = writable(false); export const randSquare = writable(false); // --- Profiles --- export const profile = writable("default"); export const subprofiles = writable([]); // --- Export progress --- export const exportStatus = writable("idle"); // idle | running | done | error export const exportCompleted = writable(0); export const exportTotal = writable(0); // --- Derived --- export const clipSpan = derived( [clips, spread], ([$clips, $spread]) => 8.0 + ($clips - 1) * $spread ); export const visibleFiles = derived( [files, hiddenFiles, hideExported, showHidden, markers], ([$files, $hidden, $hideExported, $showHidden, $markers]) => { const exportedNames = new Set($markers.map(m => m.output_path)); return $files.filter(f => { if (!$showHidden && $hidden.has(f.name)) return false; // hideExported filtering would need per-file marker lookup return true; }); } ); ``` **Step 2: Commit** ```bash git add client/src/lib/stores.ts git commit -m "feat: add Svelte stores for app state" ``` --- ### Task 5: WebSocket export progress **Files:** - Create: `client/src/lib/ws.ts` **Step 1: Create WebSocket client** ```typescript import { getServer } from "./api"; import { exportStatus, exportCompleted } from "./stores"; let socket: WebSocket | null = null; export function connectExportWs() { const wsUrl = getServer().replace(/^http/, "ws") + "/ws/export"; socket = new WebSocket(wsUrl); socket.onmessage = (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case "clip_done": exportCompleted.update(n => n + 1); break; case "all_done": exportStatus.set("done"); break; case "error": exportStatus.set("error"); console.error("Export error:", msg.msg); break; } }; socket.onclose = () => { // Reconnect after 2s setTimeout(connectExportWs, 2000); }; } export function disconnectExportWs() { if (socket) { socket.onclose = null; // prevent reconnect socket.close(); socket = null; } } ``` **Step 2: Commit** ```bash git add client/src/lib/ws.ts git commit -m "feat: add WebSocket client for export progress" ``` --- ### Task 6: mpv sidecar — Rust backend **Files:** - Create: `client/src-tauri/src/mpv.rs` - Modify: `client/src-tauri/src/main.rs` - Modify: `client/src-tauri/src/lib.rs` **Step 1: Create mpv.rs** This module spawns mpv with `--input-ipc-server`, then sends JSON IPC commands over the Unix socket. ```rust use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::process::{Child, Command}; use std::sync::Mutex; use serde_json::{json, Value}; pub struct Mpv { process: Option, socket: Option, socket_path: String, } impl Mpv { pub fn new() -> Self { let socket_path = format!("/tmp/8cut-mpv-{}", std::process::id()); Mpv { process: None, socket: None, socket_path, } } pub fn start(&mut self) -> Result<(), String> { // Kill existing self.stop(); let child = Command::new("mpv") .args([ "--idle=yes", "--force-window=no", "--vo=null", "--keep-open=yes", &format!("--input-ipc-server={}", self.socket_path), ]) .spawn() .map_err(|e| format!("Failed to start mpv: {e}"))?; self.process = Some(child); // Wait for socket for _ in 0..50 { std::thread::sleep(std::time::Duration::from_millis(100)); if let Ok(stream) = UnixStream::connect(&self.socket_path) { stream.set_nonblocking(false).ok(); self.socket = Some(stream); return Ok(()); } } Err("Timeout waiting for mpv IPC socket".into()) } pub fn stop(&mut self) { if let Some(ref mut child) = self.process { child.kill().ok(); child.wait().ok(); } self.process = None; self.socket = None; std::fs::remove_file(&self.socket_path).ok(); } pub fn command(&mut self, args: &[&str]) -> Result<(), String> { let socket = self.socket.as_mut().ok_or("mpv not running")?; let cmd = json!({ "command": args }); let mut msg = serde_json::to_string(&cmd).unwrap(); msg.push('\n'); socket.write_all(msg.as_bytes()).map_err(|e| e.to_string())?; // Read response let mut reader = BufReader::new(socket.try_clone().map_err(|e| e.to_string())?); let mut line = String::new(); reader.read_line(&mut line).map_err(|e| e.to_string())?; Ok(()) } pub fn set_property(&mut self, name: &str, value: Value) -> Result<(), String> { let socket = self.socket.as_mut().ok_or("mpv not running")?; let cmd = json!({ "command": ["set_property", name, value] }); let mut msg = serde_json::to_string(&cmd).unwrap(); msg.push('\n'); socket.write_all(msg.as_bytes()).map_err(|e| e.to_string())?; let mut reader = BufReader::new(socket.try_clone().map_err(|e| e.to_string())?); let mut line = String::new(); reader.read_line(&mut line).map_err(|e| e.to_string())?; Ok(()) } pub fn get_property(&mut self, name: &str) -> Result { let socket = self.socket.as_mut().ok_or("mpv not running")?; let cmd = json!({ "command": ["get_property", name] }); let mut msg = serde_json::to_string(&cmd).unwrap(); msg.push('\n'); socket.write_all(msg.as_bytes()).map_err(|e| e.to_string())?; let mut reader = BufReader::new(socket.try_clone().map_err(|e| e.to_string())?); let mut line = String::new(); reader.read_line(&mut line).map_err(|e| e.to_string())?; let resp: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; Ok(resp.get("data").cloned().unwrap_or(Value::Null)) } pub fn load_file(&mut self, video_url: &str, audio_url: &str) -> Result<(), String> { self.command(&["loadfile", video_url])?; self.set_property("audio-files", json!(audio_url))?; Ok(()) } pub fn seek(&mut self, time: f64) -> Result<(), String> { self.command(&["seek", &time.to_string(), "absolute"]) } pub fn pause(&mut self) -> Result<(), String> { self.set_property("pause", json!(true)) } pub fn resume(&mut self) -> Result<(), String> { self.set_property("pause", json!(false)) } pub fn set_loop(&mut self, a: f64, b: f64) -> Result<(), String> { self.set_property("ab-loop-a", json!(a))?; self.set_property("ab-loop-b", json!(b)) } pub fn clear_loop(&mut self) -> Result<(), String> { self.set_property("ab-loop-a", json!("no"))?; self.set_property("ab-loop-b", json!("no")) } pub fn time_pos(&mut self) -> Result { let val = self.get_property("time-pos")?; val.as_f64().ok_or("time-pos not a number".into()) } pub fn get_duration(&mut self) -> Result { let val = self.get_property("duration")?; val.as_f64().ok_or("duration not a number".into()) } } impl Drop for Mpv { fn drop(&mut self) { self.stop(); } } ``` **Step 2: Create Tauri commands in commands.rs** Create `client/src-tauri/src/commands.rs`: ```rust use tauri::State; use std::sync::Mutex; use serde_json::Value; use crate::mpv::Mpv; pub struct MpvState(pub Mutex); #[tauri::command] pub fn mpv_start(state: State) -> Result<(), String> { state.0.lock().unwrap().start() } #[tauri::command] pub fn mpv_stop(state: State) -> Result<(), String> { state.0.lock().unwrap().stop(); Ok(()) } #[tauri::command] pub fn mpv_load(state: State, video_url: String, audio_url: String) -> Result<(), String> { state.0.lock().unwrap().load_file(&video_url, &audio_url) } #[tauri::command] pub fn mpv_seek(state: State, time: f64) -> Result<(), String> { state.0.lock().unwrap().seek(time) } #[tauri::command] pub fn mpv_pause(state: State) -> Result<(), String> { state.0.lock().unwrap().pause() } #[tauri::command] pub fn mpv_resume(state: State) -> Result<(), String> { state.0.lock().unwrap().resume() } #[tauri::command] pub fn mpv_set_loop(state: State, a: f64, b: f64) -> Result<(), String> { state.0.lock().unwrap().set_loop(a, b) } #[tauri::command] pub fn mpv_clear_loop(state: State) -> Result<(), String> { state.0.lock().unwrap().clear_loop() } #[tauri::command] pub fn mpv_time_pos(state: State) -> Result { state.0.lock().unwrap().time_pos() } #[tauri::command] pub fn mpv_duration(state: State) -> Result { state.0.lock().unwrap().get_duration() } ``` **Step 3: Wire up main.rs / lib.rs** `client/src-tauri/src/lib.rs`: ```rust mod mpv; mod commands; use commands::MpvState; use mpv::Mpv; use std::sync::Mutex; pub fn run() { tauri::Builder::default() .manage(MpvState(Mutex::new(Mpv::new()))) .invoke_handler(tauri::generate_handler![ commands::mpv_start, commands::mpv_stop, commands::mpv_load, commands::mpv_seek, commands::mpv_pause, commands::mpv_resume, commands::mpv_set_loop, commands::mpv_clear_loop, commands::mpv_time_pos, commands::mpv_duration, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` `client/src-tauri/src/main.rs`: ```rust #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { client_lib::run(); } ``` Add `serde_json` to `client/src-tauri/Cargo.toml` dependencies: ```toml [dependencies] serde_json = "1" serde = { version = "1", features = ["derive"] } tauri = { version = "2", features = [] } tauri-build = { version = "2", features = [] } ``` **Step 4: Verify it compiles** ```bash cd /media/p5/8-cut/client pnpm tauri build --debug 2>&1 | tail -5 ``` **Step 5: Commit** ```bash git add client/src-tauri/ git commit -m "feat: add mpv sidecar IPC and Tauri commands" ``` --- ### Task 7: mpv TypeScript bridge **Files:** - Create: `client/src/lib/mpv.ts` **Step 1: Create the bridge** ```typescript import { invoke } from "@tauri-apps/api/core"; export async function mpvStart(): Promise { return invoke("mpv_start"); } export async function mpvStop(): Promise { return invoke("mpv_stop"); } export async function mpvLoad(videoUrl: string, audioUrl: string): Promise { return invoke("mpv_load", { videoUrl, audioUrl }); } export async function mpvSeek(time: number): Promise { return invoke("mpv_seek", { time }); } export async function mpvPause(): Promise { return invoke("mpv_pause"); } export async function mpvResume(): Promise { return invoke("mpv_resume"); } export async function mpvSetLoop(a: number, b: number): Promise { return invoke("mpv_set_loop", { a, b }); } export async function mpvClearLoop(): Promise { return invoke("mpv_clear_loop"); } export async function mpvTimePos(): Promise { return invoke("mpv_time_pos"); } export async function mpvDuration(): Promise { return invoke("mpv_duration"); } ``` **Step 2: Commit** ```bash git add client/src/lib/mpv.ts git commit -m "feat: add mpv TypeScript bridge" ``` --- ### Task 8: File browser component **Files:** - Create: `client/src/components/FileBrowser.svelte` **Step 1: Create file browser** ```svelte
    {#each filteredFiles as file}
  • selectFile(file)} on:contextmenu|preventDefault={() => { if ($hiddenFiles.has(file.name)) { unhideFile(file.name, $profile).then(loadFiles); } else { hideFile(file.name, $profile).then(loadFiles); } }} > {file.name} {formatSize(file.size)}
  • {/each}
``` **Step 2: Commit** ```bash git add client/src/components/FileBrowser.svelte git commit -m "feat: add file browser component" ``` --- ### Task 9: Timeline component **Files:** - Create: `client/src/components/Timeline.svelte` **Step 1: Create canvas-based timeline** ```svelte ``` **Step 2: Commit** ```bash git add client/src/components/Timeline.svelte git commit -m "feat: add canvas-based timeline component" ``` --- ### Task 10: Export panel component **Files:** - Create: `client/src/components/ExportPanel.svelte` **Step 1: Create the export controls** ```svelte
{#each $subprofiles as sub, i} {/each}
``` **Step 2: Commit** ```bash git add client/src/components/ExportPanel.svelte git commit -m "feat: add export panel component" ``` --- ### Task 11: Profile bar component **Files:** - Create: `client/src/components/ProfileBar.svelte` **Step 1: Create profile bar** ```svelte
{#each $subprofiles as sub} removeSubprofile(sub)}> {sub} {/each}
``` **Step 2: Commit** ```bash git add client/src/components/ProfileBar.svelte git commit -m "feat: add profile bar component" ``` --- ### Task 12: Main App layout — wire everything together **Files:** - Modify: `client/src/App.svelte` **Step 1: Compose the main layout** ```svelte
{#if $currentFile}

{$currentFile.name}

{:else}

Select a file

{/if}
{#if $duration > 0} {($cursor / 60).toFixed(0)}:{($cursor % 60).toFixed(1).padStart(4, "0")} / {($duration / 60).toFixed(0)}:{($duration % 60).toFixed(1).padStart(4, "0")} {/if}
``` **Step 2: Verify** ```bash cd /media/p5/8-cut/client pnpm tauri dev ``` Expected: Window opens with sidebar file browser, player area, timeline, transport bar, and export panel. Selecting a file triggers mpv load + stream. **Step 3: Commit** ```bash git add client/src/App.svelte git commit -m "feat: wire up main app layout with all components" ``` --- ### Task 13: Keyboard shortcuts **Files:** - Modify: `client/src/App.svelte` **Step 1: Add global keydown handler** Add to the `