209 lines
8.6 KiB
Python
209 lines
8.6 KiB
Python
"""
|
|
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}*")
|