diff --git a/backend/routers/export.py b/backend/routers/export.py index 03e3e99..a7f6d9e 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -48,6 +48,8 @@ class ExportRequest(BaseModel): resolution: str = "1080p" format: str = "mp4" enhanceAudio: bool = False + normalize_loudness: bool = False + normalize_target_lufs: float = -14.0 captions: str = "none" words: Optional[List[ExportWordModel]] = None deleted_indices: Optional[List[int]] = None @@ -166,6 +168,8 @@ async def export_video(req: ExportRequest): gain_ranges=mapped_gain_segments, speed_ranges=speed_segments, global_gain_db=req.global_gain_db, + normalize_loudness=req.normalize_loudness, + normalize_target_lufs=req.normalize_target_lufs, ) else: output = export_reencode( @@ -178,6 +182,8 @@ async def export_video(req: ExportRequest): gain_ranges=mapped_gain_segments, speed_ranges=speed_segments, global_gain_db=req.global_gain_db, + normalize_loudness=req.normalize_loudness, + normalize_target_lufs=req.normalize_target_lufs, ) finally: if ass_path and os.path.exists(ass_path): diff --git a/backend/services/video_editor.py b/backend/services/video_editor.py index ffa9200..4098cdd 100644 --- a/backend/services/video_editor.py +++ b/backend/services/video_editor.py @@ -209,6 +209,8 @@ def export_reencode( gain_ranges: List[dict] = None, speed_ranges: List[dict] = None, global_gain_db: float = 0.0, + normalize_loudness: bool = False, + normalize_target_lufs: float = -14.0, ) -> str: """ Export video with full re-encode. Slower but supports resolution changes, @@ -241,6 +243,9 @@ def export_reencode( end = mute_range['end'] filters.append(f"volume=0:enable='between(t,{start},{end})'") + if normalize_loudness: + filters.append(f"loudnorm=I={normalize_target_lufs}:LRA=7:TP=-1.5") + return ",".join(filters) if filters else "anull" has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6 @@ -367,6 +372,12 @@ def export_reencode( filter_complex = "".join(filter_parts) + # Add loudnorm to the cutting path audio chain if enabled + audio_map_label = "[outa]" + if normalize_loudness: + filter_complex += f";{audio_map_label}loudnorm=I={normalize_target_lufs}:LRA=7:TP=-1.5[outa_norm]" + audio_map_label = "[outa_norm]" + scale = scale_map.get(resolution, "") if scale: filter_complex += f";[outv]{scale}[outv_scaled]" @@ -383,16 +394,17 @@ def export_reencode( "-i", input_path, "-filter_complex", filter_complex, "-map", video_map, - "-map", "[outa]", + "-map", audio_map_label, *codec_args, "-movflags", "+faststart", output_path, ] logger.info( - "Re-encoding %s segments (speed-adjusted=%s) -> %s (%s)", + "Re-encoding %s segments (speed-adjusted=%s, normalize=%s) -> %s (%s)", n, has_speed, + normalize_loudness, output_path, resolution, ) @@ -415,6 +427,8 @@ def export_reencode_with_subs( gain_ranges: List[dict] = None, speed_ranges: List[dict] = None, global_gain_db: float = 0.0, + normalize_loudness: bool = False, + normalize_target_lufs: float = -14.0, ) -> str: """ Export video with re-encode and burn-in subtitles (ASS format). @@ -451,6 +465,9 @@ def export_reencode_with_subs( end = mute_range['end'] filters.append(f"volume=0:enable='between(t,{start},{end})'") + if normalize_loudness: + filters.append(f"loudnorm=I={normalize_target_lufs}:LRA=7:TP=-1.5") + return ",".join(filters) if filters else "anull" has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6 diff --git a/frontend/src/components/ExportDialog.tsx b/frontend/src/components/ExportDialog.tsx index 4f5b8a6..58dfdcf 100644 --- a/frontend/src/components/ExportDialog.tsx +++ b/frontend/src/components/ExportDialog.tsx @@ -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(null); - const [options, setOptions] = useState>({ + const [options, setOptions] = useState & { normalizeAudio: boolean; normalizeTarget: number }>({ mode: 'fast', resolution: '1080p', format: 'mp4', enhanceAudio: false, captions: 'none', + normalizeAudio: false, + normalizeTarget: -14, }); const [exportError, setExportError] = useState(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 (

Export Video

@@ -167,43 +149,37 @@ export default function ExportDialog() { ]} /> - {/* Audio normalization */} -
-

- - Audio Normalization -

-

- Normalize loudness to a target LUFS level. YouTube uses -14 LUFS, - Spotify uses -16 LUFS, broadcast uses -23 LUFS. -

-
- - -
- {normalizeResult && ( -

{normalizeResult}

+ {/* Audio normalization — integrated into export */} +
+ + {options.normalizeAudio && ( +
+ + +
)}
diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts index 9a8a068..41feeea 100644 --- a/frontend/src/types/project.ts +++ b/frontend/src/types/project.ts @@ -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;