composer / composer_simple.py
factorstudios's picture
Upload 9 files
735a97b verified
Raw
History Blame Contribute Delete
9.27 kB
"""
Sunset Reel β€” Simplified Frame-by-Frame Renderer
Renders static frame with text overlay from each scene image (no complex motions)
Uses PIL only, exports via ffmpeg
"""
import os
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
# ---------------------------------------------------------------------------
# GLOBALS
# ---------------------------------------------------------------------------
RESOLUTION = (1080, 1920) # W x H
FPS = 30
OUTPUT_PATH = "renders/sunset_reel.mp4"
SELECTED_DIR = "selected"
FONT_PATH = "../trendclip/assets/fonts/Montserrat-Bold.ttf"
TEXT_WHITE = (255, 255, 255)
TEXT_STROKE = (0, 0, 0)
STROKE_W = 8
# Easing
def ease_out(t): return 1 - (1 - t) ** 3
def ease_in_out(t): return t * t * (3 - 2 * t)
def ease_in(t): return t * t * t
def lerp(a, b, t): return a + (b - a) * t
# ---------------------------------------------------------------------------
# SCENE CONFIG
# ---------------------------------------------------------------------------
SCENE_CONFIG = [
{"idx": 0, "label": "Chase The\nGolden Hour", "duration_s": 3.5, "boost_reds": 15},
{"idx": 1, "label": "Scout Your\nSpot First", "duration_s": 3.0, "push_purples": True},
{"idx": 2, "label": "Balance The\nBright Sky", "duration_s": 3.5, "sky_cool": True},
{"idx": 3, "label": "Rule Of\nThirds Grid", "duration_s": 4.0},
{"idx": 4, "label": "Layer The\nForeground Depth", "duration_s": 4.0, "saturate": True},
{"idx": 5, "label": "Add A\nSilhouette Subject", "duration_s": 3.5, "deep_amber": True},
{"idx": 6, "label": "Edit The\nWarmth Gently", "duration_s": 4.5, "match_reel": True},
]
END_CARD = {
"duration_s": 2.5,
"lines": [
("Stop guessing.", 96),
("Start shooting.", 96),
("Which of these do you already do?", 52),
("Save this before you forget", 46),
],
}
# ---------------------------------------------------------------------------
# COLOUR GRADE
# ---------------------------------------------------------------------------
def grade_image(img: Image.Image, grade_cfg: dict) -> Image.Image:
"""Apply colour grading to image"""
enhancer = ImageEnhance.Brightness(img)
img = enhancer.enhance(1.02)
if grade_cfg.get("boost_reds"):
enhancer = ImageEnhance.Color(img)
img = enhancer.enhance(1.1)
if grade_cfg.get("saturate"):
enhancer = ImageEnhance.Color(img)
img = enhancer.enhance(1.22)
if grade_cfg.get("deep_amber"):
enhancer = ImageEnhance.Color(img)
img = enhancer.enhance(1.15)
return img
# ---------------------------------------------------------------------------
# IMAGE LOAD + CROP
# ---------------------------------------------------------------------------
def load_scene_image(idx: int) -> Image.Image:
path = os.path.join(SELECTED_DIR, f"scene_{idx:02d}.jpg")
img = Image.open(path).convert("RGB")
return crop_to_fill(img, *RESOLUTION)
def crop_to_fill(img: Image.Image, target_w: int, target_h: int) -> Image.Image:
iw, ih = img.size
scale = max(target_w / iw, target_h / ih)
new_w = int(iw * scale)
new_h = int(ih * scale)
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
left = (new_w - target_w) // 2
top = (new_h - target_h) // 2
return img.crop((left, top, left + target_w, top + target_h))
# ---------------------------------------------------------------------------
# TEXT DRAWING
# ---------------------------------------------------------------------------
def get_font(size: int) -> ImageFont.FreeTypeFont:
try:
return ImageFont.truetype(FONT_PATH, size)
except Exception:
return ImageFont.load_default()
def draw_text_stroked(draw, text, pos, font, align="left"):
"""White text with stroke"""
x, y = pos
w, _ = RESOLUTION
# Multi-line
lines = text.split("\n")
line_spacing = int(font.size * 1.25)
for i, line in enumerate(lines):
bbox = draw.textbbox((0, 0), line, font=font)
line_w = bbox[2] - bbox[0]
ly = y + i * line_spacing
if align == "center":
lx = x - line_w // 2
elif align == "right":
lx = x - line_w
else:
lx = x
# Stroke layers
for sw in [STROKE_W, STROKE_W - 2, STROKE_W - 4, 2]:
for ax in range(-sw, sw + 1, max(1, sw // 3)):
for ay in range(-sw, sw + 1, max(1, sw // 3)):
if ax * ax + ay * ay <= sw * sw:
draw.text((lx + ax, ly + ay), line, font=font, fill=TEXT_STROKE)
# White fill
draw.text((lx, ly), line, font=font, fill=TEXT_WHITE)
def render_text_frame(cfg: dict, frame: int, total_frames: int) -> Image.Image:
"""Render text layer for frame"""
w, h = RESOLUTION
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
font = get_font(90)
label = cfg["label"]
# Simple fade-in at frame 8
if frame >= 8:
progress = min(1.0, (frame - 8) / 18)
opacity_int = int(255 * progress)
# Draw stroked text
draw_text_stroked(draw, label, (int(w * 0.08), int(h * 0.76)), font, align="left")
return layer
# ---------------------------------------------------------------------------
# MAIN RENDER
# ---------------------------------------------------------------------------
def render():
w, h = RESOLUTION
Path(os.path.dirname(OUTPUT_PATH)).mkdir(parents=True, exist_ok=True)
# Temp frame folder
frames_dir = "renders/frames_tmp"
Path(frames_dir).mkdir(parents=True, exist_ok=True)
print(f"\n{'='*55}")
print(f" Sunset Reel Renderer (Simplified)")
print(f" {len(SCENE_CONFIG)} scenes + end card | {FPS}fps | {w}x{h}")
print(f"{'='*55}\n")
# Render scenes
print("[1/2] Rendering frames...")
total_frames_written = 0
for scene_i, cfg in enumerate(SCENE_CONFIG):
total_frames = int(cfg["duration_s"] * FPS)
base = load_scene_image(cfg["idx"])
base = grade_image(base, cfg)
print(f" Scene {cfg['idx']:02d} β€” {cfg['label'].replace(chr(10),' ')} ({total_frames}f)")
for frame in range(total_frames):
# Create base image
img = base.copy()
# Add text overlay
text_layer = render_text_frame(cfg, frame, total_frames)
img_rgba = img.convert("RGBA")
img_rgba = Image.alpha_composite(img_rgba, text_layer)
img = img_rgba.convert("RGB")
# Save frame
frame_path = os.path.join(frames_dir, f"frame_{total_frames_written:06d}.jpg")
img.save(frame_path, quality=92)
total_frames_written += 1
print(f" βœ“ {total_frames} frames")
# Render end card
end_frames = int(END_CARD["duration_s"] * FPS)
print(f"\n End card ({end_frames}f)")
for frame in range(end_frames):
img = Image.new("RGB", (w, h), (8, 8, 8))
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
start_y = int(h * 0.14)
step = int((h * 0.72) / len(END_CARD["lines"]))
for i, (line_text, fsize) in enumerate(END_CARD["lines"]):
entry_f = 14 + i * 10
if frame >= entry_f:
progress = min(1.0, (frame - entry_f) / 18)
font = get_font(fsize)
y = start_y + i * step
slide_y = int(lerp(20, 0, ease_out(progress)))
draw_text_stroked(draw, line_text, (w // 2, y + slide_y), font, align="center")
img_rgba = img.convert("RGBA")
img_rgba = Image.alpha_composite(img_rgba, layer)
img = img_rgba.convert("RGB")
frame_path = os.path.join(frames_dir, f"frame_{total_frames_written:06d}.jpg")
img.save(frame_path, quality=92)
total_frames_written += 1
print(f" βœ“ {end_frames} frames")
print(f"\n Total frames: {total_frames_written} (~{total_frames_written/FPS:.1f}s)\n")
# Encode with ffmpeg
print("[2/2] Encoding MP4 via ffmpeg...")
cmd = (
f"ffmpeg -y -framerate {FPS} -i {frames_dir}/frame_%06d.jpg "
f"-c:v libx264 -crf 20 -preset fast -pix_fmt yuv420p -movflags +faststart "
f"{OUTPUT_PATH} 2>&1"
)
ret = os.system(cmd)
if ret == 0:
import shutil
shutil.rmtree(frames_dir, ignore_errors=True)
size_mb = os.path.getsize(OUTPUT_PATH) / (1024 * 1024)
print(f"\n βœ“ Output: {OUTPUT_PATH}")
print(f" βœ“ Size : {size_mb:.1f} MB\n")
print(f"{'='*55}\n")
else:
print(f" βœ— ffmpeg failed (code {ret})")
if __name__ == "__main__":
render()