2026-09-21 11:20:32 -04:00
< script lang = "ts" >
import { open } from '@tauri-apps/plugin-dialog';
import { exportClips , type ExportConfig } from '$lib/bindings/export';
2026-09-22 10:29:22 -04:00
import { checkSubtitlesFilterAvailable } from '$lib/bindings/dependencies';
2026-09-21 11:20:32 -04:00
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');
2026-09-22 10:29:22 -04:00
let includeCaptions = $state(true);
let burnInCaptions = $state(false);
let canBurnIn = $state(true);
2026-09-21 11:20:32 -04:00
let isExporting = $state(false);
2026-09-22 10:29:22 -04:00
let exportClipPercent = $state(0);
let exportClipLabel = $state('');
$effect(() => {
checkSubtitlesFilterAvailable().then((available) => {
canBurnIn = available;
if (!available) burnInCaptions = false;
});
});
2026-09-21 11:20:32 -04:00
let exportProgress = $state('');
let exportError = $state< string | null > (null);
let exportedPaths = $state< string [ ] > ([]);
let clips = $derived(getClips());
let selectedId = $derived(getSelectedClipId());
2026-09-22 10:29:22 -04:00
let isExportReady = $derived(
session.exportStatus === 'complete' & & session.exportFilePath !== null
2026-09-21 11:20:32 -04:00
);
2026-09-22 10:29:22 -04:00
let exportDownloadPercent = $derived(Math.round(session.exportProgress * 100));
let hasCaptions = $derived(session.captionFilePath !== null);
2026-09-21 11:20:32 -04:00
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() {
2026-09-22 10:29:22 -04:00
if (!session.exportFilePath || clipsToExport.length === 0) return;
2026-09-21 11:20:32 -04:00
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,
2026-09-22 10:29:22 -04:00
sourceFilePath: session.exportFilePath,
includeCaptions: includeCaptions & & hasCaptions,
burnInCaptions: burnInCaptions & & includeCaptions & & hasCaptions,
captionFilePath: hasCaptions ? session.captionFilePath : null,
captionStyle: (burnInCaptions & & includeCaptions & & hasCaptions)
? {
fontFamily: preferences.captionSettings.fontFamily,
fontSize: preferences.captionSettings.fontSize,
textColor: preferences.captionSettings.textColor,
bold: preferences.captionSettings.bold,
outlineColor: preferences.captionSettings.outlineColor,
backgroundEnabled: preferences.captionSettings.backgroundEnabled,
backgroundOpacity: preferences.captionSettings.backgroundOpacity,
backgroundColor: preferences.captionSettings.backgroundColor,
textOutline: preferences.captionSettings.textOutline,
shadowEnabled: preferences.captionSettings.shadowEnabled,
shadowDepth: preferences.captionSettings.shadowDepth,
shadowColor: preferences.captionSettings.shadowColor,
dimmedColorMode: preferences.captionSettings.dimmedColorMode,
dimmedOpacity: preferences.captionSettings.dimmedOpacity,
dimmedColor: preferences.captionSettings.dimmedColor,
position: preferences.captionSettings.position,
wordHighlight: preferences.captionSettings.wordHighlight,
}
: null,
2026-09-21 11:20:32 -04:00
};
try {
await exportClips(
config,
(current, total, label) => {
exportProgress = `Exporting ${ current } /${ total } : ${ label } `;
2026-09-22 10:29:22 -04:00
exportClipPercent = 0;
exportClipLabel = label;
2026-09-21 11:20:32 -04:00
},
(paths) => {
exportedPaths = paths;
isExporting = false;
},
(message) => {
exportError = message;
isExporting = false;
2026-09-22 10:29:22 -04:00
},
(current, total, label, percent) => {
exportClipPercent = Math.round(percent * 100);
exportClipLabel = label;
exportProgress = `Exporting ${ current } /${ total } : ${ label } `;
2026-09-21 11:20:32 -04:00
}
);
} 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 >
2026-09-22 10:29:22 -04:00
{ #if ! isExportReady }
2026-09-21 11:20:32 -04:00
< div class = "waiting" >
2026-09-22 10:29:22 -04:00
< p > Downloading best quality ({ exportDownloadPercent } %)…</ p >
2026-09-21 11:20:32 -04:00
< div class = "progress-bar" >
2026-09-22 10:29:22 -04:00
< div class = "progress-fill" style = "width: { exportDownloadPercent } %" ></ div >
2026-09-21 11:20:32 -04:00
< / 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 >
2026-09-22 10:29:22 -04:00
{ #if hasCaptions }
< section >
< h3 > Captions< / h3 >
< label >
< input type = "checkbox" bind:checked = { includeCaptions } / >
Include captions{ session . captionsAreAuto ? ' (auto-generated)' : '' }
< / label >
{ #if includeCaptions }
< div class = "caption-sub-options" >
< label class:disabled = { ! canBurnIn } >
< input type = "checkbox" bind:checked = { burnInCaptions } disabled= { ! canBurnIn } />
Burn into video
< / label >
{ #if ! canBurnIn }
< p class = "caption-note caption-note-warn" > Burn-in requires ffmpeg compiled with libass (the < code > subtitles< / code > filter). The standard Homebrew < code > ffmpeg< / code > formula does not include it. Install the full build instead: < code > brew install ffmpeg-full< / code > , then add < code > /opt/homebrew/opt/ffmpeg-full/bin< / code > to your PATH ahead of the regular ffmpeg.< / p >
{ :else if burnInCaptions && cutMode === 'lossless' }
< p class = "caption-note caption-note-warn" > Burn-in requires re-encoding (precise mode will be used)< / p >
{ :else if burnInCaptions }
< p class = "caption-note" > Captions will be burned into the video with your style settings< / p >
{ : else }
< p class = "caption-note" > Captions will be muxed as a subtitle track< / p >
{ /if }
< / div >
{ /if }
< / section >
{ /if }
2026-09-21 11:20:32 -04:00
< 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 }
2026-09-22 10:29:22 -04:00
< div class = "exporting" >
< div class = "export-label" > { exportProgress } </ div >
< div class = "export-progress-bar" >
< div class = "export-progress-fill" style = "width: { exportClipPercent } %" ></ div >
< / div >
< div class = "export-percent" > { exportClipPercent } %</ div >
< / div >
2026-09-21 11:20:32 -04:00
{ /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;
}
2026-09-22 10:29:22 -04:00
.caption-sub-options {
margin-left: 28px;
}
label.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.caption-note {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--text-muted);
}
.caption-note-warn {
color: var(--warning, #f9e2af);
}
2026-09-21 11:20:32 -04:00
.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 {
2026-09-22 10:29:22 -04:00
margin-bottom: 8px;
}
.export-label {
2026-09-21 11:20:32 -04:00
color: var(--accent);
font-size: 13px;
2026-09-22 10:29:22 -04:00
margin-bottom: 6px;
}
.export-progress-bar {
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.export-progress-fill {
height: 100%;
background: var(--accent, #89b4fa);
border-radius: 3px;
transition: width 0.2s ease;
}
.export-percent {
font-size: 12px;
color: var(--text-secondary);
2026-09-21 11:20:32 -04:00
}
.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 >