""" Scene Composer — Hardcoded Edit Engine Follows the exact structure and logic from sunset_reel_edit_plan.md Global architecture: - Resolution: 1080x1920 (9:16 vertical) - FPS: 30 - Colour: global warm grade (+8 temp, -5 tint), vignette 35% - Typography: Montserrat Bold (title 100px), medium weight (body 64px) - Motion language: Ken Burns default (1.0→1.06), text slide-up 14px @ 18f - 7 scenes + end card """ import os import json import math from pathlib import Path from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance # --------------------------------------------------------------------------- # GLOBAL CONSTANTS — from edit plan # --------------------------------------------------------------------------- RESOLUTION = (1080, 1920) FPS = 30 VIGNETTE_OPACITY = 0.35 # 35% vignette on all scenes # Global warm grade values (applied as multipliers/shifts in _apply_global_grade) GLOBAL_TEMP_SHIFT = +8 # warm shift (boosts reds/yellows) GLOBAL_TINT_SHIFT = -5 # tint (slight green pull-back) # Typography — sizes map to Montserrat Bold TTF FONT_SIZE_TITLE = 100 # Title / hook (SF Pro Display equiv) FONT_SIZE_BODY = 64 # Body tip text FONT_SIZE_ACCENT = 140 # Number accent (Bebas equiv — still Montserrat here) FONT_SIZE_CAPTION = 48 # Hashtag / caption TEXT_COLOR = (255, 255, 255, 255) # White TEXT_SHADOW = (0, 0, 0, 80) # 10% drop shadow STROKE_COLOR = (0, 0, 0, 200) STROKE_WIDTH = 9 # --------------------------------------------------------------------------- # SCENE CONFIG — hardcoded from edit plan, one dict per scene # --------------------------------------------------------------------------- SCENE_CONFIG = [ # ------------------------------------------------------------------ # Scene 00 — Chase the Golden Hour # Motion: Ken Burns push-up, scale 1.0→1.06 # Text: Title lower-third, slides up at f8, holds 2.2s, fades f96 # Grade: Boost oranges/reds +15, reduce highlights -20 # Trans: Cross dissolve 12f # ------------------------------------------------------------------ { "idx": 0, "label": "Chase The Golden Hour", "duration_s": 3.5, "motion": { "type": "ken_burns", "direction": "up", "scale_start": 1.0, "scale_end": 1.06, }, "text": { "type": "lower_third", # positioned lower third "entry": "slide_up", "entry_frame": 8, "hold_s": 2.2, "fade_frame": 96, "font_size": FONT_SIZE_TITLE, "align": "left", }, "grade": { "boost_reds": +15, # orange/red channel boost "highlights": -20, # pull back highlights "temp": GLOBAL_TEMP_SHIFT, "tint": GLOBAL_TINT_SHIFT, }, "transition_out": { "type": "cross_dissolve", "frames": 12, }, }, # ------------------------------------------------------------------ # Scene 01 — Scout Your Spot # Motion: Horizontal slow pan right, +30px offset over 3s # Text: Typewriter reveal, 20 chars/s, starts f12 # Grade: Crush blacks, push purples into shadows # Trans: Hard cut (intentional rhythm break) # ------------------------------------------------------------------ { "idx": 1, "label": "Scout Your Spot First", "duration_s": 3.0, "motion": { "type": "pan", "direction": "right", "offset_px": 30, # +30px rightward drift }, "text": { "type": "typewriter", "chars_per_sec": 20, "entry_frame": 12, "font_size": FONT_SIZE_BODY, "align": "left", }, "grade": { "crush_blacks": True, # deeper black crush "push_purples": True, # purple shadow tint "temp": GLOBAL_TEMP_SHIFT, "tint": GLOBAL_TINT_SHIFT, }, "transition_out": { "type": "hard_cut", "frames": 1, }, }, # ------------------------------------------------------------------ # Scene 02 — Balance the Bright Sky # Motion: Static + horizon split parallax (-8px sky drift) # Text: Two-line stagger, line1 f6, line2 f14, hold 2.5s # Grade: Dual-tone — sky cooler +4, ground warmer +12 # Trans: Zoom-out wipe 16f # ------------------------------------------------------------------ { "idx": 2, "label": "Balance The Bright Sky", "duration_s": 3.5, "motion": { "type": "horizon_parallax", "sky_offset_px": -8, # sky shifts left subtly "horizon_ratio": 0.42, # horizon at 42% from top }, "text": { "type": "stagger_two_line", "line1_frame": 6, "line2_frame": 14, "hold_s": 2.5, "font_size": FONT_SIZE_BODY, "align": "left", }, "grade": { "sky_temp": +4, # sky cooler "ground_temp": +12, # ground warmer "temp": GLOBAL_TEMP_SHIFT, "tint": GLOBAL_TINT_SHIFT, }, "transition_out": { "type": "zoom_out_wipe", "frames": 16, }, }, # ------------------------------------------------------------------ # Scene 03 — Rule of Thirds Grid ⚠️ AE Required # Motion: Static lock — all motion is overlay animation # Text: Grid draw-on f0-f12 → annotations pop f14-f20 → tip f24 # Grade: Preserve existing grid colour (white ~40% opacity) # Trans: Cross dissolve 10f # Note: Image already has baked grid — mask it, animate draw-on # ------------------------------------------------------------------ { "idx": 3, "label": "Rule Of Thirds Grid", "duration_s": 4.0, "motion": { "type": "static", }, "text": { "type": "grid_sequence", "grid_draw_frames": (0, 12), # draw-on window "annotations": [ # label, pop_frame, (x_ratio, y_ratio) ("Silhouettes", 14, (0.68, 0.30)), ("Horizon Placement", 15, (0.12, 0.64)), ("Leading Lines", 16, (0.60, 0.75)), ("S-Curve", 17, (0.65, 0.82)), ("Reflections", 18, (0.12, 0.82)), ], "annotation_scale_punch": (0.8, 1.0), "tip_frame": 24, "font_size": FONT_SIZE_BODY, }, "grade": { "grid_opacity": 0.40, # white grid lines ~40% "temp": GLOBAL_TEMP_SHIFT, "tint": GLOBAL_TINT_SHIFT, }, "transition_out": { "type": "cross_dissolve", "frames": 10, }, "ae_required": True, "ae_note": "Grid draw-on via trim path. Mask baked grid at comp start.", }, # ------------------------------------------------------------------ # Scene 04 — Layer the Foreground Depth ⚠️ AE Required # Motion: 3-layer parallax — FG 18px, MG 6px, BG static # Text: Right-edge entry at f10, hold 2.8s # Grade: Saturate magentas/pinks in FG, preserve sunburst # Trans: Ink-bleed / dissolve 14f # ------------------------------------------------------------------ { "idx": 4, "label": "Layer The Foreground Depth", "duration_s": 4.0, "motion": { "type": "parallax_3layer", "layers": { "fg": {"drift_px": 18, "direction": "up"}, # wildflowers "mg": {"drift_px": 6, "direction": "up"}, # pine trees "bg": {"drift_px": 0, "direction": None}, # sky / static }, }, "text": { "type": "right_slide", "entry_frame": 10, "hold_s": 2.8, "font_size": FONT_SIZE_BODY, "align": "right", }, "grade": { "saturate_magentas": +20, # boost pinks/magentas in FG "preserve_highlights": True, # don't clip sunburst "temp": GLOBAL_TEMP_SHIFT, "tint": GLOBAL_TINT_SHIFT, }, "transition_out": { "type": "dissolve", "frames": 14, }, "ae_required": True, "ae_note": "Roto Brush / manual mask for 3-layer extraction.", }, # ------------------------------------------------------------------ # Scene 05 — Add a Silhouette Subject # Motion: Push-in toward subjects, scale 1.0→1.04 # Text: Centered fade-up at f12, single line, minimal # Grade: Deep amber, crush blacks to pure silhouette # Trans: Fade to black 20f # ------------------------------------------------------------------ { "idx": 5, "label": "Add A Silhouette Subject", "duration_s": 3.5, "motion": { "type": "ken_burns", "direction": "in", # push-in (no directional drift) "scale_start": 1.0, "scale_end": 1.04, }, "text": { "type": "center_fade", "entry_frame": 12, "font_size": FONT_SIZE_BODY, "align": "center", "single_line": True, # minimal — image speaks }, "grade": { "deep_amber": True, # amber tone throughout "crush_blacks": True, # subjects to pure silhouette "protect_reflection": True, # water reflection stays bright "temp": GLOBAL_TEMP_SHIFT, "tint": GLOBAL_TINT_SHIFT, }, "transition_out": { "type": "fade_to_black", "frames": 20, }, }, # ------------------------------------------------------------------ # Scene 06 — Edit the Warmth Gently ⚠️ AE Required (CTA anchor) # Motion: Scroll-up pan, top→bottom of infographic over 3.5s # Text: Count-up per param (0.4s each) as it scrolls into frame # Grade: Match reel warm grade, don't over-process the infographic # Trans: Slide-left 16f to end card # ------------------------------------------------------------------ { "idx": 6, "label": "Edit The Warmth Gently", "duration_s": 4.5, "motion": { "type": "scroll_pan", "direction": "down", # reveal top→bottom "pan_duration_s": 3.5, }, "text": { "type": "countup_params", "countup_duration_s": 0.4, "params": [ ("Exposure", -39), ("Highlights", -30), ("Shadows", +59), ("Contrast", +7), ("Brightness", -28), ("Saturation", -11), ("Vibrance", +63), ("Warmth", +60), ("Tint", +29), ], }, "grade": { "match_reel_grade": True, # light warm pass only "temp": GLOBAL_TEMP_SHIFT, "tint": GLOBAL_TINT_SHIFT, }, "transition_out": { "type": "slide_left", "frames": 16, }, "ae_required": True, "ae_note": "Slider control expressions for count-up. linear(time, startT, endT, 0, targetValue).", }, ] # ------------------------------------------------------------------ # End card config # ------------------------------------------------------------------ END_CARD_CONFIG = { "duration_s": 2.0, "background": (10, 10, 10), # near-black "fade_in_frames": 12, "lines": [ ("Stop guessing and start shooting", FONT_SIZE_TITLE), ("Which of these do you already do?", FONT_SIZE_BODY), ("Drop a 🌅 if you're running out tonight!", FONT_SIZE_BODY), ("Save this before you forget", FONT_SIZE_CAPTION), ], "hashtags": [ "#SunsetPhotography", "#GoldenHour", "#PhotographyTips", "#PhotoHack", "#ContentCreator", ], } # --------------------------------------------------------------------------- # COMPOSER CLASS # --------------------------------------------------------------------------- class SceneComposer: def __init__( self, manifest_path: str, selected_dir: str, output_dir: str = "output", font_path: str = "../trendclip/assets/fonts/Montserrat-Bold.ttf", ): self.manifest_path = manifest_path self.selected_dir = selected_dir self.output_dir = output_dir self.font_path = font_path Path(self.output_dir).mkdir(parents=True, exist_ok=True) with open(manifest_path, "r") as f: self.manifest = json.load(f) print(f"[Composer] Loaded manifest : {len(self.manifest.get('scenes', []))} scenes") print(f"[Composer] Selected dir : {selected_dir}") print(f"[Composer] Output dir : {output_dir}\n") # ------------------------------------------------------------------ # Public entry point # ------------------------------------------------------------------ def compose_all_scenes(self): """Compose all 7 scenes + end card using hardcoded edit plan.""" print(f"[Composer] Processing {len(SCENE_CONFIG)} scenes + end card...\n") for cfg in SCENE_CONFIG: self._compose_scene(cfg) self._compose_end_card() print(f"\n[Composer] ✓ All scenes composed.") print(f"[Composer] Output → {self.output_dir}/\n") # ------------------------------------------------------------------ # Scene composition # ------------------------------------------------------------------ def _compose_scene(self, cfg: dict): idx = cfg["idx"] label = cfg["label"] src = os.path.join(self.selected_dir, f"scene_{idx:02d}.jpg") if not os.path.exists(src): print(f"[Scene {idx}] ✗ Missing: {src}") return ae_flag = " ⚠️ AE_REQUIRED" if cfg.get("ae_required") else "" print(f"[Scene {idx}] {label}{ae_flag}") if cfg.get("ae_note"): print(f" AE note: {cfg['ae_note']}") try: img = self._load_and_resize(src) img = self._apply_global_grade(img, cfg["grade"]) img = self._apply_scene_grade(img, cfg["grade"]) img = self._apply_motion_preview(img, cfg["motion"], cfg["duration_s"]) img = self._apply_vignette(img) img = self._render_text(img, cfg) img = self._apply_transition_overlay(img, cfg["transition_out"]) out = os.path.join(self.output_dir, f"scene_{idx:02d}_composed.jpg") img.save(out, quality=95) print(f" ✓ Saved → {out}\n") except Exception as e: print(f" ✗ Error: {e}\n") raise # ------------------------------------------------------------------ # Image helpers # ------------------------------------------------------------------ def _load_and_resize(self, path: str) -> Image.Image: img = Image.open(path).convert("RGB") if img.size != RESOLUTION: img = img.resize(RESOLUTION, Image.Resampling.LANCZOS) return img def _apply_global_grade(self, img: Image.Image, grade: dict) -> Image.Image: """ Global warm grade applied to every scene: +8 temperature → boost reds and yellows, pull down blues -5 tint → slight pull toward magenta (pull green back) Exposure normalise pass (mild) """ r, g, b = img.split() # Temperature: +8 → warm shift # Boost R channel, slight boost G, pull B down temp = grade.get("temp", GLOBAL_TEMP_SHIFT) if temp != 0: r = r.point(lambda p: min(255, p + int(temp * 1.2))) g = g.point(lambda p: min(255, p + int(temp * 0.4))) b = b.point(lambda p: max(0, p - int(temp * 0.8))) # Tint: -5 → pull back green slightly tint = grade.get("tint", GLOBAL_TINT_SHIFT) if tint != 0: g = g.point(lambda p: max(0, min(255, p + int(tint * 0.6)))) img = Image.merge("RGB", (r, g, b)) # Mild exposure normalise — bring slightly flat images to ~0.5 mean enhancer = ImageEnhance.Brightness(img) img = enhancer.enhance(1.02) return img def _apply_scene_grade(self, img: Image.Image, grade: dict) -> Image.Image: """Per-scene colour grade from edit plan.""" r, g, b = img.split() # --- Scene 00: boost reds/oranges, reduce highlights if grade.get("boost_reds"): boost = grade["boost_reds"] r = r.point(lambda p: min(255, p + boost)) g = g.point(lambda p: min(255, p + int(boost * 0.3))) if grade.get("highlights") and grade["highlights"] < 0: pull = abs(grade["highlights"]) # Pull back highlights — only affect bright pixels r = r.point(lambda p: p - int((p / 255) ** 2 * pull)) g = g.point(lambda p: p - int((p / 255) ** 2 * pull)) b = b.point(lambda p: p - int((p / 255) ** 2 * pull)) # --- Scene 01 / 05: crush blacks if grade.get("crush_blacks"): r = r.point(lambda p: max(0, p - 15) if p < 60 else p) g = g.point(lambda p: max(0, p - 15) if p < 60 else p) b = b.point(lambda p: max(0, p - 15) if p < 60 else p) # --- Scene 01: push purples into shadows if grade.get("push_purples"): r = r.point(lambda p: min(255, p + 8) if p < 80 else p) b = b.point(lambda p: min(255, p + 12) if p < 80 else p) # --- Scene 04: saturate magentas / pinks in FG if grade.get("saturate_magentas"): boost = grade["saturate_magentas"] enhancer = ImageEnhance.Color(Image.merge("RGB", (r, g, b))) merged = enhancer.enhance(1.0 + boost / 100.0) r, g, b = merged.split() # --- Scene 05: deep amber tone if grade.get("deep_amber"): r = r.point(lambda p: min(255, p + 10)) b = b.point(lambda p: max(0, p - 8)) # --- Scene 02: dual-tone (crude approximation — full impl needs mask) # sky_temp and ground_temp applied as global shift here # (full dual-tone requires AE horizon mask) sky_temp = grade.get("sky_temp", 0) if sky_temp != 0: # approximation: slight cool pull on the whole image b = b.point(lambda p: min(255, p + int(sky_temp * 0.5))) img = Image.merge("RGB", (r, g, b)) return img def _apply_motion_preview( self, img: Image.Image, motion: dict, duration_s: float ) -> Image.Image: """ Simulate the motion at the midpoint frame for the static preview. Ken Burns: crop to midpoint scale/position. Pan: shift to 50% of offset. Parallax / scroll: show mid-scroll state. Static: no change. """ w, h = RESOLUTION mtype = motion["type"] if mtype == "ken_burns": scale_start = motion.get("scale_start", 1.0) scale_end = motion.get("scale_end", 1.06) mid_scale = (scale_start + scale_end) / 2 direction = motion.get("direction", "up") new_w = int(w * mid_scale) new_h = int(h * mid_scale) img_scaled = img.resize((new_w, new_h), Image.Resampling.LANCZOS) # Crop anchor based on direction if direction == "up": left = (new_w - w) // 2 top = new_h - h # anchor bottom, reveals top elif direction == "in": left = (new_w - w) // 2 top = (new_h - h) // 2 else: left = (new_w - w) // 2 top = (new_h - h) // 2 img = img_scaled.crop((left, top, left + w, top + h)) elif mtype == "pan": direction = motion.get("direction", "right") offset_px = motion.get("offset_px", 30) mid_offset = offset_px // 2 # Scale slightly wider to allow pan headroom img_wide = img.resize((w + offset_px * 2, h), Image.Resampling.LANCZOS) if direction == "right": left = offset_px - mid_offset else: left = offset_px + mid_offset img = img_wide.crop((left, 0, left + w, h)) elif mtype == "scroll_pan": # Show mid-scroll: pan downward to reveal bottom half pan_s = motion.get("pan_duration_s", 3.5) # Scroll ~20% down at midpoint preview pan_px = int(h * 0.20) img_tall = img.resize((w, h + pan_px * 2), Image.Resampling.LANCZOS) top = pan_px # midpoint position img = img_tall.crop((0, top, w, top + h)) elif mtype == "parallax_3layer": # Simulate slight upward drift at midpoint drift_px = motion["layers"]["fg"]["drift_px"] // 2 img_tall = img.resize((w, h + drift_px * 2), Image.Resampling.LANCZOS) img = img_tall.crop((0, drift_px, w, drift_px + h)) elif mtype == "horizon_parallax": # Shift sky layer left by half offset (approximation on flat image) sky_offset = abs(motion.get("sky_offset_px", 8)) // 2 img_wide = img.resize((w + sky_offset * 2, h), Image.Resampling.LANCZOS) img = img_wide.crop((sky_offset, 0, sky_offset + w, h)) # "static" and "grid_sequence" — no transform return img def _apply_vignette(self, img: Image.Image) -> Image.Image: """35% opacity vignette — applied to all scenes per global architecture.""" w, h = RESOLUTION vignette = Image.new("L", (w, h), 0) draw = ImageDraw.Draw(vignette) # Radial vignette: black ellipse with feathered edge for i in range(60, 0, -1): alpha = int((60 - i) / 60 * 255 * VIGNETTE_OPACITY) margin_x = int(w * 0.01 * i) margin_y = int(h * 0.01 * i) draw.ellipse( [margin_x, margin_y, w - margin_x, h - margin_y], fill=alpha, ) vignette_layer = Image.new("RGB", (w, h), (0, 0, 0)) img_rgba = img.convert("RGBA") vig_rgba = vignette_layer.convert("RGBA") vig_rgba.putalpha(vignette) img_rgba = Image.alpha_composite(img_rgba, vig_rgba) return img_rgba.convert("RGB") def _apply_transition_overlay(self, img: Image.Image, transition: dict) -> Image.Image: """ Apply a visual hint of the transition type as a subtle edge overlay on the static preview frame. """ ttype = transition["type"] frames = transition.get("frames", 12) w, h = RESOLUTION # Represent the transition as a thin coloured edge band # (colour-codes the transition type for reference) TRANS_COLORS = { "cross_dissolve": (255, 255, 255, 30), "hard_cut": (255, 80, 80, 40), "zoom_out_wipe": (255, 200, 80, 30), "dissolve": (200, 200, 255, 25), "fade_to_black": ( 0, 0, 0, 60), "slide_left": ( 80, 180, 255, 30), } color = TRANS_COLORS.get(ttype, (255, 255, 255, 20)) overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) band_h = max(4, int(frames * 2)) # band height proportional to frame count draw.rectangle([(0, h - band_h), (w, h)], fill=color) img_rgba = img.convert("RGBA") img_rgba = Image.alpha_composite(img_rgba, overlay) return img_rgba.convert("RGB") # ------------------------------------------------------------------ # Text rendering # ------------------------------------------------------------------ def _render_text(self, img: Image.Image, cfg: dict) -> Image.Image: """ Render text onto the composed image according to each scene's text config. This represents the static/midpoint frame of the animation. """ text_cfg = cfg["text"] ttype = text_cfg["type"] label = cfg["label"] txt_layer = Image.new("RGBA", RESOLUTION, (0, 0, 0, 0)) draw = ImageDraw.Draw(txt_layer) font = self._load_font(text_cfg.get("font_size", FONT_SIZE_BODY)) w, h = RESOLUTION if ttype == "lower_third": # Slide up from lower third — position at 75% height y_pos = int(h * 0.75) x_pos = int(w * 0.08) self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="left") elif ttype == "typewriter": # Show partially revealed text (mid-animation) chars_shown = int(len(label) * 0.6) # 60% revealed at midpoint partial = label[:chars_shown] + "▌" y_pos = int(h * 0.15) x_pos = int(w * 0.08) self._draw_text_with_stroke(draw, partial, (x_pos, y_pos), font, align="left") elif ttype == "stagger_two_line": # Show both lines visible (line 2 has entered by midframe) parts = label.split(" ", 2) line1 = parts[0] if len(parts) > 0 else label line2 = " ".join(parts[1:]) if len(parts) > 1 else "" y_pos = int(h * 0.12) x_pos = int(w * 0.08) line_h = text_cfg.get("font_size", FONT_SIZE_BODY) + 20 self._draw_text_with_stroke(draw, line1, (x_pos, y_pos), font, align="left") self._draw_text_with_stroke(draw, line2, (x_pos, y_pos + line_h), font, align="left") elif ttype == "grid_sequence": # Draw the rule-of-thirds grid overlay + annotation labels self._draw_grid_overlay(draw, text_cfg, w, h) # Main label at top y_pos = int(h * 0.08) x_pos = w // 2 self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="center") elif ttype == "right_slide": # Text enters from right — show fully on screen at midpoint y_pos = int(h * 0.20) x_pos = int(w * 0.92) self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="right") elif ttype == "center_fade": # Centered text over subjects y_pos = int(h * 0.50) x_pos = w // 2 self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="center") elif ttype == "countup_params": # Show param labels with their final values (static render of count-up end state) self._draw_countup_params(draw, text_cfg, w, h) else: # Fallback: centered label y_pos = int(h * 0.50) x_pos = w // 2 self._draw_text_with_stroke(draw, label, (x_pos, y_pos), font, align="center") # Composite text layer onto image img_rgba = img.convert("RGBA") img_rgba = Image.alpha_composite(img_rgba, txt_layer) return img_rgba.convert("RGB") def _draw_text_with_stroke( self, draw: ImageDraw.Draw, text: str, pos: tuple, font: ImageFont.FreeTypeFont, align: str = "left", ): """Draw text with 9px stroke + drop shadow as per typography system.""" x, y = pos w, h = RESOLUTION # Wrap text to 85% of canvas width wrapped = self._wrap_text(text, draw, font, w * 0.85) # Resolve x anchor for alignment if align == "center": bbox = draw.textbbox((0, 0), wrapped, font=font) text_w = bbox[2] - bbox[0] x = x - text_w // 2 elif align == "right": bbox = draw.textbbox((0, 0), wrapped, font=font) text_w = bbox[2] - bbox[0] x = x - text_w # Drop shadow (10% opacity black, offset +4,+4) draw.text((x + 4, y + 4), wrapped, font=font, fill=(0, 0, 0, 26)) # Stroke layers (9px) for sw in [9, 7, 5, 3, 1]: for ax in range(-sw, sw + 1): for ay in range(-sw, sw + 1): if ax * ax + ay * ay <= sw * sw: draw.text((x + ax, y + ay), wrapped, font=font, fill=STROKE_COLOR) # White text draw.text((x, y), wrapped, font=font, fill=TEXT_COLOR) def _draw_grid_overlay(self, draw: ImageDraw.Draw, text_cfg: dict, w: int, h: int): """ Render the rule-of-thirds grid overlay for scene 03. Grid lines: white at 40% opacity. Annotation labels at their specified positions. """ grid_color = (255, 255, 255, int(255 * 0.40)) # Vertical lines at 1/3 and 2/3 draw.line([(w // 3, 0), (w // 3, h)], fill=grid_color, width=3) draw.line([(w * 2 // 3, 0), (w * 2 // 3, h)], fill=grid_color, width=3) # Horizontal lines at 1/3 and 2/3 draw.line([(0, h // 3), (w, h // 3)], fill=grid_color, width=3) draw.line([(0, h * 2 // 3), (w, h * 2 // 3)], fill=grid_color, width=3) # Annotation labels ann_font = self._load_font(FONT_SIZE_BODY - 20) for label, _, (xr, yr) in text_cfg.get("annotations", []): ax = int(w * xr) ay = int(h * yr) self._draw_text_with_stroke(draw, label, (ax, ay), ann_font, align="left") def _draw_countup_params(self, draw: ImageDraw.Draw, text_cfg: dict, w: int, h: int): """ Render the edit params for scene 06 at their final count-up values. Laid out as a vertical list centered on screen. """ params = text_cfg.get("params", []) font = self._load_font(FONT_SIZE_BODY) label_font = self._load_font(FONT_SIZE_BODY - 16) num_font = self._load_font(FONT_SIZE_ACCENT) total_params = len(params) block_h = int(h * 0.72) # usable vertical space step = block_h // total_params start_y = int(h * 0.14) for i, (param_name, value) in enumerate(params): y = start_y + i * step cx = w // 2 val_str = (f"+{value}" if value > 0 else str(value)) # Param name (left of center) self._draw_text_with_stroke( draw, param_name, (cx - 20, y), label_font, align="right" ) # Value (right of center, accent size) self._draw_text_with_stroke( draw, val_str, (cx + 20, y - 20), num_font, align="left" ) # ------------------------------------------------------------------ # End card # ------------------------------------------------------------------ def _compose_end_card(self): """Render the end card — black bg, stacked caption text.""" cfg = END_CARD_CONFIG w, h = RESOLUTION img = Image.new("RGB", (w, h), cfg["background"]) txt = Image.new("RGBA", (w, h), (0, 0, 0, 0)) draw = ImageDraw.Draw(txt) total_lines = len(cfg["lines"]) usable_h = int(h * 0.80) start_y = int(h * 0.10) # Distribute lines evenly step = usable_h // total_lines for i, (line_text, font_size) in enumerate(cfg["lines"]): y = start_y + i * step font = self._load_font(font_size) self._draw_text_with_stroke(draw, line_text, (w // 2, y), font, align="center") # Hashtags at bottom tag_font = self._load_font(FONT_SIZE_CAPTION - 12) tags_text = " ".join(cfg["hashtags"]) self._draw_text_with_stroke( draw, tags_text, (w // 2, int(h * 0.92)), tag_font, align="center" ) img_rgba = img.convert("RGBA") img_rgba = Image.alpha_composite(img_rgba, txt) img_out = img_rgba.convert("RGB") out = os.path.join(self.output_dir, "scene_end_card.jpg") img_out.save(out, quality=95) print(f"[End Card] ✓ Saved → {out}\n") # ------------------------------------------------------------------ # Utilities # ------------------------------------------------------------------ def _load_font(self, size: int) -> ImageFont.FreeTypeFont: try: return ImageFont.truetype(self.font_path, size) except Exception: # Fallback: try system fonts fallbacks = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/System/Library/Fonts/Helvetica.ttc", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", ] for fb in fallbacks: if os.path.exists(fb): return ImageFont.truetype(fb, size) return ImageFont.load_default() def _wrap_text( self, text: str, draw: ImageDraw.Draw, font: ImageFont.FreeTypeFont, max_width: float, ) -> str: words = text.split() lines = [] cur_line = [] for word in words: test = " ".join(cur_line + [word]) bbox = draw.textbbox((0, 0), test, font=font) if bbox[2] - bbox[0] <= max_width: cur_line.append(word) else: if cur_line: lines.append(" ".join(cur_line)) cur_line = [word] if cur_line: lines.append(" ".join(cur_line)) return "\n".join(lines) def print_scene_summary(self): """Print a full summary of all scene configs — useful for handoff to AE.""" print("\n" + "=" * 60) print("SCENE SUMMARY — Sunset Reel Edit Plan") print("=" * 60) for cfg in SCENE_CONFIG: ae = " [AE REQUIRED]" if cfg.get("ae_required") else "" print(f"\nScene {cfg['idx']:02d} — {cfg['label']}{ae}") print(f" Duration : {cfg['duration_s']} s ({int(cfg['duration_s'] * FPS)} frames)") print(f" Motion : {cfg['motion']['type']}") print(f" Text type : {cfg['text']['type']}") print(f" Transition : {cfg['transition_out']['type']} ({cfg['transition_out']['frames']}f)") if cfg.get("ae_note"): print(f" AE note : {cfg['ae_note']}") print(f"\nEnd Card : {END_CARD_CONFIG['duration_s']} s — black bg, stacked captions") total = sum(c["duration_s"] for c in SCENE_CONFIG) + END_CARD_CONFIG["duration_s"] print(f"\nTotal runtime : ~{total:.1f} s ({int(total * FPS)} frames @ {FPS}fps)") print("=" * 60 + "\n") # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- if __name__ == "__main__": manifest_file = "../content_gen_server/manifest_response.json" selected_dir = "selected" output_dir = "output" composer = SceneComposer(manifest_file, selected_dir, output_dir) composer.print_scene_summary() composer.compose_all_scenes()