2026-04-15 17:13:56 -06:00
|
|
|
import { useState, useCallback } from 'react';
|
2026-03-03 06:31:04 -05:00
|
|
|
import { useEditorStore } from '../store/editorStore';
|
2026-05-04 16:37:25 -06:00
|
|
|
import { Download, Loader2, Zap, Cog, Info, Volume2 } from 'lucide-react';
|
2026-03-03 06:31:04 -05:00
|
|
|
import type { ExportOptions } from '../types/project';
|
|
|
|
|
|
|
|
|
|
export default function ExportDialog() {
|
2026-05-04 17:43:00 -06:00
|
|
|
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
2026-03-03 06:31:04 -05:00
|
|
|
useEditorStore();
|
|
|
|
|
|
2026-04-15 20:17:05 -06:00
|
|
|
const hasCuts = cutRanges.length > 0;
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-05-04 18:39:36 -06:00
|
|
|
// Detect if input is audio-only by its extension
|
|
|
|
|
const audioExtensions = new Set(['.wav', '.mp3', '.flac', '.m4a', '.ogg', '.aac', '.wma']);
|
|
|
|
|
const inputExt = videoPath ? '.' + videoPath.split('.').pop()?.toLowerCase() : '';
|
|
|
|
|
const isAudioOnly = videoPath ? audioExtensions.has(inputExt) : false;
|
|
|
|
|
|
2026-05-04 17:43:00 -06:00
|
|
|
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'> & { normalizeAudio: boolean; normalizeTarget: number }>({
|
2026-05-04 18:39:36 -06:00
|
|
|
mode: isAudioOnly ? 'reencode' : 'fast',
|
2026-03-03 06:31:04 -05:00
|
|
|
resolution: '1080p',
|
2026-05-04 18:39:36 -06:00
|
|
|
format: isAudioOnly ? 'wav' : 'mp4',
|
2026-03-03 06:31:04 -05:00
|
|
|
enhanceAudio: false,
|
|
|
|
|
captions: 'none',
|
2026-05-04 17:43:00 -06:00
|
|
|
normalizeAudio: false,
|
|
|
|
|
normalizeTarget: -14,
|
2026-03-03 06:31:04 -05:00
|
|
|
});
|
2026-04-15 21:51:05 -06:00
|
|
|
const [exportError, setExportError] = useState<string | null>(null);
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-05-04 18:39:36 -06:00
|
|
|
const HANDLE_EXPORT_filters = useCallback(() => {
|
|
|
|
|
const ext = options.format;
|
|
|
|
|
const nameMap: Record<string, string> = {
|
|
|
|
|
mp4: 'MP4',
|
|
|
|
|
mov: 'MOV',
|
|
|
|
|
webm: 'WebM',
|
|
|
|
|
wav: 'WAV Audio',
|
|
|
|
|
};
|
|
|
|
|
return [{ name: nameMap[ext] || 'File', extensions: [ext] }];
|
|
|
|
|
}, [options.format]);
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
const handleExport = useCallback(async () => {
|
|
|
|
|
if (!videoPath) return;
|
|
|
|
|
|
2026-05-04 18:39:36 -06:00
|
|
|
const defaultExt = options.format === 'wav' ? 'wav' : 'mp4';
|
2026-03-03 06:31:04 -05:00
|
|
|
const outputPath = await window.electronAPI?.saveFile({
|
2026-05-04 18:39:36 -06:00
|
|
|
defaultPath: videoPath.replace(/\.[^.]+$/, `_edited.${defaultExt}`),
|
|
|
|
|
filters: HANDLE_EXPORT_filters(),
|
2026-03-03 06:31:04 -05:00
|
|
|
});
|
|
|
|
|
if (!outputPath) return;
|
|
|
|
|
|
|
|
|
|
setExporting(true, 0);
|
2026-04-15 21:51:05 -06:00
|
|
|
setExportError(null);
|
2026-03-03 06:31:04 -05:00
|
|
|
try {
|
|
|
|
|
const keepSegments = getKeepSegments();
|
|
|
|
|
|
|
|
|
|
const deletedSet = new Set<number>();
|
2026-04-15 20:17:05 -06:00
|
|
|
for (const range of cutRanges) {
|
|
|
|
|
for (let i = 0; i < words.length; i++) {
|
|
|
|
|
const w = words[i];
|
|
|
|
|
if (w.start >= range.start && w.end <= range.end) deletedSet.add(i);
|
|
|
|
|
}
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 17:43:00 -06:00
|
|
|
// Map frontend camelCase gain/speed fields to backend snake_case
|
|
|
|
|
const backendGainRanges = gainRanges.map((r) => ({
|
|
|
|
|
start: r.start,
|
|
|
|
|
end: r.end,
|
|
|
|
|
gain_db: r.gainDb,
|
|
|
|
|
}));
|
|
|
|
|
const backendSpeedRanges = speedRanges.map((r) => ({
|
|
|
|
|
start: r.start,
|
|
|
|
|
end: r.end,
|
|
|
|
|
speed: r.speed,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
const res = await fetch(`${backendUrl}/export`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
input_path: videoPath,
|
|
|
|
|
output_path: outputPath,
|
|
|
|
|
keep_segments: keepSegments,
|
2026-05-04 17:43:00 -06:00
|
|
|
mute_ranges: muteRanges.length > 0 ? muteRanges.map((r) => ({ start: r.start, end: r.end })) : undefined,
|
|
|
|
|
gain_ranges: backendGainRanges.length > 0 ? backendGainRanges : undefined,
|
|
|
|
|
speed_ranges: backendSpeedRanges.length > 0 ? backendSpeedRanges : undefined,
|
2026-04-15 16:10:35 -06:00
|
|
|
global_gain_db: globalGainDb,
|
2026-03-03 06:31:04 -05:00
|
|
|
words: options.captions !== 'none' ? words : undefined,
|
|
|
|
|
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
|
2026-05-04 17:43:00 -06:00
|
|
|
mode: options.mode,
|
|
|
|
|
resolution: options.resolution,
|
|
|
|
|
format: options.format,
|
|
|
|
|
enhanceAudio: options.enhanceAudio,
|
|
|
|
|
normalize_loudness: options.normalizeAudio,
|
|
|
|
|
normalize_target_lufs: options.normalizeTarget,
|
|
|
|
|
captions: options.captions,
|
2026-03-03 06:31:04 -05:00
|
|
|
}),
|
|
|
|
|
});
|
2026-04-15 21:51:05 -06:00
|
|
|
if (!res.ok) {
|
|
|
|
|
let detail = res.statusText;
|
|
|
|
|
try {
|
|
|
|
|
const body = await res.json();
|
|
|
|
|
if (body?.detail) detail = String(body.detail);
|
|
|
|
|
} catch {
|
|
|
|
|
// Keep statusText fallback when response body is not JSON.
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`Export failed: ${detail}`);
|
|
|
|
|
}
|
2026-03-03 06:31:04 -05:00
|
|
|
setExporting(false, 100);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Export error:', err);
|
2026-04-15 21:51:05 -06:00
|
|
|
setExportError(err instanceof Error ? err.message : 'Export failed');
|
2026-03-03 06:31:04 -05:00
|
|
|
setExporting(false);
|
|
|
|
|
}
|
2026-05-04 18:39:36 -06:00
|
|
|
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters]);
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-4 space-y-5">
|
|
|
|
|
<h3 className="text-sm font-semibold">Export Video</h3>
|
|
|
|
|
|
|
|
|
|
{/* Mode */}
|
|
|
|
|
<fieldset className="space-y-2">
|
|
|
|
|
<legend className="text-xs text-editor-text-muted font-medium">Export Mode</legend>
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
<ModeCard
|
|
|
|
|
active={options.mode === 'fast'}
|
|
|
|
|
onClick={() => setOptions((o) => ({ ...o, mode: 'fast' }))}
|
|
|
|
|
icon={<Zap className="w-4 h-4" />}
|
|
|
|
|
title="Fast"
|
|
|
|
|
desc="Stream copy, seconds"
|
|
|
|
|
/>
|
|
|
|
|
<ModeCard
|
|
|
|
|
active={options.mode === 'reencode'}
|
|
|
|
|
onClick={() => setOptions((o) => ({ ...o, mode: 'reencode' }))}
|
|
|
|
|
icon={<Cog className="w-4 h-4" />}
|
|
|
|
|
title="Re-encode"
|
|
|
|
|
desc="Custom quality, slower"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</fieldset>
|
|
|
|
|
|
|
|
|
|
{/* Resolution (only for re-encode) */}
|
|
|
|
|
{options.mode === 'reencode' && (
|
|
|
|
|
<SelectField
|
|
|
|
|
label="Resolution"
|
|
|
|
|
value={options.resolution}
|
|
|
|
|
onChange={(v) => setOptions((o) => ({ ...o, resolution: v as ExportOptions['resolution'] }))}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: '720p', label: '720p (HD)' },
|
|
|
|
|
{ value: '1080p', label: '1080p (Full HD)' },
|
|
|
|
|
{ value: '4k', label: '4K (Ultra HD)' },
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Format */}
|
|
|
|
|
<SelectField
|
|
|
|
|
label="Format"
|
|
|
|
|
value={options.format}
|
|
|
|
|
onChange={(v) => setOptions((o) => ({ ...o, format: v as ExportOptions['format'] }))}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: 'mp4', label: 'MP4 (H.264)' },
|
|
|
|
|
{ value: 'mov', label: 'MOV (QuickTime)' },
|
|
|
|
|
{ value: 'webm', label: 'WebM (VP9)' },
|
2026-05-04 18:39:36 -06:00
|
|
|
...(isAudioOnly ? [{ value: 'wav' as const, label: 'WAV (Uncompressed)' }] : []),
|
2026-03-03 06:31:04 -05:00
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-05-04 17:43:00 -06:00
|
|
|
{/* Audio normalization — integrated into export */}
|
|
|
|
|
<div className="space-y-2 pt-1 border-t border-editor-border">
|
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={options.normalizeAudio}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, normalizeAudio: e.target.checked }))}
|
|
|
|
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
|
|
|
|
/>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs font-medium">Normalize loudness</span>
|
|
|
|
|
<p className="text-[10px] text-editor-text-muted">
|
|
|
|
|
Apply LUFS normalization during export. Requires re-encode.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
{options.normalizeAudio && (
|
|
|
|
|
<div className="flex items-center gap-2 pl-6">
|
|
|
|
|
<Volume2 className="w-3 h-3 text-editor-text-muted shrink-0" />
|
|
|
|
|
<select
|
|
|
|
|
value={options.normalizeTarget}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, normalizeTarget: Number(e.target.value) }))}
|
2026-05-04 18:39:36 -06:00
|
|
|
className="flex-1 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent [color-scheme:dark]"
|
2026-05-04 17:43:00 -06:00
|
|
|
>
|
|
|
|
|
<option value={-14}>YouTube (-14 LUFS)</option>
|
|
|
|
|
<option value={-16}>Spotify (-16 LUFS)</option>
|
|
|
|
|
<option value={-23}>Broadcast (-23 LUFS)</option>
|
|
|
|
|
<option value={-11}>Loud (-11 LUFS)</option>
|
|
|
|
|
<option value={-9}>Very Loud (-9 LUFS)</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-05-04 16:37:25 -06:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
{/* Audio enhancement */}
|
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={options.enhanceAudio}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, enhanceAudio: e.target.checked }))}
|
|
|
|
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs">Enhance audio (Studio Sound)</span>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
{/* Captions */}
|
|
|
|
|
<SelectField
|
|
|
|
|
label="Captions"
|
|
|
|
|
value={options.captions}
|
|
|
|
|
onChange={(v) => setOptions((o) => ({ ...o, captions: v as ExportOptions['captions'] }))}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: 'none', label: 'No captions' },
|
|
|
|
|
{ value: 'burn-in', label: 'Burn-in (permanent)' },
|
|
|
|
|
{ value: 'sidecar', label: 'Sidecar SRT file' },
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Export button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleExport}
|
|
|
|
|
disabled={isExporting || !videoPath}
|
|
|
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-semibold transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{isExporting ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
|
|
|
Exporting... {Math.round(exportProgress)}%
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Download className="w-4 h-4" />
|
|
|
|
|
Export
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-04-15 21:51:05 -06:00
|
|
|
{exportError && (
|
|
|
|
|
<div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
|
|
|
|
{exportError}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
{options.mode === 'fast' && !hasCuts && (
|
|
|
|
|
<p className="text-[10px] text-editor-text-muted text-center">
|
|
|
|
|
Fast mode uses stream copy — no quality loss, exports in seconds.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{options.mode === 'fast' && hasCuts && (
|
|
|
|
|
<div className="flex items-start gap-1.5 p-2 bg-editor-accent/10 rounded text-[10px] text-editor-accent">
|
|
|
|
|
<Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
|
|
|
|
<span>
|
|
|
|
|
Word-level cuts require re-encoding for frame-accurate output. Export will
|
|
|
|
|
automatically use re-encode mode. This takes longer but ensures your cuts are precise.
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ModeCard({
|
|
|
|
|
active,
|
|
|
|
|
onClick,
|
|
|
|
|
icon,
|
|
|
|
|
title,
|
|
|
|
|
desc,
|
|
|
|
|
}: {
|
|
|
|
|
active: boolean;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
title: string;
|
|
|
|
|
desc: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-colors ${
|
|
|
|
|
active
|
|
|
|
|
? 'border-editor-accent bg-editor-accent/10'
|
|
|
|
|
: 'border-editor-border hover:border-editor-text-muted'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{icon}
|
|
|
|
|
<span className="text-xs font-medium">{title}</span>
|
|
|
|
|
<span className="text-[10px] text-editor-text-muted">{desc}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SelectField({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
onChange,
|
|
|
|
|
options,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
options: Array<{ value: string; label: string }>;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<label className="text-xs text-editor-text-muted font-medium">{label}</label>
|
|
|
|
|
<select
|
|
|
|
|
value={value}
|
|
|
|
|
onChange={(e) => onChange(e.target.value)}
|
2026-05-04 18:39:36 -06:00
|
|
|
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent [color-scheme:dark]"
|
2026-03-03 06:31:04 -05:00
|
|
|
>
|
|
|
|
|
{options.map((opt) => (
|
|
|
|
|
<option key={opt.value} value={opt.value}>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|