← 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()