Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |