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

@ -117,6 +117,129 @@ def _split_keep_segments_by_speed(
return result
def _build_zoom_filter(zoom_config: dict = None) -> str:
"""Build FFmpeg video filter snippet for zoom/punch-in effect.
zoom_config: {enabled, zoomFactor, panX, panY}
Returns empty string if disabled. Should be prepended to the video filter chain.
"""
if not zoom_config or not zoom_config.get("enabled"):
return ""
factor = float(zoom_config.get("zoomFactor", 1.0))
if abs(factor - 1.0) < 0.01:
return ""
pan_x = float(zoom_config.get("panX", 0.0))
pan_y = float(zoom_config.get("panY", 0.0))
return f"crop=iw/{factor}:ih/{factor}:((iw-iw/{factor})/2)+({pan_x}*(iw-iw/{factor})/2):((ih-ih/{factor})/2)+({pan_y}*(ih-ih/{factor})/2),scale=iw:ih"
def mix_background_music(
video_path: str,
music_path: str,
output_path: str,
volume_db: float = 0.0,
ducking_enabled: bool = False,
ducking_db: float = 6.0,
ducking_attack_ms: float = 10.0,
ducking_release_ms: float = 200.0,
) -> str:
"""Mix background music into a video with optional ducking.
Uses FFmpeg amix + sidechaincompress. Output is written to output_path.
"""
ffmpeg = _find_ffmpeg()
escaped_music = music_path.replace("\\", "/").replace(":", "\\:")
# Build the filter graph
if ducking_enabled:
filter_complex = (
f"[0:a]asplit[main][sidechain];"
f"movie='{escaped_music}':loop=0,volume={volume_db}dB[music];"
f"[main][music]amix=inputs=2:duration=first:dropout_transition=2[mixed];"
f"[mixed][sidechain]sidechaincompress="
f"threshold=-30dB:ratio=100:attack={ducking_attack_ms}ms:"
f"release={ducking_release_ms}ms:makeup=1:level_sc={ducking_db}[outa]"
)
else:
filter_complex = (
f"movie='{escaped_music}':loop=0,volume={volume_db}dB[music];"
f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[outa]"
)
cmd = [
ffmpeg, "-y",
"-i", video_path,
"-filter_complex", filter_complex,
"-map", "0:v",
"-map", "[outa]",
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-shortest",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Background music mix failed: {result.stderr[-500:]}")
return output_path
def concat_clips(
main_path: str,
append_paths: list,
output_path: str,
) -> str:
"""Concatenate multiple video clips using FFmpeg concat demuxer.
The main_path is kept as-is. append_paths are appended after it.
"""
if not append_paths:
raise ValueError("No clips to concatenate")
ffmpeg = _find_ffmpeg()
import tempfile
import os
temp_dir = tempfile.mkdtemp(prefix="aive_concat_")
try:
segment_files = [main_path]
segment_files.extend(append_paths)
# Create concat file list
concat_file = os.path.join(temp_dir, "concat.txt")
with open(concat_file, "w") as f:
for path in segment_files:
resolved = os.path.abspath(path)
f.write(f"file '{resolved}'\n")
cmd = [
ffmpeg, "-y",
"-f", "concat",
"-safe", "0",
"-i", concat_file,
"-c", "copy",
"-movflags", "+faststart",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Clip concat failed: {result.stderr[-500:]}")
return output_path
finally:
for f in os.listdir(temp_dir):
try:
os.remove(os.path.join(temp_dir, f))
except OSError:
pass
try:
os.rmdir(temp_dir)
except OSError:
pass
def _find_ffmpeg() -> str:
"""Locate ffmpeg binary."""
for cmd in ["ffmpeg", "ffmpeg.exe"]:
@ -213,6 +336,29 @@ def export_stream_copy(
pass
def _apply_zoom_post(input_path: str, output_path: str, zoom_config: dict) -> str:
"""Re-encode video applying zoom/punch-in crop+scale as a post-process step."""
ffmpeg = _find_ffmpeg()
zoom_filter = _build_zoom_filter(zoom_config)
if not zoom_filter:
return input_path
cmd = [
ffmpeg, "-y",
"-i", input_path,
"-filter_complex", f"[0:v]{zoom_filter}[v]",
"-map", "[v]",
"-map", "0:a?",
"-c:a", "copy",
"-movflags", "+faststart",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Zoom post-process failed: {result.stderr[-500:]}")
return output_path
def export_reencode(
input_path: str,
output_path: str,
@ -225,6 +371,7 @@ def export_reencode(
global_gain_db: float = 0.0,
normalize_loudness: bool = False,
normalize_target_lufs: float = -14.0,
zoom_config: dict = None,
) -> str:
"""
Export video with full re-encode. Slower but supports resolution changes,
@ -421,6 +568,15 @@ def export_reencode(
if result.returncode != 0:
raise RuntimeError(f"FFmpeg re-encode failed: {result.stderr[-500:]}")
# Apply zoom post-processing if configured
if zoom_config and zoom_config.get("enabled") and has_video:
import tempfile as _tf
import os as _os
zoomed_path = output_path + ".zoomed.mp4"
_apply_zoom_post(output_path, zoomed_path, zoom_config)
_os.replace(zoomed_path, output_path)
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
return output_path
@ -437,6 +593,7 @@ def export_reencode_with_subs(
global_gain_db: float = 0.0,
normalize_loudness: bool = False,
normalize_target_lufs: float = -14.0,
zoom_config: dict = None,
) -> str:
"""
Export video with re-encode and burn-in subtitles (ASS format).
@ -578,6 +735,15 @@ def export_reencode_with_subs(
if result.returncode != 0:
raise RuntimeError(f"FFmpeg re-encode with subs failed: {result.stderr[-500:]}")
# Apply zoom post-processing if configured
if zoom_config and zoom_config.get("enabled"):
import tempfile as _tf
import os as _os
zoomed_path = output_path + ".zoomed.mp4"
_apply_zoom_post(output_path, zoomed_path, zoom_config)
_os.replace(zoomed_path, output_path)
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
return output_path