feat: add clip export with lossless/precise modes and export dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<String>,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_clips(
|
||||||
|
config: ExportConfig,
|
||||||
|
on_event: Channel<ExportEvent>,
|
||||||
|
) -> Result<Vec<String>, 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}"))?
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ mod commands;
|
|||||||
mod models;
|
mod models;
|
||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
use commands::{dependencies, media_analysis, video};
|
use commands::{dependencies, export, media_analysis, video};
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -20,6 +20,7 @@ pub fn run() {
|
|||||||
media_analysis::extract_waveform,
|
media_analysis::extract_waveform,
|
||||||
media_analysis::extract_thumbnails,
|
media_analysis::extract_thumbnails,
|
||||||
media_analysis::cleanup_thumbnails,
|
media_analysis::cleanup_thumbnails,
|
||||||
|
export::export_clips,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import StatusBar from '$lib/components/StatusBar.svelte';
|
import StatusBar from '$lib/components/StatusBar.svelte';
|
||||||
import SetupWizard from '$lib/components/SetupWizard.svelte';
|
import SetupWizard from '$lib/components/SetupWizard.svelte';
|
||||||
import PreferencesPanel from '$lib/components/PreferencesPanel.svelte';
|
import PreferencesPanel from '$lib/components/PreferencesPanel.svelte';
|
||||||
|
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||||||
import { loadPreferences } from '$lib/stores/preferences.svelte';
|
import { loadPreferences } from '$lib/stores/preferences.svelte';
|
||||||
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
||||||
import { adjustShuttle } from '$lib/transport/playback';
|
import { adjustShuttle } from '$lib/transport/playback';
|
||||||
@@ -111,6 +112,10 @@
|
|||||||
<PreferencesPanel onClose={() => (showPreferences = false)} />
|
<PreferencesPanel onClose={() => (showPreferences = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showExportDialog}
|
||||||
|
<ExportDialog onClose={() => (showExportDialog = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="toolbar">
|
<header class="toolbar">
|
||||||
<UrlInput />
|
<UrlInput />
|
||||||
@@ -121,7 +126,7 @@
|
|||||||
<VideoPlayer />
|
<VideoPlayer />
|
||||||
<TransportControls bind:this={transportControls} />
|
<TransportControls bind:this={transportControls} />
|
||||||
<Timeline />
|
<Timeline />
|
||||||
<ClipList />
|
<ClipList onExport={() => (showExportDialog = true)} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|||||||
54
src/lib/bindings/export.ts
Normal file
54
src/lib/bindings/export.ts
Normal file
@@ -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<void> {
|
||||||
|
const onEvent = new Channel<ExportEvent>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
import { formatTime, parseTime } from '$lib/utils/time';
|
import { formatTime, parseTime } from '$lib/utils/time';
|
||||||
import { session } from '$lib/stores/videoSession.svelte';
|
import { session } from '$lib/stores/videoSession.svelte';
|
||||||
|
|
||||||
|
let { onExport }: { onExport?: () => void } = $props();
|
||||||
|
|
||||||
let clips = $derived(clipStore.clips);
|
let clips = $derived(clipStore.clips);
|
||||||
let selectedId = $derived(clipStore.selectedClipId);
|
let selectedId = $derived(clipStore.selectedClipId);
|
||||||
|
|
||||||
@@ -89,6 +91,11 @@
|
|||||||
>✕</button>
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if onExport}
|
||||||
|
<div class="export-buttons">
|
||||||
|
<button type="button" onclick={() => onExport()}>Export All</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,6 +123,17 @@
|
|||||||
margin-bottom: 4px;
|
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 {
|
.clip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
315
src/lib/components/ExportDialog.svelte
Normal file
315
src/lib/components/ExportDialog.svelte
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { exportClips, type ExportConfig } from '$lib/bindings/export';
|
||||||
|
import { session } from '$lib/stores/videoSession.svelte';
|
||||||
|
import { getClips, getSelectedClipId } from '$lib/stores/clips.svelte';
|
||||||
|
import { preferences, setOutputDirectory } from '$lib/stores/preferences.svelte';
|
||||||
|
|
||||||
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
|
let clipScope = $state<'selected' | 'all'>('all');
|
||||||
|
let exportScope = $state<'individual' | 'merged'>('individual');
|
||||||
|
let cutMode = $state<'lossless' | 'precise'>('lossless');
|
||||||
|
let preciseFormat = $state('mp4');
|
||||||
|
let isExporting = $state(false);
|
||||||
|
let exportProgress = $state('');
|
||||||
|
let exportError = $state<string | null>(null);
|
||||||
|
let exportedPaths = $state<string[]>([]);
|
||||||
|
|
||||||
|
let clips = $derived(getClips());
|
||||||
|
let selectedId = $derived(getSelectedClipId());
|
||||||
|
let isDownloadComplete = $derived(
|
||||||
|
session.downloadProgress >= 1 && session.localFilePath !== null
|
||||||
|
);
|
||||||
|
let downloadPercent = $derived(Math.round(session.downloadProgress * 100));
|
||||||
|
|
||||||
|
let clipsToExport = $derived(
|
||||||
|
clipScope === 'selected' && selectedId
|
||||||
|
? clips.filter((c) => c.id === selectedId)
|
||||||
|
: clips
|
||||||
|
);
|
||||||
|
|
||||||
|
async function pickOutputDir() {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
defaultPath: preferences.outputDirectory,
|
||||||
|
title: 'Choose export directory',
|
||||||
|
});
|
||||||
|
if (selected) {
|
||||||
|
await setOutputDirectory(selected as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
if (!session.localFilePath || clipsToExport.length === 0) return;
|
||||||
|
|
||||||
|
isExporting = true;
|
||||||
|
exportError = null;
|
||||||
|
exportedPaths = [];
|
||||||
|
|
||||||
|
const config: ExportConfig = {
|
||||||
|
clips: clipsToExport.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
startTime: c.startTime,
|
||||||
|
endTime: c.endTime,
|
||||||
|
label: c.label,
|
||||||
|
color: c.color,
|
||||||
|
})),
|
||||||
|
cutMode:
|
||||||
|
cutMode === 'lossless'
|
||||||
|
? { mode: 'lossless' }
|
||||||
|
: { mode: 'precise', format: preciseFormat },
|
||||||
|
exportScope:
|
||||||
|
exportScope === 'individual'
|
||||||
|
? { scope: 'individual' }
|
||||||
|
: { scope: 'merged' },
|
||||||
|
outputDirectory: preferences.outputDirectory,
|
||||||
|
videoTitle: session.title,
|
||||||
|
sourceFilePath: session.localFilePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportClips(
|
||||||
|
config,
|
||||||
|
(current, total, label) => {
|
||||||
|
exportProgress = `Exporting ${current}/${total}: ${label}`;
|
||||||
|
},
|
||||||
|
(paths) => {
|
||||||
|
exportedPaths = paths;
|
||||||
|
isExporting = false;
|
||||||
|
},
|
||||||
|
(message) => {
|
||||||
|
exportError = message;
|
||||||
|
isExporting = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
if (!exportError) {
|
||||||
|
isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overlay" role="presentation" onclick={onClose}>
|
||||||
|
<div class="dialog" role="dialog" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h2>Export Clips</h2>
|
||||||
|
|
||||||
|
{#if !isDownloadComplete}
|
||||||
|
<div class="waiting">
|
||||||
|
<p>Download at {downloadPercent}% — export will start when ready.</p>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {downloadPercent}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if exportedPaths.length > 0}
|
||||||
|
<div class="success">
|
||||||
|
<h3>✓ Export complete</h3>
|
||||||
|
{#each exportedPaths as path}
|
||||||
|
<code class="path">{path}</code>
|
||||||
|
{/each}
|
||||||
|
<button class="primary" onclick={onClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<section>
|
||||||
|
<h3>Clips to export</h3>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={clipScope} value="all" />
|
||||||
|
All clips ({clips.length})
|
||||||
|
</label>
|
||||||
|
{#if selectedId}
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={clipScope} value="selected" />
|
||||||
|
Selected clip only
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Output mode</h3>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={exportScope} value="individual" />
|
||||||
|
Individual files
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={exportScope} value="merged" />
|
||||||
|
Merged into one video
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Cut mode</h3>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={cutMode} value="lossless" />
|
||||||
|
Lossless (fast, keyframe-aligned)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" bind:group={cutMode} value="precise" />
|
||||||
|
Precise (re-encode, exact frames)
|
||||||
|
</label>
|
||||||
|
{#if cutMode === 'precise'}
|
||||||
|
<div class="format-select">
|
||||||
|
<label>
|
||||||
|
Format:
|
||||||
|
<select bind:value={preciseFormat}>
|
||||||
|
<option value="mp4">MP4 (H.264)</option>
|
||||||
|
<option value="mkv">MKV</option>
|
||||||
|
<option value="webm">WebM</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Save to</h3>
|
||||||
|
<div class="row">
|
||||||
|
<code class="path">{preferences.outputDirectory}</code>
|
||||||
|
<button onclick={pickOutputDir}>Change…</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if exportError}
|
||||||
|
<div class="error">{exportError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isExporting}
|
||||||
|
<div class="exporting">{exportProgress}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick={onClose}>Cancel</button>
|
||||||
|
<button
|
||||||
|
class="primary"
|
||||||
|
onclick={handleExport}
|
||||||
|
disabled={isExporting || clipsToExport.length === 0}
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-select {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.exporting {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success h3 {
|
||||||
|
color: var(--success);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.success .path {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.success .primary {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user