""" Remotion video rendering bridge. Calls Remotion CLI from Python via subprocess. FFmpeg fallback for environments without Remotion bundle. """ import subprocess import json import os import tempfile import shutil import http.server import threading import time # Global asset server _asset_server = None _asset_server_port = 8888 _has_drawtext = None # cached detection def _check_drawtext_support() -> bool: """Check if ffmpeg was compiled with the drawtext filter (requires libfreetype).""" global _has_drawtext if _has_drawtext is not None: return _has_drawtext try: result = subprocess.run( ["ffmpeg", "-filters"], capture_output=True, text=True, timeout=10, ) _has_drawtext = "drawtext" in result.stdout except Exception: _has_drawtext = False print(f"FFmpeg drawtext support: {_has_drawtext}") return _has_drawtext def _safe_text_for_ffmpeg(text: str) -> str: """Escape text for ffmpeg drawtext filter.""" text = text.replace("\\", "\\\\") text = text.replace("'", "'\\''") text = text.replace('"', '\\"') text = text.replace(":", "\\:") text = text.replace("%", "%%") return text def start_asset_server(directory: str, port: int = 8888) -> str: """Start a background HTTP server for serving local assets to Remotion.""" global _asset_server, _asset_server_port os.makedirs(directory, exist_ok=True) _asset_server_port = port handler = lambda *args, **kwargs: http.server.SimpleHTTPRequestHandler( *args, directory=directory, **kwargs ) try: server = http.server.HTTPServer(("127.0.0.1", port), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() _asset_server = server print(f"Asset server started on http://127.0.0.1:{port}") return f"http://127.0.0.1:{port}" except OSError: for alt_port in range(8889, 8900): try: server = http.server.HTTPServer(("127.0.0.1", alt_port), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() _asset_server = server _asset_server_port = alt_port print(f"Asset server started on http://127.0.0.1:{alt_port}") return f"http://127.0.0.1:{alt_port}" except OSError: continue return f"http://127.0.0.1:{port}" def get_video_dimensions(size: str) -> tuple[int, int]: """Convert size string to width, height.""" size_map = { "9:16 (Vertical/Reels)": (1080, 1920), "16:9 (Horizontal/YouTube)": (1920, 1080), "1:1 (Square/Posts)": (1080, 1080), "4:5 (Portrait/Instagram)": (1080, 1350), "4:3 (Standard)": (1440, 1080), } return size_map.get(size, (1080, 1920)) def prepare_remotion_props( title: str, scenes: list[dict], audio_url: str, theme: str, channel_name: str, width: int, height: int, duration_frames: int, fps: int = 30, ) -> dict: """Prepare props dict for Remotion rendering.""" clean_scenes = [] for s in scenes: clean_scenes.append({ "scene_number": s.get("scene_number", 0), "duration": s.get("duration", 5), "hook": str(s.get("hook", "")), "narration": str(s.get("narration", "")), "visual": str(s.get("visual", "")), "imageUrl": str(s.get("imageUrl", "")), }) return { "title": title, "scenes": clean_scenes, "audioSrc": audio_url, "theme": theme.lower(), "channelName": channel_name, "width": width, "height": height, "durationInFrames": duration_frames, "fps": fps, } def render_video( props: dict, output_path: str, bundle_path: str = "/home/user/app/dist/bundle", composition_id: str = "ViralVideo", concurrency: int = 2, timeout_ms: int = 180000, progress_callback=None, ) -> str: """Render video using Remotion CLI.""" os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) props_file = tempfile.mktemp(suffix=".json") with open(props_file, "w") as f: json.dump(props, f, indent=2) if progress_callback: progress_callback("🎥 Starting Remotion render...") try: cmd = [ "npx", "remotion", "render", bundle_path, composition_id, output_path, f"--props={props_file}", f"--concurrency={concurrency}", f"--timeout={timeout_ms}", "--log=verbose", "--overwrite", "--codec=h264", "--image-format=jpeg", "--jpeg-quality=85", ] print(f"Running: {' '.join(cmd)}") process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd="/home/user/app", env={ **os.environ, "PUPPETEER_EXECUTABLE_PATH": "/usr/bin/chromium", "CHROMIUM_PATH": "/usr/bin/chromium", }, ) output_lines = [] while True: line = process.stdout.readline() if not line and process.poll() is not None: break if line: line = line.strip() output_lines.append(line) print(f"[Remotion] {line}") if "%" in line and progress_callback: progress_callback(f"🎥 Rendering: {line}") return_code = process.wait() if return_code != 0: error_output = "\n".join(output_lines[-30:]) raise RuntimeError( f"Remotion render failed (code {return_code}):\n{error_output}" ) if not os.path.exists(output_path): raise RuntimeError(f"Render completed but output file not found: {output_path}") file_size = os.path.getsize(output_path) print(f"Video rendered: {output_path} ({file_size / 1024 / 1024:.1f} MB)") if progress_callback: progress_callback("✅ Video rendered successfully!") return output_path finally: if os.path.exists(props_file): os.unlink(props_file) def _build_segment_from_image( img_path: str, duration: float, hook: str, width: int, height: int, fps: int, text_color: str, segment_path: str, ) -> bool: """Create a video segment from an image with Ken Burns + optional text overlay.""" use_drawtext = _check_drawtext_support() safe_hook = _safe_text_for_ffmpeg(hook) fade_out_start = max(duration - 0.5, 0.1) num_frames = max(int(duration * fps), 1) vf_parts = [ f"scale={width * 2}:{height * 2}:force_original_aspect_ratio=increase", f"crop={width}:{height}", f"zoompan=z='min(zoom+0.0008,1.15)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d={num_frames}:s={width}x{height}:fps={fps}", ] if use_drawtext and hook: vf_parts.append( f"drawtext=text='{safe_hook}':fontsize=42:fontcolor={text_color}" f":x=(w-text_w)/2:y=h-h/4:borderw=3:bordercolor=black@0.5" f":fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" ) vf_parts.append(f"fade=t=in:st=0:d=0.5,fade=t=out:st={fade_out_start}:d=0.5") cmd = [ "ffmpeg", "-y", "-loop", "1", "-i", img_path, "-t", str(duration), "-vf", ",".join(vf_parts), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "fast", "-crf", "23", segment_path, ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode != 0: print(f"FFmpeg segment error: {result.stderr[-300:]}") return os.path.exists(segment_path) def _build_segment_from_color( duration: float, hook: str, width: int, height: int, fps: int, bg_color: str, text_color: str, segment_path: str, ) -> bool: """Create a solid-color video segment with optional text overlay.""" use_drawtext = _check_drawtext_support() safe_hook = _safe_text_for_ffmpeg(hook) fade_out_start = max(duration - 0.5, 0.1) vf_parts = [] if use_drawtext and hook: vf_parts.append( f"drawtext=text='{safe_hook}':fontsize=48:fontcolor={text_color}" f":x=(w-text_w)/2:y=(h-text_h)/2" f":fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" ) vf_parts.append(f"fade=t=in:st=0:d=0.5,fade=t=out:st={fade_out_start}:d=0.5") cmd = [ "ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c={bg_color}:s={width}x{height}:d={duration}:r={fps}", "-vf", ",".join(vf_parts), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "fast", "-crf", "23", segment_path, ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: print(f"FFmpeg color segment error: {result.stderr[-300:]}") return os.path.exists(segment_path) def render_video_with_ffmpeg_fallback( scenes: list[dict], audio_path: str, output_path: str, width: int = 1080, height: int = 1920, fps: int = 30, theme: str = "white", title: str = "", channel_name: str = "", scene_timestamps: list = None, progress_callback=None, ) -> str: """ FFmpeg-based fallback video renderer. Creates video from images with transitions and synced audio. """ if progress_callback: progress_callback("🎥 Rendering with FFmpeg...") from pydub import AudioSegment audio = AudioSegment.from_file(audio_path) total_duration = len(audio) / 1000.0 if not scene_timestamps: scene_duration = total_duration / max(len(scenes), 1) scene_timestamps = [] for i in range(len(scenes)): scene_timestamps.append({ "scene_index": i, "start_ms": int(i * scene_duration * 1000), "end_ms": int((i + 1) * scene_duration * 1000), "duration_ms": int(scene_duration * 1000), }) bg_color = "black" if theme == "black" else "white" text_color = "white" if theme == "black" else "black" out_dir = os.path.dirname(output_path) or "." os.makedirs(out_dir, exist_ok=True) concat_inputs = [] temp_segments = [] for i, scene in enumerate(scenes): img_path = scene.get("localImagePath", "") ts = scene_timestamps[i] if i < len(scene_timestamps) else {"duration_ms": 5000} duration = max(ts["duration_ms"] / 1000.0, 0.5) hook = scene.get("hook", "") segment_path = os.path.join(out_dir, f"seg_{i}.mp4") temp_segments.append(segment_path) if img_path and os.path.exists(img_path): ok = _build_segment_from_image( img_path, duration, hook, width, height, fps, text_color, segment_path, ) else: ok = _build_segment_from_color( duration, hook, width, height, fps, bg_color, text_color, segment_path, ) if ok: concat_inputs.append(segment_path) if progress_callback: progress_callback(f"🎥 Rendering scene {i + 1}/{len(scenes)}...") if not concat_inputs: raise RuntimeError("FFmpeg could not produce any video segments") # Concat all segments + add audio concat_file = os.path.join(out_dir, "concat.txt") with open(concat_file, "w") as f: for seg in concat_inputs: f.write(f"file '{seg}'\n") cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file, "-i", audio_path, "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", "-shortest", "-preset", "fast", output_path, ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) # Cleanup temps for seg in temp_segments: if os.path.exists(seg): os.unlink(seg) if os.path.exists(concat_file): os.unlink(concat_file) if result.returncode != 0: raise RuntimeError(f"FFmpeg concat failed: {result.stderr[-500:]}") if not os.path.exists(output_path): raise RuntimeError("Video render failed - output file not created") file_size = os.path.getsize(output_path) print(f"Video rendered: {output_path} ({file_size / 1024 / 1024:.1f} MB)") if progress_callback: progress_callback("✅ Video rendered!") return output_path