- Associate labels with controls in CaptionSettingsPanel (9 warnings) - Add tabindex and keyboard handlers to dialog overlays (8 warnings) - Add <track> to video element for caption accessibility - Add keyboard support to resize handle separator Co-authored-by: Cursor <cursoragent@cursor.com>
454 lines
12 KiB
Svelte
454 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { open } from '@tauri-apps/plugin-dialog';
|
|
import { exportClips, type ExportConfig } from '$lib/bindings/export';
|
|
import { checkSubtitlesFilterAvailable } from '$lib/bindings/dependencies';
|
|
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 includeCaptions = $state(true);
|
|
let burnInCaptions = $state(false);
|
|
let canBurnIn = $state(true);
|
|
let isExporting = $state(false);
|
|
let exportClipPercent = $state(0);
|
|
let exportClipLabel = $state('');
|
|
|
|
$effect(() => {
|
|
checkSubtitlesFilterAvailable().then((available) => {
|
|
canBurnIn = available;
|
|
if (!available) burnInCaptions = false;
|
|
});
|
|
});
|
|
let exportProgress = $state('');
|
|
let exportError = $state<string | null>(null);
|
|
let exportedPaths = $state<string[]>([]);
|
|
|
|
let clips = $derived(getClips());
|
|
let selectedId = $derived(getSelectedClipId());
|
|
let isExportReady = $derived(
|
|
session.exportStatus === 'complete' && session.exportFilePath !== null
|
|
);
|
|
let exportDownloadPercent = $derived(Math.round(session.exportProgress * 100));
|
|
let hasCaptions = $derived(session.captionFilePath !== null);
|
|
|
|
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.exportFilePath || 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.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,
|
|
};
|
|
|
|
try {
|
|
await exportClips(
|
|
config,
|
|
(current, total, label) => {
|
|
exportProgress = `Exporting ${current}/${total}: ${label}`;
|
|
exportClipPercent = 0;
|
|
exportClipLabel = label;
|
|
},
|
|
(paths) => {
|
|
exportedPaths = paths;
|
|
isExporting = false;
|
|
},
|
|
(message) => {
|
|
exportError = message;
|
|
isExporting = false;
|
|
},
|
|
(current, total, label, percent) => {
|
|
exportClipPercent = Math.round(percent * 100);
|
|
exportClipLabel = label;
|
|
exportProgress = `Exporting ${current}/${total}: ${label}`;
|
|
}
|
|
);
|
|
} catch {
|
|
if (!exportError) {
|
|
isExporting = false;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="overlay"
|
|
role="presentation"
|
|
tabindex="-1"
|
|
onclick={onClose}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
|
if (e.key !== 'Escape') e.preventDefault();
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
class="dialog"
|
|
role="dialog"
|
|
tabindex="-1"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onkeydown={(e) => e.stopPropagation()}
|
|
>
|
|
<h2>Export Clips</h2>
|
|
|
|
{#if !isExportReady}
|
|
<div class="waiting">
|
|
<p>Downloading best quality ({exportDownloadPercent}%)…</p>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: {exportDownloadPercent}%"></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>
|
|
|
|
{#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}
|
|
|
|
<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">
|
|
<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>
|
|
{/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;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.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 {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.export-label {
|
|
color: var(--accent);
|
|
font-size: 13px;
|
|
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);
|
|
}
|
|
|
|
.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>
|