export works
This commit is contained in:
@ -48,6 +48,8 @@ class ExportRequest(BaseModel):
|
|||||||
resolution: str = "1080p"
|
resolution: str = "1080p"
|
||||||
format: str = "mp4"
|
format: str = "mp4"
|
||||||
enhanceAudio: bool = False
|
enhanceAudio: bool = False
|
||||||
|
normalize_loudness: bool = False
|
||||||
|
normalize_target_lufs: float = -14.0
|
||||||
captions: str = "none"
|
captions: str = "none"
|
||||||
words: Optional[List[ExportWordModel]] = None
|
words: Optional[List[ExportWordModel]] = None
|
||||||
deleted_indices: Optional[List[int]] = None
|
deleted_indices: Optional[List[int]] = None
|
||||||
@ -166,6 +168,8 @@ async def export_video(req: ExportRequest):
|
|||||||
gain_ranges=mapped_gain_segments,
|
gain_ranges=mapped_gain_segments,
|
||||||
speed_ranges=speed_segments,
|
speed_ranges=speed_segments,
|
||||||
global_gain_db=req.global_gain_db,
|
global_gain_db=req.global_gain_db,
|
||||||
|
normalize_loudness=req.normalize_loudness,
|
||||||
|
normalize_target_lufs=req.normalize_target_lufs,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
output = export_reencode(
|
output = export_reencode(
|
||||||
@ -178,6 +182,8 @@ async def export_video(req: ExportRequest):
|
|||||||
gain_ranges=mapped_gain_segments,
|
gain_ranges=mapped_gain_segments,
|
||||||
speed_ranges=speed_segments,
|
speed_ranges=speed_segments,
|
||||||
global_gain_db=req.global_gain_db,
|
global_gain_db=req.global_gain_db,
|
||||||
|
normalize_loudness=req.normalize_loudness,
|
||||||
|
normalize_target_lufs=req.normalize_target_lufs,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if ass_path and os.path.exists(ass_path):
|
if ass_path and os.path.exists(ass_path):
|
||||||
|
|||||||
@ -209,6 +209,8 @@ def export_reencode(
|
|||||||
gain_ranges: List[dict] = None,
|
gain_ranges: List[dict] = None,
|
||||||
speed_ranges: List[dict] = None,
|
speed_ranges: List[dict] = None,
|
||||||
global_gain_db: float = 0.0,
|
global_gain_db: float = 0.0,
|
||||||
|
normalize_loudness: bool = False,
|
||||||
|
normalize_target_lufs: float = -14.0,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Export video with full re-encode. Slower but supports resolution changes,
|
Export video with full re-encode. Slower but supports resolution changes,
|
||||||
@ -241,6 +243,9 @@ def export_reencode(
|
|||||||
end = mute_range['end']
|
end = mute_range['end']
|
||||||
filters.append(f"volume=0:enable='between(t,{start},{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"
|
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
|
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)
|
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, "")
|
scale = scale_map.get(resolution, "")
|
||||||
if scale:
|
if scale:
|
||||||
filter_complex += f";[outv]{scale}[outv_scaled]"
|
filter_complex += f";[outv]{scale}[outv_scaled]"
|
||||||
@ -383,16 +394,17 @@ def export_reencode(
|
|||||||
"-i", input_path,
|
"-i", input_path,
|
||||||
"-filter_complex", filter_complex,
|
"-filter_complex", filter_complex,
|
||||||
"-map", video_map,
|
"-map", video_map,
|
||||||
"-map", "[outa]",
|
"-map", audio_map_label,
|
||||||
*codec_args,
|
*codec_args,
|
||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
output_path,
|
output_path,
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Re-encoding %s segments (speed-adjusted=%s) -> %s (%s)",
|
"Re-encoding %s segments (speed-adjusted=%s, normalize=%s) -> %s (%s)",
|
||||||
n,
|
n,
|
||||||
has_speed,
|
has_speed,
|
||||||
|
normalize_loudness,
|
||||||
output_path,
|
output_path,
|
||||||
resolution,
|
resolution,
|
||||||
)
|
)
|
||||||
@ -415,6 +427,8 @@ def export_reencode_with_subs(
|
|||||||
gain_ranges: List[dict] = None,
|
gain_ranges: List[dict] = None,
|
||||||
speed_ranges: List[dict] = None,
|
speed_ranges: List[dict] = None,
|
||||||
global_gain_db: float = 0.0,
|
global_gain_db: float = 0.0,
|
||||||
|
normalize_loudness: bool = False,
|
||||||
|
normalize_target_lufs: float = -14.0,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Export video with re-encode and burn-in subtitles (ASS format).
|
Export video with re-encode and burn-in subtitles (ASS format).
|
||||||
@ -451,6 +465,9 @@ def export_reencode_with_subs(
|
|||||||
end = mute_range['end']
|
end = mute_range['end']
|
||||||
filters.append(f"volume=0:enable='between(t,{start},{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"
|
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
|
has_audio_filters = bool(mute_ranges) or bool(gain_ranges) or abs(float(global_gain_db)) > 1e-6
|
||||||
|
|||||||
@ -4,20 +4,19 @@ import { Download, Loader2, Zap, Cog, Info, Volume2 } from 'lucide-react';
|
|||||||
import type { ExportOptions } from '../types/project';
|
import type { ExportOptions } from '../types/project';
|
||||||
|
|
||||||
export default function ExportDialog() {
|
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();
|
useEditorStore();
|
||||||
|
|
||||||
const hasCuts = cutRanges.length > 0;
|
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',
|
mode: 'fast',
|
||||||
resolution: '1080p',
|
resolution: '1080p',
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
enhanceAudio: false,
|
enhanceAudio: false,
|
||||||
captions: 'none',
|
captions: 'none',
|
||||||
|
normalizeAudio: false,
|
||||||
|
normalizeTarget: -14,
|
||||||
});
|
});
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
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`, {
|
const res = await fetch(`${backendUrl}/export`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -54,13 +65,19 @@ export default function ExportDialog() {
|
|||||||
input_path: videoPath,
|
input_path: videoPath,
|
||||||
output_path: outputPath,
|
output_path: outputPath,
|
||||||
keep_segments: keepSegments,
|
keep_segments: keepSegments,
|
||||||
mute_ranges: muteRanges,
|
mute_ranges: muteRanges.length > 0 ? muteRanges.map((r) => ({ start: r.start, end: r.end })) : undefined,
|
||||||
gain_ranges: gainRanges,
|
gain_ranges: backendGainRanges.length > 0 ? backendGainRanges : undefined,
|
||||||
speed_ranges: speedRanges,
|
speed_ranges: backendSpeedRanges.length > 0 ? backendSpeedRanges : undefined,
|
||||||
global_gain_db: globalGainDb,
|
global_gain_db: globalGainDb,
|
||||||
words: options.captions !== 'none' ? words : undefined,
|
words: options.captions !== 'none' ? words : undefined,
|
||||||
deleted_indices: options.captions !== 'none' ? [...deletedSet] : 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) {
|
if (!res.ok) {
|
||||||
@ -81,41 +98,6 @@ export default function ExportDialog() {
|
|||||||
}
|
}
|
||||||
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]);
|
}, [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 (
|
return (
|
||||||
<div className="p-4 space-y-5">
|
<div className="p-4 space-y-5">
|
||||||
<h3 className="text-sm font-semibold">Export Video</h3>
|
<h3 className="text-sm font-semibold">Export Video</h3>
|
||||||
@ -167,20 +149,28 @@ export default function ExportDialog() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Audio normalization */}
|
{/* Audio normalization — integrated into export */}
|
||||||
<div className="space-y-2 border-t border-editor-border pt-3">
|
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||||
<h4 className="text-xs font-semibold flex items-center gap-1.5">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<Volume2 className="w-3.5 h-3.5" />
|
<input
|
||||||
Audio Normalization
|
type="checkbox"
|
||||||
</h4>
|
checked={options.normalizeAudio}
|
||||||
<p className="text-[10px] text-editor-text-muted leading-relaxed">
|
onChange={(e) => setOptions((o) => ({ ...o, normalizeAudio: e.target.checked }))}
|
||||||
Normalize loudness to a target LUFS level. YouTube uses <strong>-14 LUFS</strong>,
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||||
Spotify uses <strong>-16 LUFS</strong>, broadcast uses <strong>-23 LUFS</strong>.
|
/>
|
||||||
|
<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>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
</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
|
<select
|
||||||
value={normalizeTarget}
|
value={options.normalizeTarget}
|
||||||
onChange={(e) => setNormalizeTarget(Number(e.target.value))}
|
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"
|
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={-14}>YouTube (-14 LUFS)</option>
|
||||||
@ -189,21 +179,7 @@ export default function ExportDialog() {
|
|||||||
<option value={-11}>Loud (-11 LUFS)</option>
|
<option value={-11}>Loud (-11 LUFS)</option>
|
||||||
<option value={-9}>Very Loud (-9 LUFS)</option>
|
<option value={-9}>Very Loud (-9 LUFS)</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
{normalizeResult && (
|
|
||||||
<p className="text-[10px] text-editor-success">{normalizeResult}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -87,7 +87,7 @@ export interface ExportOptions {
|
|||||||
outputPath: string;
|
outputPath: string;
|
||||||
mode: 'fast' | 'reencode';
|
mode: 'fast' | 'reencode';
|
||||||
resolution: '720p' | '1080p' | '4k';
|
resolution: '720p' | '1080p' | '4k';
|
||||||
format: 'mp4' | 'mov' | 'webm';
|
format: 'mp4' | 'mov' | 'webm' | 'wav';
|
||||||
enhanceAudio: boolean;
|
enhanceAudio: boolean;
|
||||||
captions: 'none' | 'burn-in' | 'sidecar';
|
captions: 'none' | 'burn-in' | 'sidecar';
|
||||||
captionStyle?: CaptionStyle;
|
captionStyle?: CaptionStyle;
|
||||||
|
|||||||
Reference in New Issue
Block a user