import random from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont from moviepy.editor import AudioFileClip, ColorClip, CompositeVideoClip, ImageClip, VideoFileClip def split_script(script_text, target_scenes=3): words = [w for w in script_text.split() if w.strip()] if not words: return [] chunk = max(1, len(words) // max(1, target_scenes)) scenes = [] idx = 0 while idx < len(words): scene_words = words[idx:idx + chunk] scenes.append(" ".join(scene_words)) idx += chunk return scenes def _resolve_dimensions(aspect_ratio): if aspect_ratio == "16:9": return 1280, 720 if aspect_ratio == "1:1": return 1080, 1080 return 720, 1280 def _build_caption_clip(text, width, height, start_time, duration): img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) font = ImageFont.load_default() wrap_width = 42 words = text.split() lines = [] buf = [] count = 0 for word in words: buf.append(word) count += len(word) + 1 if count >= wrap_width: lines.append(" ".join(buf)) buf = [] count = 0 if buf: lines.append(" ".join(buf)) caption = "\n".join(lines[:4]) if lines else text bbox = draw.multiline_textbbox((0, 0), caption, font=font, spacing=6) tw = bbox[2] - bbox[0] th = bbox[3] - bbox[1] x = (width - tw) // 2 y = int(height * 0.75) - (th // 2) draw.rounded_rectangle((x - 16, y - 12, x + tw + 16, y + th + 12), radius=14, fill=(0, 0, 0, 150)) draw.multiline_text((x, y), caption, font=font, fill=(255, 255, 255, 235), spacing=6, align="center") return ImageClip(np.array(img)).set_start(start_time).set_duration(duration) def _fit_clip_to_canvas(clip, width, height): if clip.w == 0 or clip.h == 0: return clip.set_duration(clip.duration) scale = max(width / clip.w, height / clip.h) resized = clip.resize(scale) return resized.crop(width=width, height=height, x_center=resized.w / 2, y_center=resized.h / 2) def _build_background_clip(job_id, background_asset, width, height, duration): source = (background_asset or {}).get("source", "procedural") asset_path = (background_asset or {}).get("path") asset_type = (background_asset or {}).get("type") color_sets = [ (22, 58, 95), (13, 91, 67), (88, 46, 21), (66, 36, 110), (17, 48, 75), ] if asset_path and asset_type == "video": try: clip = VideoFileClip(str(asset_path)).without_audio() fitted = _fit_clip_to_canvas(clip, width, height) if fitted.duration < duration: loops = int(duration // fitted.duration) + 1 if fitted.duration > 0 else 1 fitted = fitted.loop(n=loops) return fitted.subclip(0, duration).set_duration(duration), source except Exception: pass if asset_path and asset_type == "image": try: clip = ImageClip(str(asset_path)).set_duration(duration) fitted = _fit_clip_to_canvas(clip, width, height) return fitted.set_duration(duration), source except Exception: pass random.seed(job_id) base_color = random.choice(color_sets) fallback = ColorClip((width, height), color=base_color).set_duration(duration) return fallback, "procedural" def render_video(job_id, script_text, settings, outputs_dir, mixed_audio_path, background_asset=None): """Render a real MP4 file with audio and optional subtitle overlays.""" outputs_dir = Path(outputs_dir) outputs_dir.mkdir(parents=True, exist_ok=True) scenes = split_script(script_text, target_scenes=4) audio_clip = AudioFileClip(str(mixed_audio_path)) duration = float(audio_clip.duration) width, height = _resolve_dimensions(str(settings.get("aspectRatio", "9:16"))) bg, resolved_source = _build_background_clip(job_id, background_asset, width, height, duration) dim = ColorClip((width, height), color=(0, 0, 0)).set_opacity(0.2).set_duration(duration) layers = [bg, dim] if settings.get("includeSubtitles", True): if scenes: seg_dur = duration / len(scenes) for i, text in enumerate(scenes): layers.append(_build_caption_clip(text, width, height, i * seg_dur, seg_dur)) final_clip = CompositeVideoClip(layers).set_audio(audio_clip) out_path = outputs_dir / f"{job_id}.mp4" final_clip.write_videofile( str(out_path), fps=24, codec="libx264", audio_codec="aac", preset="medium", threads=2, verbose=False, logger=None, ) final_clip.close() audio_clip.close() for layer in layers: try: layer.close() except Exception: pass return { "video_path": str(out_path), "background_source": resolved_source, }