From 8a83c36cad37d72ae5fc001f304ba9d37f5574c9 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 21 Sep 2026 11:20:32 -0400 Subject: [PATCH] feat: add clip export with lossless/precise modes and export dialog Co-authored-by: Cursor --- src-tauri/src/commands/export.rs | 112 ++++++++- src-tauri/src/lib.rs | 3 +- src-tauri/src/services/clip_exporter.rs | 238 +++++++++++++++++- src/App.svelte | 7 +- src/lib/bindings/export.ts | 54 ++++ src/lib/components/ClipList.svelte | 18 ++ src/lib/components/ExportDialog.svelte | 315 ++++++++++++++++++++++++ 7 files changed, 743 insertions(+), 4 deletions(-) create mode 100644 src/lib/bindings/export.ts create mode 100644 src/lib/components/ExportDialog.svelte diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index 32e11c8..7917c49 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -1 +1,111 @@ -// Clip export commands — implemented in Task 12 +use crate::models::{ExportConfig, ExportScope}; +use crate::services::clip_exporter; +use serde::Serialize; +use tauri::ipc::Channel; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "event", content = "data")] +pub enum ExportEvent { + Progress { + current: usize, + total: usize, + label: String, + }, + Finished { + paths: Vec, + }, + Error { + message: String, + }, +} + +#[tauri::command] +pub async fn export_clips( + config: ExportConfig, + on_event: Channel, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let ext = clip_exporter::get_extension(&config.source_file_path, &config.cut_mode); + + match config.export_scope { + ExportScope::Individual => { + let total = config.clips.len(); + let mut output_paths = Vec::new(); + + for (i, clip) in config.clips.iter().enumerate() { + let _ = on_event.send(ExportEvent::Progress { + current: i + 1, + total, + label: clip.label.clone(), + }); + + let output = clip_exporter::generate_output_path( + &config.output_directory, + &config.video_title, + &clip.label, + &ext, + ); + + clip_exporter::export_single_clip( + clip, + &config.source_file_path, + &output, + &config.cut_mode, + )?; + + output_paths.push(output); + } + + let _ = on_event.send(ExportEvent::Finished { + paths: output_paths.clone(), + }); + Ok(output_paths) + } + ExportScope::Merged => { + let _ = on_event.send(ExportEvent::Progress { + current: 1, + total: 1, + label: "Merging clips".to_string(), + }); + + let output = clip_exporter::generate_output_path( + &config.output_directory, + &config.video_title, + "Merged", + &ext, + ); + + let temp_dir = std::env::temp_dir() + .join("video-clipper-merge") + .join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&temp_dir) + .map_err(|e| format!("Failed to create temp dir: {e}"))?; + + let mut sorted_clips = config.clips.clone(); + sorted_clips.sort_by(|a, b| { + a.start_time + .partial_cmp(&b.start_time) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + clip_exporter::export_merged( + &sorted_clips, + &config.source_file_path, + &output, + &config.cut_mode, + &temp_dir.to_string_lossy(), + )?; + + let _ = std::fs::remove_dir_all(&temp_dir); + + let paths = vec![output]; + let _ = on_event.send(ExportEvent::Finished { + paths: paths.clone(), + }); + Ok(paths) + } + } + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 35e4840..cdc2e28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,7 +2,7 @@ mod commands; mod models; mod services; -use commands::{dependencies, media_analysis, video}; +use commands::{dependencies, export, media_analysis, video}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -20,6 +20,7 @@ pub fn run() { media_analysis::extract_waveform, media_analysis::extract_thumbnails, media_analysis::cleanup_thumbnails, + export::export_clips, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/services/clip_exporter.rs b/src-tauri/src/services/clip_exporter.rs index 53dd126..2aa3166 100644 --- a/src-tauri/src/services/clip_exporter.rs +++ b/src-tauri/src/services/clip_exporter.rs @@ -1 +1,237 @@ -// Implemented in Task 12 +use crate::models::{Clip, CutMode}; +use std::path::Path; +use std::process::Command; + +pub fn build_ffmpeg_args( + clip: &Clip, + source: &str, + output: &str, + cut_mode: &CutMode, +) -> Vec { + let mut args = vec![ + "-y".to_string(), + "-ss".to_string(), + format!("{}", clip.start_time), + "-to".to_string(), + format!("{}", clip.end_time), + "-i".to_string(), + source.to_string(), + ]; + + match cut_mode { + CutMode::Lossless => { + args.extend(["-c".to_string(), "copy".to_string()]); + } + CutMode::Precise { format: _ } => { + args.extend([ + "-c:v".to_string(), + "libx264".to_string(), + "-c:a".to_string(), + "aac".to_string(), + ]); + } + } + + args.push(output.to_string()); + args +} + +pub fn generate_output_path( + output_dir: &str, + video_title: &str, + clip_label: &str, + extension: &str, +) -> String { + let sanitized_title = sanitize_filename(video_title); + let sanitized_label = sanitize_filename(clip_label); + let base = format!( + "{}/{} - {}.{}", + output_dir, sanitized_title, sanitized_label, extension + ); + + if !Path::new(&base).exists() { + return base; + } + + for i in 2..100 { + let candidate = format!( + "{}/{} - {} ({}).{}", + output_dir, sanitized_title, sanitized_label, i, extension + ); + if !Path::new(&candidate).exists() { + return candidate; + } + } + + base +} + +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| { + if c == '/' + || c == '\\' + || c == ':' + || c == '"' + || c == '|' + || c == '?' + || c == '*' + || c == '<' + || c == '>' + { + '_' + } else { + c + } + }) + .collect() +} + +pub fn get_extension(source: &str, cut_mode: &CutMode) -> String { + match cut_mode { + CutMode::Lossless => Path::new(source) + .extension() + .map(|e| e.to_string_lossy().to_string()) + .unwrap_or_else(|| "mp4".to_string()), + CutMode::Precise { format } => format.clone(), + } +} + +pub fn export_single_clip( + clip: &Clip, + source: &str, + output: &str, + cut_mode: &CutMode, +) -> Result<(), String> { + let args = build_ffmpeg_args(clip, source, output, cut_mode); + let output_result = Command::new("ffmpeg") + .args(&args) + .output() + .map_err(|e| format!("Failed to run ffmpeg: {e}"))?; + + if !output_result.status.success() { + let stderr = String::from_utf8_lossy(&output_result.stderr); + return Err(format!("ffmpeg export failed: {stderr}")); + } + + Ok(()) +} + +pub fn export_merged( + clips: &[Clip], + source: &str, + output: &str, + cut_mode: &CutMode, + temp_dir: &str, +) -> Result<(), String> { + let mut temp_files = Vec::new(); + let ext = get_extension(source, cut_mode); + + for (i, clip) in clips.iter().enumerate() { + let temp_path = format!("{}/merge_part_{}.{}", temp_dir, i, ext); + export_single_clip(clip, source, &temp_path, cut_mode)?; + temp_files.push(temp_path); + } + + let concat_path = format!("{}/concat_list.txt", temp_dir); + let concat_content: String = temp_files + .iter() + .map(|p| format!("file '{}'", p)) + .collect::>() + .join("\n"); + std::fs::write(&concat_path, concat_content) + .map_err(|e| format!("Failed to write concat file: {e}"))?; + + let result = Command::new("ffmpeg") + .args([ + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + &concat_path, + "-c", + "copy", + output, + ]) + .output() + .map_err(|e| format!("Failed to run ffmpeg concat: {e}"))?; + + if !result.status.success() { + let stderr = String::from_utf8_lossy(&result.stderr); + return Err(format!("ffmpeg merge failed: {stderr}")); + } + + for f in &temp_files { + let _ = std::fs::remove_file(f); + } + let _ = std::fs::remove_file(&concat_path); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_ffmpeg_args_lossless() { + let clip = Clip { + id: "1".to_string(), + start_time: 10.5, + end_time: 20.0, + label: "Clip 1".to_string(), + color: "#000".to_string(), + }; + let args = build_ffmpeg_args(&clip, "input.mp4", "output.mp4", &CutMode::Lossless); + assert!(args.contains(&"-c".to_string())); + assert!(args.contains(&"copy".to_string())); + assert!(args.contains(&"10.5".to_string())); + assert!(args.contains(&"20".to_string())); + } + + #[test] + fn test_build_ffmpeg_args_precise() { + let clip = Clip { + id: "1".to_string(), + start_time: 5.0, + end_time: 15.0, + label: "Clip 1".to_string(), + color: "#000".to_string(), + }; + let mode = CutMode::Precise { + format: "mp4".to_string(), + }; + let args = build_ffmpeg_args(&clip, "input.mp4", "output.mp4", &mode); + assert!(args.contains(&"-c:v".to_string())); + assert!(args.contains(&"libx264".to_string())); + } + + #[test] + fn test_generate_output_path_no_collision() { + let path = generate_output_path("/tmp", "My Video", "Clip 1", "mp4"); + assert!(path.contains("My Video")); + assert!(path.contains("Clip 1")); + assert!(path.ends_with(".mp4")); + } + + #[test] + fn test_sanitize_filename() { + assert_eq!(sanitize_filename("a/b:c"), "a_b_c"); + assert_eq!(sanitize_filename("normal"), "normal"); + } + + #[test] + fn test_get_extension_lossless() { + assert_eq!(get_extension("video.webm", &CutMode::Lossless), "webm"); + } + + #[test] + fn test_get_extension_precise() { + let mode = CutMode::Precise { + format: "mkv".to_string(), + }; + assert_eq!(get_extension("video.webm", &mode), "mkv"); + } +} diff --git a/src/App.svelte b/src/App.svelte index b09a95a..cefb63c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -7,6 +7,7 @@ import StatusBar from '$lib/components/StatusBar.svelte'; import SetupWizard from '$lib/components/SetupWizard.svelte'; import PreferencesPanel from '$lib/components/PreferencesPanel.svelte'; + import ExportDialog from '$lib/components/ExportDialog.svelte'; import { loadPreferences } from '$lib/stores/preferences.svelte'; import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte'; import { adjustShuttle } from '$lib/transport/playback'; @@ -111,6 +112,10 @@ (showPreferences = false)} /> {/if} +{#if showExportDialog} + (showExportDialog = false)} /> +{/if} +
@@ -121,7 +126,7 @@ - + (showExportDialog = true)} /> diff --git a/src/lib/bindings/export.ts b/src/lib/bindings/export.ts new file mode 100644 index 0000000..89cdfd7 --- /dev/null +++ b/src/lib/bindings/export.ts @@ -0,0 +1,54 @@ +import { invoke, Channel } from '@tauri-apps/api/core'; + +export interface ExportConfig { + clips: Array<{ + id: string; + startTime: number; + endTime: number; + label: string; + color: string; + }>; + cutMode: { mode: 'lossless' } | { mode: 'precise'; format: string }; + exportScope: { scope: 'individual' } | { scope: 'merged' }; + outputDirectory: string; + videoTitle: string; + sourceFilePath: string; +} + +type ExportEvent = + | { event: 'progress'; data: { current: number; total: number; label: string } } + | { event: 'finished'; data: { paths: string[] } } + | { event: 'error'; data: { message: string } }; + +export async function exportClips( + config: ExportConfig, + onProgress: (current: number, total: number, label: string) => void, + onFinished: (paths: string[]) => void, + onError: (message: string) => void +): Promise { + const onEvent = new Channel(); + onEvent.onmessage = (message) => { + switch (message.event) { + case 'progress': + onProgress(message.data.current, message.data.total, message.data.label); + break; + case 'finished': + onFinished(message.data.paths); + break; + case 'error': + onError(message.data.message); + break; + default: { + const _exhaustive: never = message; + throw new Error(`Unhandled export event: ${(_exhaustive as ExportEvent).event}`); + } + } + }; + + try { + await invoke('export_clips', { config, onEvent }); + } catch (e) { + onError(e instanceof Error ? e.message : String(e)); + throw e; + } +} diff --git a/src/lib/components/ClipList.svelte b/src/lib/components/ClipList.svelte index fb6bfce..b9e3ac6 100644 --- a/src/lib/components/ClipList.svelte +++ b/src/lib/components/ClipList.svelte @@ -8,6 +8,8 @@ import { formatTime, parseTime } from '$lib/utils/time'; import { session } from '$lib/stores/videoSession.svelte'; + let { onExport }: { onExport?: () => void } = $props(); + let clips = $derived(clipStore.clips); let selectedId = $derived(clipStore.selectedClipId); @@ -89,6 +91,11 @@ >✕
{/each} + {#if onExport} +
+ +
+ {/if} {/if} @@ -116,6 +123,17 @@ margin-bottom: 4px; } + .export-buttons { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border); + } + + .export-buttons button { + font-size: 13px; + padding: 6px 12px; + } + .clip-row { display: flex; align-items: center; diff --git a/src/lib/components/ExportDialog.svelte b/src/lib/components/ExportDialog.svelte new file mode 100644 index 0000000..99744cf --- /dev/null +++ b/src/lib/components/ExportDialog.svelte @@ -0,0 +1,315 @@ + + + + +