← back to Beverlyhillsvideos

filmgen/store_card.py

102 lines

#!/usr/bin/env python3
"""Render a text-forward LUXE store title card (1080x1920 vertical / IG Reel) — $0, local.
No brand logos or generated b-roll: store name in Didot + editorial tagline on a deep-green
ground with a gold hairline frame, BHV wordmark, and the on-screen no-affiliation disclaimer
baked in. Later animated by store_card.sh (ffmpeg zoompan) + narrated with `say`.
Usage: python3 filmgen/store_card.py "<Store Name>" "<one-line tagline>" out.png
"""
import sys
from PIL import Image, ImageDraw, ImageFont, ImageFilter

W, H = 1080, 1920
GREEN = (18, 40, 31)      # deep luxe green ground
GREEN2 = (9, 22, 17)      # darker for vignette
CREAM = (246, 243, 236)
GOLD = (184, 147, 90)
MUTED = (176, 170, 158)
FAINT = (120, 128, 118)

SERIF = "/System/Library/Fonts/Supplemental/Didot.ttc"
SANS = "filmgen/fonts/Jost.ttf"
sf = lambda s, i=0: ImageFont.truetype(SERIF, s, index=i)
jf = lambda s: ImageFont.truetype(SANS, s)


def center(d, cx, y, txt, fnt, fill):
    w = d.textlength(txt, font=fnt)
    d.text((cx - w / 2, y), txt, font=fnt, fill=fill)


def tracked(d, cx, y, txt, fnt, fill, tr):
    ws = [d.textlength(c, font=fnt) for c in txt]
    tot = sum(ws) + tr * (len(txt) - 1)
    x = cx - tot / 2
    for c, w in zip(txt, ws):
        d.text((x, y), c, font=fnt, fill=fill)
        x += w + tr


def wrap(d, txt, fnt, maxw):
    words, lines, cur = txt.split(), [], ""
    for w in words:
        t = (cur + " " + w).strip()
        if d.textlength(t, font=fnt) <= maxw:
            cur = t
        else:
            lines.append(cur); cur = w
    if cur:
        lines.append(cur)
    return lines


def render(name, tagline, out):
    img = Image.new("RGB", (W, H), GREEN)
    # radial vignette toward the edges
    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))
    img = Image.composite(img, Image.new("RGB", (W, H), GREEN2), vig)
    d = ImageDraw.Draw(img)

    # gold hairline inner frame
    m = 54
    d.rectangle([m, m, W - m, H - m], outline=GOLD, width=2)

    cx = W / 2
    # eyebrow
    tracked(d, cx, H * 0.30, "RODEO DRIVE · BEVERLY HILLS", jf(30), GOLD, 8)

    # store name (Didot, wrap long names)
    lines = wrap(d, name, sf(150), W - 2 * m - 120) or [name]
    tsize = 150 if len(lines) == 1 and len(name) <= 12 else (120 if len(lines) == 1 else 104)
    tf = sf(tsize)
    ty = H * 0.40
    for ln in lines:
        center(d, cx, ty, ln, tf, CREAM)
        ty += tsize * 1.02

    # gold hairline rule
    ry = ty + 30
    d.line([(cx - 80, ry), (cx + 80, ry)], fill=GOLD, width=2)

    # tagline (muted serif italic-ish via Didot regular, wrapped)
    tgf = sf(46)
    for i, ln in enumerate(wrap(d, tagline, tgf, W - 2 * m - 160)):
        center(d, cx, ry + 44 + i * 60, ln, tgf, MUTED)

    # BHV wordmark
    center(d, cx, H * 0.82, "BEVERLY HILLS VIDEOS", sf(56), GOLD)
    tracked(d, cx, H * 0.82 + 74, "THE INSIDER FILM GUIDE", jf(24), MUTED, 8)

    # on-screen no-affiliation disclaimer (compliance guardrail C, baked in)
    tracked(d, cx, H - m - 40,
            "INDEPENDENT EDITORIAL GUIDE · NOT AFFILIATED WITH THE BRANDS SHOWN",
            jf(18), FAINT, 2)

    img.save(out)
    print("wrote", out)


if __name__ == "__main__":
    render(sys.argv[1], sys.argv[2], sys.argv[3])