# 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}`); } // For {path:path} routes, encode each segment individually to preserve slashes function encodePath(p: string): string { return p.split("/").map(encodeURIComponent).join("/"); } export function streamUrl(path: string, root: string, quality: string): string { return `${serverUrl}/api/stream/${encodePath(path)}?root=${encodeURIComponent(root)}&quality=${quality}`; } export function audioUrl(path: string, root: string): string { return `${serverUrl}/api/audio/${encodePath(path)}?root=${encodeURIComponent(root)}`; } export function cacheStatus(path: string, root: string): Promise> { return get(`/api/cache/status/${encodePath(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, showHidden], ([$files, $hidden, $showHidden]) => { return $files.filter(f => { if (!$showHidden && $hidden.has(f.name)) return false; 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. Uses a persistent BufReader and request_id to correctly handle mpv's interleaved events and responses. ```rust use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::process::{Child, Command}; use std::sync::atomic::{AtomicU64, Ordering}; use serde_json::{json, Value}; pub struct Mpv { process: Option, writer: Option, reader: Option>, socket_path: String, next_id: AtomicU64, } impl Mpv { pub fn new() -> Self { let socket_path = format!("/tmp/8cut-mpv-{}", std::process::id()); Mpv { process: None, writer: None, reader: None, socket_path, next_id: AtomicU64::new(1), } } pub fn start(&mut self) -> Result<(), String> { 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(); let reader_stream = stream.try_clone().map_err(|e| e.to_string())?; self.writer = Some(stream); self.reader = Some(BufReader::new(reader_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.writer = None; self.reader = None; std::fs::remove_file(&self.socket_path).ok(); } /// Send a command and wait for the matching response (by request_id). /// Skips over asynchronous mpv events while waiting. fn send_and_recv(&mut self, cmd: Value) -> Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let writer = self.writer.as_mut().ok_or("mpv not running")?; let reader = self.reader.as_mut().ok_or("mpv not running")?; let mut msg_val = cmd; msg_val["request_id"] = json!(id); let mut msg = serde_json::to_string(&msg_val).unwrap(); msg.push('\n'); writer.write_all(msg.as_bytes()).map_err(|e| e.to_string())?; // Read lines until we find the response matching our request_id let mut line = String::new(); loop { line.clear(); reader.read_line(&mut line).map_err(|e| e.to_string())?; let parsed: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; // mpv events have "event" key, responses have "request_id" if parsed.get("request_id").and_then(|v| v.as_u64()) == Some(id) { return Ok(parsed); } // Otherwise it's an async event — skip it } } pub fn command(&mut self, args: &[&str]) -> Result<(), String> { let resp = self.send_and_recv(json!({ "command": args }))?; if resp.get("error").and_then(|e| e.as_str()) != Some("success") { return Err(format!("mpv error: {}", resp.get("error").unwrap_or(&Value::Null))); } Ok(()) } pub fn set_property(&mut self, name: &str, value: Value) -> Result<(), String> { self.command(&["set_property", name, &value.to_string()]) } pub fn get_property(&mut self, name: &str) -> Result { let resp = self.send_and_recv(json!({ "command": ["get_property", name] }))?; if resp.get("error").and_then(|e| e.as_str()) != Some("success") { return Err(format!("mpv error: {}", resp.get("error").unwrap_or(&Value::Null))); } Ok(resp.get("data").cloned().unwrap_or(Value::Null)) } pub fn load_file(&mut self, video_url: &str, audio_url: &str) -> Result<(), String> { // Pass audio-file option during load so both streams sync from the start let options = format!("audio-file={}", audio_url); self.command(&["loadfile", video_url, "replace", &options]) } 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() { // Crate name matches the `name` field in Cargo.toml (with hyphens → underscores). // The Tauri scaffold sets this — adjust if the package is named differently. app_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 = [] } [build-dependencies] 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 `