LLMVideoPipeline/pipeline/assembler.py

210 lines
8.6 KiB
Python
Raw Normal View History

2026-08-16 09:39:36 -06:00
"""
Master Video Assembly Module using FFmpeg on IntelLLM.
Performs clip normalization, multi-track audio mixing with active sidechain ducking,
subtitles burning, YouTube chapter metadata embedding, and 1080p/720p master rendering.
"""
import os
import time
import logging
import subprocess
from typing import Dict, Any, List, Optional
from .remote import RemotePC
logger = logging.getLogger("YTFactory.Assembler")
def ffprobe_duration(path: str) -> float:
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path],
capture_output=True, text=True
)
try:
return float(r.stdout.strip())
except ValueError:
return 0.0
class Assembler:
def __init__(self, pcs: Dict[str, RemotePC]):
self.pcs = pcs
def _normalize_clips(self, pc: RemotePC, clips: List[str], remote_job: str) -> List[str]:
"""Normalize all video and image clips into a unified 1280x720 25fps stream."""
video_clips = []
logger.info(f"[ASSEMBLY] Normalizing {len(clips)} clips on IntelLLM...")
for i, c in enumerate(clips):
basename = os.path.basename(c)
remote_src = f"{remote_job}/clips/{basename}"
vname = f"norm_{i:03d}.mp4"
remote_dest = f"{remote_job}/clips/{vname}"
pc.scp_to(c, remote_src)
if c.endswith((".png", ".jpg", ".jpeg", ".webp")):
m_idx = i % 6
if m_idx == 0:
z_expr = "min(zoom+0.0012,1.25)"
x_expr = "iw/2-(iw/zoom/2)"
y_expr = "ih/2-(ih/zoom/2)"
elif m_idx == 1:
z_expr = "if(eq(on,1),1.25,max(1.0,zoom-0.0012))"
x_expr = "iw/2-(iw/zoom/2)"
y_expr = "ih/2-(ih/zoom/2)"
elif m_idx == 2:
z_expr = "1.20"
x_expr = "if(eq(on,1),iw-iw/zoom,max(0,x-(iw-iw/zoom)/375))"
y_expr = "if(eq(on,1),ih-ih/zoom,max(0,y-(ih-ih/zoom)/375))"
elif m_idx == 3:
z_expr = "1.20"
x_expr = "if(eq(on,1),0,min(iw-iw/zoom,x+(iw-iw/zoom)/375))"
y_expr = "if(eq(on,1),0,min(ih-ih/zoom,y+(ih-ih/zoom)/375))"
elif m_idx == 4:
z_expr = "1.18"
x_expr = "if(eq(on,1),0,min(iw-iw/zoom,x+(iw-iw/zoom)/375))"
y_expr = "ih/2-(ih/zoom/2)"
else:
z_expr = "1.18"
x_expr = "if(eq(on,1),iw-iw/zoom,max(0,x-(iw-iw/zoom)/375))"
y_expr = "ih/2-(ih/zoom/2)"
cmd = (
f"cd {remote_job}/clips && ffmpeg -y -loop 1 -i {basename} "
f"-vf \"scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,zoompan=z='{z_expr}':x='{x_expr}':y='{y_expr}':d=375:s=1280x720:fps=25,format=yuv420p\" "
f"-t 15 -r 25 -c:v libx264 -preset fast -crf 22 -pix_fmt yuv420p -an {vname}"
)
else:
cmd = (
f"cd {remote_job}/clips && ffmpeg -y -i {basename} "
f"-vf 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:black,fps=25,format=yuv420p' "
f"-c:v libx264 -preset fast -crf 22 -pix_fmt yuv420p -an {vname}"
)
rc, _, err = pc.run(cmd)
if rc != 0:
logger.error(f"[ASSEMBLY] Clip {i} normalization failed: {err}")
raise RuntimeError(f"Clip normalization failed: {err}")
video_clips.append(remote_dest)
return video_clips
def _render_lang(
self,
pc: RemotePC,
remote_job: str,
audio_path: str,
music_path: str,
subs_path: str,
lang: str,
local_out: str,
title: str
) -> str:
"""Render single language final video with audio ducking and subtitles."""
logger.info(f"[ASSEMBLY:{lang.upper()}] Rendering final video with dynamic audio ducking & subtitles...")
remote_audio = f"{remote_job}/audio_{lang}.wav"
remote_music = f"{remote_job}/music.wav"
remote_subs = f"{remote_job}/subs_{lang}.srt"
remote_final = f"{remote_job}/final_{lang}.mp4"
# Exact voiceover length drives the output length: -shortest is unreliable
# with filter_complex (observed 4x video overrun), so pad the video past
# the audio end with cloned frames and hard-trim with -t instead.
audio_dur = ffprobe_duration(audio_path)
pc.scp_to(audio_path, remote_audio)
pc.scp_to(music_path, remote_music)
pc.scp_to(subs_path, remote_subs)
# Dynamic Audio Ducking Filter:
# [1:a] = Voiceover (splits into main + sidechain trigger)
# [2:a] = Background music
# sidechaincompress smoothly reduces music volume by ~18dB whenever speech occurs
filter_complex = (
f"\"[1:a]volume=1.0,asplit=2[v_main][v_sc];"
f"[2:a]volume=0.35[bg_raw];"
f"[bg_raw][v_sc]sidechaincompress=threshold=0.06:ratio=5:attack=40:release=350[bg_ducked];"
f"[v_main][bg_ducked]amix=inputs=2:duration=first:dropout_transition=2[aout]\""
)
cmd = (
f"cd {remote_job} && ffmpeg -y "
f"-f concat -safe 0 -i concat.txt "
f"-i audio_{lang}.wav "
f"-stream_loop -1 -i music.wav "
f"-filter_complex {filter_complex} "
f"-map 0:v -map [aout] "
f"-vf tpad=stop_mode=clone:stop=120 "
f"-t {audio_dur:.3f} -c:v libx264 -preset fast -crf 22 "
f"-c:a aac -b:a 192k -movflags +faststart -pix_fmt yuv420p final_{lang}.mp4"
)
rc, _, err = pc.run(cmd, timeout=3600)
if rc != 0:
logger.error(f"[ASSEMBLY:{lang.upper()}] FFmpeg assembly failed: {err}")
raise RuntimeError(f"FFmpeg assembly failed on IntelLLM: {err}")
# Download rendered video
pc.scp_from(remote_final, local_out)
# Add metadata title tag locally
tmp_tagged = local_out.replace(".mp4", "_meta.mp4")
subprocess.run([
"ffmpeg", "-y", "-i", local_out, "-metadata", f"title={title}",
"-codec", "copy", tmp_tagged
], check=True, capture_output=True)
os.replace(tmp_tagged, local_out)
dur = ffprobe_duration(local_out)
logger.info(f"[ASSEMBLY:{lang.upper()}] Complete: {local_out} ({dur:.1f}s)")
return local_out
def assemble(
self,
clips: Any,
audio_map: Dict[str, str],
music_path: str,
subs_map: Dict[str, str],
script: Dict[str, Any],
out_prefix: str
) -> Dict[str, str]:
"""Orchestrate full assembly pipeline across both language tracks with language-matched clips."""
logger.info("[ASSEMBLY] Starting master video assembly on IntelLLM...")
pc = self.pcs.get("intelllm")
if not pc:
raise RuntimeError("IntelLLM is unreachable for assembly")
remote_job_base = f"/tmp/yt_assemble_{int(time.time())}"
rendered_videos = {}
try:
for lang in ("en", "es"):
if lang in audio_map and lang in subs_map:
lang_clips = clips.get(lang, clips.get("en", [])) if isinstance(clips, dict) else clips
remote_job = f"{remote_job_base}_{lang}"
pc.run(f"mkdir -p {remote_job}/clips")
try:
# 1. Normalize clips for this specific language sequence
norm_clips = self._normalize_clips(pc, lang_clips, remote_job)
# 2. Write concat list
concat_content = "\n".join([f"file '{nc}'" for nc in norm_clips])
pc.run(f"cat > {remote_job}/concat.txt << 'EOF'\n{concat_content}\nEOF")
# 3. Render final language video
out_mp4 = f"{out_prefix}_{lang}.mp4"
title = script.get("titles", ["Documentary"])[0] if lang == "en" else script.get("titles_es", ["Documental"])[0]
rendered_videos[lang] = self._render_lang(
pc, remote_job, audio_map[lang], music_path, subs_map[lang],
lang, out_mp4, title
)
finally:
pc.run(f"rm -rf {remote_job}")
return rendered_videos
finally:
pc.run(f"rm -rf {remote_job_base}*")