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