[object Object]

← back to Beverlyhillsvideos Tiktok

auto-save: 2026-08-06T06:19:10 (2 files) — scripts/build_video.py specs/

08bbfba20faa2a3b0ffef4197782ddabd622a6ef · 2026-08-06 06:19:11 -0700 · Steve Abrams

Files touched

Diff

commit 08bbfba20faa2a3b0ffef4197782ddabd622a6ef
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 06:19:11 2026 -0700

    auto-save: 2026-08-06T06:19:10 (2 files) — scripts/build_video.py specs/
---
 scripts/build_video.py      | 77 +++++++++++++++++++++++++++++++++++++++++++++
 specs/bumper-90210edit.json | 11 +++++++
 specs/video1.json           | 15 +++++++++
 3 files changed, 103 insertions(+)

diff --git a/scripts/build_video.py b/scripts/build_video.py
new file mode 100644
index 0000000..75c1cdf
--- /dev/null
+++ b/scripts/build_video.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""Template-driven @BHvideoguide video builder. $0 local (PIL + macOS say + ffmpeg).
+Usage: python3 scripts/build_video.py specs/<name>.json
+Spec JSON: {"name","voice","rate","narration","beats":[{"type","lines","sub","dur"}]}
+  beat types: "title" (Didot lines, last line gold), "stamp" (hairline+bold word+sub), "end" (wordmark+cta+url)
+Renders assets/renders/<name>.mp4. Nothing is posted."""
+import os, sys, json, subprocess
+from PIL import Image, ImageDraw, ImageFont
+
+ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+W,H=1080,1920
+IVORY=(247,244,239); INK=(17,17,17); GOLD=(184,151,90); MUTE=(120,116,110)
+DIDOT="/System/Library/Fonts/Supplemental/Didot.ttc"; HELV="/System/Library/Fonts/Helvetica.ttc"
+def f(p,s,i=0): return ImageFont.truetype(p,s,index=i)
+
+def ctext(d,txt,font,y,fill,sp=0):
+    if sp==0:
+        b=d.textbbox((0,0),txt,font=font); d.text(((W-(b[2]-b[0]))/2,y),txt,font=font,fill=fill); return
+    ws=[d.textbbox((0,0),c,font=font)[2] for c in txt]; x=(W-(sum(ws)+sp*(len(txt)-1)))/2
+    for c,w in zip(txt,ws): d.text((x,y),c,font=font,fill=fill); x+=w+sp
+def wordmark(d): ctext(d,"B E V E R L Y   H I L L S   V I D E O S",f(HELV,26),H-90,MUTE,sp=2)
+def hairline(d,y,w=180): d.rectangle([(W-w)//2,y,(W+w)//2,y+3],fill=GOLD)
+
+def draw_beat(beat):
+    img=Image.new("RGB",(W,H),IVORY); d=ImageDraw.Draw(img)
+    t=beat["type"]
+    if t=="title":
+        lines=beat["lines"]; sz=beat.get("size",96); n=len(lines)
+        y=(H-n*(sz+24))//2
+        for i,ln in enumerate(lines):
+            ctext(d,ln,f(DIDOT,sz),y,GOLD if i==n-1 and beat.get("gold_last",True) else INK); y+=sz+24
+    elif t=="stamp":
+        hairline(d,820); ctext(d,beat["lines"][0],f(HELV,64,1),880,INK,sp=6)
+        if beat.get("sub"): ctext(d,beat["sub"],f(HELV,34),980,MUTE,sp=1)
+        hairline(d,1060)
+    elif t=="end":
+        wl=beat.get("wordmark",["BEVERLY HILLS","VIDEOS"]); y=640
+        for ln in wl: ctext(d,ln,f(DIDOT,84),y,INK); y+=100
+        hairline(d,y+30); ctext(d,beat.get("cta","The 90210 edit. Follow."),f(HELV,40),y+100,INK,sp=1)
+        ctext(d,beat.get("url","beverlyhillsvideos.com"),f(HELV,38,1),y+190,GOLD,sp=1)
+    wordmark(d); return img
+
+def main(spec_path):
+    spec=json.load(open(spec_path)); name=spec["name"]
+    RD=os.path.join(ROOT,"assets","renders"); FR=os.path.join(RD,"frames_"+name); CL=os.path.join(RD,"clips_"+name)
+    os.makedirs(FR,exist_ok=True); os.makedirs(CL,exist_ok=True)
+    # VO
+    vo_aiff=os.path.join(RD,f"vo_{name}.aiff"); vo_wav=os.path.join(RD,f"vo_{name}.wav")
+    subprocess.run(["say","-v",spec.get("voice","Daniel"),"-r",str(spec.get("rate",168)),"-o",vo_aiff,spec["narration"]],check=True)
+    subprocess.run(["ffmpeg","-y","-i",vo_aiff,"-ar","44100","-ac","2",vo_wav],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+    vodur=float(subprocess.run(["ffprobe","-v","error","-show_entries","format=duration","-of","csv=p=0",vo_wav],capture_output=True,text=True).stdout.strip())
+    # frames + clips
+    FPS=30; XF=0.6; clips=[]
+    for i,beat in enumerate(spec["beats"]):
+        p=os.path.join(FR,f"{i:02d}.png"); draw_beat(beat).save(p)
+        dur=beat["dur"]; n=int(dur*FPS); out=os.path.join(CL,f"c{i:02d}.mp4")
+        z=f"zoompan=z='min(zoom+0.0007,1.05)':d={n}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{H}:fps={FPS}"
+        subprocess.run(["ffmpeg","-y","-loop","1","-i",p,"-vf",z,"-frames:v",str(n),"-c:v","libx264","-pix_fmt","yuv420p","-r",str(FPS),out],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+        clips.append((out,dur))
+    # xfade chain
+    ins=[]; [ins.extend(["-i",c]) for c,_ in clips]
+    fc=""; prev="[0:v]"; off=0.0
+    for i in range(1,len(clips)):
+        off+=clips[i-1][1]-XF; lbl=f"[x{i}]"; fc+=f"{prev}[{i}:v]xfade=transition=fade:duration={XF}:offset={off:.3f}{lbl};"; prev=lbl
+    silent=os.path.join(RD,f"{name}_silent.mp4")
+    subprocess.run(["ffmpeg","-y",*ins,"-filter_complex",fc.rstrip(";"),"-map",prev,"-c:v","libx264","-pix_fmt","yuv420p",silent],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+    # mux VO with fade matched to its length
+    fo=max(vodur-0.8,0.5)
+    final=os.path.join(RD,f"BHvideoguide_{name}.mp4")
+    subprocess.run(["ffmpeg","-y","-i",silent,"-i",vo_wav,"-filter_complex",
+        f"[1:a]afade=t=in:st=0:d=0.4,afade=t=out:st={fo:.2f}:d=1.2,apad[a]",
+        "-map","0:v","-map","[a]","-c:v","copy","-c:a","aac","-b:a","192k","-shortest",final],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+    vdur=subprocess.run(["ffprobe","-v","error","-show_entries","format=duration","-of","csv=p=0",final],capture_output=True,text=True).stdout.strip()
+    print(f"OK {final}  video={vdur}s  vo={vodur:.1f}s  beats={len(spec['beats'])}")
+
+if __name__=="__main__":
+    main(sys.argv[1] if len(sys.argv)>1 else os.path.join(ROOT,"specs","video1.json"))
diff --git a/specs/bumper-90210edit.json b/specs/bumper-90210edit.json
new file mode 100644
index 0000000..4ce28c8
--- /dev/null
+++ b/specs/bumper-90210edit.json
@@ -0,0 +1,11 @@
+{
+  "name": "bumper_90210-edit",
+  "voice": "Daniel",
+  "rate": 166,
+  "narration": "The 90210 Edit. Three things worth your attention in Beverly Hills this week.",
+  "beats": [
+    {"type":"title","lines":["The 90210 Edit"],"size":104,"gold_last":true,"dur":3.0},
+    {"type":"stamp","lines":["THIS WEEK"],"sub":"three things worth your attention","dur":3.2},
+    {"type":"end","wordmark":["BEVERLY HILLS","VIDEOS"],"cta":"New edit every week. Follow.","url":"beverlyhillsvideos.com","dur":3.6}
+  ]
+}
diff --git a/specs/video1.json b/specs/video1.json
new file mode 100644
index 0000000..fa7da92
--- /dev/null
+++ b/specs/video1.json
@@ -0,0 +1,15 @@
+{
+  "name": "video1_brand-intro",
+  "voice": "Daniel",
+  "rate": 168,
+  "narration": "You've seen the postcard. The palm trees. The Rodeo Drive windows. The gates. But there's a Beverly Hills the postcard never shows. The real estate that sits at the summit of global luxury. The tables where the deals are made. The quiet corners the locals keep for themselves. This is Beverly Hills — beyond the postcard. From the city, on video.",
+  "beats": [
+    {"type":"title","lines":["You've seen","the postcard."],"size":96,"gold_last":false,"dur":3.6},
+    {"type":"title","lines":["There's a city","the postcard","never shows."],"size":86,"gold_last":true,"dur":4.4},
+    {"type":"stamp","lines":["REAL ESTATE"],"sub":"the summit of global luxury","dur":2.6},
+    {"type":"stamp","lines":["DINING & DEALS"],"sub":"the tables that run the city","dur":2.6},
+    {"type":"stamp","lines":["THE QUIET CORNERS"],"sub":"what only locals know","dur":2.6},
+    {"type":"title","lines":["Beverly Hills,","beyond the","postcard."],"size":100,"gold_last":true,"dur":4.8},
+    {"type":"end","wordmark":["BEVERLY HILLS","VIDEOS"],"cta":"The 90210 edit. Follow.","url":"beverlyhillsvideos.com","dur":5.2}
+  ]
+}

← 088599c auto-save: 2026-08-06T05:48:58 (1 files) — 02-content-launch  ·  back to Beverlyhillsvideos Tiktok  ·  yoloforever cycle 3: template builder + #5 sourcing fix + DE 0546af8 →