composer / compose_scenes.py
factorstudios's picture
Upload 9 files
735a97b verified
Raw
History Blame Contribute Delete
37.6 kB
"""
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()