""" Sunset Reel — Fast-Paced TikTok/Reels Renderer 7 scenes × ~1s each = ~7s total video Snap zoom, quick center-pop text, hard cuts Pure code rendering with PIL + cv2 """ import os import numpy as np import cv2 from pathlib import Path from PIL import Image, ImageDraw, ImageFont, ImageEnhance, ImageOps import random import json # --------------------------------------------------------------------------- # GLOBALS # --------------------------------------------------------------------------- RESOLUTION = (1080, 1920) # W x H FPS = 30 OUTPUT_PATH = "renders/sunset_reel.mp4" SELECTED_DIR = "selected" FONT_PATH = "asset/TR Impact.TTF" FONT_PATH_REG = "asset/TR Impact.TTF" GLOBAL_TEMP = +8 GLOBAL_TINT = -5 TEXT_WHITE = (255, 255, 255, 255) TEXT_STROKE = (0, 0, 0, 210) TEXT_SHADOW = (0, 0, 0, 60) 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 # --------------------------------------------------------------------------- # FAST-PACED SCENE CONFIG - Types of Anger # --------------------------------------------------------------------------- SCENE_CONFIG = [ # ── INTRO / HOOK ───────────────────────────────────────────── { "idx": 0, "label": "WHICH TYPE OF\nANGER DO YOU HAVE?", "duration_s": 4.7, "motion": {"type": "slow_push_in", "scale_start": 1.0, "scale_end": 1.08}, "text": {"type": "center_stroke_pop", "entry_frame": 2, "hold_frames": 125, "font_size": 95, "align": "center"}, "grade": {"crush_blacks": 15, "contrast": 1.15}, "transition": {"type": "hard_cut", "frames": 1}, }, # ── TYPE 1: SHOUTING ───────────────────────────────────────── { "idx": 1, "label": "~SHOUTING", "duration_s": 2.3, "motion": {"type": "snap_zoom", "scale_start": 1.0, "scale_end": 1.12}, "text": {"type": "center_pop", "entry_frame": 0, "hold_frames": 69, "font_size": 110, "align": "center"}, "grade": {"warm_tint": True, "lift_mids": 10}, "transition": {"type": "whip_pan_right", "frames": 4}, }, # ── TYPE 2: REVENGE ────────────────────────────────────────── { "idx": 2, "label": "~REVENGE", "duration_s": 2.3, "motion": {"type": "static"}, "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"}, "grade": {"desaturate": True, "lift_blacks": 5}, "transition": {"type": "whip_pan_right", "frames": 4}, }, # ── TYPE 3: IGNORING ───────────────────────────────────────── { "idx": 3, "label": "~IGNORING", "duration_s": 2.3, "motion": {"type": "static"}, "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"}, "grade": {"cool_tint": True, "highlights": -15}, "transition": {"type": "whip_pan_right", "frames": 4}, }, # ── TYPE 4: SLAMMING ───────────────────────────────────────── { "idx": 4, "label": "~SLAMMING", "duration_s": 2.3, "motion": {"type": "static"}, "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"}, "grade": {"soft_pink": True, "lift_mids": 15}, "transition": {"type": "whip_pan_right", "frames": 4}, }, # ── TYPE 5: CURSING ────────────────────────────────────────── { "idx": 5, "label": "~CURSING", "duration_s": 2.3, "motion": {"type": "static"}, "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"}, "grade": {"indoor_warm": True, "lift_shadows": 8}, "transition": {"type": "whip_pan_right", "frames": 4}, }, # ── TYPE 6: WALKING AWAY ───────────────────────────────────── { "idx": 6, "label": "~WALKING AWAY", "duration_s": 2.3, "motion": {"type": "static"}, "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"}, "grade": {"teal_orange": True, "crush_blacks": 10}, "transition": {"type": "whip_pan_right", "frames": 4}, }, # ── TYPE 7: FIGHTING ───────────────────────────────────────── { "idx": 7, "label": "~FIGHTING", "duration_s": 2.3, "motion": {"type": "static"}, "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"}, "grade": {"dark_moody": True, "crush_blacks": 20, "desaturate": 15}, "transition": {"type": "whip_pan_right", "frames": 4}, }, # ── TYPE 8: CRYING ─────────────────────────────────────────── { "idx": 8, "label": "~CRYING", "duration_s": 2.3, "motion": {"type": "static"}, "text": {"type": "center_fade_pop", "entry_frame": 2, "hold_frames": 66, "font_size": 110, "align": "center"}, "grade": {"warm_indoor": True, "soft_glow": True, "lift_mids": 12}, "transition": {"type": "end_fade_black", "frames": 30}, }, ] # --------------------------------------------------------------------------- # COLOUR GRADE # --------------------------------------------------------------------------- def grade_image(img: Image.Image, grade: dict) -> Image.Image: r, g, b = img.split() # Global warm grade r = r.point(lambda p: min(255, p + int(GLOBAL_TEMP * 1.2))) g = g.point(lambda p: min(255, p + int(GLOBAL_TEMP * 0.35))) b = b.point(lambda p: max(0, p - int(GLOBAL_TEMP * 0.8))) g = g.point(lambda p: max(0, min(255, p + int(GLOBAL_TINT * 0.5)))) # Scene-specific if grade.get("boost_reds"): v = grade["boost_reds"] r = r.point(lambda p: min(255, p + v)) g = g.point(lambda p: min(255, p + int(v * 0.25))) if grade.get("crush_blacks"): v = grade.get("crush_blacks", 10) r = r.point(lambda p: max(0, p - v) if p < 55 else p) g = g.point(lambda p: max(0, p - v) if p < 55 else p) b = b.point(lambda p: max(0, p - v) if p < 55 else p) if grade.get("contrast"): v = grade["contrast"] r = r.point(lambda p: int((p - 128) * v + 128)) g = g.point(lambda p: int((p - 128) * v + 128)) b = b.point(lambda p: int((p - 128) * v + 128)) if grade.get("lift_shadows"): v = grade.get("lift_shadows", 0) r = r.point(lambda p: min(255, p + v)) g = g.point(lambda p: min(255, p + v)) b = b.point(lambda p: min(255, p + v)) if grade.get("warm_tint"): r = r.point(lambda p: min(255, p + 8)) g = g.point(lambda p: min(255, p + 3)) if grade.get("cool_tint"): b = b.point(lambda p: min(255, p + 8)) r = r.point(lambda p: max(0, p - 5)) if grade.get("desaturate"): v = grade.get("desaturate", 10) merged = Image.merge("RGB", (r, g, b)) merged = ImageEnhance.Color(merged).enhance(max(0, 1.0 - v/100.0)) r, g, b = merged.split() if grade.get("lift_blacks"): v = grade["lift_blacks"] r = r.point(lambda p: min(255, p + v)) g = g.point(lambda p: min(255, p + v)) b = b.point(lambda p: min(255, p + v)) if grade.get("lift_mids"): v = grade["lift_mids"] r = r.point(lambda p: int(p + v * (1 - abs(p - 128) / 128))) g = g.point(lambda p: int(p + v * (1 - abs(p - 128) / 128))) b = b.point(lambda p: int(p + v * (1 - abs(p - 128) / 128))) if grade.get("highlights"): v = abs(grade["highlights"]) r = r.point(lambda p: p - int((p / 255) ** 2.2 * v)) g = g.point(lambda p: p - int((p / 255) ** 2.2 * v)) b = b.point(lambda p: p - int((p / 255) ** 2.2 * v)) if grade.get("teal_orange"): r = r.point(lambda p: min(255, p + 5)) b = b.point(lambda p: max(0, p - 8)) if grade.get("soft_pink"): r = r.point(lambda p: min(255, p + 10)) b = b.point(lambda p: min(255, p + 5)) if grade.get("indoor_warm"): r = r.point(lambda p: min(255, p + 12)) g = g.point(lambda p: min(255, p + 5)) if grade.get("soft_glow"): merged = Image.merge("RGB", (r, g, b)) merged = ImageEnhance.Brightness(merged).enhance(1.05) r, g, b = merged.split() if grade.get("dark_moody"): r = r.point(lambda p: max(0, p - 15)) g = g.point(lambda p: max(0, p - 15)) b = b.point(lambda p: max(0, p - 10)) img = Image.merge("RGB", (r, g, b)) img = ImageEnhance.Brightness(img).enhance(1.02) return img # --------------------------------------------------------------------------- # IMAGE LOAD + CROP-TO-FILL # --------------------------------------------------------------------------- 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)) # --------------------------------------------------------------------------- # MOTION - snap_zoom (sharp, punchy zoom effect) # --------------------------------------------------------------------------- def get_motion_frame(base: Image.Image, motion: dict, t: float) -> Image.Image: mtype = motion["type"] w, h = RESOLUTION if mtype == "snap_zoom": s_start = motion["scale_start"] s_end = motion["scale_end"] scale = lerp(s_start, s_end, ease_out(t)) nw = int(w * scale) nh = int(h * scale) scaled = base.resize((nw, nh), Image.Resampling.BILINEAR) left = (nw - w) // 2 top = (nh - h) // 2 left = max(0, min(left, nw - w)) top = max(0, min(top, nh - h)) return scaled.crop((left, top, left + w, top + h)) elif mtype == "slow_push_in": s_start = motion.get("scale_start", 1.0) s_end = motion.get("scale_end", 1.08) scale = lerp(s_start, s_end, ease_in_out(t)) nw = int(w * scale) nh = int(h * scale) scaled = base.resize((nw, nh), Image.Resampling.BILINEAR) left = (nw - w) // 2 top = (nh - h) // 2 left = max(0, min(left, nw - w)) top = max(0, min(top, nh - h)) return scaled.crop((left, top, left + w, top + h)) else: # static or others return base # --------------------------------------------------------------------------- # FONT CACHE # --------------------------------------------------------------------------- _font_cache = {} def get_font(size: int) -> ImageFont.FreeTypeFont: if size not in _font_cache: try: _font_cache[size] = ImageFont.truetype(FONT_PATH, size) except Exception: try: _font_cache[size] = ImageFont.truetype(FONT_PATH_REG, size) except Exception: _font_cache[size] = ImageFont.load_default() return _font_cache[size] # --------------------------------------------------------------------------- # TEXT DRAWING # --------------------------------------------------------------------------- def wrap_text(text: str, font, max_width: int = 900) -> str: """ Wrap text to fit within max_width pixels. Breaks long lines into multiple lines. """ from PIL import ImageDraw temp_draw = ImageDraw.Draw(Image.new("RGB", (1, 1))) lines = text.split("\n") wrapped_lines = [] for line in lines: if not line.strip(): wrapped_lines.append("") continue words = line.split() current_line = "" for word in words: test_line = current_line + (" " if current_line else "") + word bbox = temp_draw.textbbox((0, 0), test_line, font=font) width = bbox[2] - bbox[0] if width <= max_width: current_line = test_line else: if current_line: wrapped_lines.append(current_line) current_line = word if current_line: wrapped_lines.append(current_line) return "\n".join(wrapped_lines) def draw_text_stroked(draw, text, pos, font, align="left", opacity=1.0): """White text with stroke, drop shadow, and opacity.""" x, y = pos w, _ = RESOLUTION lines = text.split("\n") line_heights = [] line_widths = [] for line in lines: bb = draw.textbbox((0, 0), line, font=font) line_widths.append(bb[2] - bb[0]) line_heights.append(bb[3] - bb[1]) line_spacing = int(font.size * 1.25) for i, line in enumerate(lines): lw = line_widths[i] ly = y + i * line_spacing if align == "center": lx = x - lw // 2 elif align == "right": lx = x - lw else: lx = x alpha_stroke = int(TEXT_STROKE[3] * opacity) alpha_white = int(TEXT_WHITE[3] * opacity) alpha_shadow = int(TEXT_SHADOW[3] * opacity) stroke_col = TEXT_STROKE[:3] + (alpha_stroke,) white_col = TEXT_WHITE[:3] + (alpha_white,) shadow_col = TEXT_SHADOW[:3] + (alpha_shadow,) # Drop shadow draw.text((lx + 4, ly + 4), line, font=font, fill=shadow_col) # 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=stroke_col) # White fill draw.text((lx, ly), line, font=font, fill=white_col) # --------------------------------------------------------------------------- # TEXT ANIMATIONS - quick_center_pop # --------------------------------------------------------------------------- def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image: tcfg = cfg["text"] ttype = tcfg["type"] label = cfg["label"] w, h = RESOLUTION layer = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(layer) font = get_font(tcfg["font_size"]) # Wrap text to fit screen width wrapped_label = wrap_text(label, font, max_width=900) if ttype == "quick_center_pop" or ttype == "center_stroke_pop": entry_f = tcfg["entry_frame"] hold_f = tcfg["hold_frames"] fade_start = entry_f + hold_f if frame < entry_f: pass elif frame < entry_f + 6: # 0.2s pop-in progress = ease_out((frame - entry_f) / 6) opacity = min(1.0, progress * 1.5) x = w // 2 y = h // 2 draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=opacity) elif frame < fade_start: # Hold full opacity x = w // 2 y = h // 2 draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=1.0) else: # Fade out quickly fade_progress = min(1.0, (frame - fade_start) / 4) opacity = 1.0 - fade_progress x = w // 2 y = h // 2 draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=opacity) elif ttype == "center_pop" or ttype == "center_fade_pop": entry_f = tcfg["entry_frame"] hold_f = tcfg["hold_frames"] fade_start = entry_f + hold_f if frame < entry_f: pass elif frame < entry_f + 6: progress = ease_out((frame - entry_f) / 6) opacity = min(1.0, progress * 1.5) x = w // 2 y = h // 2 draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=opacity) elif frame < fade_start: x = w // 2 y = h // 2 draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=1.0) else: fade_progress = min(1.0, (frame - fade_start) / 4) opacity = 1.0 - fade_progress x = w // 2 y = h // 2 draw_text_stroked(draw, wrapped_label, (x, y), font, align="center", opacity=opacity) return layer # --------------------------------------------------------------------------- # TRANSITION - hard_cut only # --------------------------------------------------------------------------- def apply_transition(frame_a: Image.Image, frame_b: Image.Image, ttype: str, progress: float) -> Image.Image: """Apply dynamic transition between frames.""" w, h = RESOLUTION if ttype == "whip_pan_right": # Slide frame_b in from left with motion blur effect offset = int(w * (1 - ease_out(progress))) result = Image.new("RGB", (w, h)) result.paste(frame_a, (0, 0)) result.paste(frame_b, (offset, 0)) return result elif ttype == "flash": if progress < 0.3: flash_intensity = (progress / 0.3) * 0.5 brightened = ImageEnhance.Brightness(frame_a).enhance(1.0 + flash_intensity) alpha = min(0.5, progress / 0.3 * 0.5) return Image.blend(brightened, frame_b, alpha) else: blend_t = (progress - 0.3) / 0.7 return Image.blend(frame_a, frame_b, blend_t) elif ttype == "end_fade_black": # Fade to black black = Image.new("RGB", (w, h), (0, 0, 0)) return Image.blend(frame_a, black, progress) else: # Default to hard cut return frame_b if progress >= 0.5 else frame_a # --------------------------------------------------------------------------- # MAIN RENDER # --------------------------------------------------------------------------- def render(): global SCENE_CONFIG # Load SCENE_CONFIG from JSON if provided via environment variable config_path = os.environ.get("COMPOSER_MANIFEST_CONFIG") if config_path: # Try as absolute path first, then relative if not os.path.isabs(config_path): config_path = os.path.abspath(config_path) if os.path.exists(config_path): print(f"[INFO] Loading config from: {config_path}") try: with open(config_path, "r") as f: manifest_data = json.load(f) SCENE_CONFIG = manifest_data.get("scenes", SCENE_CONFIG) print(f"[INFO] Loaded {len(SCENE_CONFIG)} scenes from manifest") except Exception as e: print(f"[WARN] Failed to load config: {e}") else: print(f"[WARN] Config file not found: {config_path}") w, h = RESOLUTION Path(os.path.dirname(OUTPUT_PATH)).mkdir(parents=True, exist_ok=True) tmp_path = OUTPUT_PATH.replace(".mp4", "_raw.mp4") writer = cv2.VideoWriter( tmp_path, cv2.VideoWriter_fourcc(*"mp4v"), FPS, (w, h), ) print(f"\n{'='*55}") print(f" Dynamic Video Composer") print(f" {len(SCENE_CONFIG)} scenes | {FPS}fps | {w}x{h}") print(f"{'='*55}\n") # Preload and grade all base images print("[1/3] Loading + grading images...") base_images = [] for cfg in SCENE_CONFIG: print(f" Loading scene {cfg['idx']}...", end=" ", flush=True) raw = load_scene_image(cfg["idx"]) print(f"grading...", end=" ", flush=True) graded = grade_image(raw, cfg["grade"]) base_images.append(graded) print(f"ok", flush=True) print(" [OK] Done\n") # Render scenes print("[2/3] Rendering frames...") total_scenes = len(SCENE_CONFIG) frames_written = 0 for scene_i, cfg in enumerate(SCENE_CONFIG): total_frames = int(cfg["duration_s"] * FPS) trans_cfg = cfg["transition"] trans_frames = trans_cfg["frames"] base = base_images[scene_i] # Preload next scene base for transitions if scene_i + 1 < total_scenes: next_cfg = SCENE_CONFIG[scene_i + 1] next_base = base_images[scene_i + 1] else: next_cfg = None next_base = None print(f" Scene {cfg['idx']:02d} -- {cfg['label'].replace(chr(10),' ')} ({total_frames}f, {cfg['duration_s']}s)") for frame in range(total_frames): # Motion frame t_motion = frame / max(total_frames - 1, 1) img = get_motion_frame(base, cfg["motion"], t_motion) # Text layer 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") # Transition blend at end of scene frames_into_trans = frame - (total_frames - trans_frames) if frames_into_trans >= 0 and next_base is not None: trans_t = frames_into_trans / max(trans_frames - 1, 1) t_next = 0.0 next_motion = get_motion_frame(next_base, next_cfg["motion"], t_next) img = apply_transition(img, next_motion, trans_cfg["type"], trans_t) # Write frame (cv2 expects BGR) arr = np.array(img) writer.write(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)) frames_written += 1 print(f" [OK] {total_frames} frames") # Hard cut to black at end print(f"\n Hard cut to black") black_frame = np.zeros((h, w, 3), dtype=np.uint8) for _ in range(2): # 2 frames of black writer.write(black_frame) frames_written += 1 print(f" [OK] 2 frames") writer.release() print(f"\n Total frames written: {frames_written} (~{frames_written/FPS:.1f}s)\n") # Re-encode with ffmpeg print("[3/3] Encoding H.264 MP4 via ffmpeg...") cmd = ( f"ffmpeg -y -i {tmp_path} " f"-vcodec libx264 -crf 20 -preset fast " f"-pix_fmt yuv420p " f"-movflags +faststart " f"{OUTPUT_PATH} 2>&1" ) ret = os.system(cmd) if ret == 0: os.remove(tmp_path) size_mb = os.path.getsize(OUTPUT_PATH) / (1024 * 1024) print(f"\n [OK] Output: {OUTPUT_PATH}") print(f" [OK] Size : {size_mb:.1f} MB") else: print(f" [ERROR] ffmpeg failed (code {ret}). Raw file kept: {tmp_path}") print(f"\n{'='*55}\n") if __name__ == "__main__": render()