← back to Beverlyhillsvideos Tiktok
scripts/build_video.py
78 lines
#!/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"))