← back to Beverlyhillsvideos
filmgen/store_card_anim.py
116 lines
#!/usr/bin/env python3
"""Animated luxe store card → PNG frame sequence (1080x1920). $0, local.
Staggered reveals: eyebrow fades in → name rises + fades → gold rule draws from center
→ tagline fades → wordmark + disclaimer fade. Held with a faint drift after intro.
Usage: python3 filmgen/store_card_anim.py "<Name>" "<tagline>" <frames_dir> <fps> <dur_s>
"""
import sys, os, math
from PIL import Image, ImageDraw, ImageFont, ImageFilter
W, H = 1080, 1920
GREEN, GREEN2 = (18, 40, 31), (9, 22, 17)
CREAM, GOLD, MUTED, FAINT = (246, 243, 236), (184, 147, 90), (176, 170, 158), (120, 128, 118)
SERIF = "/System/Library/Fonts/Supplemental/Didot.ttc"
SANS = "filmgen/fonts/Jost.ttf"
sf = lambda s: ImageFont.truetype(SERIF, s)
jf = lambda s: ImageFont.truetype(SANS, s)
def clean(t): # swap glyphs Didot lacks (em/en dash) for a rendering-safe middot
return t.replace(" — ", " · ").replace("—", " · ").replace(" – ", " · ").replace("–", " · ")
def smooth(a, b, t): # smoothstep ease over window [a,b]
if t <= a: return 0.0
if t >= b: return 1.0
x = (t - a) / (b - a)
return x * x * (3 - 2 * x)
def measure(txt, fnt):
im = Image.new("L", (10, 10)); d = ImageDraw.Draw(im)
return d.textlength(txt, font=fnt)
def wrap(txt, fnt, maxw):
words, lines, cur = txt.split(), [], ""
for w in words:
t = (cur + " " + w).strip()
if measure(t, fnt) <= maxw: cur = t
else: lines.append(cur); cur = w
if cur: lines.append(cur)
return lines
def text_layer(txt, fnt, fill, cy, tr=0):
"""Full-frame RGBA layer with horizontally-centered (optionally tracked) text at baseline cy."""
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
if tr:
ws = [d.textlength(c, font=fnt) for c in txt]
tot = sum(ws) + tr * (len(txt) - 1); x = W / 2 - tot / 2
for c, w in zip(txt, ws):
d.text((x, cy), c, font=fnt, fill=fill + (255,)); x += w + tr
else:
w = d.textlength(txt, font=fnt)
d.text((W / 2 - w / 2, cy), txt, font=fnt, fill=fill + (255,))
return layer
def rule_layer(width):
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
return layer # drawn per-frame (width animates) — placeholder
def main():
name, tagline, outdir, fps, dur = sys.argv[1], clean(sys.argv[2]), sys.argv[3], int(sys.argv[4]), float(sys.argv[5])
os.makedirs(outdir, exist_ok=True)
m = 54
# static background (green + vignette + gold frame)
bg = Image.new("RGB", (W, H), GREEN)
vig = Image.new("L", (W, H), 0)
ImageDraw.Draw(vig).ellipse([-W*0.25, -H*0.15, W*1.25, H*1.15], fill=120)
vig = vig.filter(ImageFilter.GaussianBlur(260))
bg = Image.composite(bg, Image.new("RGB", (W, H), GREEN2), vig)
ImageDraw.Draw(bg).rectangle([m, m, W-m, H-m], outline=GOLD, width=2)
base = bg.convert("RGBA")
# elements: (layer, appear_start, appear_end, rise_px, y_center)
nm_lines = wrap(name, sf(150), W - 2*m - 120) or [name]
tsize = 150 if len(nm_lines) == 1 and len(name) <= 12 else (120 if len(nm_lines) == 1 else 104)
name_top = H * 0.40
els = []
els.append((text_layer("RODEO DRIVE · BEVERLY HILLS", jf(30), GOLD, H*0.30, tr=8), 0.3, 1.1, 18))
ny = name_top
for i, ln in enumerate(nm_lines):
els.append((text_layer(ln, sf(tsize), CREAM, ny), 0.7 + i*0.12, 1.7 + i*0.12, 46))
ny += tsize * 1.02
rule_y = ny + 30
tag_lines = wrap(tagline, sf(46), W - 2*m - 160)
for i, ln in enumerate(tag_lines):
els.append((text_layer(ln, sf(46), MUTED, rule_y + 44 + i*60), 1.9 + i*0.08, 2.7 + i*0.08, 22))
els.append((text_layer("BEVERLY HILLS VIDEOS", sf(56), GOLD, H*0.82), 2.4, 3.2, 24))
els.append((text_layer("THE INSIDER FILM GUIDE", jf(24), MUTED, H*0.82+74, tr=8), 2.6, 3.4, 16))
els.append((text_layer("INDEPENDENT EDITORIAL GUIDE · NOT AFFILIATED WITH THE BRANDS SHOWN",
jf(18), FAINT, H-m-40, tr=2), 2.9, 3.6, 12))
n = int(round(dur * fps))
for f in range(n):
t = f / fps
frame = base.copy()
for layer, a0, a1, rise in els:
op = smooth(a0, a1, t)
if op <= 0.001: continue
dy = int((1 - op) * rise)
lay = layer if dy == 0 else Image.new("RGBA", (W, H), (0,0,0,0))
if dy: lay.paste(layer, (0, dy), layer)
if op < 1.0:
alpha = lay.split()[3].point(lambda p: int(p * op))
lay.putalpha(alpha)
frame = Image.alpha_composite(frame, lay)
# animated gold rule (draws from center) on top
rw = int(160 * smooth(1.5, 2.1, t))
if rw > 0:
d = ImageDraw.Draw(frame)
d.line([(W/2 - rw/2, rule_y), (W/2 + rw/2, rule_y)], fill=GOLD + (255,), width=2)
frame.convert("RGB").save(f"{outdir}/f{f:04d}.png")
print(f"rendered {n} frames to {outdir}")
if __name__ == "__main__":
main()