""" video_assembler.py — High-speed video assembly via FFmpeg stdin pipe. Avoids MoviePy's per-frame overhead by piping raw RGB bytes directly to FFmpeg. """ import subprocess import tempfile import os from typing import Generator from .renderer import WIDTH, HEIGHT, TARGET_FPS from .logger import get_logger logger = get_logger("video_assembler") def assemble( frame_generator: Generator[bytes, None, None], audio_path: str, output_path: str | None = None, ) -> str: """ Pipe frames from frame_generator into FFmpeg, mux with audio_path, and write an MP4 to output_path. Returns the path to the finished MP4. """ if output_path is None: tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) output_path = tmp.name tmp.close() ffmpeg_cmd = [ "ffmpeg", "-y", # Video input from stdin "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{WIDTH}x{HEIGHT}", "-r", str(TARGET_FPS), "-i", "pipe:0", # Audio input from file "-i", audio_path, # Output encoding "-vcodec", "libx264", "-pix_fmt", "yuv420p", "-preset", "ultrafast", "-crf", "23", # Map both streams "-map", "0:v:0", "-map", "1:a:0", "-shortest", output_path, ] logger.info(f"=== ASSEMBLER INPUT === {WIDTH}x{HEIGHT}@{TARGET_FPS}fps, audio={audio_path} → {output_path}") process = subprocess.Popen( ffmpeg_cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) try: frame_count = 0 for frame_bytes in frame_generator: process.stdin.write(frame_bytes) frame_count += 1 if frame_count % (TARGET_FPS * 5) == 0: logger.info(f"[assembler] piped {frame_count} frames ({frame_count / TARGET_FPS:.1f}s)...") process.stdin.close() process.wait() if process.returncode != 0: stderr = process.stderr.read().decode(errors="replace") raise RuntimeError(f"FFmpeg failed (code {process.returncode}):\n{stderr}") except Exception: process.kill() if os.path.exists(output_path): os.remove(output_path) raise logger.info(f"=== ASSEMBLER OUTPUT === wrote {frame_count} frames → {output_path}") return output_path