← 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"))