[object Object]

← back to Beverlyhillsvideos

stores: animated store-card renderer (staggered reveals + em-dash fix)

c5e7566c84e87050662869ad06b78be9c1822f5b · 2026-08-06 14:56:06 -0700 · Steve Abrams

- filmgen/store_card_anim.py: frame-based animator — eyebrow/name/rule/tagline/
  wordmark reveal on a stagger with smoothstep easing; sanitizes em/en dashes to
  a rendering-safe middot (Didot lacks the glyph).
- filmgen/store_card_anim.sh: frames -> mp4 (gentle zoom + fade) + say narration.
- Sample public/video/store/chanel.mp4. $0/local, no b-roll, no brand logos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit c5e7566c84e87050662869ad06b78be9c1822f5b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 14:56:06 2026 -0700

    stores: animated store-card renderer (staggered reveals + em-dash fix)
    
    - filmgen/store_card_anim.py: frame-based animator — eyebrow/name/rule/tagline/
      wordmark reveal on a stagger with smoothstep easing; sanitizes em/en dashes to
      a rendering-safe middot (Didot lacks the glyph).
    - filmgen/store_card_anim.sh: frames -> mp4 (gentle zoom + fade) + say narration.
    - Sample public/video/store/chanel.mp4. $0/local, no b-roll, no brand logos.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 filmgen/store_card.py      | 101 +++++++++++++++++++++++++++++++++++++++
 filmgen/store_card.sh      |  28 +++++++++++
 filmgen/store_card_anim.py | 115 +++++++++++++++++++++++++++++++++++++++++++++
 filmgen/store_card_anim.sh |  25 ++++++++++
 4 files changed, 269 insertions(+)

diff --git a/filmgen/store_card.py b/filmgen/store_card.py
new file mode 100644
index 0000000..3fbe3ac
--- /dev/null
+++ b/filmgen/store_card.py
@@ -0,0 +1,101 @@
+#!/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])
diff --git a/filmgen/store_card.sh b/filmgen/store_card.sh
new file mode 100755
index 0000000..0327ec8
--- /dev/null
+++ b/filmgen/store_card.sh
@@ -0,0 +1,28 @@
+#!/bin/zsh
+# store_card.sh — render + animate + narrate one store card locally ($0).
+#   filmgen/store_card.sh "<Store Name>" "<tagline>" "<narration>" <out-slug>
+# Produces public/video/store/<slug>.mp4 (1080x1920). No paid APIs.
+set -e
+cd "$(dirname "$0")/.."
+NAME="$1"; TAGLINE="$2"; NARR="$3"; SLUG="$4"
+VOICE="${STORE_VOICE:-Samantha}"          # swap to a Premium/cloned voice later
+OUTDIR="public/video/store"; mkdir -p "$OUTDIR" filmgen/work
+PNG="filmgen/work/${SLUG}-card.png"
+AIFF="filmgen/work/${SLUG}-narr.aiff"
+MP4="${OUTDIR}/${SLUG}.mp4"
+
+python3 filmgen/store_card.py "$NAME" "$TAGLINE" "$PNG"
+
+# narration → aac; measure its length so the film matches the voiceover (+1.4s tail)
+say -v "$VOICE" -o "$AIFF" "$NARR"
+NDUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$AIFF")
+DUR=$(python3 -c "print(max(8.0, float('$NDUR')+1.4))")
+FR=$(python3 -c "print(int($DUR*30))")
+END=$(python3 -c "print(round($DUR-0.6,2))")
+
+# slow Ken Burns zoom on the still + fade in/out, mux narration under it
+ffmpeg -y -loop 1 -i "$PNG" -i "$AIFF" \
+  -filter_complex "[0:v]scale=1350:2400,zoompan=z='min(zoom+0.00035,1.10)':d=${FR}:s=1080x1920:fps=30,fade=in:0:12,fade=out:st=${END}:d=0.6,format=yuv420p[v]" \
+  -map "[v]" -map 1:a -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 128k \
+  -t "$DUR" -movflags +faststart "$MP4" 2>/dev/null
+echo "wrote $MP4  (${DUR}s, voice=${VOICE})"
diff --git a/filmgen/store_card_anim.py b/filmgen/store_card_anim.py
new file mode 100644
index 0000000..4b29878
--- /dev/null
+++ b/filmgen/store_card_anim.py
@@ -0,0 +1,115 @@
+#!/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()
diff --git a/filmgen/store_card_anim.sh b/filmgen/store_card_anim.sh
new file mode 100755
index 0000000..c4e51c0
--- /dev/null
+++ b/filmgen/store_card_anim.sh
@@ -0,0 +1,25 @@
+#!/bin/zsh
+# store_card_anim.sh — animated store card ($0, local).
+#   filmgen/store_card_anim.sh "<Name>" "<tagline>" "<narration>" <slug>
+set -e
+cd "$(dirname "$0")/.."
+NAME="$1"; TAGLINE="$2"; NARR="$3"; SLUG="$4"
+VOICE="${STORE_VOICE:-Samantha}"; FPS=24
+OUT="public/video/store"; mkdir -p "$OUT" filmgen/work
+FRAMES="filmgen/work/${SLUG}-frames"; rm -rf "$FRAMES"
+AIFF="filmgen/work/${SLUG}-narr.aiff"; MP4="${OUT}/${SLUG}.mp4"
+
+say -v "$VOICE" -o "$AIFF" "$NARR"
+NDUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$AIFF")
+DUR=$(python3 -c "print(max(8.0,float('$NDUR')+1.4))")
+END=$(python3 -c "print(round($DUR-0.6,2))")
+
+python3 filmgen/store_card_anim.py "$NAME" "$TAGLINE" "$FRAMES" "$FPS" "$DUR"
+
+# frames → mp4 with a very gentle zoom + fade-out, narration muxed
+ffmpeg -y -framerate $FPS -i "$FRAMES/f%04d.png" -i "$AIFF" \
+  -filter_complex "[0:v]zoompan=z='min(zoom+0.0002,1.05)':d=1:fps=${FPS}:s=1080x1920,fade=out:st=${END}:d=0.6,format=yuv420p[v]" \
+  -map "[v]" -map 1:a -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 128k \
+  -t "$DUR" -movflags +faststart "$MP4" 2>/dev/null
+rm -rf "$FRAMES"
+echo "wrote $MP4 (${DUR}s, voice=${VOICE})"

← 58e6a6f stores: bake IP/copyright compliance guardrails into store-f  ·  back to Beverlyhillsvideos  ·  auto-data-snapshot: 2026-08-06T15:32:11 (1 data files) — soc 569b718 →