feat: add clip export with lossless/precise modes and export dialog
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 { 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;
|
||||
|
||||
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