feat: add clip export with lossless/precise modes and export dialog

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-21 11:20:32 -04:00
parent 8013ef7c55
commit 8a83c36cad
7 changed files with 743 additions and 4 deletions

View 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;
}
}

View File

@@ -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 @@
>✕</button>
</div>
{/each}
{#if onExport}
<div class="export-buttons">
<button type="button" onclick={() => onExport()}>Export All</button>
</div>
{/if}
{/if}
</div>
@@ -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;

View 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>