added cut and mute zones

This commit is contained in:
2026-04-03 11:14:31 -06:00
parent d7bc6ea74d
commit 585262c3e7
13 changed files with 554 additions and 117 deletions

View File

@ -32,6 +32,7 @@ class ExportRequest(BaseModel):
input_path: str
output_path: str
keep_segments: List[SegmentModel]
mute_ranges: Optional[List[SegmentModel]] = None
mode: str = "fast"
resolution: str = "1080p"
format: str = "mp4"
@ -64,15 +65,16 @@ def _mux_audio(video_path: str, audio_path: str, output_path: str) -> str:
async def export_video(req: ExportRequest):
try:
segments = [{"start": s.start, "end": s.end} for s in req.keep_segments]
mute_segments = [{"start": s.start, "end": s.end} for s in req.mute_ranges] if req.mute_ranges else None
if not segments:
if not segments and not mute_segments:
raise HTTPException(status_code=400, detail="No segments to export")
use_stream_copy = req.mode == "fast" and len(segments) == 1
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments
needs_reencode_for_subs = req.captions == "burn-in"
# Burn-in captions require re-encode
if needs_reencode_for_subs:
# Burn-in captions or mute ranges require re-encode
if needs_reencode_for_subs or mute_segments:
use_stream_copy = False
words_dicts = [w.model_dump() for w in req.words] if req.words else []
@ -98,6 +100,7 @@ async def export_video(req: ExportRequest):
ass_path,
resolution=req.resolution,
format_hint=req.format,
mute_ranges=mute_segments,
)
else:
output = export_reencode(
@ -106,6 +109,7 @@ async def export_video(req: ExportRequest):
segments,
resolution=req.resolution,
format_hint=req.format,
mute_ranges=mute_segments,
)
finally:
if ass_path and os.path.exists(ass_path):

View File

