from __future__ import annotations from pathlib import Path from typing import Any, Iterable import math import uuid from moviepy import ( AudioFileClip, CompositeAudioClip, ImageClip, concatenate_videoclips, vfx, ) from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps VIDEO_SIZE = (1080, 1920) INK = (32, 24, 18) PARCHMENT = (237, 218, 181) PARCHMENT_DARK = (190, 139, 82) GOLD = (220, 167, 75) MIN_SCENE_DURATION = 3.4 TITLE_DURATION = 2.8 FINAL_DURATION = 4.2 ON_SCREEN_WORD_LIMIT = 8 ON_SCREEN_CHAR_LIMIT = 54 CAPTION_WORD_LIMIT = 22 CAPTION_CHAR_LIMIT = 150 def _load_font(size: int, bold: bool = False, serif: bool = True) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: family = "DejaVuSerif" if serif else "DejaVuSans" suffix = "-Bold" if bold else "" candidates = [ f"/usr/share/fonts/truetype/dejavu/{family}{suffix}.ttf", f"/usr/local/share/fonts/{family}{suffix}.ttf", "/System/Library/Fonts/Supplemental/Georgia Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Georgia.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf", ] for path in candidates: try: return ImageFont.truetype(path, size=size) except OSError: continue try: return ImageFont.truetype(f"{family}{suffix}.ttf", size=size) except OSError: return ImageFont.load_default() def _measure(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]: bbox = draw.textbbox((0, 0), text, font=font) return bbox[2] - bbox[0], bbox[3] - bbox[1] def _wrap_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> list[str]: text = " ".join((text or "").split()) if not text: return [] lines: list[str] = [] current = "" for word in text.split(" "): candidate = word if not current else f"{current} {word}" width, _ = _measure(draw, candidate, font) if width <= max_width: current = candidate continue if current: lines.append(current) current = word else: clipped = word while clipped: for index in range(len(clipped), 0, -1): piece = clipped[:index] width, _ = _measure(draw, piece, font) if width <= max_width: lines.append(piece) clipped = clipped[index:] break if current: lines.append(current) return lines def _draw_centered_wrapped( draw: ImageDraw.ImageDraw, text: str, y: int, font: ImageFont.ImageFont, fill: tuple[int, int, int], max_width: int, line_gap: int = 12, stroke: int = 0, stroke_fill: tuple[int, int, int] | None = None, ) -> int: lines = _wrap_text(draw, text, font, max_width) for line in lines: width, height = _measure(draw, line, font) x = (VIDEO_SIZE[0] - width) // 2 draw.text( (x, y), line, font=font, fill=fill, stroke_width=stroke, stroke_fill=stroke_fill or fill, ) y += height + line_gap return y def _shorten(text: str | None, limit: int) -> str: text = " ".join((text or "").split()) if len(text) <= limit: return text return f"{text[: limit - 3].rstrip()}..." def _word_count(text: str | None) -> int: return len(" ".join((text or "").split()).split()) def _compact_text(text: str | None, max_words: int, max_chars: int) -> str: clean = " ".join((text or "").split()) if not clean: return "" words = clean.split() clipped = " ".join(words[:max_words]) clipped = _shorten(clipped, max_chars) clipped = clipped.rstrip(" ,;:-") if clipped and clipped != clean and not clipped.endswith("..."): return f"{clipped}..." return clipped def _parchment_base(size: tuple[int, int] = VIDEO_SIZE) -> Image.Image: image = Image.new("RGB", size, PARCHMENT) draw = ImageDraw.Draw(image) for inset, color in [(34, INK), (48, PARCHMENT_DARK), (62, INK)]: draw.rounded_rectangle( (inset, inset, size[0] - inset, size[1] - inset), radius=22, outline=color, width=4 if inset != 48 else 2, ) return image def _save_title_card( out_path: Path, title: str, subtitle: str, eyebrow: str = "YeOldeCut presents", ) -> str: image = _parchment_base() draw = ImageDraw.Draw(image) eyebrow_font = _load_font(42, serif=False) title_font = _load_font(96, bold=True) subtitle_font = _load_font(44) y = 420 y = _draw_centered_wrapped(draw, eyebrow.upper(), y, eyebrow_font, PARCHMENT_DARK, 900, line_gap=10) y += 74 y = _draw_centered_wrapped(draw, _shorten(title, 62), y, title_font, INK, 920, line_gap=20) y += 58 _draw_centered_wrapped(draw, _compact_text(subtitle, 20, 124), y, subtitle_font, INK, 840, line_gap=14) draw.line((190, 1240, 890, 1240), fill=PARCHMENT_DARK, width=5) _draw_centered_wrapped(draw, "A tiny-model cinematic roast", 1290, subtitle_font, INK, 840) image.save(out_path) return str(out_path) def _save_final_card(out_path: Path, title: str, caption: str) -> str: image = _parchment_base() draw = ImageDraw.Draw(image) title_font = _load_font(80, bold=True) caption_font = _load_font(42) small_font = _load_font(34, serif=False) y = 520 y = _draw_centered_wrapped(draw, _shorten(title, 76), y, title_font, INK, 920, line_gap=20) y += 76 y = _draw_centered_wrapped(draw, _compact_text(caption, 24, 150), y, caption_font, INK, 860, line_gap=16) y += 84 _draw_centered_wrapped(draw, "Generated with YeOldeCut", y, small_font, PARCHMENT_DARK, 840) image.save(out_path) return str(out_path) def _save_scene_card(out_path: Path, image_path: str | None, scene: dict[str, Any], index: int) -> str: size = VIDEO_SIZE if image_path and Path(image_path).exists(): source = Image.open(image_path).convert("RGB") background = ImageOps.fit(source, size).filter(ImageFilter.GaussianBlur(28)) dim = Image.new("RGBA", size, (22, 16, 12, 166)) image = Image.alpha_composite(background.convert("RGBA"), dim) foreground = ImageOps.contain(source, (940, 940)) x = (size[0] - foreground.width) // 2 y = 310 shadow = Image.new("RGBA", (foreground.width + 34, foreground.height + 34), (0, 0, 0, 0)) shadow_draw = ImageDraw.Draw(shadow) shadow_draw.rounded_rectangle((0, 0, shadow.width - 1, shadow.height - 1), radius=28, fill=(0, 0, 0, 110)) image.alpha_composite(shadow, (x - 17, y - 10)) frame = Image.new("RGBA", (foreground.width + 26, foreground.height + 26), (245, 226, 190, 255)) image.alpha_composite(frame, (x - 13, y - 13)) image.paste(foreground, (x, y)) else: image = _parchment_base().convert("RGBA") draw = ImageDraw.Draw(image) tag_font = _load_font(34, bold=True, serif=False) title_font = _load_font(58, bold=True) caption_font = _load_font(38) tag = f"Scene {index + 1}" draw.rounded_rectangle((78, 90, 1002, 166), radius=24, fill=(237, 218, 181, 232), outline=GOLD, width=3) _draw_centered_wrapped(draw, tag.upper(), 108, tag_font, INK, 840) on_screen = scene.get("on_screen_text") or scene.get("scene_template") or "The court considers the evidence" _draw_centered_wrapped( draw, _compact_text(on_screen, ON_SCREEN_WORD_LIMIT, ON_SCREEN_CHAR_LIMIT), 188, title_font, (250, 235, 207), 900, line_gap=12, stroke=3, stroke_fill=(0, 0, 0), ) narration = scene.get("narration") or "" panel_top = 1392 draw.rounded_rectangle( (88, panel_top, 992, 1718), radius=28, fill=(30, 22, 16, 222), outline=(237, 218, 181, 230), width=3, ) _draw_centered_wrapped( draw, _compact_text(narration, CAPTION_WORD_LIMIT, CAPTION_CHAR_LIMIT), panel_top + 38, caption_font, (252, 239, 214), 780, line_gap=12, ) image.convert("RGB").save(out_path) return str(out_path) def _duration_for(path: str | None) -> float: if not path: return 0.0 clip = None try: clip = AudioFileClip(path) return float(clip.duration or 0.0) except Exception: return 0.0 finally: if clip is not None: clip.close() def _scene_durations(scenes: list[dict[str, Any]], target_total: float) -> list[float]: if not scenes: return [] raw = [] for scene in scenes: narration_words = _word_count(scene.get("narration")) screen_words = _word_count(scene.get("on_screen_text") or scene.get("scene_template")) estimated = max(MIN_SCENE_DURATION, (narration_words / 2.45) + (screen_words * 0.18) + 0.9) try: model_duration = float(scene.get("duration_seconds") or 0) except (TypeError, ValueError): model_duration = 0.0 raw.append(max(estimated, min(model_duration, 8.0) if model_duration > 0 else 0.0)) if sum(raw) <= 0: return [target_total / len(scenes)] * len(scenes) scale = target_total / sum(raw) return [max(MIN_SCENE_DURATION, value * scale) for value in raw] def _close_all(clips: Iterable[Any]) -> None: for clip in clips: if clip is None: continue close = getattr(clip, "close", None) if close: close() def _with_transitions(clips: list[ImageClip], duration: float = 0.55) -> list[ImageClip]: if len(clips) < 2: return clips transitioned: list[ImageClip] = [] for index, clip in enumerate(clips): effects = [] if index > 0: effects.append(vfx.CrossFadeIn(duration)) if index < len(clips) - 1: effects.append(vfx.CrossFadeOut(duration)) transitioned.append(clip.with_effects(effects) if effects else clip) return transitioned def compose_movie( script: dict[str, Any], image_paths: list[str], narration_path: str, music_path: str | None, out_dir: str | Path = "outputs", fps: int = 24, transition_seconds: float = 0.55, ) -> str: out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) slide_dir = out_dir / "slides" slide_dir.mkdir(parents=True, exist_ok=True) scenes = list(script.get("scenes") or []) movie_title = script.get("movie_title") or "The Court of Tiny Sorrows" logline = script.get("logline") or "A small model makes a grand spectacle of a small heartbreak." final_card = script.get("final_card") or "Thus concludes the judgment." share_caption = script.get("share_caption") or "I fed a rejection text to YeOldeCut and received a medieval roast film." title_duration = TITLE_DURATION final_duration = FINAL_DURATION narration_duration = _duration_for(narration_path) clip_count = len(scenes) + 2 overlap_total = transition_seconds * max(0, clip_count - 1) target_scene_total = max( narration_duration - title_duration - final_duration + overlap_total, len(scenes) * MIN_SCENE_DURATION, 12.0, ) scene_durations = _scene_durations(scenes, target_scene_total) slide_paths: list[tuple[str, float]] = [] title_path = slide_dir / "title.png" slide_paths.append((_save_title_card(title_path, script.get("title_card") or movie_title, logline), title_duration)) for index, scene in enumerate(scenes): image_path = image_paths[index] if index < len(image_paths) else None scene_path = slide_dir / f"scene-{index + 1:02d}.png" duration = scene_durations[index] if index < len(scene_durations) else 4.0 slide_paths.append((_save_scene_card(scene_path, image_path, scene, index), duration)) outro_path = slide_dir / "final.png" slide_paths.append((_save_final_card(outro_path, final_card, share_caption), final_duration)) clips = [ImageClip(path).with_duration(duration) for path, duration in slide_paths] transitioned_clips = _with_transitions(clips, duration=transition_seconds) video = None audio = None audio_clips = [] output_path = out_dir / f"{uuid.uuid4()}.mp4" try: video = concatenate_videoclips( transitioned_clips, method="compose", padding=-transition_seconds if len(transitioned_clips) > 1 else 0, ) if music_path: music = AudioFileClip(music_path).with_volume_scaled(0.14) if music.duration and music.duration > video.duration: music = music.subclipped(0, video.duration) audio_clips.append(music) voice = AudioFileClip(narration_path).with_volume_scaled(1.0) audio_clips.append(voice) audio = CompositeAudioClip(audio_clips) video = video.with_audio(audio) video.write_videofile( str(output_path), fps=fps, codec="libx264", audio_codec="aac", audio_fps=44100, preset="medium", threads=max(1, min(4, math.floor((fps or 24) / 8))), ffmpeg_params=["-movflags", "+faststart"], pixel_format="yuv420p", logger=None, ) finally: _close_all([video, audio, *audio_clips, *transitioned_clips, *clips]) return str(output_path)