implemented the lower priority features; haven't tested them

This commit is contained in:
2026-05-05 20:46:55 -06:00
parent cde635a660
commit 4d4dfa7f7c
12 changed files with 957 additions and 60 deletions

View File

@ -1,10 +1,10 @@
import { useState, useCallback } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText } from 'lucide-react';
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
import type { ExportOptions } from '../types/project';
export default function ExportDialog() {
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
useEditorStore();
const hasCuts = cutRanges.length > 0;
@ -22,6 +22,10 @@ export default function ExportDialog() {
captions: 'none',
normalizeAudio: false,
normalizeTarget: -14,
zoom: { enabled: false, zoomFactor: 1.25, panX: 0, panY: 0 },
removeBackground: false,
backgroundReplacement: 'blur',
backgroundReplacementValue: '',
});
const [exportError, setExportError] = useState<string | null>(null);
const [transcriptFormat, setTranscriptFormat] = useState<'txt' | 'srt'>('txt');
@ -147,27 +151,51 @@ export default function ExportDialog() {
speed: r.speed,
}));
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 || '';
}
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,
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,
}),
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = res.statusText;
@ -185,7 +213,7 @@ export default function ExportDialog() {
setExportError(err instanceof Error ? err.message : 'Export failed');
setExporting(false);
}
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters]);
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters, additionalClips, backgroundMusic]);
return (
<div className="p-4 space-y-5">
@ -239,6 +267,139 @@ export default function ExportDialog() {
]}
/>
{/* 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"
/>
<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"
/>
<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"
/>
<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"
/>
<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"
/>
<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>
)}
{/* 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">