Files
8-cut/docs/plans/2026-04-16-client-implementation.md
T
Ethanfel 39f873bec2 fix: server bug fixes from review
- DB: add threading.Lock on all write methods and multi-step reads
- export.py: check audio extraction return code, raise on failure
- routes/export: counter race condition fix with _counter_lock
- routes/export: delete validation accepts EXPORT_DIR_suffix siblings
- routes/export: evict old finished jobs to prevent unbounded growth
- client plan: fix 10 bugs (mpv IPC, encodePath, input_path sep, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:53:38 +02:00

1675 lines
42 KiB
Markdown

# 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
<script lang="ts">
</script>
<main>
<h1>8-cut</h1>
</main>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1e1e1e;
color: #e0e0e0;
}
main {
padding: 8px;
height: 100vh;
box-sizing: border-box;
}
</style>
```
**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<T>(path: string): Promise<T> {
const res = await fetch(`${serverUrl}${path}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function post<T>(path: string, body?: unknown): Promise<T> {
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<T>(path: string): Promise<T> {
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<string[]> {
return get("/api/roots");
}
export function getFiles(root?: string): Promise<VideoFile[]> {
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<Record<string, string>> {
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<Marker[]> {
return get(`/api/markers/${encodeURIComponent(filename)}?profile=${encodeURIComponent(profile)}`);
}
export function getProfiles(): Promise<string[]> {
return get("/api/profiles");
}
export function getLabels(): Promise<string[]> {
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<unknown> {
return post(`/api/hidden/${encodeURIComponent(filename)}?profile=${encodeURIComponent(profile)}`);
}
export function unhideFile(filename: string, profile: string = "default"): Promise<unknown> {
return del(`/api/hidden/${encodeURIComponent(filename)}?profile=${encodeURIComponent(profile)}`);
}
export function getHidden(profile: string = "default"): Promise<string[]> {
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<string[]>([]);
export const files = writable<VideoFile[]>([]);
export const hiddenFiles = writable<Set<string>>(new Set());
export const currentFile = writable<VideoFile | null>(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<number | null>(null);
export const playing = writable(false);
export const quality = writable("low");
// --- Timeline ---
export const markers = writable<Marker[]>([]);
export const locked = writable(false);
// --- Export settings ---
export const clips = writable(3);
export const spread = writable(3.0);
export const shortSide = writable<number | null>(512);
export const portraitRatio = writable<string | null>(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<string[]>([]);
// --- Export progress ---
export const exportStatus = writable<string>("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<Child>,
writer: Option<UnixStream>,
reader: Option<BufReader<UnixStream>>,
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<Value, String> {
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<Value, String> {
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<f64, String> {
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<f64, String> {
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<Mpv>);
#[tauri::command]
pub fn mpv_start(state: State<MpvState>) -> Result<(), String> {
state.0.lock().unwrap().start()
}
#[tauri::command]
pub fn mpv_stop(state: State<MpvState>) -> Result<(), String> {
state.0.lock().unwrap().stop();
Ok(())
}
#[tauri::command]
pub fn mpv_load(state: State<MpvState>, 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<MpvState>, time: f64) -> Result<(), String> {
state.0.lock().unwrap().seek(time)
}
#[tauri::command]
pub fn mpv_pause(state: State<MpvState>) -> Result<(), String> {
state.0.lock().unwrap().pause()
}
#[tauri::command]
pub fn mpv_resume(state: State<MpvState>) -> Result<(), String> {
state.0.lock().unwrap().resume()
}
#[tauri::command]
pub fn mpv_set_loop(state: State<MpvState>, a: f64, b: f64) -> Result<(), String> {
state.0.lock().unwrap().set_loop(a, b)
}
#[tauri::command]
pub fn mpv_clear_loop(state: State<MpvState>) -> Result<(), String> {
state.0.lock().unwrap().clear_loop()
}
#[tauri::command]
pub fn mpv_time_pos(state: State<MpvState>) -> Result<f64, String> {
state.0.lock().unwrap().time_pos()
}
#[tauri::command]
pub fn mpv_duration(state: State<MpvState>) -> Result<f64, String> {
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<void> {
return invoke("mpv_start");
}
export async function mpvStop(): Promise<void> {
return invoke("mpv_stop");
}
export async function mpvLoad(videoUrl: string, audioUrl: string): Promise<void> {
return invoke("mpv_load", { videoUrl, audioUrl });
}
export async function mpvSeek(time: number): Promise<void> {
return invoke("mpv_seek", { time });
}
export async function mpvPause(): Promise<void> {
return invoke("mpv_pause");
}
export async function mpvResume(): Promise<void> {
return invoke("mpv_resume");
}
export async function mpvSetLoop(a: number, b: number): Promise<void> {
return invoke("mpv_set_loop", { a, b });
}
export async function mpvClearLoop(): Promise<void> {
return invoke("mpv_clear_loop");
}
export async function mpvTimePos(): Promise<number> {
return invoke("mpv_time_pos");
}
export async function mpvDuration(): Promise<number> {
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
<script lang="ts">
import { onMount } from "svelte";
import { getFiles, getRoots, getHidden, getMarkers, hideFile, unhideFile } from "$lib/api";
import {
files, roots, hiddenFiles, currentFile, hideExported, showHidden,
profile, markers
} from "$lib/stores";
let selectedRoot = "";
onMount(async () => {
$roots = await getRoots();
if ($roots.length) {
selectedRoot = $roots[0];
await loadFiles();
}
});
async function loadFiles() {
$files = await getFiles(selectedRoot);
const hidden = await getHidden($profile);
$hiddenFiles = new Set(hidden);
}
async function selectFile(file: typeof $files[0]) {
$currentFile = file;
$markers = await getMarkers(file.name, $profile);
}
function formatSize(bytes: number): string {
if (bytes > 1e9) return (bytes / 1e9).toFixed(1) + " GB";
if (bytes > 1e6) return (bytes / 1e6).toFixed(0) + " MB";
return (bytes / 1e3).toFixed(0) + " KB";
}
$: filteredFiles = $files.filter(f => {
if (!$showHidden && $hiddenFiles.has(f.name)) return false;
return true;
});
</script>
<div class="file-browser">
<div class="controls">
<select bind:value={selectedRoot} on:change={loadFiles}>
{#each $roots as root}
<option value={root}>{root}</option>
{/each}
</select>
<label><input type="checkbox" bind:checked={$showHidden} /> Hidden</label>
</div>
<ul class="file-list">
{#each filteredFiles as file}
<li
class:selected={$currentFile?.path === file.path}
on:click={() => selectFile(file)}
on:contextmenu|preventDefault={() => {
if ($hiddenFiles.has(file.name)) {
unhideFile(file.name, $profile).then(loadFiles);
} else {
hideFile(file.name, $profile).then(loadFiles);
}
}}
>
<span class="name">{file.name}</span>
<span class="size">{formatSize(file.size)}</span>
</li>
{/each}
</ul>
</div>
<style>
.file-browser {
display: flex;
flex-direction: column;
height: 100%;
min-width: 200px;
}
.controls {
display: flex;
gap: 4px;
padding: 4px;
align-items: center;
}
.controls select {
flex: 1;
background: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444;
padding: 2px;
}
.file-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
flex: 1;
}
.file-list li {
padding: 4px 8px;
cursor: pointer;
display: flex;
justify-content: space-between;
font-size: 12px;
}
.file-list li:hover { background: #333; }
.file-list li.selected { background: #0066cc; }
.size { color: #888; font-size: 11px; }
</style>
```
**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
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
duration, cursor, playPos, markers, clips, spread, locked, clipSpan
} from "$lib/stores";
export let onCursorChange: (time: number) => void = () => {};
export let onSeek: (time: number) => void = () => {};
export let onMarkerClick: (marker: { start_time: number; output_path: string }) => void = () => {};
export let onMarkerDelete: (outputPath: string) => void = () => {};
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let dragging = false;
const HEIGHT = 160;
function timeToX(t: number): number {
if ($duration <= 0) return 0;
return (t / $duration) * canvas.width;
}
function xToTime(x: number): number {
if ($duration <= 0) return 0;
return Math.max(0, Math.min($duration, (x / canvas.width) * $duration));
}
function draw() {
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Background
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, w, h);
// Clip span region
if ($duration > 0) {
const x0 = timeToX($cursor);
const x1 = timeToX($cursor + $clipSpan);
ctx.fillStyle = "rgba(0, 100, 200, 0.15)";
ctx.fillRect(x0, 0, x1 - x0, h);
}
// Markers
for (const m of $markers) {
const x = timeToX(m.start_time);
ctx.fillStyle = "#22aa44";
ctx.fillRect(x - 1, 0, 3, h);
}
// Cursor
if ($duration > 0) {
const cx = timeToX($cursor);
ctx.fillStyle = "#ff4444";
ctx.fillRect(cx - 1, 0, 3, h);
}
// Play position
if ($playPos !== null && $duration > 0) {
const px = timeToX($playPos);
ctx.fillStyle = "#ffaa00";
ctx.fillRect(px - 1, 0, 2, h);
}
// Time labels
if ($duration > 0) {
ctx.fillStyle = "#888";
ctx.font = "11px monospace";
const step = Math.max(10, Math.pow(10, Math.floor(Math.log10($duration / 5))));
for (let t = 0; t <= $duration; t += step) {
const x = timeToX(t);
ctx.fillText(formatTime(t), x + 2, h - 4);
ctx.fillRect(x, h - 16, 1, 16);
}
}
}
function formatTime(s: number): string {
const m = Math.floor(s / 60);
const sec = (Math.floor(s % 60 * 10) / 10).toFixed(1);
return `${m}:${sec.padStart(4, "0")}`;
}
function handleMouseDown(e: MouseEvent) {
if ($locked) return;
dragging = true;
const time = xToTime(e.offsetX);
$cursor = time;
onCursorChange(time);
}
function handleMouseMove(e: MouseEvent) {
if (!dragging || $locked) return;
const time = xToTime(e.offsetX);
$cursor = time;
onCursorChange(time);
}
function handleMouseUp() {
dragging = false;
}
function handleDblClick(e: MouseEvent) {
const time = xToTime(e.offsetX);
// Check if near a marker
for (const m of $markers) {
const mx = timeToX(m.start_time);
if (Math.abs(e.offsetX - mx) < 8) {
onMarkerClick(m);
return;
}
}
onSeek(time);
}
function handleContextMenu(e: MouseEvent) {
const time = xToTime(e.offsetX);
for (const m of $markers) {
const mx = timeToX(m.start_time);
if (Math.abs(e.offsetX - mx) < 8) {
onMarkerDelete(m.output_path);
return;
}
}
}
// Redraw on any state change
$: if (canvas && ctx) {
void $duration, $cursor, $playPos, $markers, $clips, $spread, $clipSpan;
draw();
}
onMount(() => {
ctx = canvas.getContext("2d")!;
const obs = new ResizeObserver(() => {
canvas.width = canvas.clientWidth;
canvas.height = HEIGHT;
draw();
});
obs.observe(canvas);
return () => obs.disconnect();
});
</script>
<canvas
bind:this={canvas}
style="width:100%;height:{HEIGHT}px"
on:mousedown={handleMouseDown}
on:mousemove={handleMouseMove}
on:mouseup={handleMouseUp}
on:mouseleave={handleMouseUp}
on:dblclick={handleDblClick}
on:contextmenu|preventDefault={handleContextMenu}
/>
<style>
canvas {
display: block;
background: #1a1a1a;
cursor: crosshair;
}
</style>
```
**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
<script lang="ts">
import { startExport } from "$lib/api";
import {
currentFile, cursor, clips, spread, shortSide, portraitRatio,
cropCenter, format, label, category, clipName, profile,
encoder, hwEncode, trackSubject, randPortrait, randSquare,
exportStatus, exportCompleted, exportTotal, subprofiles
} from "$lib/stores";
const CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"];
const RATIOS = ["Off", "9:16", "4:5", "1:1"];
export async function doExport(folderSuffix: string = "") {
if (!$currentFile) return;
$exportStatus = "running";
$exportCompleted = 0;
$exportTotal = $clips;
const req = {
input_path: `${$currentFile.root}/${$currentFile.path}`,
cursor: $cursor,
name: $clipName || $currentFile.name.replace(/\.[^.]+$/, ""),
clips: $clips,
spread: $spread,
short_side: $shortSide,
portrait_ratio: $portraitRatio,
crop_center: $cropCenter,
format: $format,
label: $label,
category: $category,
profile: $profile,
folder_suffix: folderSuffix,
encoder: $hwEncode ? "h264_nvenc" : "libx264",
};
try {
await startExport(req);
} catch (e) {
$exportStatus = "error";
console.error(e);
}
}
</script>
<div class="export-panel">
<div class="row">
<button on:click={() => doExport()} disabled={$exportStatus === "running"}>
Export{#if $exportStatus === "running"} ({$exportCompleted}/{$exportTotal}){/if}
</button>
{#each $subprofiles as sub, i}
<button on:click={() => doExport(sub)} title="Export {sub}">
{sub}
</button>
{/each}
</div>
<div class="row">
<label>Clips <input type="number" bind:value={$clips} min="1" max="99" /></label>
<label>Spread <input type="number" bind:value={$spread} min="2" max="8" step="0.5" /></label>
<label>Size <input type="number" bind:value={$shortSide} min="0" max="4320" step="64" /></label>
<label>Ratio
<select bind:value={$portraitRatio}>
{#each RATIOS as r}
<option value={r === "Off" ? null : r}>{r}</option>
{/each}
</select>
</label>
</div>
<div class="row">
<label>Label <input type="text" bind:value={$label} /></label>
<label>Category
<select bind:value={$category}>
{#each CATEGORIES as c}
<option value={c}>{c || "—"}</option>
{/each}
</select>
</label>
<label>Format
<select bind:value={$format}>
<option>MP4</option>
<option>WebP sequence</option>
</select>
</label>
<label><input type="checkbox" bind:checked={$hwEncode} /> GPU</label>
</div>
</div>
<style>
.export-panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
font-size: 12px;
}
.row {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
label { display: flex; align-items: center; gap: 2px; }
input[type="number"] { width: 50px; background: #2d2d2d; color: #e0e0e0; border: 1px solid #444; }
input[type="text"] { width: 120px; background: #2d2d2d; color: #e0e0e0; border: 1px solid #444; }
select { background: #2d2d2d; color: #e0e0e0; border: 1px solid #444; }
button { background: #0066cc; color: white; border: none; padding: 4px 12px; cursor: pointer; }
button:disabled { background: #444; }
</style>
```
**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
<script lang="ts">
import { onMount } from "svelte";
import { getProfiles } from "$lib/api";
import { profile, subprofiles } from "$lib/stores";
let profiles: string[] = [];
onMount(async () => {
profiles = await getProfiles();
if (profiles.length && !profiles.includes($profile)) {
$profile = profiles[0];
}
});
function addSubprofile() {
const name = prompt("Subprofile suffix:");
if (name && !$subprofiles.includes(name)) {
$subprofiles = [...$subprofiles, name];
}
}
function removeSubprofile(name: string) {
$subprofiles = $subprofiles.filter(s => s !== name);
}
</script>
<div class="profile-bar">
<select bind:value={$profile}>
{#each profiles as p}
<option value={p}>{p}</option>
{/each}
</select>
<span class="subs">
{#each $subprofiles as sub}
<span class="sub-tag" on:contextmenu|preventDefault={() => removeSubprofile(sub)}>
{sub}
</span>
{/each}
<button on:click={addSubprofile}>+</button>
</span>
</div>
<style>
.profile-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px;
font-size: 12px;
}
select { background: #2d2d2d; color: #e0e0e0; border: 1px solid #444; }
.subs { display: flex; gap: 4px; align-items: center; }
.sub-tag {
background: #444;
padding: 2px 6px;
border-radius: 3px;
cursor: context-menu;
font-size: 11px;
}
button { background: #333; color: #e0e0e0; border: 1px solid #555; padding: 1px 6px; cursor: pointer; }
</style>
```
**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
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import FileBrowser from "./components/FileBrowser.svelte";
import Timeline from "./components/Timeline.svelte";
import ExportPanel from "./components/ExportPanel.svelte";
import ProfileBar from "./components/ProfileBar.svelte";
import { mpvStart, mpvLoad, mpvSeek, mpvPause, mpvResume, mpvSetLoop, mpvClearLoop, mpvTimePos, mpvDuration } from "$lib/mpv";
import { streamUrl, audioUrl, deleteExport, getMarkers } from "$lib/api";
import { connectExportWs } from "$lib/ws";
import {
currentFile, cursor, duration, playPos, playing, quality,
clips, spread, locked, markers, profile, clipSpan
} from "$lib/stores";
let pollInterval: ReturnType<typeof setInterval>;
onMount(async () => {
await mpvStart();
connectExportWs();
// Poll mpv for time position
pollInterval = setInterval(async () => {
if ($playing) {
try {
$playPos = await mpvTimePos();
} catch { /* mpv not ready */ }
}
}, 50);
});
onDestroy(() => {
clearInterval(pollInterval);
});
// Load file into mpv when currentFile OR quality changes
$: if ($currentFile) {
void $quality; // trigger reactivity on quality change too
const vUrl = streamUrl($currentFile.path, $currentFile.root, $quality);
const aUrl = audioUrl($currentFile.path, $currentFile.root);
mpvLoad(vUrl, aUrl).then(async () => {
// Wait for mpv to report duration
await new Promise(r => setTimeout(r, 500));
try { $duration = await mpvDuration(); } catch {}
});
}
async function handleCursorChange(time: number) {
await mpvSeek(time);
}
async function handlePlay() {
const a = $cursor;
const b = $cursor + $clipSpan;
await mpvSeek(a);
await mpvSetLoop(a, b);
await mpvResume();
$playing = true;
}
async function handlePause() {
await mpvPause();
await mpvClearLoop();
$playing = false;
}
async function handleMarkerClick(m: { start_time: number; output_path: string }) {
if ($locked) {
// Jump cursor to marker end
const span = 8.0 + ($clips - 1) * $spread;
$cursor = m.start_time + span;
await mpvSeek($cursor);
} else {
$cursor = m.start_time;
await mpvSeek(m.start_time);
}
}
async function handleMarkerDelete(outputPath: string) {
await deleteExport(outputPath);
if ($currentFile) {
$markers = await getMarkers($currentFile.name, $profile);
}
}
</script>
<main>
<div class="layout">
<div class="sidebar">
<FileBrowser />
</div>
<div class="content">
<ProfileBar />
<div class="player-area">
<div class="video-placeholder">
{#if $currentFile}
<p>{$currentFile.name}</p>
{:else}
<p>Select a file</p>
{/if}
</div>
</div>
<Timeline
onCursorChange={handleCursorChange}
onSeek={handleCursorChange}
onMarkerClick={handleMarkerClick}
onMarkerDelete={handleMarkerDelete}
/>
<div class="transport">
<button on:click={handlePlay} disabled={!$currentFile}></button>
<button on:click={handlePause}>⏸</button>
<button on:click={() => $locked = !$locked}>
{$locked ? "🔒" : "🔓"}
</button>
<span class="time">
{#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}
</span>
<select bind:value={$quality} style="margin-left:auto">
<option value="potato">480p</option>
<option value="low">720p</option>
<option value="medium">1080p</option>
<option value="high">Original</option>
</select>
</div>
<ExportPanel bind:this={exportPanelRef} />
</div>
</div>
</main>
<style>
main { height: 100vh; overflow: hidden; }
.layout {
display: flex;
height: 100%;
}
.sidebar {
width: 220px;
border-right: 1px solid #333;
overflow: hidden;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.player-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
min-height: 200px;
}
.video-placeholder {
color: #666;
text-align: center;
}
.transport {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #222;
}
.transport button {
background: #333;
color: #e0e0e0;
border: 1px solid #555;
padding: 4px 10px;
cursor: pointer;
}
.time {
font-family: monospace;
font-size: 13px;
}
select { background: #2d2d2d; color: #e0e0e0; border: 1px solid #444; }
</style>
```
**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 `<script>` in App.svelte:
```typescript
// Export trigger — called from keyboard shortcuts and forwarded to ExportPanel
let exportPanelRef: ExportPanel;
function handleKeydown(e: KeyboardEvent) {
// Ignore when typing in inputs
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
switch (e.key) {
case " ":
e.preventDefault();
$playing ? handlePause() : handlePlay();
break;
case "e":
case "E":
exportPanelRef?.doExport();
break;
case "ArrowLeft":
$cursor = Math.max(0, $cursor - 1);
handleCursorChange($cursor);
break;
case "ArrowRight":
$cursor = Math.min($duration, $cursor + 1);
handleCursorChange($cursor);
break;
}
// Number keys 1-9 for subprofile export
const num = parseInt(e.key);
if (num >= 1 && num <= 9) {
const idx = num - 1;
if (idx < $subprofiles.length) {
exportPanelRef?.doExport($subprofiles[idx]);
}
}
}
```
Add `<svelte:window on:keydown={handleKeydown} />` to the template.
**Step 2: Commit**
```bash
git add client/src/App.svelte
git commit -m "feat: add keyboard shortcuts"
```
---
### Task 14: Settings persistence
**Files:**
- Create: `client/src/lib/settings.ts`
**Step 1: Create settings save/load using localStorage**
```typescript
import {
serverUrl, quality, clips, spread, shortSide, portraitRatio,
format, hwEncode, profile, subprofiles, clipName, exportFolder
} from "./stores";
import { get } from "svelte/store";
const KEY = "8cut-settings";
interface Settings {
serverUrl: string;
quality: string;
clips: number;
spread: number;
shortSide: number | null;
portraitRatio: string | null;
format: string;
hwEncode: boolean;
profile: string;
subprofiles: string[];
}
export function saveSettings() {
const data: Settings = {
serverUrl: get(serverUrl),
quality: get(quality),
clips: get(clips),
spread: get(spread),
shortSide: get(shortSide),
portraitRatio: get(portraitRatio),
format: get(format),
hwEncode: get(hwEncode),
profile: get(profile),
subprofiles: get(subprofiles),
};
localStorage.setItem(KEY, JSON.stringify(data));
}
export function loadSettings() {
const raw = localStorage.getItem(KEY);
if (!raw) return;
try {
const data: Settings = JSON.parse(raw);
serverUrl.set(data.serverUrl);
quality.set(data.quality);
clips.set(data.clips);
spread.set(data.spread);
shortSide.set(data.shortSide);
portraitRatio.set(data.portraitRatio);
format.set(data.format);
hwEncode.set(data.hwEncode);
profile.set(data.profile);
subprofiles.set(data.subprofiles);
} catch {}
}
```
Call `loadSettings()` in `App.svelte` `onMount`, and subscribe to stores to auto-save:
```typescript
import { loadSettings, saveSettings } from "$lib/settings";
onMount(() => {
loadSettings();
// Auto-save on changes
const unsubs = [
quality.subscribe(() => saveSettings()),
clips.subscribe(() => saveSettings()),
spread.subscribe(() => saveSettings()),
profile.subscribe(() => saveSettings()),
subprofiles.subscribe(() => saveSettings()),
];
return () => unsubs.forEach(u => u());
});
```
**Step 2: Commit**
```bash
git add client/src/lib/settings.ts client/src/App.svelte
git commit -m "feat: add settings persistence via localStorage"
```
---
### Task 15: Package for Linux
**Step 1: Configure tauri.conf.json bundle settings**
In `client/src-tauri/tauri.conf.json`, ensure the bundle section includes:
```json
{
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"identifier": "com.8cut.client",
"icon": []
}
}
```
**Step 2: Build**
```bash
cd /media/p5/8-cut/client
pnpm tauri build
```
Expected: Produces `.deb` and `.AppImage` in `client/src-tauri/target/release/bundle/`.
**Step 3: Commit**
```bash
git add client/src-tauri/tauri.conf.json
git commit -m "feat: configure Linux packaging (deb + AppImage)"
```
---