""" Sunset Reel — Simplified Frame-by-Frame Renderer Renders static frame with text overlay from each scene image (no complex motions) Uses PIL only, exports via ffmpeg """ import os import json from pathlib import Path from PIL import Image, ImageDraw, ImageFont, ImageEnhance # --------------------------------------------------------------------------- # GLOBALS # --------------------------------------------------------------------------- RESOLUTION = (1080, 1920) # W x H FPS = 30 OUTPUT_PATH = "renders/sunset_reel.mp4" SELECTED_DIR = "selected" FONT_PATH = "../trendclip/assets/fonts/Montserrat-Bold.ttf" TEXT_WHITE = (255, 255, 255) TEXT_STROKE = (0, 0, 0) STROKE_W = 8 # Easing def ease_out(t): return 1 - (1 - t) ** 3 def ease_in_out(t): return t * t * (3 - 2 * t) def ease_in(t): return t * t * t def lerp(a, b, t): return a + (b - a) * t # --------------------------------------------------------------------------- # SCENE CONFIG # --------------------------------------------------------------------------- SCENE_CONFIG = [ {"idx": 0, "label": "Chase The\nGolden Hour", "duration_s": 3.5, "boost_reds": 15}, {"idx": 1, "label": "Scout Your\nSpot First", "duration_s": 3.0, "push_purples": True}, {"idx": 2, "label": "Balance The\nBright Sky", "duration_s": 3.5, "sky_cool": True}, {"idx": 3, "label": "Rule Of\nThirds Grid", "duration_s": 4.0}, {"idx": 4, "label": "Layer The\nForeground Depth", "duration_s": 4.0, "saturate": True}, {"idx": 5, "label": "Add A\nSilhouette Subject", "duration_s": 3.5, "deep_amber": True}, {"idx": 6, "label": "Edit The\nWarmth Gently", "duration_s": 4.5, "match_reel": True}, ] END_CARD = { "duration_s": 2.5, "lines": [ ("Stop guessing.", 96), ("Start shooting.", 96), ("Which of these do you already do?", 52), ("Save this before you forget", 46), ], } # --------------------------------------------------------------------------- # COLOUR GRADE # --------------------------------------------------------------------------- def grade_image(img: Image.Image, grade_cfg: dict) -> Image.Image: """Apply colour grading to image""" enhancer = ImageEnhance.Brightness(img) img = enhancer.enhance(1.02) if grade_cfg.get("boost_reds"): enhancer = ImageEnhance.Color(img) img = enhancer.enhance(1.1) if grade_cfg.get("saturate"): enhancer = ImageEnhance.Color(img) img = enhancer.enhance(1.22) if grade_cfg.get("deep_amber"): enhancer = ImageEnhance.Color(img) img = enhancer.enhance(1.15) return img # --------------------------------------------------------------------------- # IMAGE LOAD + CROP # --------------------------------------------------------------------------- def load_scene_image(idx: int) -> Image.Image: path = os.path.join(SELECTED_DIR, f"scene_{idx:02d}.jpg") img = Image.open(path).convert("RGB") return crop_to_fill(img, *RESOLUTION) def crop_to_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image: iw, ih = img.size scale = max(target_w / iw, target_h / ih) new_w = int(iw * scale) new_h = int(ih * scale) img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) left = (new_w - target_w) // 2 top = (new_h - target_h) // 2 return img.crop((left, top, left + target_w, top + target_h)) # --------------------------------------------------------------------------- # TEXT DRAWING # --------------------------------------------------------------------------- def get_font(size: int) -> ImageFont.FreeTypeFont: try: return ImageFont.truetype(FONT_PATH, size) except Exception: return ImageFont.load_default() def draw_text_stroked(draw, text, pos, font, align="left"): """White text with stroke""" x, y = pos w, _ = RESOLUTION # Multi-line lines = text.split("\n") line_spacing = int(font.size * 1.25) for i, line in enumerate(lines): bbox = draw.textbbox((0, 0), line, font=font) line_w = bbox[2] - bbox[0] ly = y + i * line_spacing if align == "center": lx = x - line_w // 2 elif align == "right": lx = x - line_w else: lx = x # Stroke layers for sw in [STROKE_W, STROKE_W - 2, STROKE_W - 4, 2]: for ax in range(-sw, sw + 1, max(1, sw // 3)): for ay in range(-sw, sw + 1, max(1, sw // 3)): if ax * ax + ay * ay <= sw * sw: draw.text((lx + ax, ly + ay), line, font=font, fill=TEXT_STROKE) # White fill draw.text((lx, ly), line, font=font, fill=TEXT_WHITE) def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image: """Render text layer for frame""" w, h = RESOLUTION layer = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(layer) font = get_font(90) label = cfg["label"] # Simple fade-in at frame 8 if frame >= 8: progress = min(1.0, (frame - 8) / 18) opacity_int = int(255 * progress) # Draw stroked text draw_text_stroked(draw, label, (int(w * 0.08), int(h * 0.76)), font, align="left") return layer # --------------------------------------------------------------------------- # MAIN RENDER # --------------------------------------------------------------------------- def render(): w, h = RESOLUTION Path(os.path.dirname(OUTPUT_PATH)).mkdir(parents=True, exist_ok=True) # Temp frame folder frames_dir = "renders/frames_tmp" Path(frames_dir).mkdir(parents=True, exist_ok=True) print(f"\n{'='*55}") print(f" Sunset Reel Renderer (Simplified)") print(f" {len(SCENE_CONFIG)} scenes + end card | {FPS}fps | {w}x{h}") print(f"{'='*55}\n") # Render scenes print("[1/2] Rendering frames...") total_frames_written = 0 for scene_i, cfg in enumerate(SCENE_CONFIG): total_frames = int(cfg["duration_s"] * FPS) base = load_scene_image(cfg["idx"]) base = grade_image(base, cfg) print(f" Scene {cfg['idx']:02d} — {cfg['label'].replace(chr(10),' ')} ({total_frames}f)") for frame in range(total_frames): # Create base image img = base.copy() # Add text overlay text_layer = render_text_frame(cfg, frame, total_frames) img_rgba = img.convert("RGBA") img_rgba = Image.alpha_composite(img_rgba, text_layer) img = img_rgba.convert("RGB") # Save frame frame_path = os.path.join(frames_dir, f"frame_{total_frames_written:06d}.jpg") img.save(frame_path, quality=92) total_frames_written += 1 print(f" ✓ {total_frames} frames") # Render end card end_frames = int(END_CARD["duration_s"] * FPS) print(f"\n End card ({end_frames}f)") for frame in range(end_frames): img = Image.new("RGB", (w, h), (8, 8, 8)) layer = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(layer) start_y = int(h * 0.14) step = int((h * 0.72) / len(END_CARD["lines"])) for i, (line_text, fsize) in enumerate(END_CARD["lines"]): entry_f = 14 + i * 10 if frame >= entry_f: progress = min(1.0, (frame - entry_f) / 18) font = get_font(fsize) y = start_y + i * step slide_y = int(lerp(20, 0, ease_out(progress))) draw_text_stroked(draw, line_text, (w // 2, y + slide_y), font, align="center") img_rgba = img.convert("RGBA") img_rgba = Image.alpha_composite(img_rgba, layer) img = img_rgba.convert("RGB") frame_path = os.path.join(frames_dir, f"frame_{total_frames_written:06d}.jpg") img.save(frame_path, quality=92) total_frames_written += 1 print(f" ✓ {end_frames} frames") print(f"\n Total frames: {total_frames_written} (~{total_frames_written/FPS:.1f}s)\n") # Encode with ffmpeg print("[2/2] Encoding MP4 via ffmpeg...") cmd = ( f"ffmpeg -y -framerate {FPS} -i {frames_dir}/frame_%06d.jpg " f"-c:v libx264 -crf 20 -preset fast -pix_fmt yuv420p -movflags +faststart " f"{OUTPUT_PATH} 2>&1" ) ret = os.system(cmd) if ret == 0: import shutil shutil.rmtree(frames_dir, ignore_errors=True) size_mb = os.path.getsize(OUTPUT_PATH) / (1024 * 1024) print(f"\n ✓ Output: {OUTPUT_PATH}") print(f" ✓ Size : {size_mb:.1f} MB\n") print(f"{'='*55}\n") else: print(f" ✗ ffmpeg failed (code {ret})") if __name__ == "__main__": render()