""" 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}*")