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