@ -28,19 +28,25 @@ def export_stream_copy(
input_path: str,
output_path: str,
keep_segments: List[dict],
mute_ranges: List[dict] = None,
) -> str:
"""
Export video using FFmpeg concat demuxer with stream copy.
~100x faster than re-encoding. No quality loss.
Falls back to re-encoding if mute_ranges are provided.
Args:
input_path: source video file
output_path: destination file
keep_segments: list of {"start": float, "end": float} to keep
mute_ranges: list of {"start": float, "end": float} to mute (optional)
Returns:
output_path on success
"""
if mute_ranges:
# Mute ranges require audio filtering, so fall back to re-encoding
return export_reencode(input_path, output_path, keep_segments, "1080p", "mp4", mute_ranges)
ffmpeg = _find_ffmpeg()
input_path = str(Path(input_path).resolve())
output_path = str(Path(output_path).resolve())
@ -105,60 +111,108 @@ def export_reencode(
keep_segments: List[dict],
resolution: str = "1080p",
format_hint: str = "mp4",
mute_ranges: List[dict] = None,
) -> str:
"""
Export video with full re-encode. Slower but supports resolution changes,
format conversion, and avoids stream-copy edge cases.
If mute_ranges are provided, applies audio muting instead of cutting.
"""
ffmpeg = _find_ffmpeg()
input_path = str(Path(input_path).resolve())
output_path = str(Path(output_path).resolve())
if not keep_segments:
raise ValueError("No segments to export")
scale_map = {
"720p": "scale=-2:720",
"1080p": "scale=-2:1080",
"4k": "scale=-2:2160",
}
filter_parts = []
for i, seg in enumerate(keep_segments):
filter_parts.append(
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];"
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];"
)
# Handle muting case - keep full video but silence audio ranges
if mute_ranges and len(mute_ranges) > 0:
# Build volume filter for muting
volume_filters = []
for i, mute_range in enumerate(mute_ranges):
start = mute_range['start']
end = mute_range['end']
# Use volume=0 to mute, enable to specify time range
volume_filters.append(f"volume=0:enable='between(t,{start},{end})'")
n = len(keep_segments)
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n))
filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]")
# Combine all volume filters
if volume_filters:
audio_filter = ",".join(volume_filters)
else:
audio_filter = "anull" # No muting needed
filter_complex = "".join(filter_parts)
# Video filter - just scaling if needed
scale = scale_map.get(resolution, "")
if scale:
video_filter = scale
video_map = "[v]"
else:
video_filter = "null"
video_map = "0:v"
scale = scale_map.get(resolution, "")
if scale:
filter_complex += f";[outv]{scale}[outv_scaled]"
video_map = "[outv_scaled]"
filter_complex = f"[0:a]{audio_filter}[a];[0:v]{video_filter}{video_map}"
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", video_map,
"-map", "[a]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
logger.info(f"Re-encoding with {len(mute_ranges)} mute ranges -> {output_path} ({resolution})")
else:
video_map = "[outv]"
# Original cutting logic
if not keep_segments:
raise ValueError("No segments to export")
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
filter_parts = []
for i, seg in enumerate(keep_segments):
filter_parts.append(
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];"
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];"
)
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", video_map,
"-map", "[outa]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
n = len(keep_segments)
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n))
filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]")
filter_complex = "".join(filter_parts)
scale = scale_map.get(resolution, "")
if scale:
filter_complex += f";[outv]{scale}[outv_scaled]"
video_map = "[outv_scaled]"
else:
video_map = "[outv]"
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", video_map,
"-map", "[outa]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
logger.info(f"Re-encoding {n} segments -> {output_path} ({resolution})")
logger.info(f"Re-encoding {n} segments -> {output_path} ({resolution})")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"FFmpeg re-encode failed: {result.stderr[-500:]}")
@ -173,64 +227,109 @@ def export_reencode_with_subs(
subtitle_path: str,
resolution: str = "1080p",
format_hint: str = "mp4",
mute_ranges: List[dict] = None,
) -> str:
"""
Export video with re-encode and burn-in subtitles (ASS format).
Applies trim+concat first, then overlays the subtitle file.
If mute_ranges are provided, applies audio muting instead of cutting.
"""
ffmpeg = _find_ffmpeg()
input_path = str(Path(input_path).resolve())
output_path = str(Path(output_path).resolve())
subtitle_path = str(Path(subtitle_path).resolve())
if not keep_segments:
raise ValueError("No segments to export")
scale_map = {
"720p": "scale=-2:720",
"1080p": "scale=-2:1080",
"4k": "scale=-2:2160",
}
filter_parts = []
for i, seg in enumerate(keep_segments):
filter_parts.append(
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];"
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];"
)
# Handle muting case - keep full video but silence audio ranges
if mute_ranges and len(mute_ranges) > 0:
# Build volume filter for muting
volume_filters = []
for i, mute_range in enumerate(mute_ranges):
start = mute_range['start']
end = mute_range['end']
volume_filters.append(f"volume=0:enable='between(t,{start},{end})'")
n = len(keep_segments)
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n))
filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]")
if volume_filters:
audio_filter = ",".join(volume_filters)
else:
audio_filter = "anull"
filter_complex = "".join(filter_parts)
# Video filter with subtitles
escaped_sub = subtitle_path.replace("\\", "/").replace(":", "\\:")
scale = scale_map.get(resolution, "")
if scale:
video_filter = f"{scale},ass='{escaped_sub}'"
else:
video_filter = f"ass='{escaped_sub}'"
# Escape path for FFmpeg subtitle filter (Windows backslashes need escaping)
escaped_sub = subtitle_path.replace("\\", "/").replace(":", "\\:")
filter_complex = f"[0:a]{audio_filter}[a];[0:v]{video_filter}[v]"
scale = scale_map.get(resolution, "")
if scale:
filter_complex += f";[outv]{scale},ass='{escaped_sub}'[outv_final]"
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", "[v]",
"-map", "[a]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
logger.info(f"Re-encoding with subtitles and {len(mute_ranges)} mute ranges -> {output_path} ({resolution})")
else:
filter_complex += f";[outv]ass='{escaped_sub}'[outv_final]"
video_map = "[outv_final]"
# Original cutting logic with subtitles
if not keep_segments:
raise ValueError("No segments to export")
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
filter_parts = []
for i, seg in enumerate(keep_segments):
filter_parts.append(
f"[0:v]trim=start={seg['start']}:end={seg['end']},setpts=PTS-STARTPTS[v{i}];"
f"[0:a]atrim=start={seg['start']}:end={seg['end']},asetpts=PTS-STARTPTS[a{i}];"
)
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", video_map,
"-map", "[outa]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
n = len(keep_segments)
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n))
filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]")
filter_complex = "".join(filter_parts)
# Escape path for FFmpeg subtitle filter (Windows backslashes need escaping)
escaped_sub = subtitle_path.replace("\\", "/").replace(":", "\\:")
scale = scale_map.get(resolution, "")
if scale:
filter_complex += f";[outv]{scale},ass='{escaped_sub}'[outv_final]"
else:
filter_complex += f";[outv]ass='{escaped_sub}'[outv_final]"
video_map = "[outv_final]"
codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-c:a", "aac", "-b:a", "192k"]
if format_hint == "webm":
codec_args = ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus"]
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", filter_complex,
"-map", video_map,
"-map", "[outa]",
*codec_args,
"-movflags", "+faststart",
output_path,
]
logger.info(f"Re-encoding {n} segments with subtitles -> {output_path} ({resolution})")
logger.info(f"Re-encoding {n} segments with subtitles -> {output_path} ({resolution})")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"FFmpeg re-encode with subs failed: {result.stderr[-500:]}")