← 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
A filmgen/store_card.pyA filmgen/store_card.shA filmgen/store_card_anim.pyA filmgen/store_card_anim.sh
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 →