From f7756320e552a6d37af4f7d42f7bec33bf2a45d9 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 16 Apr 2026 17:02:56 +0200 Subject: [PATCH] docs: add Tauri + Svelte client implementation plan 15-task plan covering Rust install, Tauri scaffold, mpv sidecar, API client, stores, UI components, keyboard shortcuts, and packaging. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-16-client-implementation.md | 1650 +++++++++++++++++ 1 file changed, 1650 insertions(+) create mode 100644 docs/plans/2026-04-16-client-implementation.md diff --git a/docs/plans/2026-04-16-client-implementation.md b/docs/plans/2026-04-16-client-implementation.md new file mode 100644 index 0000000..43ac247 --- /dev/null +++ b/docs/plans/2026-04-16-client-implementation.md @@ -0,0 +1,1650 @@ +# 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 `