export works

This commit is contained in:
2026-05-04 17:43:00 -06:00
parent 90b1999a57
commit 5758401dda
4 changed files with 83 additions and 84 deletions

View File

@ -4,20 +4,19 @@ import { Download, Loader2, Zap, Cog, Info, Volume2 } 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, setExportedAudioPath, getKeepSegments } =
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
useEditorStore();
const hasCuts = cutRanges.length > 0;
const [isNormalizing, setIsNormalizing] = useState(false);
const [normalizeTarget, setNormalizeTarget] = useState(-14);
const [normalizeResult, setNormalizeResult] = useState<string | null>(null);
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'>>({
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'> & { normalizeAudio: boolean; normalizeTarget: number }>({
mode: 'fast',
resolution: '1080p',
format: 'mp4',
enhanceAudio: false,
captions: 'none',
normalizeAudio: false,
normalizeTarget: -14,
});
const [exportError, setExportError] = useState<string | null>(null);
@ -47,6 +46,18 @@ export default function ExportDialog() {
}
}
// 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,
}));
const res = await fetch(`${backendUrl}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -54,13 +65,19 @@ export default function ExportDialog() {
input_path: videoPath,
output_path: outputPath,
keep_segments: keepSegments,
mute_ranges: muteRanges,
gain_ranges: gainRanges,
speed_ranges: speedRanges,
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,
...options,
mode: options.mode,
resolution: options.resolution,
format: options.format,
enhanceAudio: options.enhanceAudio,
normalize_loudness: options.normalizeAudio,
normalize_target_lufs: options.normalizeTarget,
captions: options.captions,
}),
});
if (!res.ok) {
@ -81,41 +98,6 @@ export default function ExportDialog() {
}
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]);
const handleNormalize = useCallback(async () => {
if (!videoPath) return;
setIsNormalizing(true);
setNormalizeResult(null);
try {
const res = await fetch(`${backendUrl}/audio/normalize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
input_path: videoPath,
target_lufs: normalizeTarget,
output_path: '',
}),
});
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
if (body?.detail) detail = String(body.detail);
} catch {
// Keep statusText fallback
}
throw new Error(detail);
}
const data = await res.json();
setExportedAudioPath(data.output_path);
setNormalizeResult(`Normalized to ${data.target_lufs} LUFS → ${data.output_path.split('/').pop() || 'done'}`);
} catch (err) {
console.error('Normalize error:', err);
setNormalizeResult(`Error: ${err instanceof Error ? err.message : 'Normalization failed'}`);
} finally {
setIsNormalizing(false);
}
}, [videoPath, backendUrl, normalizeTarget, setExportedAudioPath]);
return (
<div className="p-4 space-y-5">
<h3 className="text-sm font-semibold">Export Video</h3>
@ -167,43 +149,37 @@ export default function ExportDialog() {
]}
/>
{/* Audio normalization */}
<div className="space-y-2 border-t border-editor-border pt-3">
<h4 className="text-xs font-semibold flex items-center gap-1.5">
<Volume2 className="w-3.5 h-3.5" />
Audio Normalization
</h4>
<p className="text-[10px] text-editor-text-muted leading-relaxed">
Normalize loudness to a target LUFS level. YouTube uses <strong>-14 LUFS</strong>,
Spotify uses <strong>-16 LUFS</strong>, broadcast uses <strong>-23 LUFS</strong>.
</p>
<div className="flex items-center gap-2">
<select
value={normalizeTarget}
onChange={(e) => setNormalizeTarget(Number(e.target.value))}
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"
>
<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>
<button
onClick={handleNormalize}
disabled={isNormalizing || !videoPath}
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"
>
{isNormalizing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Volume2 className="w-3 h-3" />
)}
{isNormalizing ? 'Normalizing...' : 'Normalize'}
</button>
</div>
{normalizeResult && (
<p className="text-[10px] text-editor-success">{normalizeResult}</p>
{/* 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) }))}
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"
>
<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>
)}
</div>

View File

@ -87,7 +87,7 @@ export interface ExportOptions {
outputPath: string;
mode: 'fast' | 'reencode';
resolution: '720p' | '1080p' | '4k';
format: 'mp4' | 'mov' | 'webm';
format: 'mp4' | 'mov' | 'webm' | 'wav';
enhanceAudio: boolean;
captions: 'none' | 'burn-in' | 'sidecar';
captionStyle?: CaptionStyle;