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-05 20:46:55 -06:00
|
|
|
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
|
2026-03-03 06:31:04 -05:00
|
|
|
import type { ExportOptions } from '../types/project';
|
|
|
|
|
|
|
|
|
|
export default function ExportDialog() {
|
2026-05-05 20:46:55 -06:00
|
|
|
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
|
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-05 23:31:18 -06:00
|
|
|
// Compute set of deleted word indices from cutRanges
|
|
|
|
|
const getDeletedSet = useCallback(() => {
|
|
|
|
|
const deletedSet = new Set<number>();
|
|
|
|
|
for (const range of cutRanges) {
|
|
|
|
|
for (let i = 0; i < words.length; i++) {
|
|
|
|
|
if (words[i].start >= range.start && words[i].end <= range.end) {
|
|
|
|
|
deletedSet.add(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return deletedSet;
|
|
|
|
|
}, [cutRanges, words]);
|
|
|
|
|
|
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-05-05 20:46:55 -06:00
|
|
|
zoom: { enabled: false, zoomFactor: 1.25, panX: 0, panY: 0 },
|
|
|
|
|
removeBackground: false,
|
|
|
|
|
backgroundReplacement: 'blur',
|
|
|
|
|
backgroundReplacementValue: '',
|
2026-03-03 06:31:04 -05:00
|
|
|
});
|
2026-04-15 21:51:05 -06:00
|
|
|
const [exportError, setExportError] = useState<string | null>(null);
|
2026-05-04 23:54:14 -06:00
|
|
|
const [transcriptFormat, setTranscriptFormat] = useState<'txt' | 'srt'>('txt');
|
|
|
|
|
const [isTranscribingTranscript, setIsTranscribingTranscript] = useState(false);
|
|
|
|
|
|
|
|
|
|
const handleTranscriptExport = useCallback(async () => {
|
|
|
|
|
if (!videoPath || words.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const defaultExt = transcriptFormat === 'srt' ? 'srt' : 'txt';
|
|
|
|
|
const outputPath = await window.electronAPI?.saveFile({
|
|
|
|
|
defaultPath: videoPath.replace(/\.[^.]+$/, `_transcript.${defaultExt}`),
|
|
|
|
|
filters: transcriptFormat === 'srt'
|
|
|
|
|
? [{ name: 'SRT Subtitles', extensions: ['srt'] }]
|
|
|
|
|
: [{ name: 'Text File', extensions: ['txt'] }],
|
|
|
|
|
});
|
|
|
|
|
if (!outputPath) return;
|
|
|
|
|
|
|
|
|
|
setIsTranscribingTranscript(true);
|
|
|
|
|
try {
|
|
|
|
|
// Compute deleted word set
|
2026-05-05 23:31:18 -06:00
|
|
|
const deletedSet = getDeletedSet();
|
2026-05-04 23:54:14 -06:00
|
|
|
|
|
|
|
|
// Generate content entirely on the frontend — no backend needed
|
|
|
|
|
let content: string;
|
|
|
|
|
if (transcriptFormat === 'srt') {
|
|
|
|
|
const lines: string[] = [];
|
|
|
|
|
let counter = 1;
|
|
|
|
|
const activeWords: Array<[number, typeof words[0]]> = [];
|
|
|
|
|
for (let i = 0; i < words.length; i++) {
|
|
|
|
|
if (!deletedSet.has(i)) activeWords.push([i, words[i]]);
|
|
|
|
|
}
|
|
|
|
|
const wordsPerLine = 8;
|
|
|
|
|
for (let ci = 0; ci < activeWords.length; ci += wordsPerLine) {
|
|
|
|
|
const chunk = activeWords.slice(ci, ci + wordsPerLine);
|
|
|
|
|
if (chunk.length === 0) continue;
|
|
|
|
|
const startTime = chunk[0][1].start;
|
|
|
|
|
const endTime = chunk[chunk.length - 1][1].end;
|
|
|
|
|
|
|
|
|
|
const fmt = (s: number) => {
|
|
|
|
|
const h = Math.floor(s / 3600);
|
|
|
|
|
const m = Math.floor((s % 3600) / 60);
|
|
|
|
|
const sec = Math.floor(s % 60);
|
|
|
|
|
const ms = Math.floor((s % 1) * 1000);
|
|
|
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')},${String(ms).padStart(3, '0')}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
lines.push(String(counter));
|
|
|
|
|
lines.push(`${fmt(startTime)} --> ${fmt(endTime)}`);
|
|
|
|
|
lines.push(chunk.map(([, w]) => w.word).join(' '));
|
|
|
|
|
lines.push('');
|
|
|
|
|
counter++;
|
|
|
|
|
}
|
|
|
|
|
content = lines.join('\n');
|
|
|
|
|
} else {
|
|
|
|
|
// Plain text
|
|
|
|
|
const activeWords: string[] = [];
|
|
|
|
|
for (let i = 0; i < words.length; i++) {
|
|
|
|
|
if (!deletedSet.has(i)) activeWords.push(words[i].word);
|
|
|
|
|
}
|
|
|
|
|
content = activeWords.join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write directly via Tauri — instant, no backend round-trip
|
|
|
|
|
await window.electronAPI?.writeFile(outputPath, content);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Transcript export error:', err);
|
|
|
|
|
setExportError(err instanceof Error ? err.message : 'Transcript export failed');
|
|
|
|
|
} finally {
|
|
|
|
|
setIsTranscribingTranscript(false);
|
|
|
|
|
}
|
2026-05-05 23:31:18 -06:00
|
|
|
}, [videoPath, words, getDeletedSet, transcriptFormat]);
|
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();
|
2026-05-05 23:31:18 -06:00
|
|
|
const deletedSet = getDeletedSet();
|
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-05-05 20:46:55 -06:00
|
|
|
const body: Record<string, any> = {
|
|
|
|
|
input_path: videoPath,
|
|
|
|
|
output_path: outputPath,
|
|
|
|
|
keep_segments: keepSegments,
|
|
|
|
|
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,
|
|
|
|
|
global_gain_db: globalGainDb,
|
|
|
|
|
words: options.captions !== 'none' ? words : undefined,
|
|
|
|
|
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
|
|
|
|
|
mode: options.mode,
|
|
|
|
|
resolution: options.resolution,
|
|
|
|
|
format: options.format,
|
|
|
|
|
enhanceAudio: options.enhanceAudio,
|
|
|
|
|
normalize_loudness: options.normalizeAudio,
|
|
|
|
|
normalize_target_lufs: options.normalizeTarget,
|
|
|
|
|
captions: options.captions,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Zoom
|
|
|
|
|
if (options.zoom?.enabled) {
|
|
|
|
|
body.zoom = options.zoom;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Additional clips
|
|
|
|
|
if (additionalClips.length > 0) {
|
|
|
|
|
body.additional_clips = additionalClips.map((c) => c.path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Background music
|
|
|
|
|
if (backgroundMusic) {
|
|
|
|
|
body.background_music = backgroundMusic;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Background removal
|
|
|
|
|
if (options.removeBackground) {
|
|
|
|
|
body.remove_background = true;
|
|
|
|
|
body.background_replacement = options.backgroundReplacement || 'blur';
|
|
|
|
|
body.background_replacement_value = options.backgroundReplacementValue || '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
const res = await fetch(`${backendUrl}/export`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
2026-05-05 20:46:55 -06:00
|
|
|
body: JSON.stringify(body),
|
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-05 23:31:18 -06:00
|
|
|
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, getDeletedSet, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters, additionalClips, backgroundMusic]);
|
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"
|
2026-05-06 10:53:27 -06:00
|
|
|
tooltip="Stream copy — fast, no quality loss, but does not apply cuts or effects"
|
2026-03-03 06:31:04 -05:00
|
|
|
/>
|
|
|
|
|
<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"
|
2026-05-06 10:53:27 -06:00
|
|
|
tooltip="Full re-encode — applies cuts, gain, speed, zoom, captions, and effects"
|
2026-03-03 06:31:04 -05:00
|
|
|
/>
|
|
|
|
|
</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)' },
|
|
|
|
|
]}
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Output video resolution — higher resolution = larger file"
|
2026-03-03 06:31:04 -05:00
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 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-06 10:53:27 -06:00
|
|
|
data-tooltip="Output container format — MP4 is most compatible"
|
2026-03-03 06:31:04 -05:00
|
|
|
/>
|
|
|
|
|
|
2026-05-05 20:46:55 -06:00
|
|
|
{/* Video zoom / punch-in */}
|
|
|
|
|
<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.zoom?.enabled || false}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, enabled: e.target.checked } }))}
|
|
|
|
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Crop and reposition the video frame — useful for removing black bars or reframing"
|
2026-05-05 20:46:55 -06:00
|
|
|
/>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs font-medium flex items-center gap-1">
|
|
|
|
|
<ZoomIn className="w-3 h-3" />
|
|
|
|
|
Video zoom / punch-in
|
|
|
|
|
</span>
|
|
|
|
|
<p className="text-[10px] text-editor-text-muted">
|
|
|
|
|
Crop and zoom into the center of the video. Requires re-encode.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
{options.zoom?.enabled && (
|
|
|
|
|
<div className="pl-6 space-y-2">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-[10px] text-editor-text-muted w-16">Zoom:</span>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={1}
|
|
|
|
|
max={3}
|
|
|
|
|
step={0.05}
|
|
|
|
|
value={options.zoom?.zoomFactor || 1}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, zoomFactor: Number(e.target.value) } }))}
|
|
|
|
|
className="flex-1 h-1.5"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Magnification level — 1.0x is original, higher values zoom in"
|
2026-05-05 20:46:55 -06:00
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-editor-text w-10 text-right">{options.zoom?.zoomFactor?.toFixed(2)}x</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-[10px] text-editor-text-muted w-16">Pan X:</span>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={-1}
|
|
|
|
|
max={1}
|
|
|
|
|
step={0.05}
|
|
|
|
|
value={options.zoom?.panX || 0}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panX: Number(e.target.value) } }))}
|
|
|
|
|
className="flex-1 h-1.5"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Horizontal position of the crop window — negative moves left, positive moves right"
|
2026-05-05 20:46:55 -06:00
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panX || 0) * 100).toFixed(0)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-[10px] text-editor-text-muted w-16">Pan Y:</span>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={-1}
|
|
|
|
|
max={1}
|
|
|
|
|
step={0.05}
|
|
|
|
|
value={options.zoom?.panY || 0}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panY: Number(e.target.value) } }))}
|
|
|
|
|
className="flex-1 h-1.5"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Vertical position of the crop window — negative moves up, positive moves down"
|
2026-05-05 20:46:55 -06:00
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panY || 0) * 100).toFixed(0)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Background removal */}
|
|
|
|
|
{!isAudioOnly && (
|
|
|
|
|
<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.removeBackground || false}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, removeBackground: e.target.checked }))}
|
|
|
|
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Remove or replace the background behind the speaker"
|
2026-05-05 20:46:55 -06:00
|
|
|
/>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs font-medium flex items-center gap-1">
|
|
|
|
|
<Video className="w-3 h-3" />
|
|
|
|
|
Remove background
|
|
|
|
|
</span>
|
|
|
|
|
<p className="text-[10px] text-editor-text-muted">
|
|
|
|
|
Replace or blur the background. Uses MediaPipe if available.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</label>
|
|
|
|
|
{options.removeBackground && (
|
|
|
|
|
<div className="pl-6 space-y-2">
|
|
|
|
|
<SelectField
|
|
|
|
|
label="Background replacement"
|
|
|
|
|
value={options.backgroundReplacement || 'blur'}
|
|
|
|
|
onChange={(v) => setOptions((o) => ({ ...o, backgroundReplacement: v as 'blur' | 'color' | 'image' }))}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: 'blur', label: 'Blur background' },
|
|
|
|
|
{ value: 'color', label: 'Solid color' },
|
|
|
|
|
{ value: 'image', label: 'Custom image' },
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
{options.backgroundReplacement === 'color' && (
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={options.backgroundReplacementValue || '#00FF00'}
|
|
|
|
|
onChange={(e) => setOptions((o) => ({ ...o, backgroundReplacementValue: e.target.value }))}
|
|
|
|
|
placeholder="#00FF00"
|
|
|
|
|
className="w-full 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]"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{options.backgroundReplacement === 'image' && (
|
|
|
|
|
<p className="text-[10px] text-editor-text-muted">Place a background image file path above.</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Background music track info */}
|
|
|
|
|
{backgroundMusic && (
|
|
|
|
|
<div className="pt-1 border-t border-editor-border">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-editor-accent">
|
|
|
|
|
<Music className="w-3 h-3" />
|
|
|
|
|
Background music: {backgroundMusic.path.split(/[/\\]/).pop()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Append clips info */}
|
|
|
|
|
{additionalClips.length > 0 && (
|
|
|
|
|
<div className="pt-1 border-t border-editor-border">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-xs text-editor-accent">
|
|
|
|
|
<Video className="w-3 h-3" />
|
|
|
|
|
{additionalClips.length} additional clip{additionalClips.length > 1 ? 's' : ''} appended
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
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"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Normalize audio to a consistent loudness target"
|
2026-05-04 17:43:00 -06:00
|
|
|
/>
|
|
|
|
|
<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-06 10:53:27 -06:00
|
|
|
data-tooltip="Loudness target — YouTube (-14), Spotify (-16), Broadcast (-23)"
|
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"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Apply noise reduction and speech enhancement"
|
2026-03-03 06:31:04 -05:00
|
|
|
/>
|
|
|
|
|
<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' },
|
|
|
|
|
]}
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Burn captions into video, export as separate SRT/VTT file, or none"
|
2026-03-03 06:31:04 -05:00
|
|
|
/>
|
|
|
|
|
|
2026-05-04 23:54:14 -06:00
|
|
|
{/* Transcript-only export */}
|
|
|
|
|
<div className="space-y-2 pt-1 border-t border-editor-border">
|
|
|
|
|
<h4 className="text-xs font-semibold flex items-center gap-1.5">
|
|
|
|
|
<FileText className="w-3.5 h-3.5" />
|
|
|
|
|
Export Transcript Only
|
|
|
|
|
</h4>
|
|
|
|
|
<p className="text-[10px] text-editor-text-muted leading-relaxed">
|
|
|
|
|
Export the edited transcript as plain text or SRT without rendering video.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<select
|
|
|
|
|
value={transcriptFormat}
|
|
|
|
|
onChange={(e) => setTranscriptFormat(e.target.value as 'txt' | 'srt')}
|
|
|
|
|
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]"
|
|
|
|
|
>
|
|
|
|
|
<option value="txt">Plain Text (.txt)</option>
|
|
|
|
|
<option value="srt">Subtitles (.srt)</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleTranscriptExport}
|
|
|
|
|
disabled={isTranscribingTranscript || words.length === 0}
|
|
|
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40 transition-colors"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Export just the transcript text or subtitles without the video"
|
2026-05-04 23:54:14 -06:00
|
|
|
>
|
|
|
|
|
{isTranscribingTranscript ? (
|
|
|
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<FileText className="w-3 h-3" />
|
|
|
|
|
)}
|
|
|
|
|
Export
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Export video button */}
|
2026-03-03 06:31:04 -05:00
|
|
|
<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"
|
2026-05-06 10:53:27 -06:00
|
|
|
data-tooltip="Start export with current settings"
|
2026-03-03 06:31:04 -05:00
|
|
|
>
|
|
|
|
|
{isExporting ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
|
|
|
Exporting... {Math.round(exportProgress)}%
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Download className="w-4 h-4" />
|
2026-05-04 23:54:14 -06:00
|
|
|
Export Video
|
2026-03-03 06:31:04 -05:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</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,
|
2026-05-06 10:53:27 -06:00
|
|
|
tooltip,
|
2026-03-03 06:31:04 -05:00
|
|
|
}: {
|
|
|
|
|
active: boolean;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
title: string;
|
|
|
|
|
desc: string;
|
2026-05-06 10:53:27 -06:00
|
|
|
tooltip?: string;
|
2026-03-03 06:31:04 -05:00
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClick}
|
2026-05-06 10:53:27 -06:00
|
|
|
title={tooltip}
|
2026-03-03 06:31:04 -05:00
|
|
|
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,
|
2026-05-06 10:53:27 -06:00
|
|
|
title,
|
2026-03-03 06:31:04 -05:00
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
options: Array<{ value: string; label: string }>;
|
2026-05-06 10:53:27 -06:00
|
|
|
title?: string;
|
2026-03-03 06:31:04 -05:00
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<label className="text-xs text-editor-text-muted font-medium">{label}</label>
|
|
|
|
|
<select
|
2026-05-06 10:53:27 -06:00
|
|
|
title={title}
|
2026-03-03 06:31:04 -05:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|