← back to Beverlyhillsvideos Tiktok

scripts/build_video1.py

114 lines

#!/usr/bin/env python3
"""Build @BHvideoguide Video #1 (brand intro) — kinetic typography, 9:16, $0 local.
Frames via PIL (Didot/Helvetica), assembled by ffmpeg with crossfades + Ken Burns + VO."""
import os, subprocess
from PIL import Image, ImageDraw, ImageFont

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FR = os.path.join(ROOT, "assets", "renders", "frames"); os.makedirs(FR, exist_ok=True)
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(path,sz,idx=0): return ImageFont.truetype(path,sz,index=idx)

def center_text(d, txt, font, y, fill, spacing=0):
    # supports manual letter-spacing; returns width used
    if spacing==0:
        b=d.textbbox((0,0),txt,font=font); w=b[2]-b[0]
        d.text(((W-w)/2, y), txt, font=font, fill=fill); return
    widths=[d.textbbox((0,0),c,font=font)[2] for c in txt]
    total=sum(widths)+spacing*(len(txt)-1)
    x=(W-total)/2
    for c,wc in zip(txt,widths):
        d.text((x,y),c,font=font,fill=fill); x+=wc+spacing

def wordmark(d):
    # bottom-center lower-third brand stamp on every frame
    fnt=f(HELV,26)
    center_text(d,"B E V E R L Y   H I L L S   V I D E O S",fnt,H-90,MUTE,spacing=2)

def hairline(d,y,w=180):
    d.rectangle([(W-w)//2,y,(W+w)//2,y+3],fill=GOLD)

def frame(name, draw_fn, bg=IVORY):
    img=Image.new("RGB",(W,H),bg); d=ImageDraw.Draw(img); draw_fn(d); wordmark(d)
    p=os.path.join(FR,name); img.save(p); return p

# ---- beat frames ----
def b1(d):
    center_text(d,"You've seen",f(DIDOT,96),740,INK)
    center_text(d,"the postcard.",f(DIDOT,96),860,INK)
def b2(d):
    center_text(d,"There's a city",f(DIDOT,86),700,INK)
    center_text(d,"the postcard",f(DIDOT,86),810,INK)
    center_text(d,"never shows.",f(DIDOT,86),920,GOLD)
def stamp(word,sub):
    def _(d):
        hairline(d,820)
        center_text(d,word,f(HELV,64,1),880,INK,spacing=6)  # idx1 = bold
        center_text(d,sub,f(HELV,34),980,MUTE,spacing=1)
        hairline(d,1060)
    return _
def b6(d):
    center_text(d,"Beverly Hills,",f(DIDOT,100),720,INK)
    center_text(d,"beyond the",f(DIDOT,100),850,INK)
    center_text(d,"postcard.",f(DIDOT,100),980,GOLD)
def endcard(d):
    center_text(d,"BEVERLY HILLS",f(DIDOT,84),640,INK)
    center_text(d,"VIDEOS",f(DIDOT,84),740,INK)
    hairline(d,880)
    center_text(d,"The 90210 edit. Follow.",f(HELV,40),950,INK,spacing=1)
    center_text(d,"beverlyhillsvideos.com",f(HELV,38,1),1040,GOLD,spacing=1)

frames=[
 ("01.png",b1,3.6),
 ("02.png",b2,4.4),
 ("03.png",stamp("REAL ESTATE","the summit of global luxury"),2.6),
 ("04.png",stamp("DINING & DEALS","the tables that run the city"),2.6),
 ("05.png",stamp("THE QUIET CORNERS","what only locals know"),2.6),
 ("06.png",b6,4.8),
 ("07.png",endcard,5.2),
]
paths=[]
for name,fn,dur in frames:
    paths.append((frame(name,fn),dur))
print("frames:",len(paths))

# ---- ffmpeg assembly: per-clip Ken Burns zoom, then xfade chain ----
FPS=30; XF=0.6
tmp=os.path.join(ROOT,"assets","renders","clips"); os.makedirs(tmp,exist_ok=True)
clips=[]
for i,(p,dur) in enumerate(paths):
    out=os.path.join(tmp,f"c{i:02d}.mp4"); n=int(dur*FPS)
    # gentle zoom in (1.0 -> 1.05): single looped frame, capped at exactly n frames
    zoom=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",zoom,"-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))
# build xfade chain
inputs=[];
for c,_ in clips: inputs+=["-i",c]
fc=""; prev="[0:v]"; offset=0.0
for i in range(1,len(clips)):
    offset+=clips[i-1][1]-XF
    lbl=f"[x{i}]"
    fc+=f"{prev}[{i}:v]xfade=transition=fade:duration={XF}:offset={offset:.3f}{lbl};"
    prev=lbl
fc=fc.rstrip(";")
vid=os.path.join(ROOT,"assets","renders","video1_silent.mp4")
subprocess.run(["ffmpeg","-y",*inputs,"-filter_complex",fc,"-map",prev,
    "-c:v","libx264","-pix_fmt","yuv420p",vid],check=True,
    stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
# mux VO (pad audio to video length)
vo=os.path.join(ROOT,"assets","renders","vo1.wav")
final=os.path.join(ROOT,"assets","renders","BHvideoguide_video1_brand-intro.mp4")
subprocess.run(["ffmpeg","-y","-i",vid,"-i",vo,
    "-filter_complex","[1:a]afade=t=in:st=0:d=0.4,afade=t=out:st=21.0:d=1.4,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)
d=subprocess.run(["ffprobe","-v","error","-show_entries","format=duration:stream=width,height",
    "-of","default=noprint_wrappers=1",final],capture_output=True,text=True).stdout
print("FINAL:",final); print(d)