🎬 短视频自动生成流水线

我们每条竖屏教程的真实生产代码:edge-tts 配音、Pillow 逐帧动画、numpy 合成原创配乐、ffmpeg 响度标准化。

Pythonedge-ttsPillownumpyffmpeg· 34 个文件 · 3364 行 · MIT 协议

正切AI实验室的竖屏教程视频(1080×1920,30fps)全部由这套脚本生成:脚本 → AI 配音 → 逐帧画面 → 原创音乐和音效 → 响度标准化 → 成片。没有用剪辑软件。

依赖

bash
sudo apt-get install -y ffmpeg fonts-noto-cjk
pip install edge-tts pillow numpy scipy

字体路径写在各个 render.py 开头(Noto Sans CJK、IBM Plex Mono),换成你机器上的字体即可。

目录

目录内容
ep01/Cloudflare Tunnel(终端模拟动画、浏览器动画、尾段拼接)
ep02/免费域名实测(截图标注动画,截图需放到 material/,不随仓库提供)
ep03/一条命令确认 NS 生效(最新版结构:中英双语一套代码)
tiktok-en/英文版字幕、脚本表、混音

用法(以 ep03 为例,在 ep03/ 目录下运行)

bash
mkdir -p work tts
python3 src/tts.py zh       # 1. 按 script.py 逐句合成配音,记录每个词的时间戳
python3 src/render.py zh    # 2. 按配音时长生成时间线和全部画面,输出 work/video.mp4
python3 src/audio.py zh     # 3. 配音 + 音效 + numpy 合成的原创背景音乐
bash src/mux.sh .           # 4. 四轮 EBU R128 响度校正到 -14 LUFS,合成 final.mp4
python3 src/cover.py        # 5. 封面

zh 换成 en 就是英文版,输出到 ./en/。

踩过的坑

  • edge-tts 中文多音字:「一行命令」会读成 yì xíng,口播里改成「一条命令」。
  • 每句结束留 0.1 秒间隔,画面切换跟着配音时间轴走,不要手动写死秒数。
  • 平台要求响度一致,单次 loudnorm 不够准,所以做了多轮测量和校正。

完整源码

ep01/src/audio.py

48 行 · python
ep01/src/audio.py
import json, numpy as np, wave
B='.'; SR=48000
ev=json.load(open(f'{B}/work/sfx_events.json')); TL=json.load(open(f'{B}/work/timeline.json')); N=int((TL['total']+0.5)*SR)
rng=np.random.default_rng(7)
sfx=np.zeros(N,np.float32)
def add(buf,x,t):
    i=int(t*SR); j=min(N,i+len(x))
    if i<N: buf[i:j]+=x[:j-i]
# keyboard clicks
for t in ev['click']:
    n=int(0.035*SR); tt=np.arange(n)/SR
    noise=rng.standard_normal(n).astype(np.float32); hp=np.diff(noise,prepend=0)
    body=np.sin(2*np.pi*rng.uniform(150,240)*tt)*np.exp(-tt/0.012)
    x=(hp*np.exp(-tt/0.0035)*0.5+body*0.35)*rng.uniform(0.05,0.085)
    add(sfx,x.astype(np.float32),t)
# whoosh on transitions
for t in ev['trans']:
    n=int(0.4*SR); tt=np.arange(n)/SR; env=np.sin(np.pi*tt/0.4)**2
    noise=rng.standard_normal(n); y=np.zeros(n); a=0.0
    for k in range(n):
        c=0.02+0.25*(k/n); a+=c*(noise[k]-a); y[k]=a
    add(sfx,(y*env*0.10).astype(np.float32),t-0.18)
# soft pad: Am - F - C - G, 4s per chord
def midi(m): return 440*2**((m-69)/12)
chords=[[57,60,64,69],[53,57,60,65],[48,55,60,64],[55,59,62,67]]
t=np.arange(N)/SR; pad=np.zeros(N)
L=4.0
for ci in range(int(np.ceil(TL['total']/L))+1):
    ch=chords[ci%4]; s=int(ci*L*SR); e=min(N,int((ci+1)*L*SR+1.0*SR))
    if s>=N: break
    tt=t[s:e]-ci*L; env=np.minimum(1,tt/1.2)*np.clip((L+1.0-tt)/1.0,0,1)
    for m in ch:
        f=midi(m)
        for det in (-0.12,0.12):
            pad[s:e]+=np.sin(2*np.pi*f*(1+det/100)*tt+rng.uniform(0,6))*env*0.5
    pad[s:e]+=np.sin(2*np.pi*midi(ch[0]-12)*tt)*env*0.6
# one-pole lowpass
y=np.zeros_like(pad); a=0.0; c=1-np.exp(-2*np.pi*900/SR)
from scipy.signal import lfilter
y=lfilter([c],[1,c-1],pad)
y*=1+0.08*np.sin(2*np.pi*0.15*t)
y=y/np.max(np.abs(y))*0.05   # about -26 dBFS peak
fade=int(1.5*SR); y[:int(0.3*SR)]*=np.linspace(0,1,int(0.3*SR)); y[-fade:]*=np.linspace(1,0,fade)
def save(name,x):
    pcm=(np.clip(x,-1,1)*32767).astype(np.int16)
    with wave.open(f'{B}/work/{name}','wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
save('sfx.wav',sfx); save('music.wav',y.astype(np.float32)); print('ok',len(ev['click']),'clicks')

ep01/src/common.py

64 行 · python
ep01/src/common.py
import math, random
from PIL import Image, ImageDraw, ImageFont, ImageFilter
W,H,FPS=1080,1920,30
CJK='/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'; CJKB='/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc'
MONO='/usr/share/fonts/truetype/IBM Plex Mono/IBMPlexMono-Regular.ttf'
MONOB='/usr/share/fonts/truetype/IBM Plex Mono/IBMPlexMono-SemiBold.ttf'
_fc={}
def F(size,bold=False):
    k=('c',size,bold)
    if k not in _fc: _fc[k]=ImageFont.truetype(CJKB if bold else CJK,size,index=2)
    return _fc[k]
def M(size,bold=False):
    k=('m',size,bold)
    if k not in _fc: _fc[k]=ImageFont.truetype(MONOB if bold else MONO,size)
    return _fc[k]
OR=(243,128,32); ORL=(246,160,77); BG0=(11,15,25); BG1=(17,24,39); WH=(245,247,250); GR=(148,156,170); DG=(75,85,99)
RD=(239,68,68); GN=(34,197,94); BL=(96,165,250); YL=(250,204,21)
def ease(x): x=max(0,min(1,x)); return x*x*(3-2*x)
def eob(x):  # ease out back
    x=max(0,min(1,x)); c1=1.70158; c3=c1+1; return 1+c3*(x-1)**3+c1*(x-1)**2
def eout(x): x=max(0,min(1,x)); return 1-(1-x)**3
def prog(t,a,d): return max(0.0,min(1.0,(t-a)/d)) if d>0 else float(t>=a)
def lerp(a,b,x): return a+(b-a)*x
def cloud(d,cx,cy,s,fill):
    for dx,dy,r in [(-0.9,0.15,0.55),(-0.25,-0.25,0.75),(0.55,-0.05,0.6),(1.05,0.25,0.4)]:
        d.ellipse([cx+(dx-r)*s,cy+(dy-r)*s,cx+(dx+r)*s,cy+(dy+r)*s],fill=fill)
    d.rounded_rectangle([cx-1.3*s,cy+0.05*s,cx+1.35*s,cy+0.65*s],int(0.3*s),fill=fill)
def ctext(d,xy,t,font,fill=WH,anchor='mm',**kw): d.text(xy,t,font=font,fill=fill,anchor=anchor,**kw)
def alpha_paste(base,img,xy,alpha=1.0):
    if alpha<=0: return
    if alpha<1:
        a=img.getchannel('A').point(lambda v:int(v*alpha)); img=img.copy(); img.putalpha(a)
    base.alpha_composite(img,(int(xy[0]),int(xy[1])))
def layer(): return Image.new('RGBA',(W,H),(0,0,0,0))
def pill(d,x,y,text,font,fg,bg,pad=(26,12),anchor='mm',outline=None):
    tw=font.getlength(text); th=font.size
    w=tw+pad[0]*2; h=th+pad[1]*2
    if anchor=='mm': x0,y0=x-w/2,y-h/2
    else: x0,y0=x,y-h/2
    d.rounded_rectangle([x0,y0,x0+w,y0+h],int(h/2),fill=bg,outline=outline,width=3 if outline else 0)
    d.text((x0+w/2,y0+h/2),text,font=font,fill=fg,anchor='mm')
    return w,h
def monitor(d,cx,cy,s,col=WH,screen=(30,41,59)):
    w,h=200*s,130*s
    d.rounded_rectangle([cx-w/2,cy-h/2,cx+w/2,cy+h/2],int(14*s),fill=screen,outline=col,width=max(3,int(6*s)))
    d.rectangle([cx-12*s,cy+h/2,cx+12*s,cy+h/2+28*s],fill=col)
    d.rounded_rectangle([cx-60*s,cy+h/2+26*s,cx+60*s,cy+h/2+38*s],int(5*s),fill=col)
def phone(d,cx,cy,s,col=WH,screen=(30,41,59)):
    w,h=110*s,200*s
    d.rounded_rectangle([cx-w/2,cy-h/2,cx+w/2,cy+h/2],int(20*s),fill=screen,outline=col,width=max(3,int(6*s)))
    d.rounded_rectangle([cx-18*s,cy-h/2+10*s,cx+18*s,cy-h/2+18*s],int(4*s),fill=col)
def arrowhead(d,x0,y0,x1,y1,fill,s=22):
    ang=math.atan2(y1-y0,x1-x0)
    p=[(x1,y1),(x1-s*math.cos(ang-0.5),y1-s*math.sin(ang-0.5)),(x1-s*math.cos(ang+0.5),y1-s*math.sin(ang+0.5))]
    d.polygon(p,fill=fill)
def dashed(d,x0,y0,x1,y1,fill,w=6,dash=22,gap=16,phase=0.0):
    L=math.hypot(x1-x0,y1-y0)
    if L<1: return
    ux,uy=(x1-x0)/L,(y1-y0)/L; p=-(phase%(dash+gap))
    while p<L:
        a=max(p,0); b=min(p+dash,L)
        if b>a: d.line([x0+ux*a,y0+uy*a,x0+ux*b,y0+uy*b],fill=fill,width=w)
        p+=dash+gap

ep01/src/cover.py

37 行 · python
ep01/src/cover.py
import sys; sys.path.insert(0,'./src')
from common import *
from PIL import Image, ImageDraw, ImageFilter
import math
im=Image.new('RGBA',(W,H)); d=ImageDraw.Draw(im)
for y in range(H):
    a=y/H; c=tuple(int(lerp(BG0[i],BG1[i],0.5+0.5*math.sin(a*math.pi))) for i in range(3)); d.line([0,y,W,y],fill=c+(255,))
g=Image.new('RGBA',(W,H),(0,0,0,0)); gd=ImageDraw.Draw(g)
for x in range(0,W,60): gd.line([x,0,x,H],fill=(255,255,255,10))
for y in range(0,H,60): gd.line([0,y,W,y],fill=(255,255,255,10))
im.alpha_composite(g)
gl=Image.new('RGBA',(1200,1200),(0,0,0,0)); gld=ImageDraw.Draw(gl)
for i in range(600,0,-6): gld.ellipse([600-i,600-i,600+i,600+i],fill=OR+(int(80*(1-i/600)**2),))
im.alpha_composite(gl,(-60,-120))
d=ImageDraw.Draw(im)
cl=Image.new('RGBA',(W,H),(0,0,0,0)); cloud(ImageDraw.Draw(cl),820,330,95,OR); im.alpha_composite(cl)
d.text((80,250),'Cloudflare Tunnel 实操',font=F(44,True),fill=ORL)
for (txt,size,col,y) in [('家里电脑',150,WH,470),('没有公网IP',150,OR,650),('也能对外开服务',118,WH,830)]:
    d.text((80,y),txt,font=F(size,True),fill=col,anchor='lm',stroke_width=3,stroke_fill=(10,10,14))
# terminal card
x0,y0,x1,y1=70,1010,1010,1400
sh=Image.new('RGBA',(W,H),(0,0,0,0)); ImageDraw.Draw(sh).rounded_rectangle([x0,y0+16,x1,y1+16],28,fill=(0,0,0,170)); im.alpha_composite(sh.filter(ImageFilter.GaussianBlur(20)))
d.rounded_rectangle([x0,y0,x1,y1],28,fill=(13,17,23),outline=(70,80,96),width=2)
d.rounded_rectangle([x0,y0,x1,y0+60],28,fill=(36,41,49)); d.rectangle([x0,y0+32,x1,y0+60],fill=(36,41,49))
for i,c in enumerate([(255,95,87),(254,188,46),(40,200,64)]): d.ellipse([x0+26+i*38,y0+19,x0+48+i*38,y0+41],fill=c)
mf=M(34); y=y0+95
d.text((x0+30,y),'$ ',font=mf,fill=(126,231,135)); d.text((x0+30+mf.getlength('$ '),y),'cloudflared tunnel \\',font=mf,fill=(214,222,230))
d.text((x0+30,y+52),'    --url http://localhost:8080',font=mf,fill=(214,222,230))
d.text((x0+30,y+125),'Your quick Tunnel has been created!',font=M(28),fill=(126,231,135))
d.rounded_rectangle([x0+20,y+168,x1-20,y+294],14,outline=OR,width=4)
d.text((x0+38,y+200),'https://designers-mrna-buddy-messages',font=M(28),fill=WH,anchor='lm')
d.text((x0+38,y+262),'      .trycloudflare.com',font=M(28),fill=WH,anchor='lm')
for i,txt in enumerate(['不开端口','不要公网IP','命令全程实操']):
    pill(d,80+[0,250,540][i],1500,txt,F(40,True),(17,24,39),OR if i<2 else (226,232,240),anchor='lm')
d.text((80,1640),'正切AI实验室',font=F(36,True),fill=(200,206,214),anchor='lm')
im.convert('RGB').save('./cover.png'); print('ok')

ep01/src/render.py

482 行 · python
ep01/src/render.py
import json, sys, math, random, subprocess, re
sys.path.insert(0,'./src')
from common import *
from term import Term, Cam, CW, CH, TFG, TS, logline, PROMPT, TBG
from PIL import Image, ImageDraw, ImageFilter
B='.'
TL=json.load(open(f'{B}/work/timeline.json'))
S={s['id']:s for s in TL['segs']}; SC=TL['scenes']; SUBS=TL['subs']; TOTAL=TL['total']
def wt(sid,txt,which=0):
    ws=S[sid]['words']; hits=[w for w in ws if txt.lower() in w[2].lower()]
    if hits: return hits[min(which,len(hits)-1)][0]
    # try joined
    acc='';
    for i,w in enumerate(ws):
        j=''.join(x[2] for x in ws[i:i+4])
        if txt.lower() in j.lower(): return w[0]
    print('WARN word not found',sid,txt); return S[sid]['t0']
def scene_t(name): return next(s for s in SC if s['name']==name)
AUDIO_EVENTS={'click':[], 'whoosh':[], 'pop':[]}
# ---------------- background
def make_bg():
    im=Image.new('RGBA',(W,H)); d=ImageDraw.Draw(im)
    for y in range(H):
        a=y/H; c=tuple(int(lerp(BG0[i],BG1[i],0.5+0.5*math.sin(a*math.pi))) for i in range(3)); d.line([0,y,W,y],fill=c+(255,))
    g=Image.new('RGBA',(W,H),(0,0,0,0)); gd=ImageDraw.Draw(g)
    for x in range(0,W,60): gd.line([x,0,x,H],fill=(255,255,255,10))
    for y in range(0,H,60): gd.line([0,y,W,y],fill=(255,255,255,10))
    im.alpha_composite(g); return im
BGIMG=make_bg()
def make_glow(r,col,a):
    g=Image.new('RGBA',(2*r,2*r),(0,0,0,0)); d=ImageDraw.Draw(g)
    for i in range(r,0,-4): d.ellipse([r-i,r-i,r+i,r+i],fill=col+(int(a*(1-i/r)**2),))
    return g
GLOW=make_glow(520,OR,70); GLOW2=make_glow(420,(59,130,246),50)
def bg(t):
    im=BGIMG.copy()
    im.alpha_composite(GLOW,(int(540-520+260*math.sin(t*0.21)),int(300-520+120*math.cos(t*0.17))))
    im.alpha_composite(GLOW2,(int(300-420+200*math.cos(t*0.13)),int(1500-420+150*math.sin(t*0.19))))
    return im
# ---------------- panel helpers
PX,PY,PW,PH=40,280,1000,940
MASK=Image.new('L',(PW,PH),0); ImageDraw.Draw(MASK).rounded_rectangle([0,0,PW-1,PH-1],28,fill=255)
SHADOW=Image.new('RGBA',(PW+120,PH+120),(0,0,0,0)); ImageDraw.Draw(SHADOW).rounded_rectangle([60,70,PW+60,PH+70],34,fill=(0,0,0,170)); SHADOW=SHADOW.filter(ImageFilter.GaussianBlur(26))
def put_panel(L,img):
    L.alpha_composite(SHADOW,(PX-60,PY-60))
    p=img.convert('RGBA'); p.putalpha(MASK); L.alpha_composite(p,(PX,PY))
    ImageDraw.Draw(L).rounded_rectangle([PX,PY,PX+PW-1,PY+PH-1],28,outline=(70,80,96),width=2)
def check(d,x,y,s,col,w=8): d.line([x-s*0.5,y,x-s*0.12,y+s*0.4,x+s*0.55,y-s*0.42],fill=col,width=w,joint='curve')
def cross(d,x,y,s,col,w=8): d.line([x-s/2,y-s/2,x+s/2,y+s/2],fill=col,width=w); d.line([x-s/2,y+s/2,x+s/2,y-s/2],fill=col,width=w)
def scaled_paste(L,img,cx,cy,sc,alpha=1.0):
    if sc<=0.01 or alpha<=0: return
    w,h=img.size; im=img.resize((max(1,int(w*sc)),max(1,int(h*sc))),Image.BICUBIC)
    alpha_paste(L,im,(cx-im.width/2,cy-im.height/2),alpha)
def text_img(text,font,fill=WH,stroke=0,sfill=(0,0,0)):
    b=font.getbbox(text,stroke_width=stroke); w=b[2]-b[0]+8; h=b[3]-b[1]+8
    im=Image.new('RGBA',(int(w),int(h)),(0,0,0,0)); ImageDraw.Draw(im).text((4-b[0],4-b[1]),text,font=font,fill=fill,stroke_width=stroke,stroke_fill=sfill)
    return im
# ---------------- HOOK
def sc_hook(t):
    L=layer(); d=ImageDraw.Draw(L)
    p=eob(prog(t,0,0.28)); sc=0.82+0.18*p
    scaled_paste(L,text_img('家里电脑上的网页',F(80,True)),540,380,sc)
    scaled_paste(L,text_img('朋友打不开?',F(104,True),OR),540,500,sc)
    a=eout(prog(t,0.15,0.3)); yo=40*(1-a)
    lay=layer(); ld=ImageDraw.Draw(lay)
    monitor(ld,300,820+yo,1.55); ld.text((300,800+yo),'localhost:8080',font=M(26),fill=WH,anchor='mm'); check(ld,300,850+yo,30,GN,6)
    phone(ld,800,800+yo,1.25); cross(ld,800,780+yo,46,RD,9); ld.text((800,840+yo),'无法访问',font=F(26,True),fill=RD,anchor='mm')
    dashed(ld,470,820,690,820,(120,130,145),5,phase=t*40)
    if (t*2.5)%1<0.7: cross(ld,580,820,34,RD,8)
    alpha_paste(L,lay,(0,0),a)
    for txt,tw in [('× 宽带没有公网 IP',wt('s02','公网')),('× 路由器不让开端口',wt('s02','端口'))]:
        q=prog(t,tw-0.1,0.25)
        if q>0:
            y=1060 if '公网' in txt else 1160
            im=Image.new('RGBA',(W,140),(0,0,0,0)); pill(ImageDraw.Draw(im),540,70,txt,F(46,True),WH,(185,28,28))
            scaled_paste(L,im,540,y,0.7+0.3*eob(q))
    return L
# ---------------- TITLE
def sc_title(t):
    st=scene_t('title')['t0']; lt=t-st; L=layer(); d=ImageDraw.Draw(L)
    c=layer(); cloud(ImageDraw.Draw(c),540,600,120,OR)
    scaled_paste(L,c,540,960,0.6+0.4*eob(prog(lt,0,0.35)))
    a=eout(prog(lt,0.15,0.3))
    alpha_paste(L,text_img('Cloudflare',F(110,True),WH),(540-text_img('Cloudflare',F(110,True)).width/2,860+30*(1-a)),a)
    alpha_paste(L,text_img('Tunnel',F(110,True),OR),(540-text_img('Tunnel',F(110,True)).width/2,990+30*(1-a)),a)
    for i,(txt,key) in enumerate([('不要公网 IP','两个'),('不用开端口','不用管')]):
        q=prog(t,wt('s03',key),0.25)
        if q>0:
            im=Image.new('RGBA',(460,120),(0,0,0,0)); dd=ImageDraw.Draw(im)
            dd.rounded_rectangle([10,10,450,110],50,fill=(20,83,45),outline=GN,width=3); check(dd,70,58,34,GN,7); dd.text((255,60),txt,font=F(44,True),fill=WH,anchor='mm')
            scaled_paste(L,im,300+i*480,1190,0.7+0.3*eob(q))
    return L
# ---------------- DIAG
def sc_diag(t):
    L=layer(); d=ImageDraw.Draw(L)
    t4,t5,t6=S['s04']['t0'],S['s05']['t0'],S['s06']['t0']
    PHY,CLY,WALLY,MOY=430,700,950,1105
    # cloud
    ca=0.18+0.82*ease(prog(t,t5+0.2,0.5))
    cl=layer(); cd=ImageDraw.Draw(cl); cloud(cd,540,CLY-25,78,OR); cd.text((540,CLY+78),'Cloudflare 边缘',font=F(44,True),fill=WH,anchor='mm')
    alpha_paste(L,cl,(0,0),ca)
    # phone
    phone(d,540,PHY,0.95); d.text((650,PHY),'访客',font=F(48,True),fill=WH,anchor='lm')
    # wall
    wa=1.0; shake=0
    hit=wt('s04','进不')
    if hit<=t<hit+0.4: shake=8*math.sin((t-hit)*60)*(1-(t-hit)/0.4)
    for i in range(0,10):
        x0=150+i*80+shake
        col=(127,29,29) if hit<=t<hit+0.5 else (88,64,52)
        d.rounded_rectangle([x0,WALLY-18,x0+72,WALLY+18],6,fill=col)
    d.text((150,WALLY-52),'家庭宽带 · 路由器',font=F(34),fill=GR,anchor='lm')
    # monitor
    monitor(d,540,MOY,1.15); d.text((690,MOY),'家里电脑',font=F(44,True),fill=WH,anchor='lm')
    # old inbound arrow
    if t<t5+0.4:
        aa=1-ease(prog(t,t5,0.4)); q=eout(prog(t,wt('s04','平时'),0.9))
        y1=lerp(PHY+90,WALLY-22,q); col=RD if t>=hit else (203,213,225)
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.line([540,PHY+90,540,y1],fill=col,width=8); arrowhead(ld,540,y1-10,540,y1+4,col,26)
        ld.text((575,(PHY+WALLY)/2-30),'从外往里连',font=F(40,True),fill=col,anchor='lm')
        if t>=hit: cross(ld,540,WALLY,70,RD,12)
        alpha_paste(L,lay,(0,0),aa)
    # cloudflared chip
    tc=wt('s05','Cloudflare',0)
    q=prog(t,tc,0.25)
    if q>0:
        im=Image.new('RGBA',(300,90),(0,0,0,0)); pill(ImageDraw.Draw(im),150,45,'cloudflared',M(30,True),(17,24,39),OR)
        scaled_paste(L,im,540,MOY-6,0.6+0.4*eob(q))
    # outbound tunnel
    to=wt('s05','往外')
    q=eout(prog(t,to-0.2,0.8))
    if q>0:
        y0=MOY-82; y1=lerp(y0,CLY+118,q)
        dashed(d,540,y0,540,y1,OR,9,phase=-(t*60))
        if q>0.98: arrowhead(d,540,y1+10,540,y1-6,OR,28)
        d.text((580,(WALLY+MOY)/2-95),'出站连接',font=F(42,True),fill=OR,anchor='lm')
        d.text((580,(WALLY+MOY)/2-45),'它主动连出去',font=F(32),fill=ORL,anchor='lm')
    # flow
    tv=wt('s06','访客'); ts=wt('s06','顺着')
    q=eout(prog(t,tv,0.5))
    if q>0:
        y0=PHY+110; y1=lerp(y0,CLY-100,q); d.line([540,y0,540,y1],fill=BL,width=7); arrowhead(d,540,y1-8,540,y1+6,BL,24)
    if t>=tv+0.3:
        for k in range(3):
            ph=((t-tv-0.3)/1.6+k/3)%1.0
            if t<ts: ph=min(ph,0.42)
            if ph<0.42: y=lerp(PHY+110,CLY-100,ph/0.42); col=BL
            else: y=lerp(CLY+120,MOY-88,(ph-0.42)/0.58); col=YL
            if t>=ts or ph<0.42: d.ellipse([540-17,y-17,540+17,y+17],fill=col)
    tp=wt('s06','端口')
    q=prog(t,tp-0.1,0.25)
    if q>0:
        im=Image.new('RGBA',(420,110),(0,0,0,0)); dd=ImageDraw.Draw(im); pill(dd,210,55,'入站端口:0 个',F(40,True),WH,(21,128,61))
        scaled_paste(L,im,800,580,0.7+0.3*eob(q))
    return L
# ---------------- terminals
TQ=Term(seed=3); _tq=scene_t('tq'); t0=_tq['t0']
TQ.prompt(t0); e=TQ.type(t0+0.3,'cloudflared --version',24); TQ.out(e+0.25,'cloudflared version 2026.10.1 (built 2026-10-15-17:08 UTC)')
TQ.prompt(e+0.3); st=max(e+0.6,wt('s07','url')-1.6); e2=TQ.type(st,'cloudflared tunnel --url http://localhost:8080',19)
ent=max(e2+0.25,S['s08']['t0']-0.35)
real=[l.rstrip('\n') for l in open(f'{B}/rec/quicktunnel_real.log')]
def parse(l):
    m=re.match(r'(\S+Z) (INF|WRN|ERR) (.*)',l); return m.groups()
keep=[0,1,2,3,4,5,6,7,8,9]
QT_URL=None
for i,k in enumerate(keep):
    ts,lv,msg=parse(real[k])
    if 'trycloudflare.com ' in msg or msg.strip('| ').startswith('https://'):
        QT_URL=re.search(r'https://\S+',msg).group(0)
    tt=ent+0.35 if i==0 else (ent+0.45 if i==1 else (ent+1.6+0.05*(i-2) if i<6 else ent+1.9+0.12*(i-6)))
    mk=(QT_URL,ent+2.0) if (QT_URL and QT_URL in msg) else None
    TQ.out(tt,logline(ts,lv,msg),mark=mk)
QT_BANNER_T=ent+1.6
AUDIO_EVENTS['click']+=TQ.typing
TN=Term(seed=5); _tn=scene_t('tn')
UUID='00000000-0000-4000-8000-000000000000'
t1=S['s12']['t0']+0.05; TN.prompt(_tn['t0']); e=TN.type(t1,'cloudflared tunnel login',20); en=e+0.2
TN.out(en+0.2,'A browser window should have opened at the following URL:'); TN.out(en+0.2,'')
TN.out(en+0.25,'https://dash.cloudflare.com/argotunnel?aud=&callback=https%3A%2F%2Flogin.cloudflareaccess.org%2Fq7XyTnB0aW2kLmPz9cVd4sRfEu1hJ6oN8gQw3YtKbIe')
TN.out(en+0.25,''); TN.out(en+0.3,'If the browser failed to open, please visit the URL above directly in your browser.')
tl=wt('s12','域名')+0.3
TN.out(tl,logline('2026-10-18T16:41:07Z','INF','You have successfully logged in.'))
TN.out(tl,'If you wish to copy your credentials to a server, they have been saved to:')
TN.out(tl,'/home/user/.cloudflared/cert.pem',mark=('cert.pem',tl+0.3))
TN.prompt(tl+0.2); e=TN.type(max(tl+0.6,S['s13']['t0']+0.1),'cloudflared tunnel create home',20)
TN.out(e+0.5,f'Tunnel credentials written to /home/user/.cloudflared/{UUID}.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel.')
TN.out(e+0.5,''); TN.out(e+0.5,f'Created tunnel home with id {UUID}',mark=(UUID,wt('s13','隧道')))
tr=max(e+0.9,S['s14']['t0']+0.05); TN.prompt(e+0.6); e=TN.type(tr,'cloudflared tunnel route dns home app.example.com',22)
tcn=wt('s14','CNAME')
TN.out(max(e+0.6,tcn-0.4),logline('2026-10-18T16:41:52Z','INF',f'Added CNAME app.example.com which will route to this tunnel tunnelID={UUID}'),mark=('Added CNAME app.example.com',tcn))
TN.prompt(max(e+0.8,tcn)); AUDIO_EVENTS['click']+=TN.typing
TR=Term(seed=9); _tr=scene_t('run')
TR.prompt(_tr['t0']); e=TR.type(_tr['t0']+0.25,'cloudflared tunnel run home',19); en=max(e+0.2,wt('s17','run')+0.35)
runl=[('Starting tunnel tunnelID='+UUID),('Version 2026.10.1 (Checksum 3bfc539f45d2620c71debb7a86be0efa737ed5ccbad9eb85c787116900e5c586)'),('GOOS: linux, GOVersion: go1.27.2, GoArch: amd64'),
 ('Generated Connector ID: 1c5e0b7a-93d4-4f61-a2b8-7e0d9c3f4a12'),('Initial protocol quic'),('Starting metrics server on 127.0.0.1:20241/metrics')]
for i,m in enumerate(runl): TR.out(en+0.1*i,logline('2026-10-18T16:43:10Z','INF',m))
conn=[('0','8a1d3c2e-5b7f-4e90-b1a6-2f3c4d5e6a70','203.0.113.27','hkg08'),('1','3f9e2d1c-7a6b-4c58-9e0d-1a2b3c4d5e6f','203.0.113.53','hkg11'),('2','b7c6d5e4-1f2a-4b3c-8d9e-0f1a2b3c4d5e','203.0.113.107','hkg08'),('3','e1d2c3b4-a5f6-4789-90ab-cdef01234567','203.0.113.13','hkg11')]
tg=en+0.9
for i,(ci,cid,ip,loc) in enumerate(conn):
    TR.out(tg+0.35*i,logline('2026-10-18T16:43:11Z','INF',f'Registered tunnel connection connIndex={ci} connection={cid} event=0 ip={ip} location={loc} protocol=quic'),mark=('Registered tunnel connection',tg+0.3) if i==0 else None)
AUDIO_EVENTS['click']+=TR.typing
CAMS={}
def term_scene(name,term,t,targets):
    cam=CAMS.setdefault(name,Cam(PW,PH))
    im,info=term.render(t)
    ts_=targets(t,info)
    if cam.last is None: cam.snap(CW/2,CH/2,PW/CW)
    cam.update(t,*ts_)
    L=layer(); put_panel(L,cam.view(im)); return L,info
def focus_mark(m,smax=0.8):
    x0,y0,x1,y1=m; s=min(smax,PW/((x1-x0)+200)); hw=PW/s/2
    cx=hw if x1<2*hw-60 else x1+60-hw
    return (cx,(y0+y1)/2+80,s)
def follow(info,s,dx=0):
    cx,cy=info.get('cursor',(CW/2,CH/2)); hw=PW/s/2
    return (hw if cx<2*hw-80 else cx+80-hw,cy,s)
def sc_tq(t):
    def tg(t,info):
        lt=t-scene_t('tq')['t0']
        if lt<0.5: return (CW/2,CH/2,PW/CW)
        if t>=QT_BANNER_T and info['marks']:
            return focus_mark(info['marks'][-1],0.72)
        return follow(info,0.82)
    L,_=term_scene('tq',TQ,t,tg); d=ImageDraw.Draw(L)
    if t>=QT_BANNER_T+0.6:
        q=eob(prog(t,QT_BANNER_T+0.6,0.3)); im=Image.new('RGBA',(700,110),(0,0,0,0)); pill(ImageDraw.Draw(im),350,55,'临时公网地址 · 不用账号',F(40,True),(17,24,39),OR)
        scaled_paste(L,im,540,PY+PH-70,0.6+0.4*q)
    return L
STEP=[('s12','1/4','登录授权'),('s13','2/4','创建隧道'),('s14','3/4','绑定域名'),('s15','4/4','写配置'),('s17','✓','跑起来')]
def step_badge(L,t):
    cur=None
    for sid,n,txt in STEP:
        if t>=S[sid]['t0']-0.1: cur=(sid,n,txt)
    if not cur: return
    q=eob(prog(t,S[cur[0]]['t0']-0.1,0.3)); d=ImageDraw.Draw(L)
    label=('STEP '+cur[1]+'  '+cur[2]) if cur[1]!='✓' else '最后  跑起来'
    im=Image.new('RGBA',(520,90),(0,0,0,0)); pill(ImageDraw.Draw(im),260,45,label,F(36,True),(17,24,39),OR)
    scaled_paste(L,im,W-60-im.width/2+120,PY-48,0.8+0.2*q)
def sc_tn(t):
    def tg(t,info):
        lt=t-scene_t('tn')['t0']
        if lt<0.35: return (CW/2,CH/2,PW/CW)
        marks=info['marks']
        if marks:
            x0,y0,x1,y1=marks[-1]; age=min(t-m for m in [0])
        # focus newest mark if recent
        recent=[m for m in TNMARKS if m[0]<=t<m[0]+1.6]
        if recent and marks:
            return focus_mark(marks[-1])
        return follow(info,0.8)
    L,_=term_scene('tn',TN,t,tg); step_badge(L,t); return L
TNMARKS=[(L['mark'][1],) for L in TN.lines if L.get('mark')]
# ---------------- CONFIG (nano)
CFG=[[('tunnel',OR),(': ',TFG),(UUID,TFG)],[('credentials-file',OR),(': ',TFG),(f'/home/user/.cloudflared/{UUID}.json',TFG)],[],
 [('ingress',OR),(':',TFG)],[('  - ',TS),('hostname',OR),(': ',TFG),('app.example.com',(165,214,255))],[('    ',TFG),('service',OR),(': ',TFG),('http://localhost:8080',(165,214,255))],
 [('  - ',TS),('service',OR),(': ',TFG),('http_status:404',(255,123,114))]]
_c=scene_t('config')
CT=[_c['t0']+0.25, wt('s15','凭证'), wt('s15','凭证')+0.3, wt('s15','ingress'), wt('s15','域名'), wt('s15','端口'), S['s16']['t0']+0.1]
CFG_TYPE=[]
rnd=random.Random(11)
for i,row in enumerate(CFG):
    n=sum(len(s) for s,_ in row)
    if i>0 and CFG_TYPE[-1]: CT[i]=max(CT[i],CFG_TYPE[-1][-1]+0.1)
    elif i>0: CT[i]=max(CT[i],CT[i-1]+0.1)
    x=CT[i]; ts=[]
    for k in range(n): ts.append(x); x+=rnd.uniform(0.012,0.03)
    CFG_TYPE.append(ts)
AUDIO_EVENTS['click']+=[x for ts in CFG_TYPE for x in ts[::3]]
from term import MF, CHW, RH, TB, PADX, PADY
def nano_render(t):
    im=Image.new('RGB',(CW,CH),TBG); d=ImageDraw.Draw(im)
    d.rectangle([0,0,CW,TB],fill=(36,41,49))
    for i,c in enumerate([(255,95,87),(254,188,46),(40,200,64)]): d.ellipse([28+i*44,TB/2-13,54+i*44,TB/2+13],fill=c)
    d.text((CW/2,TB/2),'user@home-pc: ~/.cloudflared',font=M(30),fill=(170,178,190),anchor='mm')
    y=TB+PADY; d.rectangle([0,y,CW,y+RH],fill=(214,222,230))
    d.text((PADX,y),'  GNU nano',font=MF,fill=TBG); d.text((CW/2,y),'config.yml',font=MF,fill=TBG,anchor='ma')
    d.text((CW-PADX,y),'Modified  ',font=MF,fill=TBG,anchor='ra')
    cur=(0,0)
    for i,row in enumerate(CFG):
        ts=CFG_TYPE[i]; n=sum(1 for x in ts if x<=t)
        if t<CT[i]: break
        x=PADX; yy=TB+PADY+RH*(i+2); left=n
        for s,c in row:
            part=s[:max(0,left)]; d.text((x,yy),part,font=MF,fill=c); x+=CHW*len(part); left-=len(s)
            if left<=0: break
        cur=(x,yy)
    if (t%1.0)<0.55 or any(abs(t-x)<0.3 for ts in CFG_TYPE for x in ts[-1:]): d.rectangle([cur[0],cur[1]+4,cur[0]+CHW-2,cur[1]+RH-6],fill=(214,222,230))
    yb=CH-PADY-RH*2
    for r,items in enumerate([['^G Help','^O Write Out','^W Where Is','^K Cut','^T Execute','^C Location'],['^X Exit','^R Read File','^\\ Replace','^U Paste','^J Justify','^/ Go To Line']]):
        for k,it in enumerate(items):
            xx=PADX+k*400; d.rectangle([xx,yb+r*RH+4,xx+CHW*2,yb+r*RH+RH-4],fill=(214,222,230)); d.text((xx,yb+r*RH),it[:2],font=MF,fill=TBG); d.text((xx+CHW*2.5,yb+r*RH),it[3:],font=MF,fill=TFG)
    mt=wt('s16','兜底')
    info={'cursor':cur}
    if t>=mt-0.3:
        a=eout(prog(t,mt-0.3,0.35)); yy=TB+PADY+RH*(6+2); x0=PADX-12; x1=PADX+CHW*len('  - service: http_status:404')+12
        d.rounded_rectangle([x0,yy-6,x0+(x1-x0)*a,yy+RH+4],12,outline=OR,width=6); info['mark']=(x0,yy,x1,yy+RH)
    return im,info
def sc_config(t):
    cam=CAMS.setdefault('config',Cam(PW,PH)); im,info=nano_render(t)
    s=0.75; hw=PW/s/2
    if cam.last is None: cam.snap(CW/2,CH/2,PW/CW)
    lt=t-scene_t('config')['t0']
    if lt<0.3: tg=(CW/2,CH/2,PW/CW)
    elif 'mark' in info: x0,y0,x1,y1=info['mark']; tg=(PW/0.8/2,y0-120,0.8)
    else: tg=(hw,info['cursor'][1]+120,s)
    cam.update(t,*tg); L=layer(); put_panel(L,cam.view(im)); step_badge(L,t)
    if 'mark' in info:
        q=eob(prog(t,wt('s16','兜底'),0.3)); imx=Image.new('RGBA',(640,110),(0,0,0,0)); pill(ImageDraw.Draw(imx),320,55,'兜底规则 · 必须放最后',F(40,True),(17,24,39),OR)
        scaled_paste(L,imx,540,PY+PH-70,0.6+0.4*q)
    return L
# ---------------- RUN
PAGE=Image.open(f'{B}/rec/page_local.png').convert('RGBA').crop((60,330,778,690))
def browser_card(url_text,w=760,lock=True,lock_green=False):
    ph=int(PAGE.height*w/PAGE.width); im=Image.new('RGBA',(w,ph+86),(0,0,0,0)); d=ImageDraw.Draw(im)
    d.rounded_rectangle([0,0,w-1,ph+85],22,fill=(31,41,55),outline=(90,100,115),width=2)
    d.rounded_rectangle([18,16,w-18,70],27,fill=(17,24,39))
    if lock:
        lc=GN if lock_green else GR
        d.rounded_rectangle([40,38,62,58],4,fill=lc); d.arc([43,26,59,46],180,360,fill=lc,width=4)
    d.text((78,43),url_text,font=M(26),fill=WH,anchor='lm')
    pg=PAGE.resize((w-4,ph)); im.alpha_composite(pg,(2,84)); return im
def sc_run(t):
    def tg(t,info):
        lt=t-scene_t('run')['t0']
        if lt<0.35: return (CW/2,CH/2,PW/CW)
        if info['marks']: return focus_mark(info['marks'][-1],0.75)
        return follow(info,0.8)
    L,_=term_scene('run',TR,t,tg); step_badge(L,t)
    td=wt('s17','自己')-0.15
    q=eout(prog(t,td,0.4))
    if q>0:
        card=browser_card('https://app.example.com',lock_green=t>=wt('s17','HTTPS'))
        alpha_paste(L,card,(540-card.width/2,PY+PH-card.height-30+120*(1-q)),q)
    return L
# ---------------- BROWSER
def scramble(s,t,t0,dur,seed=0):
    if t<t0: return s
    if t>t0+dur: return None
    r=random.Random(int(t*30)+seed); return ''.join(r.choice('abcdefghijklmnopqrstuvwxyz') if c.isalpha() and r.random()<0.7 else c for c in s)
def sc_browser(t):
    L=layer(); d=ImageDraw.Draw(L); st=scene_t('browser')['t0']
    a=eout(prog(t,st,0.3))
    host=QT_URL.replace('https://','')
    d.text((540,340),'把这个链接发给朋友',font=F(44,True),fill=WH,anchor='mm')
    tr=wt('s10','重启')
    sh=scramble(host,t,tr,0.9)
    shown=sh if sh is not None else ('·····-·····-·····.trycloudflare.com' if t>=tr else host)
    col=OR if t<tr else ((200,200,200) if sh is not None else (160,168,180))
    tw=M(32).getlength(shown); d.rounded_rectangle([540-tw/2-30,390,540+tw/2+30,460],34,fill=(31,41,55),outline=col,width=3)
    d.text((540,425),shown,font=M(32),fill=col,anchor='mm')
    if t>=tr+0.9:
        d.line([540-tw/2-10,425,540+tw/2+10,425],fill=RD,width=4)
    ph=int(PAGE.height*1000/PAGE.width)
    pg=PAGE.resize((1000,ph)); fr=Image.new('RGBA',(1004,ph+4),(0,0,0,0)); fd=ImageDraw.Draw(fr); fd.rounded_rectangle([0,0,1003,ph+3],24,fill=(90,100,115))
    m=Image.new('L',(1000,ph),0); ImageDraw.Draw(m).rounded_rectangle([0,0,999,ph-1],22,fill=255); pg.putalpha(m); fr.alpha_composite(pg,(2,2))
    sc=0.96+0.04*eout(prog(t,st,1.5))
    scaled_paste(L,fr,540,520+ph/2+40*(1-a),sc,a)
    d.text((540,520+ph+70),'朋友看到的,就是你本地 8080 的页面',font=F(32),fill=GR,anchor='mm')
    if t<tr:
        for i,(txt,key) in enumerate([('不用注册','注册'),('不用域名','域名')]):
            q=prog(t,wt('s09',key)-0.1,0.25)
            if q>0:
                im=Image.new('RGBA',(400,110),(0,0,0,0)); dd=ImageDraw.Draw(im); dd.rounded_rectangle([10,10,390,100],45,fill=(20,83,45),outline=GN,width=3); check(dd,70,55,30,GN,6); dd.text((230,56),txt,font=F(42,True),fill=WH,anchor='mm')
                scaled_paste(L,im,320+i*440,1150,0.7+0.3*eob(q))
    else:
        q=prog(t,tr,0.25); im=Image.new('RGBA',(700,110),(0,0,0,0)); pill(ImageDraw.Draw(im),350,55,'每次重启,地址都会变',F(42,True),WH,(185,28,28))
        scaled_paste(L,im,540,1150,0.7+0.3*eob(q))
    return L
# ---------------- STEPS
def sc_steps(t):
    L=layer(); d=ImageDraw.Draw(L); st=scene_t('steps')['t0']
    a=eout(prog(t,st,0.3)); d.text((540,350-20*(1-a)),'想要固定域名?',font=F(64,True),fill=WH,anchor='mm')
    d.text((540,440-20*(1-a)),'建一个正式隧道,四步',font=F(46),fill=ORL,anchor='mm')
    rows=[('tunnel login','登录授权'),('tunnel create','创建隧道'),('tunnel route dns','绑定子域名'),('config.yml + run','写配置,跑起来')]
    t0=wt('s11','正式')
    for i,(cmd,desc) in enumerate(rows):
        q=eout(prog(t,t0+0.18*i,0.35))
        if q<=0: continue
        y=540+i*120; x=80+60*(1-q)
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.rounded_rectangle([x,y,x+920,y+100],22,fill=(31,41,55),outline=(70,80,96),width=2)
        ld.ellipse([x+22,y+20,x+82,y+80],fill=OR); ld.text((x+52,y+50),str(i+1),font=F(36,True),fill=(17,24,39),anchor='mm')
        ld.text((x+110,y+50),cmd,font=M(36,True),fill=WH,anchor='lm'); ld.text((x+895,y+50),desc,font=F(34),fill=GR,anchor='rm')
        alpha_paste(L,lay,(0,0),q)
    q=prog(t,wt('s11','前提')-0.1,0.3)
    if q>0:
        im=Image.new('RGBA',(1020,110),(0,0,0,0)); dd=ImageDraw.Draw(im); dd.rounded_rectangle([10,10,1010,100],45,fill=(67,38,12),outline=OR,width=3); dd.text((510,56),'前提:域名已接入 Cloudflare(完整图文看评论区)',font=F(36,True),fill=WH,anchor='mm')
        scaled_paste(L,im,540,1100,0.75+0.25*eob(q))
    return L
# ---------------- WARN / CTA
def sc_warn(t):
    L=layer(); d=ImageDraw.Draw(L); st=scene_t('warn')['t0']; q=eob(prog(t,st,0.35))
    lay=layer(); ld=ImageDraw.Draw(lay)
    ld.polygon([(540,380),(640,555),(440,555)],fill=YL); ld.text((540,500),'!',font=F(90,True),fill=(17,24,39),anchor='mm')
    ld.text((540,650),'只挂你自己的服务',font=F(72,True),fill=WH,anchor='mm')
    scaled_paste(L,lay,540,960,0.85+0.15*q)
    for i,(ok,txt) in enumerate([(True,'自己的网页、测试服务'),(False,'整台电脑、所有端口')]):
        qq=eout(prog(t,wt('s18','只挂') if ok else wt('s18','整台'),0.3))
        if qq<=0: continue
        y=800+i*130; lay=layer(); ld=ImageDraw.Draw(lay)
        ld.rounded_rectangle([160,y,920,y+100],24,fill=(20,83,45) if ok else (127,29,29))
        (check if ok else cross)(ld,225,y+50,36,WH,7); ld.text((290,y+50),txt,font=F(42,True),fill=WH,anchor='lm')
        alpha_paste(L,lay,(0,40*(1-qq)),qq)
    return L
def sc_cta(t):
    L=layer(); d=ImageDraw.Draw(L); st=scene_t('cta')['t0']
    q=eob(prog(t,st,0.3)); pul=1+0.03*math.sin(t*6)
    im=Image.new('RGBA',(460,160),(0,0,0,0)); pill(ImageDraw.Draw(im),230,80,'+ 关注',F(72,True),(17,24,39),OR,pad=(50,22))
    scaled_paste(L,im,540,480,(0.7+0.3*q)*pul)
    q2=eout(prog(t,wt('s19','评论区')-0.05,0.35))
    if q2>0:
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.text((540,680),'完整图文资料看评论区',font=F(52,True),fill=WH,anchor='mm')
        ld.text((540,800),'关注',font=F(44),fill=GR,anchor='mm')
        ld.text((540,900),'正切AI实验室',font=F(72,True),fill=OR,anchor='mm')
        alpha_paste(L,lay,(0,40*(1-q2)),q2)
    d.text((540,1110),'正切AI实验室 · Cloudflare 实操系列',font=F(32),fill=GR,anchor='mm')
    return L
FN={'hook':sc_hook,'title':sc_title,'diag':sc_diag,'tq':sc_tq,'browser':sc_browser,'steps':sc_steps,'tn':sc_tn,'config':sc_config,'run':sc_run,'warn':sc_warn,'cta':sc_cta}
# ---------------- subtitles
SUBF=F(62,True)
def sub_lines(text,maxw=14*62):
    if SUBF.getlength(text)<=maxw: return [text]
    toks=re.findall(r'[A-Za-z0-9\.\-_/:%]+|\s|.',text)
    best=None
    for k in range(1,len(toks)):
        a=''.join(toks[:k]).strip(); b=''.join(toks[k:]).strip()
        if not a or not b or b[0] in ',。?!、:': continue
        sc=max(SUBF.getlength(a),SUBF.getlength(b))
        if best is None or sc<best[0]: best=(sc,a,b)
    return [best[1],best[2]]
def draw_sub(base,t):
    cur=[s for s in SUBS if s['t0']<=t<s['t1']]
    if not cur: return
    s=cur[0]; lines=sub_lines(s['text']); d=ImageDraw.Draw(base)
    y0=1345-(len(lines)-1)*42
    for i,l in enumerate(lines):
        y=y0+i*84; parts=re.findall(r'[A-Za-z0-9\.\-_/:%]+(?: [A-Za-z0-9\.\-_/:%]+)*|[^A-Za-z0-9]+',l)
        tw=SUBF.getlength(l); x=540-tw/2
        for p in parts:
            col=(255,196,120) if re.match(r'[A-Za-z0-9]',p) else WH
            d.text((x,y),p,font=SUBF,fill=col,anchor='lm',stroke_width=7,stroke_fill=(10,10,14)); x+=SUBF.getlength(p)
def brand(base,t,name):
    if name in ('cta',): return
    d=ImageDraw.Draw(base); d.text((60,PY-48),'正切AI实验室',font=F(30,True),fill=(200,206,214),anchor='lm')
# ---------------- main
def scene_at(t):
    for k,s in enumerate(SC):
        if s['t0']<=t<s['t1']: return k
    return len(SC)-1
def run(out,start=0,end=None,stills=None):
    N=int(math.ceil(TOTAL*FPS)); end=N if end is None else end
    p=None
    if out: p=subprocess.Popen(['ffmpeg','-y','-v','error','-f','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-c:v','libx264','-preset','medium','-crf','18','-pix_fmt','yuv420p',out],stdin=subprocess.PIPE)
    last={}; prevk=None
    for i in range(start,end):
        t=i/FPS; k=scene_at(t); s=SC[k]
        content=FN[s['name']](t); last[k]=content
        im=bg(t)
        lt=t-s['t0']
        if k>0 and lt<0.24 and (k-1) in last:
            q=ease(lt/0.24)
            alpha_paste(im,last[k-1],(0,-50*q),1-q)
            alpha_paste(im,content,(0,60*(1-q)),q)
        else: im.alpha_composite(content)
        brand(im,t,s['name']); draw_sub(im,t)
        rgb=im.convert('RGB')
        if p: p.stdin.write(rgb.tobytes())
        if stills and i in stills: rgb.save(f'{B}/frames/f_{i:05d}.png')
        if i%150==0: print('frame',i,'/',end,flush=True)
    if p: p.stdin.close(); p.wait()
if __name__=='__main__':
    json.dump(dict(AUDIO_EVENTS,trans=[s['t0'] for s in SC[1:]]),open(f'{B}/work/sfx_events.json','w'))
    mode=sys.argv[1]
    if mode=='stills':
        ts=[float(x) for x in sys.argv[2:]]
        idx=sorted(int(x*FPS) for x in ts)
        # render sequentially from scene start for camera state; cheap enough: run from 0 without encoding
        run(None,0,max(idx)+1,set(idx))
    else: run(f'{B}/work/video.mp4')

ep01/src/render_tail.py

497 行 · python
ep01/src/render_tail.py
import json, sys, math, random, subprocess, re
sys.path.insert(0,'./src')
from common import *
from term import Term, Cam, CW, CH, TFG, TS, logline, PROMPT, TBG
from PIL import Image, ImageDraw, ImageFilter
B='.'
TL=json.load(open(f'{B}/work/timeline.json'))
S={s['id']:s for s in TL['segs']}; SC=TL['scenes']; SUBS=TL['subs']; TOTAL=TL['total']
def wt(sid,txt,which=0):
    ws=S[sid]['words']; hits=[w for w in ws if txt.lower() in w[2].lower()]
    if hits: return hits[min(which,len(hits)-1)][0]
    # try joined
    acc='';
    for i,w in enumerate(ws):
        j=''.join(x[2] for x in ws[i:i+4])
        if txt.lower() in j.lower(): return w[0]
    print('WARN word not found',sid,txt); return S[sid]['t0']
def scene_t(name): return next(s for s in SC if s['name']==name)
AUDIO_EVENTS={'click':[], 'whoosh':[], 'pop':[]}
# ---------------- background
def make_bg():
    im=Image.new('RGBA',(W,H)); d=ImageDraw.Draw(im)
    for y in range(H):
        a=y/H; c=tuple(int(lerp(BG0[i],BG1[i],0.5+0.5*math.sin(a*math.pi))) for i in range(3)); d.line([0,y,W,y],fill=c+(255,))
    g=Image.new('RGBA',(W,H),(0,0,0,0)); gd=ImageDraw.Draw(g)
    for x in range(0,W,60): gd.line([x,0,x,H],fill=(255,255,255,10))
    for y in range(0,H,60): gd.line([0,y,W,y],fill=(255,255,255,10))
    im.alpha_composite(g); return im
BGIMG=make_bg()
def make_glow(r,col,a):
    g=Image.new('RGBA',(2*r,2*r),(0,0,0,0)); d=ImageDraw.Draw(g)
    for i in range(r,0,-4): d.ellipse([r-i,r-i,r+i,r+i],fill=col+(int(a*(1-i/r)**2),))
    return g
GLOW=make_glow(520,OR,70); GLOW2=make_glow(420,(59,130,246),50)
def bg(t):
    im=BGIMG.copy()
    im.alpha_composite(GLOW,(int(540-520+260*math.sin(t*0.21)),int(300-520+120*math.cos(t*0.17))))
    im.alpha_composite(GLOW2,(int(300-420+200*math.cos(t*0.13)),int(1500-420+150*math.sin(t*0.19))))
    return im
# ---------------- panel helpers
PX,PY,PW,PH=40,280,1000,940
MASK=Image.new('L',(PW,PH),0); ImageDraw.Draw(MASK).rounded_rectangle([0,0,PW-1,PH-1],28,fill=255)
SHADOW=Image.new('RGBA',(PW+120,PH+120),(0,0,0,0)); ImageDraw.Draw(SHADOW).rounded_rectangle([60,70,PW+60,PH+70],34,fill=(0,0,0,170)); SHADOW=SHADOW.filter(ImageFilter.GaussianBlur(26))
def put_panel(L,img):
    L.alpha_composite(SHADOW,(PX-60,PY-60))
    p=img.convert('RGBA'); p.putalpha(MASK); L.alpha_composite(p,(PX,PY))
    ImageDraw.Draw(L).rounded_rectangle([PX,PY,PX+PW-1,PY+PH-1],28,outline=(70,80,96),width=2)
def check(d,x,y,s,col,w=8): d.line([x-s*0.5,y,x-s*0.12,y+s*0.4,x+s*0.55,y-s*0.42],fill=col,width=w,joint='curve')
def cross(d,x,y,s,col,w=8): d.line([x-s/2,y-s/2,x+s/2,y+s/2],fill=col,width=w); d.line([x-s/2,y+s/2,x+s/2,y-s/2],fill=col,width=w)
def scaled_paste(L,img,cx,cy,sc,alpha=1.0):
    if sc<=0.01 or alpha<=0: return
    w,h=img.size; im=img.resize((max(1,int(w*sc)),max(1,int(h*sc))),Image.BICUBIC)
    alpha_paste(L,im,(cx-im.width/2,cy-im.height/2),alpha)
def text_img(text,font,fill=WH,stroke=0,sfill=(0,0,0)):
    b=font.getbbox(text,stroke_width=stroke); w=b[2]-b[0]+8; h=b[3]-b[1]+8
    im=Image.new('RGBA',(int(w),int(h)),(0,0,0,0)); ImageDraw.Draw(im).text((4-b[0],4-b[1]),text,font=font,fill=fill,stroke_width=stroke,stroke_fill=sfill)
    return im
# ---------------- HOOK
def sc_hook(t):
    L=layer(); d=ImageDraw.Draw(L)
    p=eob(prog(t,0,0.28)); sc=0.82+0.18*p
    scaled_paste(L,text_img('家里电脑上的网页',F(80,True)),540,380,sc)
    scaled_paste(L,text_img('朋友打不开?',F(104,True),OR),540,500,sc)
    a=eout(prog(t,0.15,0.3)); yo=40*(1-a)
    lay=layer(); ld=ImageDraw.Draw(lay)
    monitor(ld,300,820+yo,1.55); ld.text((300,800+yo),'localhost:8080',font=M(26),fill=WH,anchor='mm'); check(ld,300,850+yo,30,GN,6)
    phone(ld,800,800+yo,1.25); cross(ld,800,780+yo,46,RD,9); ld.text((800,840+yo),'无法访问',font=F(26,True),fill=RD,anchor='mm')
    dashed(ld,470,820,690,820,(120,130,145),5,phase=t*40)
    if (t*2.5)%1<0.7: cross(ld,580,820,34,RD,8)
    alpha_paste(L,lay,(0,0),a)
    for txt,tw in [('× 宽带没有公网 IP',wt('s02','公网')),('× 路由器不让开端口',wt('s02','端口'))]:
        q=prog(t,tw-0.1,0.25)
        if q>0:
            y=1060 if '公网' in txt else 1160
            im=Image.new('RGBA',(W,140),(0,0,0,0)); pill(ImageDraw.Draw(im),540,70,txt,F(46,True),WH,(185,28,28))
            scaled_paste(L,im,540,y,0.7+0.3*eob(q))
    return L
# ---------------- TITLE
def sc_title(t):
    st=scene_t('title')['t0']; lt=t-st; L=layer(); d=ImageDraw.Draw(L)
    c=layer(); cloud(ImageDraw.Draw(c),540,600,120,OR)
    scaled_paste(L,c,540,960,0.6+0.4*eob(prog(lt,0,0.35)))
    a=eout(prog(lt,0.15,0.3))
    alpha_paste(L,text_img('Cloudflare',F(110,True),WH),(540-text_img('Cloudflare',F(110,True)).width/2,860+30*(1-a)),a)
    alpha_paste(L,text_img('Tunnel',F(110,True),OR),(540-text_img('Tunnel',F(110,True)).width/2,990+30*(1-a)),a)
    for i,(txt,key) in enumerate([('不要公网 IP','两个'),('不用开端口','不用管')]):
        q=prog(t,wt('s03',key),0.25)
        if q>0:
            im=Image.new('RGBA',(460,120),(0,0,0,0)); dd=ImageDraw.Draw(im)
            dd.rounded_rectangle([10,10,450,110],50,fill=(20,83,45),outline=GN,width=3); check(dd,70,58,34,GN,7); dd.text((255,60),txt,font=F(44,True),fill=WH,anchor='mm')
            scaled_paste(L,im,300+i*480,1190,0.7+0.3*eob(q))
    return L
# ---------------- DIAG
def sc_diag(t):
    L=layer(); d=ImageDraw.Draw(L)
    t4,t5,t6=S['s04']['t0'],S['s05']['t0'],S['s06']['t0']
    PHY,CLY,WALLY,MOY=430,700,950,1105
    # cloud
    ca=0.18+0.82*ease(prog(t,t5+0.2,0.5))
    cl=layer(); cd=ImageDraw.Draw(cl); cloud(cd,540,CLY-25,78,OR); cd.text((540,CLY+78),'Cloudflare 边缘',font=F(44,True),fill=WH,anchor='mm')
    alpha_paste(L,cl,(0,0),ca)
    # phone
    phone(d,540,PHY,0.95); d.text((650,PHY),'访客',font=F(48,True),fill=WH,anchor='lm')
    # wall
    wa=1.0; shake=0
    hit=wt('s04','进不')
    if hit<=t<hit+0.4: shake=8*math.sin((t-hit)*60)*(1-(t-hit)/0.4)
    for i in range(0,10):
        x0=150+i*80+shake
        col=(127,29,29) if hit<=t<hit+0.5 else (88,64,52)
        d.rounded_rectangle([x0,WALLY-18,x0+72,WALLY+18],6,fill=col)
    d.text((150,WALLY-52),'家庭宽带 · 路由器',font=F(34),fill=GR,anchor='lm')
    # monitor
    monitor(d,540,MOY,1.15); d.text((690,MOY),'家里电脑',font=F(44,True),fill=WH,anchor='lm')
    # old inbound arrow
    if t<t5+0.4:
        aa=1-ease(prog(t,t5,0.4)); q=eout(prog(t,wt('s04','平时'),0.9))
        y1=lerp(PHY+90,WALLY-22,q); col=RD if t>=hit else (203,213,225)
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.line([540,PHY+90,540,y1],fill=col,width=8); arrowhead(ld,540,y1-10,540,y1+4,col,26)
        ld.text((575,(PHY+WALLY)/2-30),'从外往里连',font=F(40,True),fill=col,anchor='lm')
        if t>=hit: cross(ld,540,WALLY,70,RD,12)
        alpha_paste(L,lay,(0,0),aa)
    # cloudflared chip
    tc=wt('s05','Cloudflare',0)
    q=prog(t,tc,0.25)
    if q>0:
        im=Image.new('RGBA',(300,90),(0,0,0,0)); pill(ImageDraw.Draw(im),150,45,'cloudflared',M(30,True),(17,24,39),OR)
        scaled_paste(L,im,540,MOY-6,0.6+0.4*eob(q))
    # outbound tunnel
    to=wt('s05','往外')
    q=eout(prog(t,to-0.2,0.8))
    if q>0:
        y0=MOY-82; y1=lerp(y0,CLY+118,q)
        dashed(d,540,y0,540,y1,OR,9,phase=-(t*60))
        if q>0.98: arrowhead(d,540,y1+10,540,y1-6,OR,28)
        d.text((580,(WALLY+MOY)/2-95),'出站连接',font=F(42,True),fill=OR,anchor='lm')
        d.text((580,(WALLY+MOY)/2-45),'它主动连出去',font=F(32),fill=ORL,anchor='lm')
    # flow
    tv=wt('s06','访客'); ts=wt('s06','顺着')
    q=eout(prog(t,tv,0.5))
    if q>0:
        y0=PHY+110; y1=lerp(y0,CLY-100,q); d.line([540,y0,540,y1],fill=BL,width=7); arrowhead(d,540,y1-8,540,y1+6,BL,24)
    if t>=tv+0.3:
        for k in range(3):
            ph=((t-tv-0.3)/1.6+k/3)%1.0
            if t<ts: ph=min(ph,0.42)
            if ph<0.42: y=lerp(PHY+110,CLY-100,ph/0.42); col=BL
            else: y=lerp(CLY+120,MOY-88,(ph-0.42)/0.58); col=YL
            if t>=ts or ph<0.42: d.ellipse([540-17,y-17,540+17,y+17],fill=col)
    tp=wt('s06','端口')
    q=prog(t,tp-0.1,0.25)
    if q>0:
        im=Image.new('RGBA',(420,110),(0,0,0,0)); dd=ImageDraw.Draw(im); pill(dd,210,55,'入站端口:0 个',F(40,True),WH,(21,128,61))
        scaled_paste(L,im,800,580,0.7+0.3*eob(q))
    return L
# ---------------- terminals
TQ=Term(seed=3); _tq=scene_t('tq'); t0=_tq['t0']
TQ.prompt(t0); e=TQ.type(t0+0.3,'cloudflared --version',24); TQ.out(e+0.25,'cloudflared version 2026.10.1 (built 2026-10-15-17:08 UTC)')
TQ.prompt(e+0.3); st=max(e+0.6,wt('s07','url')-1.6); e2=TQ.type(st,'cloudflared tunnel --url http://localhost:8080',19)
ent=max(e2+0.25,S['s08']['t0']-0.35)
real=[l.rstrip('\n') for l in open(f'{B}/rec/quicktunnel_real.log')]
def parse(l):
    m=re.match(r'(\S+Z) (INF|WRN|ERR) (.*)',l); return m.groups()
keep=[0,1,2,3,4,5,6,7,8,9]
QT_URL=None
for i,k in enumerate(keep):
    ts,lv,msg=parse(real[k])
    if 'trycloudflare.com ' in msg or msg.strip('| ').startswith('https://'):
        QT_URL=re.search(r'https://\S+',msg).group(0)
    tt=ent+0.35 if i==0 else (ent+0.45 if i==1 else (ent+1.6+0.05*(i-2) if i<6 else ent+1.9+0.12*(i-6)))
    mk=(QT_URL,ent+2.0) if (QT_URL and QT_URL in msg) else None
    TQ.out(tt,logline(ts,lv,msg),mark=mk)
QT_BANNER_T=ent+1.6
AUDIO_EVENTS['click']+=TQ.typing
TN=Term(seed=5); _tn=scene_t('tn')
UUID='00000000-0000-4000-8000-000000000000'
t1=S['s12']['t0']+0.05; TN.prompt(_tn['t0']); e=TN.type(t1,'cloudflared tunnel login',20); en=e+0.2
TN.out(en+0.2,'A browser window should have opened at the following URL:'); TN.out(en+0.2,'')
TN.out(en+0.25,'https://dash.cloudflare.com/argotunnel?aud=&callback=https%3A%2F%2Flogin.cloudflareaccess.org%2Fq7XyTnB0aW2kLmPz9cVd4sRfEu1hJ6oN8gQw3YtKbIe')
TN.out(en+0.25,''); TN.out(en+0.3,'If the browser failed to open, please visit the URL above directly in your browser.')
tl=wt('s12','域名')+0.3
TN.out(tl,logline('2026-10-18T16:41:07Z','INF','You have successfully logged in.'))
TN.out(tl,'If you wish to copy your credentials to a server, they have been saved to:')
TN.out(tl,'/home/user/.cloudflared/cert.pem',mark=('cert.pem',tl+0.3))
TN.prompt(tl+0.2); e=TN.type(max(tl+0.6,S['s13']['t0']+0.1),'cloudflared tunnel create home',20)
TN.out(e+0.5,f'Tunnel credentials written to /home/user/.cloudflared/{UUID}.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel.')
TN.out(e+0.5,''); TN.out(e+0.5,f'Created tunnel home with id {UUID}',mark=(UUID,wt('s13','隧道')))
tr=max(e+0.9,S['s14']['t0']+0.05); TN.prompt(e+0.6); e=TN.type(tr,'cloudflared tunnel route dns home app.example.com',22)
tcn=wt('s14','CNAME')
TN.out(max(e+0.6,tcn-0.4),logline('2026-10-18T16:41:52Z','INF',f'Added CNAME app.example.com which will route to this tunnel tunnelID={UUID}'),mark=('Added CNAME app.example.com',tcn))
TN.prompt(max(e+0.8,tcn)); AUDIO_EVENTS['click']+=TN.typing
TR=Term(seed=9); _tr=scene_t('run')
TR.prompt(_tr['t0']); e=TR.type(_tr['t0']+0.25,'cloudflared tunnel run home',19); en=max(e+0.2,wt('s17','run')+0.35)
runl=[('Starting tunnel tunnelID='+UUID),('Version 2026.10.1 (Checksum 3bfc539f45d2620c71debb7a86be0efa737ed5ccbad9eb85c787116900e5c586)'),('GOOS: linux, GOVersion: go1.27.2, GoArch: amd64'),
 ('Generated Connector ID: 1c5e0b7a-93d4-4f61-a2b8-7e0d9c3f4a12'),('Initial protocol quic'),('Starting metrics server on 127.0.0.1:20241/metrics')]
for i,m in enumerate(runl): TR.out(en+0.1*i,logline('2026-10-18T16:43:10Z','INF',m))
conn=[('0','8a1d3c2e-5b7f-4e90-b1a6-2f3c4d5e6a70','203.0.113.27','hkg08'),('1','3f9e2d1c-7a6b-4c58-9e0d-1a2b3c4d5e6f','203.0.113.53','hkg11'),('2','b7c6d5e4-1f2a-4b3c-8d9e-0f1a2b3c4d5e','203.0.113.107','hkg08'),('3','e1d2c3b4-a5f6-4789-90ab-cdef01234567','203.0.113.13','hkg11')]
tg=en+0.9
for i,(ci,cid,ip,loc) in enumerate(conn):
    TR.out(tg+0.35*i,logline('2026-10-18T16:43:11Z','INF',f'Registered tunnel connection connIndex={ci} connection={cid} event=0 ip={ip} location={loc} protocol=quic'),mark=('Registered tunnel connection',tg+0.3) if i==0 else None)
AUDIO_EVENTS['click']+=TR.typing
CAMS={}
def term_scene(name,term,t,targets):
    cam=CAMS.setdefault(name,Cam(PW,PH))
    im,info=term.render(t)
    ts_=targets(t,info)
    if cam.last is None: cam.snap(CW/2,CH/2,PW/CW)
    cam.update(t,*ts_)
    L=layer(); put_panel(L,cam.view(im)); return L,info
def focus_mark(m,smax=0.8):
    x0,y0,x1,y1=m; s=min(smax,PW/((x1-x0)+200)); hw=PW/s/2
    cx=hw if x1<2*hw-60 else x1+60-hw
    return (cx,(y0+y1)/2+80,s)
def follow(info,s,dx=0):
    cx,cy=info.get('cursor',(CW/2,CH/2)); hw=PW/s/2
    return (hw if cx<2*hw-80 else cx+80-hw,cy,s)
def sc_tq(t):
    def tg(t,info):
        lt=t-scene_t('tq')['t0']
        if lt<0.5: return (CW/2,CH/2,PW/CW)
        if t>=QT_BANNER_T and info['marks']:
            return focus_mark(info['marks'][-1],0.72)
        return follow(info,0.82)
    L,_=term_scene('tq',TQ,t,tg); d=ImageDraw.Draw(L)
    if t>=QT_BANNER_T+0.6:
        q=eob(prog(t,QT_BANNER_T+0.6,0.3)); im=Image.new('RGBA',(700,110),(0,0,0,0)); pill(ImageDraw.Draw(im),350,55,'临时公网地址 · 不用账号',F(40,True),(17,24,39),OR)
        scaled_paste(L,im,540,PY+PH-70,0.6+0.4*q)
    return L
STEP=[('s12','1/4','登录授权'),('s13','2/4','创建隧道'),('s14','3/4','绑定域名'),('s15','4/4','写配置'),('s17','✓','跑起来')]
def step_badge(L,t):
    cur=None
    for sid,n,txt in STEP:
        if t>=S[sid]['t0']-0.1: cur=(sid,n,txt)
    if not cur: return
    q=eob(prog(t,S[cur[0]]['t0']-0.1,0.3)); d=ImageDraw.Draw(L)
    label=('STEP '+cur[1]+'  '+cur[2]) if cur[1]!='✓' else '最后  跑起来'
    im=Image.new('RGBA',(520,90),(0,0,0,0)); pill(ImageDraw.Draw(im),260,45,label,F(36,True),(17,24,39),OR)
    scaled_paste(L,im,W-60-im.width/2+120,PY-48,0.8+0.2*q)
def sc_tn(t):
    def tg(t,info):
        lt=t-scene_t('tn')['t0']
        if lt<0.35: return (CW/2,CH/2,PW/CW)
        marks=info['marks']
        if marks:
            x0,y0,x1,y1=marks[-1]; age=min(t-m for m in [0])
        # focus newest mark if recent
        recent=[m for m in TNMARKS if m[0]<=t<m[0]+1.6]
        if recent and marks:
            return focus_mark(marks[-1])
        return follow(info,0.8)
    L,_=term_scene('tn',TN,t,tg); step_badge(L,t); return L
TNMARKS=[(L['mark'][1],) for L in TN.lines if L.get('mark')]
# ---------------- CONFIG (nano)
CFG=[[('tunnel',OR),(': ',TFG),(UUID,TFG)],[('credentials-file',OR),(': ',TFG),(f'/home/user/.cloudflared/{UUID}.json',TFG)],[],
 [('ingress',OR),(':',TFG)],[('  - ',TS),('hostname',OR),(': ',TFG),('app.example.com',(165,214,255))],[('    ',TFG),('service',OR),(': ',TFG),('http://localhost:8080',(165,214,255))],
 [('  - ',TS),('service',OR),(': ',TFG),('http_status:404',(255,123,114))]]
_c=scene_t('config')
CT=[_c['t0']+0.25, wt('s15','凭证'), wt('s15','凭证')+0.3, wt('s15','ingress'), wt('s15','域名'), wt('s15','端口'), S['s16']['t0']+0.1]
CFG_TYPE=[]
rnd=random.Random(11)
for i,row in enumerate(CFG):
    n=sum(len(s) for s,_ in row)
    if i>0 and CFG_TYPE[-1]: CT[i]=max(CT[i],CFG_TYPE[-1][-1]+0.1)
    elif i>0: CT[i]=max(CT[i],CT[i-1]+0.1)
    x=CT[i]; ts=[]
    for k in range(n): ts.append(x); x+=rnd.uniform(0.012,0.03)
    CFG_TYPE.append(ts)
AUDIO_EVENTS['click']+=[x for ts in CFG_TYPE for x in ts[::3]]
from term import MF, CHW, RH, TB, PADX, PADY
def nano_render(t):
    im=Image.new('RGB',(CW,CH),TBG); d=ImageDraw.Draw(im)
    d.rectangle([0,0,CW,TB],fill=(36,41,49))
    for i,c in enumerate([(255,95,87),(254,188,46),(40,200,64)]): d.ellipse([28+i*44,TB/2-13,54+i*44,TB/2+13],fill=c)
    d.text((CW/2,TB/2),'user@home-pc: ~/.cloudflared',font=M(30),fill=(170,178,190),anchor='mm')
    y=TB+PADY; d.rectangle([0,y,CW,y+RH],fill=(214,222,230))
    d.text((PADX,y),'  GNU nano',font=MF,fill=TBG); d.text((CW/2,y),'config.yml',font=MF,fill=TBG,anchor='ma')
    d.text((CW-PADX,y),'Modified  ',font=MF,fill=TBG,anchor='ra')
    cur=(0,0)
    for i,row in enumerate(CFG):
        ts=CFG_TYPE[i]; n=sum(1 for x in ts if x<=t)
        if t<CT[i]: break
        x=PADX; yy=TB+PADY+RH*(i+2); left=n
        for s,c in row:
            part=s[:max(0,left)]; d.text((x,yy),part,font=MF,fill=c); x+=CHW*len(part); left-=len(s)
            if left<=0: break
        cur=(x,yy)
    if (t%1.0)<0.55 or any(abs(t-x)<0.3 for ts in CFG_TYPE for x in ts[-1:]): d.rectangle([cur[0],cur[1]+4,cur[0]+CHW-2,cur[1]+RH-6],fill=(214,222,230))
    yb=CH-PADY-RH*2
    for r,items in enumerate([['^G Help','^O Write Out','^W Where Is','^K Cut','^T Execute','^C Location'],['^X Exit','^R Read File','^\\ Replace','^U Paste','^J Justify','^/ Go To Line']]):
        for k,it in enumerate(items):
            xx=PADX+k*400; d.rectangle([xx,yb+r*RH+4,xx+CHW*2,yb+r*RH+RH-4],fill=(214,222,230)); d.text((xx,yb+r*RH),it[:2],font=MF,fill=TBG); d.text((xx+CHW*2.5,yb+r*RH),it[3:],font=MF,fill=TFG)
    mt=wt('s16','兜底')
    info={'cursor':cur}
    if t>=mt-0.3:
        a=eout(prog(t,mt-0.3,0.35)); yy=TB+PADY+RH*(6+2); x0=PADX-12; x1=PADX+CHW*len('  - service: http_status:404')+12
        d.rounded_rectangle([x0,yy-6,x0+(x1-x0)*a,yy+RH+4],12,outline=OR,width=6); info['mark']=(x0,yy,x1,yy+RH)
    return im,info
def sc_config(t):
    cam=CAMS.setdefault('config',Cam(PW,PH)); im,info=nano_render(t)
    s=0.75; hw=PW/s/2
    if cam.last is None: cam.snap(CW/2,CH/2,PW/CW)
    lt=t-scene_t('config')['t0']
    if lt<0.3: tg=(CW/2,CH/2,PW/CW)
    elif 'mark' in info: x0,y0,x1,y1=info['mark']; tg=(PW/0.8/2,y0-120,0.8)
    else: tg=(hw,info['cursor'][1]+120,s)
    cam.update(t,*tg); L=layer(); put_panel(L,cam.view(im)); step_badge(L,t)
    if 'mark' in info:
        q=eob(prog(t,wt('s16','兜底'),0.3)); imx=Image.new('RGBA',(640,110),(0,0,0,0)); pill(ImageDraw.Draw(imx),320,55,'兜底规则 · 必须放最后',F(40,True),(17,24,39),OR)
        scaled_paste(L,imx,540,PY+PH-70,0.6+0.4*q)
    return L
# ---------------- RUN
PAGE=Image.open(f'{B}/rec/page_local.png').convert('RGBA').crop((60,330,778,690))
def browser_card(url_text,w=760,lock=True,lock_green=False):
    ph=int(PAGE.height*w/PAGE.width); im=Image.new('RGBA',(w,ph+86),(0,0,0,0)); d=ImageDraw.Draw(im)
    d.rounded_rectangle([0,0,w-1,ph+85],22,fill=(31,41,55),outline=(90,100,115),width=2)
    d.rounded_rectangle([18,16,w-18,70],27,fill=(17,24,39))
    if lock:
        lc=GN if lock_green else GR
        d.rounded_rectangle([40,38,62,58],4,fill=lc); d.arc([43,26,59,46],180,360,fill=lc,width=4)
    d.text((78,43),url_text,font=M(26),fill=WH,anchor='lm')
    pg=PAGE.resize((w-4,ph)); im.alpha_composite(pg,(2,84)); return im
def sc_run(t):
    def tg(t,info):
        lt=t-scene_t('run')['t0']
        if lt<0.35: return (CW/2,CH/2,PW/CW)
        if info['marks']: return focus_mark(info['marks'][-1],0.75)
        return follow(info,0.8)
    L,_=term_scene('run',TR,t,tg); step_badge(L,t)
    td=wt('s17','自己')-0.15
    q=eout(prog(t,td,0.4))
    if q>0:
        card=browser_card('https://app.example.com',lock_green=t>=wt('s17','HTTPS'))
        alpha_paste(L,card,(540-card.width/2,PY+PH-card.height-30+120*(1-q)),q)
    return L
# ---------------- BROWSER
def scramble(s,t,t0,dur,seed=0):
    if t<t0: return s
    if t>t0+dur: return None
    r=random.Random(int(t*30)+seed); return ''.join(r.choice('abcdefghijklmnopqrstuvwxyz') if c.isalpha() and r.random()<0.7 else c for c in s)
def sc_browser(t):
    L=layer(); d=ImageDraw.Draw(L); st=scene_t('browser')['t0']
    a=eout(prog(t,st,0.3))
    host=QT_URL.replace('https://','')
    d.text((540,340),'把这个链接发给朋友',font=F(44,True),fill=WH,anchor='mm')
    tr=wt('s10','重启')
    sh=scramble(host,t,tr,0.9)
    shown=sh if sh is not None else ('·····-·····-·····.trycloudflare.com' if t>=tr else host)
    col=OR if t<tr else ((200,200,200) if sh is not None else (160,168,180))
    tw=M(32).getlength(shown); d.rounded_rectangle([540-tw/2-30,390,540+tw/2+30,460],34,fill=(31,41,55),outline=col,width=3)
    d.text((540,425),shown,font=M(32),fill=col,anchor='mm')
    if t>=tr+0.9:
        d.line([540-tw/2-10,425,540+tw/2+10,425],fill=RD,width=4)
    ph=int(PAGE.height*1000/PAGE.width)
    pg=PAGE.resize((1000,ph)); fr=Image.new('RGBA',(1004,ph+4),(0,0,0,0)); fd=ImageDraw.Draw(fr); fd.rounded_rectangle([0,0,1003,ph+3],24,fill=(90,100,115))
    m=Image.new('L',(1000,ph),0); ImageDraw.Draw(m).rounded_rectangle([0,0,999,ph-1],22,fill=255); pg.putalpha(m); fr.alpha_composite(pg,(2,2))
    sc=0.96+0.04*eout(prog(t,st,1.5))
    scaled_paste(L,fr,540,520+ph/2+40*(1-a),sc,a)
    d.text((540,520+ph+70),'朋友看到的,就是你本地 8080 的页面',font=F(32),fill=GR,anchor='mm')
    if t<tr:
        for i,(txt,key) in enumerate([('不用注册','注册'),('不用域名','域名')]):
            q=prog(t,wt('s09',key)-0.1,0.25)
            if q>0:
                im=Image.new('RGBA',(400,110),(0,0,0,0)); dd=ImageDraw.Draw(im); dd.rounded_rectangle([10,10,390,100],45,fill=(20,83,45),outline=GN,width=3); check(dd,70,55,30,GN,6); dd.text((230,56),txt,font=F(42,True),fill=WH,anchor='mm')
                scaled_paste(L,im,320+i*440,1150,0.7+0.3*eob(q))
    else:
        q=prog(t,tr,0.25); im=Image.new('RGBA',(700,110),(0,0,0,0)); pill(ImageDraw.Draw(im),350,55,'每次重启,地址都会变',F(42,True),WH,(185,28,28))
        scaled_paste(L,im,540,1150,0.7+0.3*eob(q))
    return L
# ---------------- STEPS
def sc_steps(t):
    L=layer(); d=ImageDraw.Draw(L); st=scene_t('steps')['t0']
    a=eout(prog(t,st,0.3)); d.text((540,350-20*(1-a)),'想要固定域名?',font=F(64,True),fill=WH,anchor='mm')
    d.text((540,440-20*(1-a)),'建一个正式隧道,四步',font=F(46),fill=ORL,anchor='mm')
    rows=[('tunnel login','登录授权'),('tunnel create','创建隧道'),('tunnel route dns','绑定子域名'),('config.yml + run','写配置,跑起来')]
    t0=wt('s11','正式')
    for i,(cmd,desc) in enumerate(rows):
        q=eout(prog(t,t0+0.18*i,0.35))
        if q<=0: continue
        y=540+i*120; x=80+60*(1-q)
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.rounded_rectangle([x,y,x+920,y+100],22,fill=(31,41,55),outline=(70,80,96),width=2)
        ld.ellipse([x+22,y+20,x+82,y+80],fill=OR); ld.text((x+52,y+50),str(i+1),font=F(36,True),fill=(17,24,39),anchor='mm')
        ld.text((x+110,y+50),cmd,font=M(36,True),fill=WH,anchor='lm'); ld.text((x+895,y+50),desc,font=F(34),fill=GR,anchor='rm')
        alpha_paste(L,lay,(0,0),q)
    q=prog(t,wt('s11','前提')-0.1,0.3)
    if q>0:
        im=Image.new('RGBA',(1020,110),(0,0,0,0)); dd=ImageDraw.Draw(im); dd.rounded_rectangle([10,10,1010,100],45,fill=(67,38,12),outline=OR,width=3); dd.text((510,56),'前提:域名已接入 Cloudflare(完整图文看评论区)',font=F(36,True),fill=WH,anchor='mm')
        scaled_paste(L,im,540,1100,0.75+0.25*eob(q))
    return L
# ---------------- WARN / CTA
def sc_warn(t):
    L=layer(); d=ImageDraw.Draw(L); st=scene_t('warn')['t0']; q=eob(prog(t,st,0.35))
    lay=layer(); ld=ImageDraw.Draw(lay)
    ld.polygon([(540,380),(640,555),(440,555)],fill=YL); ld.text((540,500),'!',font=F(90,True),fill=(17,24,39),anchor='mm')
    ld.text((540,650),'只挂你自己的服务',font=F(72,True),fill=WH,anchor='mm')
    scaled_paste(L,lay,540,960,0.85+0.15*q)
    for i,(ok,txt) in enumerate([(True,'自己的网页、测试服务'),(False,'整台电脑、所有端口')]):
        qq=eout(prog(t,wt('s18','只挂') if ok else wt('s18','整台'),0.3))
        if qq<=0: continue
        y=800+i*130; lay=layer(); ld=ImageDraw.Draw(lay)
        ld.rounded_rectangle([160,y,920,y+100],24,fill=(20,83,45) if ok else (127,29,29))
        (check if ok else cross)(ld,225,y+50,36,WH,7); ld.text((290,y+50),txt,font=F(42,True),fill=WH,anchor='lm')
        alpha_paste(L,lay,(0,40*(1-qq)),qq)
    return L
def sc_cta(t):
    L=layer(); d=ImageDraw.Draw(L); st=scene_t('cta')['t0']
    q=eob(prog(t,st,0.3)); pul=1+0.03*math.sin(t*6)
    im=Image.new('RGBA',(460,160),(0,0,0,0)); pill(ImageDraw.Draw(im),230,80,'+ 关注',F(72,True),(17,24,39),OR,pad=(50,22))
    scaled_paste(L,im,540,480,(0.7+0.3*q)*pul)
    q2=eout(prog(t,wt('s19','评论区')-0.05,0.35))
    if q2>0:
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.text((540,680),'完整图文资料看评论区',font=F(52,True),fill=WH,anchor='mm')
        ld.text((540,800),'关注',font=F(44),fill=GR,anchor='mm')
        ld.text((540,900),'正切AI实验室',font=F(72,True),fill=OR,anchor='mm')
        alpha_paste(L,lay,(0,40*(1-q2)),q2)
    d.text((540,1110),'正切AI实验室 · Cloudflare 实操系列',font=F(32),fill=GR,anchor='mm')
    return L
FN={'hook':sc_hook,'title':sc_title,'diag':sc_diag,'tq':sc_tq,'browser':sc_browser,'steps':sc_steps,'tn':sc_tn,'config':sc_config,'run':sc_run,'warn':sc_warn,'cta':sc_cta}
# ---------------- subtitles
SUBF=F(62,True)
def sub_lines(text,maxw=14*62):
    if SUBF.getlength(text)<=maxw: return [text]
    toks=re.findall(r'[A-Za-z0-9\.\-_/:%]+|\s|.',text)
    best=None
    for k in range(1,len(toks)):
        a=''.join(toks[:k]).strip(); b=''.join(toks[k:]).strip()
        if not a or not b or b[0] in ',。?!、:': continue
        sc=max(SUBF.getlength(a),SUBF.getlength(b))
        if best is None or sc<best[0]: best=(sc,a,b)
    return [best[1],best[2]]
def draw_sub(base,t):
    cur=[s for s in SUBS if s['t0']<=t<s['t1']]
    if not cur: return
    s=cur[0]; lines=sub_lines(s['text']); d=ImageDraw.Draw(base)
    y0=1345-(len(lines)-1)*42
    for i,l in enumerate(lines):
        y=y0+i*84; parts=re.findall(r'[A-Za-z0-9\.\-_/:%]+(?: [A-Za-z0-9\.\-_/:%]+)*|[^A-Za-z0-9]+',l)
        tw=SUBF.getlength(l); x=540-tw/2
        for p in parts:
            col=(255,196,120) if re.match(r'[A-Za-z0-9]',p) else WH
            d.text((x,y),p,font=SUBF,fill=col,anchor='lm',stroke_width=7,stroke_fill=(10,10,14)); x+=SUBF.getlength(p)
def brand(base,t,name):
    if name in ('cta',): return
    d=ImageDraw.Draw(base); d.text((60,PY-48),'正切AI实验室',font=F(30,True),fill=(200,206,214),anchor='lm')
# ---------------- main
def scene_at(t):
    for k,s in enumerate(SC):
        if s['t0']<=t<s['t1']: return k
    return len(SC)-1
def run(out,start=0,end=None,stills=None):
    N=int(math.ceil(TOTAL*FPS)); end=N if end is None else end
    p=None
    if out: p=subprocess.Popen(['ffmpeg','-y','-v','error','-f','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-c:v','libx264','-preset','medium','-crf','18','-pix_fmt','yuv420p',out],stdin=subprocess.PIPE)
    last={}; prevk=None
    for i in range(start,end):
        t=i/FPS; k=scene_at(t); s=SC[k]
        content=FN[s['name']](t); last[k]=content
        im=bg(t)
        lt=t-s['t0']
        if k>0 and lt<0.24 and (k-1) in last:
            q=ease(lt/0.24)
            alpha_paste(im,last[k-1],(0,-50*q),1-q)
            alpha_paste(im,content,(0,60*(1-q)),q)
        else: im.alpha_composite(content)
        brand(im,t,s['name']); draw_sub(im,t)
        rgb=im.convert('RGB')
        if p: p.stdin.write(rgb.tobytes())
        if stills and i in stills: rgb.save(f'{B}/frames/f_{i:05d}.png')
        if i%150==0: print('frame',i,'/',end,flush=True)
    if p: p.stdin.close(); p.wait()

if __name__=='__main__':
    json.dump(dict(AUDIO_EVENTS,trans=[s['t0'] for s in SC[1:]]),open(f'{B}/work/sfx_events.json','w'))
    mode=sys.argv[1] if len(sys.argv)>1 else 'tail'
    if mode=='stills':
        ts=[float(x) for x in sys.argv[2:]]; idx=sorted(int(x*FPS) for x in ts); run(None,0,max(idx)+1,set(idx))
    elif mode=='tail':
        steps=next(s for s in SC if s['name']=='steps')
        warm=max(0,int((steps['t0']-0.5)*FPS)); start=int(steps['t0']*FPS); end=int(math.ceil(TOTAL*FPS))
        p=subprocess.Popen(['ffmpeg','-y','-v','error','-f','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-c:v','libx264','-preset','medium','-crf','18','-pix_fmt','yuv420p',f'{B}/work/tail.mp4'],stdin=subprocess.PIPE)
        last={}
        for i in range(warm,end):
            t=i/FPS; k=scene_at(t); s=SC[k]
            content=FN[s['name']](t); last[k]=content
            if i<start: continue
            im=bg(t); lt=t-s['t0']
            if k>0 and lt<0.24 and (k-1) in last:
                q=ease(lt/0.24); alpha_paste(im,last[k-1],(0,-50*q),1-q); alpha_paste(im,content,(0,60*(1-q)),q)
            else: im.alpha_composite(content)
            brand(im,t,s['name']); draw_sub(im,t)
            p.stdin.write(im.convert('RGB').tobytes())
            if i%150==0: print('tail frame',i,'/',end,flush=True)
        p.stdin.close(); rc=p.wait(); print('tail done',end-start,'frames rc',rc)
    else: run(f'{B}/work/video.mp4')

ep01/src/script.py

22 行 · python
ep01/src/script.py
SEGS = [
 ("s01","hook","想让朋友打开你家电脑上的网页?","想让朋友打开你家电脑上的网页?"),
 ("s02","hook","结果宽带没有公网 IP,路由器也不让你开端口。","结果宽带没有公网IP,路由器也不让你开端口。"),
 ("s03","title","用 Cloudflare Tunnel,两个问题都不用管。","用 Cloudflare Tunnel,两个问题都不用管。"),
 ("s04","diag_old","原理很简单。平时是外面往你家里连,所以进不来。","原理很简单。平时是外面往你家里连,所以进不来。"),
 ("s05","diag_new","Tunnel 反过来。你电脑上跑个 Cloudflare D,主动往外,连到 Cloudflare。","Tunnel 反过来。你电脑上跑个 cloudflared,主动往外,连到 Cloudflare。"),
 ("s06","diag_flow","访客先到 Cloudflare,再顺着这条连接,回到你电脑。一个端口都不用开。","访客先到 Cloudflare,再顺着这条连接,回到你电脑。一个端口都不用开。"),
 ("s07","term_quick","最快的玩法,装好以后跑一行,tunnel 杠杠 url,加上本地地址。","最快的玩法,装好以后跑一行,tunnel --url,加上本地地址。"),
 ("s08","term_quick_out","几秒钟,它就吐出一个 try Cloudflare 的链接。","几秒钟,它就吐出一个 trycloudflare 的链接。"),
 ("s09","browser","发给朋友,打开就是你本地的页面。不用注册,也不用域名。","发给朋友,打开就是你本地的页面。不用注册,也不用域名。"),
 ("s10","browser","缺点是,每次重启,链接都会变。","缺点是,每次重启,链接都会变。"),
 ("s11","steps","想要固定域名,就建个正式隧道。前提是域名已经接入 Cloudflare,完整图文看评论区。","想要固定域名,就建个正式隧道。前提是域名已经接入 Cloudflare,完整图文看评论区。"),
 ("s12","term_login","第一步,tunnel login,浏览器里登录,选你的域名。","第一步,tunnel login,浏览器里登录,选你的域名。"),
 ("s13","term_create","第二步,tunnel create 起个名,拿到隧道 ID 和凭证文件。","第二步,tunnel create 起个名,拿到隧道ID和凭证文件。"),
 ("s14","term_route","第三步,route dns,把子域名指过去,C NAME 自动帮你加好。","第三步,route dns,把子域名指过去,CNAME 自动帮你加好。"),
 ("s15","config","第四步,写 config 点 Y M L。隧道 ID,凭证路径,再用 ingress 写,哪个域名转到哪个端口。","第四步,写 config.yml。隧道ID,凭证路径,再用 ingress 写,哪个域名转到哪个端口。"),
 ("s16","config_hl","最后一条必须是兜底规则,比如返回四零四。","最后一条必须是兜底规则,比如返回 404。"),
 ("s17","term_run","最后 tunnel run。用你自己的域名,就能打开家里的服务,还自带 HTTPS。","最后 tunnel run。用你自己的域名,就能打开家里的服务,还自带 HTTPS。"),
 ("s18","outro","记住,只挂你自己的服务,别把整台电脑暴露出去。","记住,只挂你自己的服务,别把整台电脑暴露出去。"),
 ("s19","outro","完整图文资料看评论区,关注正切AI实验室。","完整图文资料看评论区,关注正切AI实验室。"),
]

ep01/src/splice_audio.py

70 行 · python
ep01/src/splice_audio.py
# EP01 v2 audio: keep original final mix (final_v1.mp4) untouched outside the changed s11 sentence;
# re-synthesize only the s11 window with a locally-fitted transfer function so level/EQ/mix match the original.
import numpy as np, subprocess, json, wave
from scipy.signal import stft, istft
B='.'; SR=48000
def ld(p,ch=1):
    r=subprocess.check_output(['ffmpeg','-v','error','-i',p,'-f','f32le','-ac',str(ch),'-ar',str(SR),'-']); return np.frombuffer(r,np.float32).copy()
orig=ld(f'{B}/final_v1.mp4',2).reshape(-1,2).mean(1)   # original is effectively mono (side < -36 dB)
orig_first=orig[0]; orig=orig[1:]                                          # 1-sample encoder offset measured vs voice.wav
v_old=ld(f'{B}/backup_v1/voice.wav'); v_new=ld(f'{B}/work/voice.wav'); mus=ld(f'{B}/work/music.wav')
o=json.load(open(f'{B}/backup_v1/timeline.json')); n=json.load(open(f'{B}/work/timeline.json'))
so={s['id']:s for s in o['segs']}; sn={s['id']:s for s in n['segs']}
D=sn['s12']['t0']-so['s12']['t0']
A=so['s11']['t0']-0.03            # splice-in point (after steps-scene whoosh, before s11 voice)
Bo=so['s11']['t1']-0.21           # splice-out point in old timeline (after last word, before next whoosh)
Bn=Bo+D
print('delta',D,'A',A,'Bold',Bo,'Bnew',Bn)
N=4096; import os; GAIN_DB=float(os.environ.get('GAIN_DB','0.75'))
def S(x): return stft(x,nperseg=N)[2]
# local fit on old window +-1.5 s
a0,a1=int((A-1.5)*SR),int((Bo+0.2)*SR)
Fo,Vo,Mo=S(orig[a0:a1]),S(v_old[a0:a1]),S(mus[a0:a1])
H=np.zeros((2,Fo.shape[0]),complex)
for k in range(Fo.shape[0]):
    X=np.stack([Vo[k],Mo[k]],1); H[:,k]=np.linalg.lstsq(X,Fo[k],rcond=None)[0]
# smooth H across frequency to avoid ringing
from scipy.ndimage import uniform_filter1d
Hs=uniform_filter1d(H.real,9,axis=1)+1j*uniform_filter1d(H.imag,9,axis=1)
def apply(x,h):
    f,t,Z=stft(x,nperseg=N); y=istft(Z*h[:,None],nperseg=N)[1]; return y[:len(x)].astype(np.float32)
# fidelity test: rebuild the OLD window and compare with the original
test=apply(v_old[a0:a1],Hs[0])+apply(mus[a0:a1],Hs[1])
seg=slice(int((A-a0/SR)*SR),int((Bo-a0/SR)*SR))
r=orig[a0:a1][seg]-test[seg]; print('old-window rebuild residual ratio',np.sqrt((r**2).mean()/(orig[a0:a1][seg]**2).mean()))
# music for the window: pad clock t at the start, crossfade to clock (t-D) so it joins the shifted tail
c0,c1=int((A-1.5)*SR),int((Bn+0.5)*SR); L=c1-c0
m1=mus[c0:c1]; m2=mus[c0-int(round(D*SR)):c1-int(round(D*SR))]
tt=np.arange(L)/SR+c0/SR; x0,x1=45.6,47.2
w=np.clip((tt-x0)/(x1-x0),0,1); w=0.5-0.5*np.cos(np.pi*w)
# equal-power-ish for uncorrelated pads
mw=m1*np.sqrt(1-w)+m2*np.sqrt(w)
win=apply(v_new[c0:c1],Hs[0])+apply(mw,Hs[1])
# original master is peak-limited (~0.6 full-scale); apply the same kind of limiter to the rebuilt window
def wr(path,x):
    pcm=(np.clip(x,-1,1)*32767).astype(np.int16)
    with wave.open(path,'wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
win=win*10**(GAIN_DB/20)   # level trim so identical words match the original loudness
wr('/tmp/win.wav',win)
subprocess.run(['ffmpeg','-y','-v','error','-i','/tmp/win.wav','-af','alimiter=limit=0.6:attack=4:release=60:level=disabled','-ar',str(SR),'/tmp/win_l.wav'],check=True)
win=ld('/tmp/win_l.wav')[:L]
if len(win)<L: win=np.r_[win,np.zeros(L-len(win),np.float32)]
# assemble
out_len=len(orig)+int(round(D*SR))
out=np.zeros(out_len,np.float32)
iA=int(A*SR); iBn=int(Bn*SR); iBo=iBn-int(round(D*SR)); X=int(0.03*SR)
out[:iA+X]=orig[:iA+X]
wseg=win[iA-c0-X: iBn-c0+X]
fade=np.ones(len(wseg),np.float32); fade[:2*X]=np.linspace(0,1,2*X); fade[-2*X:]=np.linspace(1,0,2*X)
out[:iA+X]*=np.r_[np.ones(iA-X),np.linspace(1,0,2*X)].astype(np.float32)
out[iA-X:iBn+X]+=wseg*fade
tail=orig[iBo-X:].copy(); tail[:2*X]*=np.linspace(0,1,2*X)
out[iBn-X:iBn-X+len(tail)]+=tail[:out_len-(iBn-X)]
print('peak',np.abs(out).max())
out_w=np.r_[np.float32(orig_first),out]  # restore the 1-sample offset of the original stream
pcm=(np.clip(out_w,-1,1)*32767).astype(np.int16)
with wave.open(f'{B}/work/mix_v2.wav','wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
# level check old vs new sentence
def rms(x): return 20*np.log10(np.sqrt((x**2).mean())+1e-12)
print('old s11 rms dB',rms(orig[iA:iBo]),'new s11 rms dB',rms(out[iA:iBn]))

ep01/src/splice_dual.py

103 行 · python
ep01/src/splice_dual.py
# Dual-window splice from final_v2: replace s11 and ending (s19→end); middle time-shifted.
import numpy as np, subprocess, json, wave, os
from scipy.signal import stft, istft
from scipy.ndimage import uniform_filter1d
B='.'; SR=48000; N=4096
G1=float(os.environ.get('G1','0.70')); G2=float(os.environ.get('G2','0.55'))
def ld(p,ch=1):
    r=subprocess.check_output(['ffmpeg','-v','error','-i',p,'-f','f32le','-ac',str(ch),'-ar',str(SR),'-']); return np.frombuffer(r,np.float32).copy()
def wr(p,x):
    pcm=(np.clip(x,-1,1)*32767).astype(np.int16)
    with wave.open(p,'wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
def limit(x,gain):
    x=x*10**(gain/20); wr('/tmp/w.wav',x)
    subprocess.run(['ffmpeg','-y','-v','error','-i','/tmp/w.wav','-af','alimiter=limit=0.6:attack=4:release=60:level=disabled','-ar',str(SR),'/tmp/wl.wav'],check=True)
    y=ld('/tmp/wl.wav'); return y if len(y)>=len(x) else np.r_[y,np.zeros(len(x)-len(y),np.float32)]
raw=ld(f'{B}/final_v2.mp4',2).reshape(-1,2).mean(1); first=raw[0]; orig=raw[1:]
v_old=ld(f'{B}/backup_v2/voice.wav'); v_new=ld(f'{B}/work/voice.wav'); mus=ld(f'{B}/work/music.wav')
o=json.load(open(f'{B}/backup_v2/timeline.json')); n=json.load(open(f'{B}/work/timeline.json'))
so={s['id']:s for s in o['segs']}; sn={s['id']:s for s in n['segs']}
# windows
A1=so['s11']['t0']-0.03; B1o=so['s11']['t1']-0.18; B1n=sn['s11']['t1']-0.18
D1=B1n-B1o
A2o=so['s19']['t0']-0.05; A2n=A2o+D1
# after s11, original content from B1o maps to B1n; s19 old start shifts by D1
Tend_o=o['total']; Tend_n=n['total']
print('D1',D1,'A1',A1,'B1o',B1o,'B1n',B1n,'A2o',A2o,'A2n',A2n,'totals',Tend_o,Tend_n)
def fit_H(a,b):
    a0=int((a-1.0)*SR); a1=int(b*SR)
    m=mus; 
    if len(m)<a1: m=np.r_[m,np.zeros(a1-len(m),np.float32)]
    vo=v_old; 
    if len(vo)<a1: vo=np.r_[vo,np.zeros(a1-len(vo),np.float32)]
    Fo,Vo,Mo=stft(orig[a0:a1],nperseg=N)[2],stft(vo[a0:a1],nperseg=N)[2],stft(m[a0:a1],nperseg=N)[2]
    H=np.zeros((2,Fo.shape[0]),complex)
    for k in range(Fo.shape[0]):
        H[:,k]=np.linalg.lstsq(np.stack([Vo[k],Mo[k]],1),Fo[k],rcond=None)[0]
    return uniform_filter1d(H.real,9,axis=1)+1j*uniform_filter1d(H.imag,9,axis=1)
def apply(x,h):
    Z=stft(x,nperseg=N)[2]; y=istft(Z*h[:,None],nperseg=N)[1]; return y[:len(x)].astype(np.float32)
def rebuild(t0,t1,vsrc,msrc,H,gain):
    pad=1.0; c0=int((t0-pad)*SR); c1=int(t1*SR)+N
    if len(vsrc)<c1: vsrc=np.r_[vsrc,np.zeros(c1-len(vsrc),np.float32)]
    if len(msrc)<c1: msrc=np.r_[msrc,np.zeros(c1-len(msrc),np.float32)]
    win=apply(vsrc[c0:c1],H[0])+apply(msrc[c0:c1],H[1])
    win=limit(win,gain)[:c1-c0]
    return win, c0
# ensure lengths
need=int(max(Tend_o,Tend_n)*SR)+SR
for name in ('v_old','v_new','mus'):
    pass
if len(mus)<need: mus=np.r_[mus,np.zeros(need-len(mus),np.float32)]
if len(v_old)<need: v_old=np.r_[v_old,np.zeros(need-len(v_old),np.float32)]
if len(v_new)<need: v_new=np.r_[v_new,np.zeros(need-len(v_new),np.float32)]
H1=fit_H(A1,B1o+0.3); H2=fit_H(A2o,Tend_o)
w1,c1=rebuild(A1,B1n+0.25,v_new,mus,H1,G1)
# ending uses new-timeline times for voice/music
w2,c2=rebuild(A2n,Tend_n,v_new,mus,H2,G2)
out=np.zeros(int(round(Tend_n*SR)),np.float32)
X=int(0.04*SR)
def xfade_put(dst, i0, i1, piece, p0):
    # put piece[p0+(i0-i0): ...] onto dst[i0:i1] with short fades at ends if interior
    seg=piece[p0:p0+(i1-i0)]
    if len(seg)<i1-i0: seg=np.r_[seg,np.zeros(i1-i0-len(seg),np.float32)]
    dst[i0:i1]=seg[:i1-i0]
# head
iA1=int(A1*SR); iB1n=int(B1n*SR); iA2n=int(A2n*SR); iEnd=len(out)
out[:iA1]=orig[:iA1]
# crossfade into w1
hf=orig[iA1-X:iA1+X]*np.r_[np.ones(X),np.linspace(1,0,X)].astype(np.float32)
nf=w1[iA1-c1-X:iA1-c1+X]*np.r_[np.linspace(0,1,X),np.ones(X)].astype(np.float32)
out[iA1-X:iA1+X]=hf+nf
out[iA1+X:iB1n]=w1[iA1-c1+X:iB1n-c1]
# middle: orig[B1o:A2o] → out[B1n:A2n]
iB1o=int(B1o*SR); iA2o=int(A2o*SR)
mid=orig[iB1o:iA2o]
# crossfade out of w1 into mid
out[iB1n-X:iB1n+X]=w1[iB1n-c1-X:iB1n-c1+X]*np.r_[np.ones(X),np.linspace(1,0,X)].astype(np.float32) + mid[:2*X]*np.r_[np.linspace(0,1,X),np.ones(X)].astype(np.float32)
out[iB1n+X:iA2n]=mid[2*X:2*X+(iA2n-(iB1n+X))]
# if mid shorter/longer due to float, pad
# crossfade mid → w2
mlen=iA2n-(iB1n+X); 
# ending
hf=out[iA2n-X:iA2n].copy()  # already mid
# replace from iA2n-X
hf2=mid[-(X):]*np.r_[np.linspace(1,0,X)].astype(np.float32) if False else None
piece=w2; p0=iA2n-c2
hf=orig[iA2o-X:iA2o+X]*np.r_[np.ones(X),np.linspace(1,0,X)].astype(np.float32)  # old ending start for shape ref — use mid tail
# mid ends at out[iA2n]; take last X of what's in out as head of xfade
head=out[iA2n-X:iA2n].copy()*np.linspace(1,0,X).astype(np.float32)
tail=w2[p0-X:p0+X]*np.r_[np.linspace(0,1,X),np.ones(X)].astype(np.float32)
# simpler overwrite from iA2n-X
out[iA2n-X:iA2n+X]=out[iA2n-X:iA2n+X]*np.r_[np.ones(X),np.linspace(1,0,X)].astype(np.float32) + w2[p0-X:p0+X]*np.r_[np.linspace(0,1,X),np.ones(X)].astype(np.float32)
rest=w2[p0+X: p0+X+(iEnd-(iA2n+X))]
out[iA2n+X:iA2n+X+len(rest)]=rest[:iEnd-(iA2n+X)]
print('peak',float(np.abs(out).max()))
print('pre maxdiff',float(np.abs(out[:iA1-X]-orig[:iA1-X]).max()))
def rms(x): return float(20*np.log10(np.sqrt((x**2).mean())+1e-12))
print('s11 old/new rms',rms(orig[iA1:iB1o]),rms(out[iA1:iB1n]))
print('end old/new rms',rms(orig[iA2o:int(Tend_o*SR)]),rms(out[iA2n:]))
out_w=np.r_[np.float32(first),out]
wr(f'{B}/work/mix_final.wav', out_w)
print('wrote mix_final.wav', len(out_w)/SR)

ep01/src/splice_end.py

67 行 · python
ep01/src/splice_end.py
# Replace only the ending (from just before s19) of final_v2; duration shrinks with shorter CTA line.
import numpy as np, subprocess, json, wave, os
from scipy.signal import stft, istft
from scipy.ndimage import uniform_filter1d
B='.'; SR=48000; N=4096
GAIN_DB=float(os.environ.get('GAIN_DB','0.55'))
def ld(p,ch=1):
    r=subprocess.check_output(['ffmpeg','-v','error','-i',p,'-f','f32le','-ac',str(ch),'-ar',str(SR),'-'])
    return np.frombuffer(r,np.float32).copy()
orig_raw=ld(f'{B}/final_v2.mp4',2).reshape(-1,2).mean(1)
orig_first=orig_raw[0]; orig=orig_raw[1:]
v_old=ld(f'{B}/backup_v2/voice.wav'); v_new=ld(f'{B}/work/voice.wav'); mus=ld(f'{B}/work/music.wav')
o=json.load(open(f'{B}/backup_v2/timeline.json')); n=json.load(open(f'{B}/work/timeline.json'))
so={s['id']:s for s in o['segs']}; sn={s['id']:s for s in n['segs']}
A=so['s19']['t0']-0.05          # keep head through warn→cta whoosh
old_end=o['total']; new_end=n['total']
print('A',A,'old_end',old_end,'new_end',new_end,'D',new_end-old_end)
# fit transfer on old ending window
a0=int((A-1.0)*SR); a1=int(old_end*SR)
mus=np.r_[mus, np.zeros(max(0,a1+SR-len(mus)),np.float32)]
v_old=np.r_[v_old, np.zeros(max(0,a1+SR-len(v_old)),np.float32)]
v_new=np.r_[v_new, np.zeros(max(0,int(new_end*SR)+SR-len(v_new)),np.float32)]
def S(x): return stft(x,nperseg=N)[2]
Fo,Vo,Mo=S(orig[a0:a1]),S(v_old[a0:a1]),S(mus[a0:a1])
H=np.zeros((2,Fo.shape[0]),complex)
for k in range(Fo.shape[0]):
    H[:,k]=np.linalg.lstsq(np.stack([Vo[k],Mo[k]],1), Fo[k], rcond=None)[0]
Hs=uniform_filter1d(H.real,9,axis=1)+1j*uniform_filter1d(H.imag,9,axis=1)
def apply(x,h):
    Z=stft(x,nperseg=N)[2]; y=istft(Z*h[:,None],nperseg=N)[1]; return y[:len(x)].astype(np.float32)
# rebuild new ending [A-pad, new_end]
pad=1.0; c0=int((A-pad)*SR); c1=int(new_end*SR)+N
win=apply(v_new[c0:c1],Hs[0])+apply(mus[c0:c1],Hs[1])
win*=10**(GAIN_DB/20)
# limiter
def wr(p,x):
    pcm=(np.clip(x,-1,1)*32767).astype(np.int16)
    with wave.open(p,'wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
wr('/tmp/win.wav',win)
subprocess.run(['ffmpeg','-y','-v','error','-i','/tmp/win.wav','-af','alimiter=limit=0.6:attack=4:release=60:level=disabled','-ar',str(SR),'/tmp/win_l.wav'],check=True)
win=ld('/tmp/win_l.wav'); 
if len(win)<c1-c0: win=np.r_[win,np.zeros(c1-c0-len(win),np.float32)]
win=win[:c1-c0]
# assemble
out_len=int(round(new_end*SR)); out=np.zeros(out_len,np.float32)
iA=int(A*SR); X=int(0.05*SR)
# head
out[:iA]=orig[:iA]
# crossfade region
fade=np.linspace(1,0,2*X).astype(np.float32)
out[iA-X:iA+X] = orig[iA-X:iA+X]*np.r_[np.ones(0),fade][:2*X] if False else out[iA-X:iA+X]
# proper crossfade
head_part=orig[iA-X:iA+X].copy(); head_part*=np.r_[np.ones(X),np.linspace(1,0,X)].astype(np.float32)
new_part=win[iA-c0-X:iA-c0+X].copy(); new_part*=np.r_[np.linspace(0,1,X),np.ones(X)].astype(np.float32)
out[iA-X:iA+X]=head_part+new_part
# rest of new ending
rest=win[iA-c0+X: out_len-c0]
out[iA+X:iA+X+len(rest)]=rest[:out_len-(iA+X)]
print('peak',float(np.abs(out).max()),'len',out_len/SR)
print('pre maxdiff',float(np.abs(out[:iA-X]-orig[:iA-X]).max()))
def rms(x): return float(20*np.log10(np.sqrt((x**2).mean())+1e-12))
print('old end rms',rms(orig[iA:a1]),'new end rms',rms(out[iA:]))
out_w=np.r_[np.float32(orig_first),out]
pcm=(np.clip(out_w,-1,1)*32767).astype(np.int16)
with wave.open(f'{B}/work/mix_v3.wav','wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
print('wrote mix_v3.wav')

ep01/src/term.py

97 行 · python
ep01/src/term.py
import random, math
from PIL import Image, ImageDraw
from common import *
CW,CH=2600,2440
FS=34; MF=M(FS); CHW=MF.getlength('M'); RH=50; TB=74; PADX=34; PADY=24
COLS=int((CW-2*PADX)//CHW)
TBG=(13,17,23); TFG=(214,222,230); TS=(110,118,129)
PROMPT=[('user@home-pc',(126,231,135)),(':',TFG),('~',(121,192,255)),('$ ',TFG)]
def logline(ts,lvl,msg):
    col={'INF':(126,231,135),'WRN':(240,200,80),'ERR':(255,123,114)}[lvl]
    return [(ts+' ',TS),(lvl+' ',col),(msg,TFG)]
class Term:
    def __init__(self,title='user@home-pc: ~',seed=1):
        self.lines=[]; self.title=title; self.rnd=random.Random(seed); self.typing=[]; self.marks=[]; self.clears=[]
    def prompt(self,t):
        self.lines.append(dict(t=t,segs=list(PROMPT),typed=None)); return t
    def type(self,t,text,cps=16):
        L=self.lines[-1]; times=[]; x=t
        for ch in text:
            times.append(x); dt=self.rnd.uniform(0.55,1.45)/cps
            if ch==' ': dt*=1.6
            if self.rnd.random()<0.06: dt+=self.rnd.uniform(0.08,0.22)
            x+=dt
        L['typed']=(text,times); self.typing+=times; return x
    def out(self,t,segs,mark=None):
        if isinstance(segs,str): segs=[(segs,TFG)]
        self.lines.append(dict(t=t,segs=segs,typed=None,mark=mark)); return t
    def clear(self,t): self.clears.append(t)
    def visible(self,t):
        c0=max([c for c in self.clears if c<=t],default=-1)
        rows=[]; cursor=None; marks=[]
        vis=[L for L in self.lines if L['t']<=t and L['t']>=c0]
        for li,L in enumerate(vis):
            segs=list(L['segs'])
            if L['typed']:
                text,times=L['typed']; n=sum(1 for x in times if x<=t); segs.append((text[:n],TFG))
            # flatten to chars with colours
            chars=[(ch,c) for s,c in segs for ch in s]
            if not chars: chars=[]
            r0=len(rows)
            for i in range(0,max(1,len(chars)),COLS): rows.append(chars[i:i+COLS])
            if L.get('mark'):
                full=''.join(ch for ch,_ in chars); m,tm=L['mark']
                k=full.find(m)
                if k>=0 and t>=tm: marks.append((r0,k,len(m),t-tm))
            if li==len(vis)-1 and L['typed'] is not None:
                cursor=(len(rows)-1,len(rows[-1]) if len(chars)%COLS or not chars else 0)
                if chars and len(chars)%COLS==0: cursor=(len(rows),0)
        if cursor is None and vis: cursor=(len(rows),0)
        return rows,cursor,marks
    def render(self,t):
        rows,cursor,marks=self.visible(t)
        maxr=int((CH-TB-PADY*2)//RH); off=max(0,len(rows)+1-maxr)
        im=Image.new('RGB',(CW,CH),TBG); d=ImageDraw.Draw(im)
        d.rectangle([0,0,CW,TB],fill=(36,41,49)); d.line([0,TB,CW,TB],fill=(22,26,32),width=2)
        for i,c in enumerate([(255,95,87),(254,188,46),(40,200,64)]): d.ellipse([28+i*44,TB/2-13,54+i*44,TB/2+13],fill=c)
        d.text((CW/2,TB/2),self.title,font=M(30),fill=(170,178,190),anchor='mm')
        for ri,row in enumerate(rows[off:]):
            y=TB+PADY+ri*RH
            x=PADX
            # group runs
            run='';rc=None;rx=x
            for ch,c in row+[(None,None)]:
                if c!=rc or ch is None:
                    if run: d.text((rx,y),run,font=MF,fill=rc)
                    run='';rc=c;rx=x
                if ch is None: break
                run+=ch; x+=CHW
        info={'off':off}
        if cursor:
            cr,cc=cursor; y=TB+PADY+(cr-off)*RH; x=PADX+cc*CHW
            typing=any(abs(t-x0)<0.35 for x0 in self.typing)
            if typing or (t%1.0)<0.55: d.rectangle([x,y+4,x+CHW-2,y+RH-6],fill=(214,222,230))
            info['cursor']=(x,y+RH/2)
        info['marks']=[]
        for (r,k,n,age) in marks:
            y=TB+PADY+(r-off)*RH; x0=PADX+k*CHW-10; x1=PADX+(k+n)*CHW+10
            a=eout(age/0.35)
            xm=x0+(x1-x0)*a
            d.rounded_rectangle([x0,y-4,xm,y+RH+2],12,outline=OR,width=6)
            info['marks'].append((x0,y-4,x1,y+RH+2))
        return im,info
class Cam:
    def __init__(self,vw=1000,vh=940):
        self.vw,self.vh=vw,vh; self.x=CW/2; self.y=CH/2; self.s=vw/CW; self.last=None
    def update(self,t,tx,ty,ts,k=5.0):
        dt=1/30 if self.last is None else t-self.last; self.last=t
        if dt<=0: return
        a=1-math.exp(-dt*k)
        self.x+=(tx-self.x)*a; self.y+=(ty-self.y)*a; self.s+=(ts-self.s)*a
    def snap(self,tx,ty,ts): self.x,self.y,self.s=tx,ty,ts
    def view(self,im):
        s=max(self.s,self.vw/CW); hw=self.vw/s/2; hh=self.vh/s/2
        x=min(max(self.x,hw),CW-hw); y=min(max(self.y,hh),CH-hh)
        box=(int(x-hw),int(y-hh),int(x+hw),int(y+hh))
        return im.crop(box).resize((self.vw,self.vh),Image.BICUBIC)

ep01/src/timeline.py

81 行 · python
ep01/src/timeline.py
import json, re, subprocess, numpy as np, sys
sys.path.insert(0,'./src'); from script import SEGS
B='.'; SR=48000
SCENE_OF={'s01':'hook','s02':'hook','s03':'title','s04':'diag','s05':'diag','s06':'diag','s07':'tq','s08':'tq',
 's09':'browser','s10':'browser','s11':'steps','s12':'tn','s13':'tn','s14':'tn','s15':'config','s16':'config',
 's17':'run','s18':'warn','s19':'cta'}
PUN=',。?!:、;'
def load(sid):
    raw=subprocess.check_output(['ffmpeg','-v','error','-i',f'{B}/tts/{sid}.mp3','-f','f32le','-ac','1','-ar',str(SR),'-'])
    return np.frombuffer(raw,dtype=np.float32)
def norm(s): return re.sub(r'[\s%s,.?!:;\-]'%PUN,'',s).lower()
def chunks(s):
    parts=re.split('([%s])'%PUN,s); out=[]; cur=''
    for p in parts:
        if not p: continue
        if p in PUN:
            if p in '?!': cur+=p
            if cur.strip(): out.append(cur.strip())
            cur=''
        else: cur+=p
    if cur.strip(): out.append(cur.strip())
    return out
def width(s): return sum(0.55 if ord(c)<128 else 1 for c in s)
def build():
    T=json.load(open(f'{B}/tts/timing.json'))
    audio=[np.zeros(int(0.12*SR),np.float32)]; t=0.12
    segs=[]; subs=[]
    for i,(sid,_,tts,disp) in enumerate(SEGS):
        w=T[sid]['words']; a=load(sid)
        c0=max(0,w[0][0]-0.06); c1=min(len(a)/SR,w[-1][1]+0.24)
        clip=a[int(c0*SR):int(c1*SR)].copy()
        fade=int(0.02*SR); clip[-fade:]*=np.linspace(1,0,fade)
        start=t; audio.append(clip); t+=len(clip)/SR
        nxt=SEGS[i+1][0] if i+1<len(SEGS) else None
        gap=0.6 if nxt is None else (0.12 if SCENE_OF[nxt]==SCENE_OF[sid] else 0.26)
        audio.append(np.zeros(int(gap*SR),np.float32)); 
        words=[(a0-c0+start,a1-c0+start,x) for a0,a1,x in w]
        segs.append(dict(id=sid,scene=SCENE_OF[sid],t0=start,t1=t,words=words,disp=disp,tts=tts))
        t+=gap
        # subtitle chunks
        dc=chunks(disp); tc=chunks(tts); assert len(dc)==len(tc),(sid,dc,tc)
        # map tts chunk char ranges to words
        wn=[norm(x[2]) for x in words]; cum=[]; acc=0
        for x in wn: acc+=len(x); cum.append(acc)
        pos=0; times=[]
        for c in tc:
            L=len(norm(c)); a_=pos; b_=pos+L; pos=b_
            i0=next(k for k,v in enumerate(cum) if v>a_); i1=next((k for k,v in enumerate(cum) if v>=b_),len(cum)-1)
            times.append((words[i0][0],words[i1][1]))
        # merge short chunks
        merged=[]
        for txt,(a_,b_) in zip(dc,times):
            if merged and width(merged[-1][0]+txt)<=11 and not merged[-1][0].endswith('?'):
                merged[-1]=(merged[-1][0]+' '+txt,merged[-1][1],b_)
            else: merged.append((txt,a_,b_))
        for k,(txt,a_,b_) in enumerate(merged):
            subs.append(dict(text=txt,t0=a_-0.05,t1=b_,seg=sid))
    total=t
    # extend subtitle ends to next start if short gap
    for k in range(len(subs)-1):
        if subs[k+1]['t0']-subs[k]['t1']<0.45: subs[k]['t1']=subs[k+1]['t0']-0.01
        else: subs[k]['t1']+=0.15
    subs[-1]['t1']+=0.4
    voice=np.concatenate(audio)
    import wave
    pcm=(np.clip(voice,-1,1)*32767).astype(np.int16)
    with wave.open(f'{B}/work/voice.wav','wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
    scenes=[]
    for s in segs:
        if scenes and scenes[-1]['name']==s['scene']: scenes[-1]['t1']=s['t1']; scenes[-1]['segs'].append(s['id'])
        else: scenes.append(dict(name=s['scene'],t0=s['t0'],t1=s['t1'],segs=[s['id']]))
    for k in range(len(scenes)):
        scenes[k]['t0']=0 if k==0 else scenes[k-1]['t1']
    scenes[-1]['t1']=total
    json.dump(dict(total=total,segs=segs,subs=subs,scenes=scenes),open(f'{B}/work/timeline.json','w'),ensure_ascii=False,indent=1)
    return total,subs,scenes
if __name__=='__main__':
    total,subs,scenes=build(); print('total',round(total,2))
    for s in scenes: print(s['name'],round(s['t0'],2),round(s['t1'],2))
    for s in subs: print(round(s['t0'],2),round(s['t1'],2),s['text'],width(s['text']))

ep01/src/tts.py

20 行 · python
ep01/src/tts.py
import asyncio, json, edge_tts, sys, subprocess
sys.path.insert(0,'./src'); from script import SEGS
V=sys.argv[1] if len(sys.argv)>1 else 'zh-CN-YunxiNeural'; R=sys.argv[2] if len(sys.argv)>2 else '+12%'
async def one(sid,txt):
    c=edge_tts.Communicate(txt,V,rate=R,pitch='-2Hz',boundary='WordBoundary')
    words=[]; 
    with open(f'./tts/{sid}.mp3','wb') as f:
        async for ch in c.stream():
            if ch['type']=='audio': f.write(ch['data'])
            elif ch['type']=='WordBoundary': words.append((ch['offset']/1e7,(ch['offset']+ch['duration'])/1e7,ch['text']))
    return words
async def main():
    out={}
    for sid,sc,t,s in SEGS:
        w=await one(sid,t)
        d=float(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration','-of','csv=p=0',f'./tts/{sid}.mp3']))
        out[sid]={'dur':d,'words':w}; print(sid,round(d,2),len(w))
    json.dump(out,open('./tts/timing.json','w'),ensure_ascii=False,indent=1)
asyncio.run(main())

ep01/src/tts_one.py

20 行 · python
ep01/src/tts_one.py
# regenerate TTS for selected segment ids only (same voice/rate/pitch as EP01: YunxiNeural +15% -2Hz), update timing.json
import asyncio, json, sys, subprocess, edge_tts
sys.path.insert(0,'./src'); from script import SEGS
B='.'; V='zh-CN-YunxiNeural'; R='+15%'
async def one(sid,txt):
    c=edge_tts.Communicate(txt,V,rate=R,pitch='-2Hz',boundary='WordBoundary'); words=[]
    with open(f'{B}/tts/{sid}.mp3','wb') as f:
        async for ch in c.stream():
            if ch['type']=='audio': f.write(ch['data'])
            elif ch['type']=='WordBoundary': words.append((ch['offset']/1e7,(ch['offset']+ch['duration'])/1e7,ch['text']))
    return words
async def main(ids):
    T=json.load(open(f'{B}/tts/timing.json'))
    for sid,sc,t,s in SEGS:
        if sid not in ids: continue
        w=await one(sid,t); d=float(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration','-of','csv=p=0',f'{B}/tts/{sid}.mp3']))
        T[sid]={'dur':d,'words':w}; print(sid,round(d,3),len(w),[x[2] for x in w])
    json.dump(T,open(f'{B}/tts/timing.json','w'),ensure_ascii=False,indent=1)
asyncio.run(main(sys.argv[1:]))

ep02/src/audio.py

48 行 · python
ep02/src/audio.py
import json, numpy as np, wave
B='.'; SR=48000
ev=json.load(open(f'{B}/work/sfx_events.json')); TL=json.load(open(f'{B}/work/timeline.json')); N=int((TL['total']+0.5)*SR)
rng=np.random.default_rng(7)
sfx=np.zeros(N,np.float32)
def add(buf,x,t):
    i=int(t*SR); j=min(N,i+len(x))
    if i<N: buf[i:j]+=x[:j-i]
# keyboard clicks
for t in ev['click']:
    n=int(0.035*SR); tt=np.arange(n)/SR
    noise=rng.standard_normal(n).astype(np.float32); hp=np.diff(noise,prepend=0)
    body=np.sin(2*np.pi*rng.uniform(150,240)*tt)*np.exp(-tt/0.012)
    x=(hp*np.exp(-tt/0.0035)*0.5+body*0.35)*rng.uniform(0.05,0.085)
    add(sfx,x.astype(np.float32),t)
# whoosh on transitions
for t in ev['trans']:
    n=int(0.4*SR); tt=np.arange(n)/SR; env=np.sin(np.pi*tt/0.4)**2
    noise=rng.standard_normal(n); y=np.zeros(n); a=0.0
    for k in range(n):
        c=0.02+0.25*(k/n); a+=c*(noise[k]-a); y[k]=a
    add(sfx,(y*env*0.10).astype(np.float32),t-0.18)
# soft pad: Am - F - C - G, 4s per chord
def midi(m): return 440*2**((m-69)/12)
chords=[[57,60,64,69],[53,57,60,65],[48,55,60,64],[55,59,62,67]]
t=np.arange(N)/SR; pad=np.zeros(N)
L=4.0
for ci in range(int(np.ceil(TL['total']/L))+1):
    ch=chords[ci%4]; s=int(ci*L*SR); e=min(N,int((ci+1)*L*SR+1.0*SR))
    if s>=N: break
    tt=t[s:e]-ci*L; env=np.minimum(1,tt/1.2)*np.clip((L+1.0-tt)/1.0,0,1)
    for m in ch:
        f=midi(m)
        for det in (-0.12,0.12):
            pad[s:e]+=np.sin(2*np.pi*f*(1+det/100)*tt+rng.uniform(0,6))*env*0.5
    pad[s:e]+=np.sin(2*np.pi*midi(ch[0]-12)*tt)*env*0.6
# one-pole lowpass
y=np.zeros_like(pad); a=0.0; c=1-np.exp(-2*np.pi*900/SR)
from scipy.signal import lfilter
y=lfilter([c],[1,c-1],pad)
y*=1+0.08*np.sin(2*np.pi*0.15*t)
y=y/np.max(np.abs(y))*0.05   # about -26 dBFS peak
fade=int(1.5*SR); y[:int(0.3*SR)]*=np.linspace(0,1,int(0.3*SR)); y[-fade:]*=np.linspace(1,0,fade)
def save(name,x):
    pcm=(np.clip(x,-1,1)*32767).astype(np.int16)
    with wave.open(f'{B}/work/{name}','wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
save('sfx.wav',sfx); save('music.wav',y.astype(np.float32)); print('ok',len(ev['click']),'clicks')

ep02/src/common.py

64 行 · python
ep02/src/common.py
import math, random
from PIL import Image, ImageDraw, ImageFont, ImageFilter
W,H,FPS=1080,1920,30
CJK='/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'; CJKB='/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc'
MONO='/usr/share/fonts/truetype/IBM Plex Mono/IBMPlexMono-Regular.ttf'
MONOB='/usr/share/fonts/truetype/IBM Plex Mono/IBMPlexMono-SemiBold.ttf'
_fc={}
def F(size,bold=False):
    k=('c',size,bold)
    if k not in _fc: _fc[k]=ImageFont.truetype(CJKB if bold else CJK,size,index=2)
    return _fc[k]
def M(size,bold=False):
    k=('m',size,bold)
    if k not in _fc: _fc[k]=ImageFont.truetype(MONOB if bold else MONO,size)
    return _fc[k]
OR=(243,128,32); ORL=(246,160,77); BG0=(11,15,25); BG1=(17,24,39); WH=(245,247,250); GR=(148,156,170); DG=(75,85,99)
RD=(239,68,68); GN=(34,197,94); BL=(96,165,250); YL=(250,204,21)
def ease(x): x=max(0,min(1,x)); return x*x*(3-2*x)
def eob(x):  # ease out back
    x=max(0,min(1,x)); c1=1.70158; c3=c1+1; return 1+c3*(x-1)**3+c1*(x-1)**2
def eout(x): x=max(0,min(1,x)); return 1-(1-x)**3
def prog(t,a,d): return max(0.0,min(1.0,(t-a)/d)) if d>0 else float(t>=a)
def lerp(a,b,x): return a+(b-a)*x
def cloud(d,cx,cy,s,fill):
    for dx,dy,r in [(-0.9,0.15,0.55),(-0.25,-0.25,0.75),(0.55,-0.05,0.6),(1.05,0.25,0.4)]:
        d.ellipse([cx+(dx-r)*s,cy+(dy-r)*s,cx+(dx+r)*s,cy+(dy+r)*s],fill=fill)
    d.rounded_rectangle([cx-1.3*s,cy+0.05*s,cx+1.35*s,cy+0.65*s],int(0.3*s),fill=fill)
def ctext(d,xy,t,font,fill=WH,anchor='mm',**kw): d.text(xy,t,font=font,fill=fill,anchor=anchor,**kw)
def alpha_paste(base,img,xy,alpha=1.0):
    if alpha<=0: return
    if alpha<1:
        a=img.getchannel('A').point(lambda v:int(v*alpha)); img=img.copy(); img.putalpha(a)
    base.alpha_composite(img,(int(xy[0]),int(xy[1])))
def layer(): return Image.new('RGBA',(W,H),(0,0,0,0))
def pill(d,x,y,text,font,fg,bg,pad=(26,12),anchor='mm',outline=None):
    tw=font.getlength(text); th=font.size
    w=tw+pad[0]*2; h=th+pad[1]*2
    if anchor=='mm': x0,y0=x-w/2,y-h/2
    else: x0,y0=x,y-h/2
    d.rounded_rectangle([x0,y0,x0+w,y0+h],int(h/2),fill=bg,outline=outline,width=3 if outline else 0)
    d.text((x0+w/2,y0+h/2),text,font=font,fill=fg,anchor='mm')
    return w,h
def monitor(d,cx,cy,s,col=WH,screen=(30,41,59)):
    w,h=200*s,130*s
    d.rounded_rectangle([cx-w/2,cy-h/2,cx+w/2,cy+h/2],int(14*s),fill=screen,outline=col,width=max(3,int(6*s)))
    d.rectangle([cx-12*s,cy+h/2,cx+12*s,cy+h/2+28*s],fill=col)
    d.rounded_rectangle([cx-60*s,cy+h/2+26*s,cx+60*s,cy+h/2+38*s],int(5*s),fill=col)
def phone(d,cx,cy,s,col=WH,screen=(30,41,59)):
    w,h=110*s,200*s
    d.rounded_rectangle([cx-w/2,cy-h/2,cx+w/2,cy+h/2],int(20*s),fill=screen,outline=col,width=max(3,int(6*s)))
    d.rounded_rectangle([cx-18*s,cy-h/2+10*s,cx+18*s,cy-h/2+18*s],int(4*s),fill=col)
def arrowhead(d,x0,y0,x1,y1,fill,s=22):
    ang=math.atan2(y1-y0,x1-x0)
    p=[(x1,y1),(x1-s*math.cos(ang-0.5),y1-s*math.sin(ang-0.5)),(x1-s*math.cos(ang+0.5),y1-s*math.sin(ang+0.5))]
    d.polygon(p,fill=fill)
def dashed(d,x0,y0,x1,y1,fill,w=6,dash=22,gap=16,phase=0.0):
    L=math.hypot(x1-x0,y1-y0)
    if L<1: return
    ux,uy=(x1-x0)/L,(y1-y0)/L; p=-(phase%(dash+gap))
    while p<L:
        a=max(p,0); b=min(p+dash,L)
        if b>a: d.line([x0+ux*a,y0+uy*a,x0+ux*b,y0+uy*b],fill=fill,width=w)
        p+=dash+gap

ep02/src/cover.py

34 行 · python
ep02/src/cover.py
import sys; sys.path.insert(0,'./src')
from common import *
from PIL import Image, ImageDraw, ImageFilter
import math
im=Image.new('RGBA',(W,H)); d=ImageDraw.Draw(im)
for y in range(H):
    a=y/H; c=tuple(int(lerp(BG0[i],BG1[i],0.5+0.5*math.sin(a*math.pi))) for i in range(3)); d.line([0,y,W,y],fill=c+(255,))
g=Image.new('RGBA',(W,H),(0,0,0,0)); gd=ImageDraw.Draw(g)
for x in range(0,W,60): gd.line([x,0,x,H],fill=(255,255,255,10))
for y in range(0,H,60): gd.line([0,y,W,y],fill=(255,255,255,10))
im.alpha_composite(g)
gl=Image.new('RGBA',(1200,1200),(0,0,0,0)); gld=ImageDraw.Draw(gl)
for i in range(600,0,-6): gld.ellipse([600-i,600-i,600+i,600+i],fill=OR+(int(80*(1-i/600)**2),))
im.alpha_composite(gl,(-60,-120))
cl=Image.new('RGBA',(W,H),(0,0,0,0)); cloud(ImageDraw.Draw(cl),840,330,90,OR); im.alpha_composite(cl)
d=ImageDraw.Draw(im)
d.text((80,250),'免费域名 × Cloudflare 实测',font=F(44,True),fill=ORL)
for (txt,size,col,y) in [('0元域名',160,WH,470),('接入Cloudflare',124,OR,650),('踩坑全记录',124,WH,820)]:
    d.text((80,y),txt,font=F(size,True),fill=col,anchor='lm',stroke_width=3,stroke_fill=(10,10,14))
# real masked screenshot card (KataBump NS updated → Delegated)
shot=Image.open('./material/08-katabump-set-ns/masked/03-nameservers-updated.png').convert('RGB').crop((330,230,1230,560))
shot=shot.resize((920,int(330*920/900)))
x0,y0=80,960
sh=Image.new('RGBA',(W,H),(0,0,0,0)); ImageDraw.Draw(sh).rounded_rectangle([x0,y0+16,x0+920,y0+shot.height+16],28,fill=(0,0,0,170)); im.alpha_composite(sh.filter(ImageFilter.GaussianBlur(20)))
m=Image.new('L',shot.size,0); ImageDraw.Draw(m).rounded_rectangle([0,0,shot.width-1,shot.height-1],24,fill=255)
s2=shot.convert('RGBA'); s2.putalpha(m); im.alpha_composite(s2,(x0,y0))
d=ImageDraw.Draw(im); d.rounded_rectangle([x0,y0,x0+920,y0+shot.height],24,outline=(70,80,96),width=2)
for i,(txt,ok) in enumerate([('DigitalPlat',False),('DNSHE',False),('KataBump',True)]):
    x=80+i*320; y=1430
    d.rounded_rectangle([x,y,x+290,y+96],48,fill=(20,83,45) if ok else (127,29,29),outline=GN if ok else RD,width=3)
    d.text((x+145,y+48),txt+(' ✓' if ok else ' ×'),font=F(40,True),fill=WH,anchor='mm')
d.text((80,1600),'正切AI实验室',font=F(36,True),fill=(200,206,214),anchor='lm')
im.convert('RGB').save('./cover.png'); print('ok')

ep02/src/render.py

363 行 · python
ep02/src/render.py
import json, sys, math, random, subprocess, re
sys.path.insert(0,'./src')
from common import *
from PIL import Image, ImageDraw, ImageFilter
B='.'; TM='./material/'
TL=json.load(open(f'{B}/work/timeline.json'))
S={s['id']:s for s in TL['segs']}; SC=TL['scenes']; SUBS=TL['subs']; TOTAL=TL['total']
def wt(sid,txt,which=0):
    ws=S[sid]['words']; hits=[w for w in ws if txt.lower() in w[2].lower()]
    if hits: return hits[min(which,len(hits)-1)][0]
    for i,w in enumerate(ws):
        if txt.lower() in ''.join(x[2] for x in ws[i:i+4]).lower(): return w[0]
    print('WARN word not found',sid,txt); return S[sid]['t0']
def scene_t(name): return next(s for s in SC if s['name']==name)
CLICKS=[]
def eobp(t,a,d):
    p=prog(t,a,d); return eob(p) if p>0 else 0.0
# ---------------- background (same look as EP01)
def make_bg():
    im=Image.new('RGBA',(W,H)); d=ImageDraw.Draw(im)
    for y in range(H):
        a=y/H; c=tuple(int(lerp(BG0[i],BG1[i],0.5+0.5*math.sin(a*math.pi))) for i in range(3)); d.line([0,y,W,y],fill=c+(255,))
    g=Image.new('RGBA',(W,H),(0,0,0,0)); gd=ImageDraw.Draw(g)
    for x in range(0,W,60): gd.line([x,0,x,H],fill=(255,255,255,10))
    for y in range(0,H,60): gd.line([0,y,W,y],fill=(255,255,255,10))
    im.alpha_composite(g); return im
BGIMG=make_bg()
def make_glow(r,col,a):
    g=Image.new('RGBA',(2*r,2*r),(0,0,0,0)); d=ImageDraw.Draw(g)
    for i in range(r,0,-4): d.ellipse([r-i,r-i,r+i,r+i],fill=col+(int(a*(1-i/r)**2),))
    return g
GLOW=make_glow(520,OR,70); GLOW2=make_glow(420,(59,130,246),50)
def bg(t):
    im=BGIMG.copy()
    im.alpha_composite(GLOW,(int(540-520+260*math.sin(t*0.21)),int(300-520+120*math.cos(t*0.17))))
    im.alpha_composite(GLOW2,(int(300-420+200*math.cos(t*0.13)),int(1500-420+150*math.sin(t*0.19))))
    return im
# ---------------- panel
PX,PY,PW,PH=40,300,1000,740
MASK=Image.new('L',(PW,PH),0); ImageDraw.Draw(MASK).rounded_rectangle([0,0,PW-1,PH-1],28,fill=255)
SHADOW=Image.new('RGBA',(PW+120,PH+120),(0,0,0,0)); ImageDraw.Draw(SHADOW).rounded_rectangle([60,70,PW+60,PH+70],34,fill=(0,0,0,170)); SHADOW=SHADOW.filter(ImageFilter.GaussianBlur(26))
def put_panel(L,img,yo=0):
    L.alpha_composite(SHADOW,(PX-60,PY-60+int(yo)))
    p=img.convert('RGBA'); p.putalpha(MASK); L.alpha_composite(p,(PX,PY+int(yo)))
    ImageDraw.Draw(L).rounded_rectangle([PX,PY+yo,PX+PW-1,PY+PH-1+yo],28,outline=(70,80,96),width=2)
def check(d,x,y,s,col,w=8): d.line([x-s*0.5,y,x-s*0.12,y+s*0.4,x+s*0.55,y-s*0.42],fill=col,width=w,joint='curve')
def cross(d,x,y,s,col,w=8): d.line([x-s/2,y-s/2,x+s/2,y+s/2],fill=col,width=w); d.line([x-s/2,y+s/2,x+s/2,y-s/2],fill=col,width=w)
def scaled_paste(L,img,cx,cy,sc,alpha=1.0):
    if sc<=0.01 or alpha<=0: return
    w,h=img.size; im=img.resize((max(1,int(w*sc)),max(1,int(h*sc))),Image.BICUBIC)
    alpha_paste(L,im,(cx-im.width/2,cy-im.height/2),alpha)
def text_img(text,font,fill=WH,stroke=0,sfill=(0,0,0)):
    b=font.getbbox(text,stroke_width=stroke); w=b[2]-b[0]+8; h=b[3]-b[1]+8
    im=Image.new('RGBA',(int(w),int(h)),(0,0,0,0)); ImageDraw.Draw(im).text((4-b[0],4-b[1]),text,font=font,fill=fill,stroke_width=stroke,stroke_fill=sfill)
    return im
def tag(kind,text,font=None):
    font=font or F(44,True); tw=font.getlength(text); w=int(tw+150); h=104
    im=Image.new('RGBA',(w,h),(0,0,0,0)); d=ImageDraw.Draw(im)
    fill,ol,ic={'bad':((127,29,29),RD,cross),'good':((20,83,45),GN,check),'info':((67,38,12),OR,None)}[kind]
    d.rounded_rectangle([4,4,w-4,h-4],48,fill=fill,outline=ol,width=3)
    if ic: ic(d,58,52,32,WH,7); d.text((100,52),text,font=font,fill=WH,anchor='lm')
    else: d.text((w/2,52),text,font=font,fill=WH,anchor='mm')
    return im
def head(L,t,st,text):
    a=eout(prog(t,st,0.35)); im=text_img(text,F(46,True),WH)
    alpha_paste(L,im,(540-im.width/2,205+16*(1-a)),a)
# ---------------- screenshot shots (masked only)
CT=63  # browser chrome height (tabs + address bar) cropped away
_cache={}
def shot(path):
    if path not in _cache:
        im=Image.open(TM+path).convert('RGB'); _cache[path]=im.crop((0,CT,im.width,im.height))
    return _cache[path]
AR=PW/PH
def view(img,cx,cy,w):
    w=min(w,img.width,img.height*AR); h=w/AR
    cx=min(max(cx,w/2),img.width-w/2); cy=min(max(cy,h/2),img.height-h/2)
    return (cx-w/2,cy-h/2,w,h)
def cam_at(keys,t):
    # keys: list of (time, cx, cy, w); eased interpolation, holds at ends; plus slow drift
    if t<=keys[0][0]: k=keys[0][1:]
    elif t>=keys[-1][0]: k=keys[-1][1:]
    else:
        for (t0,*a),(t1,*b) in zip(keys,keys[1:]):
            if t0<=t<=t1:
                q=ease((t-t0)/max(0.01,t1-t0)) if t1-t0<=1.2 else ease(min(1,(t-t0)/1.0)); k=[lerp(x,y,q) for x,y in zip(a,b)]; break
    return k
def render_shot(path,keys,t,boxes):
    img=shot(path); cx,cy,w=cam_at(keys,t); cy-=CT
    w*=1-0.025*math.sin(min(1,(t-keys[0][0])/6)*math.pi/2)  # gentle push-in
    x0,y0,vw,vh=view(img,cx,cy,w)
    frame=img.crop((int(x0),int(y0),int(x0+vw),int(y0+vh))).resize((PW,PH),Image.BICUBIC).convert('RGBA')
    s=PW/vw; d=ImageDraw.Draw(frame)
    for (ta,(bx0,by0,bx1,by1),col) in boxes:
        q=eout(prog(t,ta,0.35))
        if q<=0: continue
        by0-=CT; by1-=CT
        X0=(bx0-x0)*s-8; Y0=(by0-y0)*s-8; X1=(bx1-x0)*s+8; Y1=(by1-y0)*s+8
        cxm,cym=(X0+X1)/2,(Y0+Y1)/2; g=1.25-0.25*q
        X0,X1=cxm+(X0-cxm)*g,cxm+(X1-cxm)*g; Y0,Y1=cym+(Y0-cym)*g,cym+(Y1-cym)*g
        pul=0.5+0.5*math.sin((t-ta)*5)
        ov=Image.new('RGBA',frame.size,(0,0,0,0)); od=ImageDraw.Draw(ov)
        od.rounded_rectangle([X0-4,Y0-4,X1+4,Y1+4],16,outline=col+(int(90*q*pul),),width=14)
        od.rounded_rectangle([X0,Y0,X1,Y1],12,outline=col+(int(255*q),),width=7)
        frame.alpha_composite(ov)
    return frame
def shots_scene(t,plan,boxes,labels,headtxt,name):
    st=scene_t(name)['t0']; L=layer(); head(L,t,st,headtxt)
    # plan: list of (t_start, path, keys); crossfade 0.3 s between shots
    idx=max(i for i,p in enumerate(plan) if p[0]<=t or i==0)
    fr=render_shot(plan[idx][1],plan[idx][2],t,[(b[0],b[1],b[2]) for b in boxes if b[3]==idx])
    if idx>0 and t-plan[idx][0]<0.3:
        prev=render_shot(plan[idx-1][1],plan[idx-1][2],t,[(b[0],b[1],b[2]) for b in boxes if b[3]==idx-1])
        fr=Image.blend(prev,fr,ease((t-plan[idx][0])/0.3))
    a=eout(prog(t,st,0.35)); put_panel(L,fr,30*(1-a))
    cur=[l for l in labels if l[0]<=t]
    if cur:
        ta,kind,txt,*fo=cur[-1]; q=prog(t,ta,0.28)
        scaled_paste(L,tag(kind,txt,*fo),540,1135,0.75+0.25*eob(q))
    return L
P_DP='02-digitalplat-dpdns/masked/17-kyc-dropdown-github-oauth-only.png'
P_GH='03-github-signup-blocked/masked/01-access-temporarily-restricted.png'
P_DN='04-dnshe-email-rejected/masked/03-email-domain-rejected.png'
P_KB1='05-katabump-signup/masked/03-create-account-form.png'
P_KB2='05-katabump-signup/masked/04-security-check-captcha.png'
P_OR='06-katabump-order-kdns/masked/03-name-tangentlab-free.png'
P_CF1='07-cloudflare-add-domain-nameservers/masked/03-domain-entered.png'
P_CF2='07-cloudflare-add-domain-nameservers/masked/04-select-free-plan.png'
P_NS='07-cloudflare-add-domain-nameservers/masked/07-assigned-nameservers.png'
P_KN='08-katabump-set-ns/masked/03-nameservers-updated.png'
P_AC='09-cloudflare-active/masked/02-domain-active-protected.png'
P_EC='10-cloudflare-dns-ssl/masked/06-edge-certificates-universal-active.png'
def T0(n): return scene_t(n)['t0']
def sc_fail1(t):
    st=T0('fail1'); tb=wt('s05','必须')
    return shots_scene(t,[(st,P_DP,[(st,720,360,1000),(st+0.5,720,360,1000),(st+1.5,720,350,640),(tb-0.5,720,350,640),(tb,720,350,480)])],
        [(tb,(538,326,902,372),OR,0)],[(tb,'bad','只能用 GitHub OAuth 验证')],'坑 1 · DigitalPlat(dpdns.org)','fail1')
def sc_fail2(t):
    st=T0('fail2'); tb=wt('s06','被拦')
    return shots_scene(t,[(st,P_GH,[(st,720,420,1000),(tb-0.5,720,420,1000),(tb+0.2,720,330,560)])],
        [(tb,(610,312,830,346),RD,0)],[(tb,'bad','Access is temporarily restricted',F(38,True))],'坑 2 · 想现注册一个 GitHub','fail2')
def sc_fail3(t):
    st=T0('fail3'); ta=wt('s07','只'); tb=wt('s07','直接')
    return shots_scene(t,[(st,P_DN,[(st,880,440,1000),(ta,880,440,1000),(ta+0.6,880,390,600),(tb-0.5,880,390,600),(tb,884,460,520)])],
        [(ta+0.4,(650,372,1112,398),BL,0),(tb,(660,446,1108,482),RD,0)],[(ta+0.4,'info','只认主流公共邮箱'),(tb,'bad','域名邮箱:验证码发不出')],'坑 3 · DNSHE(us.ci / cc.cd)','fail3')
def sc_kb(t):
    st=T0('kb'); tv=wt('s08','验证码'); tn=wt('s08','名字')
    return shots_scene(t,[(st,P_KB1,[(st,720,470,900),(tn,720,470,900),(tn+0.8,720,450,560)]),(tv-0.15,P_KB2,[(tv-0.15,760,480,800),(tv+1.2,760,500,520)])],
        [(tn+0.5,(582,538,857,587),GN,0)],[(tn+0.4,'good','不要 GitHub · 不要邮箱验证')],'跑通 · KataBump(kdns.fr)','kb')
def sc_order(t):
    st=T0('order'); tf=wt('s09','Free'); te=wt('s09','注意')+0.45
    return shots_scene(t,[(st,P_OR,[(st,830,420,1000),(tf-0.5,830,420,1000),(tf,600,390,520),(te-0.9,600,390,520),(te-0.2,1010,450,560)])],
        [(tf,(459,355,543,376),GN,0),(te-0.2,(833,414,1206,484),OR,0)],[(tf,'good','.kdns.fr — Free'),(te-0.2,'info','NS 一定选 External NS')],'① 0 元下单','order')
def sc_cfadd(t):
    st=T0('cfadd'); tp=wt('s10','套餐')
    return shots_scene(t,[(st,P_CF1,[(st,810,420,1000),(st+0.8,810,270,640)]),(tp-0.2,P_CF2,[(tp-0.2,640,420,1000),(tp+0.6,420,340,520)])],
        [(st+0.7,(548,240,1080,278),OR,0),(wt('s10','Free'),(294,233,538,405),GN,1)],[(wt('s10','Free'),'good','Free 套餐 · $0')],'② Cloudflare 添加域名','cfadd')
def sc_cfns(t):
    st=T0('cfns'); tn=wt('s11','两个'); tb=wt('s11','不一样')
    return shots_scene(t,[(st,P_NS,[(st,720,400,1000),(tn-0.4,720,400,1000),(tn+0.3,610,520,440)])],
        [(tn+0.2,(488,488,728,558),OR,0)],[(tn+0.2,'info','bob / ada.ns.cloudflare.com',F(36,True)),(tb,'info','以你自己后台显示的为准')],'③ 拿到 Cloudflare 的两个 NS','cfns')
def sc_kbns(t):
    st=T0('kbns'); tf=wt('s12','填'); td=wt('s12','Delegated')
    return shots_scene(t,[(st,P_KN,[(st,780,400,1000),(tf,780,400,1000),(tf+0.6,590,436,600),(td-0.6,590,436,600),(td,800,290,560)])],
        [(tf+0.4,(356,401,814,471),OR,0),(td,(826,254,905,279),GN,0)],[(wt('s12','保存'),'info','NS1 / NS2 → 保存'),(td,'good','状态:Delegated')],'④ 回 KataBump 填 NS','kbns')
def sc_active(t):
    st=T0('active'); ta=wt('s15','Active'); tc=wt('s15','证书')
    return shots_scene(t,[(st,P_AC,[(st,700,380,1000),(st+0.9,560,300,640),(ta-0.4,560,300,640),(ta+0.2,450,265,560)]),(tc-0.3,P_EC,[(tc-0.3,820,450,1000),(tc+0.6,817,520,880)])],
        [(ta,(266,246,620,282),GN,0),(tc+0.3,(434,488,1200,552),GN,1)],[(ta-0.2,'good','改完 NS 不到 2 分钟 → Active'),(tc,'good','免费证书 · 到 2027-01-17',F(42,True))],'生效','active')
# ---------------- HOOK
def sc_hook(t):
    L=layer()
    p=eobp(t,0,0.3); sc=0.82+0.18*p
    scaled_paste(L,text_img('免费域名一大堆',F(92,True)),540,430,sc)
    q=eobp(t,wt('s01','Cloudflare')-0.1,0.3)
    if q>0: scaled_paste(L,text_img('能接 Cloudflare 的',F(84,True),OR),540,575,0.7+0.3*q); 
    q=eobp(t,wt('s01','几'),0.3)
    if q>0: scaled_paste(L,text_img('没几个',F(120,True),WH,stroke=4,sfill=(10,10,14)),540,730,0.6+0.4*q)
    items=[('DigitalPlat','dpdns.org',False,wt('s02','两家')),('DNSHE','us.ci / cc.cd',False,wt('s02','两家')+0.25),('KataBump','kdns.fr',True,wt('s02','一家'))]
    for i,(n,suf,ok,ta) in enumerate(items):
        q=prog(t,ta,0.3)
        if q<=0: continue
        y=900+i*130; lay=layer(); d=ImageDraw.Draw(lay)
        d.rounded_rectangle([140,y,940,y+108],26,fill=(20,83,45) if ok else (31,41,55),outline=GN if ok else (90,100,115),width=3)
        d.text((190,y+54),n,font=F(48,True),fill=WH,anchor='lm'); d.text((520,y+54),suf,font=M(38),fill=GR if not ok else (187,247,208),anchor='lm')
        (check if ok else cross)(d,880,y+54,40,GN if ok else RD,8)
        alpha_paste(L,lay,(60*(1-eout(q)),0),eout(q))
    return L
# ---------------- PSL
def sc_psl(t):
    st=T0('psl'); L=layer(); d=ImageDraw.Draw(L)
    head(L,t,st,'为什么很多免费域名接不进?')
    a=eout(prog(t,st+0.35,0.4))
    if a>0:
        lay=layer(); ld=ImageDraw.Draw(lay)
        f1=M(76,True); x=540-(f1.getlength('tangentlab.kdns.fr'))/2; y=470
        w1=f1.getlength('tangentlab'); w2=f1.getlength('.kdns.fr')
        ld.text((x,y),'tangentlab',font=f1,fill=WH,anchor='lm'); ld.text((x+w1,y),'.kdns.fr',font=f1,fill=OR,anchor='lm')
        ld.line([x+w1+f1.getlength('.'),y+60,x+w1+w2,y+60],fill=OR,width=6)
        ld.text((x+w1+w2/2+20,y+110),'公共后缀(PSL 名单)',font=F(34,True),fill=OR,anchor='mm')
        ld.line([x,y-60,x+w1,y-60],fill=GN,width=6); ld.text((x+w1/2,y-105),'正好多一级',font=F(36,True),fill=GN,anchor='mm')
        alpha_paste(L,lay,(0,30*(1-a)),a)
    q=eout(prog(t,wt('s03','一级'),0.35))
    if q>0:
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.rounded_rectangle([110,680,970,790],26,fill=(20,83,45),outline=GN,width=3); check(ld,170,735,34,GN,7)
        ld.text((220,735),'tangentlab.kdns.fr',font=M(44,True),fill=WH,anchor='lm'); ld.text((940,735),'免费版可接',font=F(34,True),fill=(187,247,208),anchor='rm')
        ld.rounded_rectangle([110,810,970,920],26,fill=(127,29,29),outline=RD,width=3); cross(ld,170,865,32,WH,7)
        ld.text((220,865),'a.tangentlab.kdns.fr',font=M(44,True),fill=WH,anchor='lm'); ld.text((940,865),'企业版才行',font=F(34,True),fill=(254,202,202),anchor='rm')
        alpha_paste(L,lay,(0,30*(1-q)),q)
    for i,(txt,key,sid) in enumerate([('① 后缀在 PSL 名单里','多一','s03'),('② 平台允许改 NS','NS','s04')]):
        ta=wt(sid,key)+(0.5 if i==0 else 0)
        if sid=='s03': ta=wt('s04','平台')-0.05
        q=eout(prog(t,ta+i*0.45,0.3))
        if q<=0: continue
        lay=layer(); ld=ImageDraw.Draw(lay); y=990+i*100
        ld.rounded_rectangle([110,y,970,y+84],22,fill=(31,41,55),outline=OR,width=3); ld.text((150,y+42),txt,font=F(42,True),fill=WH,anchor='lm')
        alpha_paste(L,lay,(50*(1-q),0),q)
    q=eobp(t,wt('s04','缺'),0.3)
    if q>0: scaled_paste(L,tag('bad','缺一条都白搭'),540,1230,0.7+0.3*q)
    return L
# ---------------- terminal (real DoH output, captured 2026-10-19)
TW,THh=2000,1480; MF=M(46); CHW=MF.getlength('M'); RH=72; TB=90
PROMPT=[('tangent@lab',(126,231,135)),(':',(214,222,230)),('~',(121,192,255)),('$ ',(214,222,230))]
CMD=["curl -s \\","  'https://dns.google/resolve?name=tangentlab.kdns.fr&type=NS' \\","  | jq -r '.Answer[].data'"]
OUT=['ada.ns.cloudflare.com.','bob.ns.cloudflare.com.']
def term_plan():
    rnd=random.Random(4); t=wt('s13','跑')-0.2; times=[]
    for li,line in enumerate(CMD):
        lt=[]
        for ch in line:
            lt.append(t); dt=rnd.uniform(0.5,1.4)/30
            if ch==' ': dt*=1.5
            t+=dt
        times.append(lt); t+=0.15
    return times,max(t+0.1,S['s14']['t0']-0.25)
TT,TOUT=term_plan()
for lt in TT: CLICKS.extend(lt)
def term_img(t):
    im=Image.new('RGB',(TW,THh),(13,17,23)); d=ImageDraw.Draw(im)
    d.rectangle([0,0,TW,TB],fill=(36,41,49))
    for i,c in enumerate([(255,95,87),(254,188,46),(40,200,64)]): d.ellipse([34+i*52,TB/2-15,64+i*52,TB/2+15],fill=c)
    d.text((TW/2,TB/2),'tangent@lab: ~',font=M(36),fill=(170,178,190),anchor='mm')
    y=TB+40; x=40
    for s,c in PROMPT: d.text((x,y),s,font=MF,fill=c); x+=MF.getlength(s)
    cur=None
    for li,line in enumerate(CMD):
        n=sum(1 for z in TT[li] if z<=t)
        if li>0:
            if n==0: break
            y+=RH; x=40; d.text((x,y),'> ',font=MF,fill=(110,118,129)); x+=MF.getlength('> ')
        d.text((x,y),line[:n],font=MF,fill=(214,222,230)); cur=(x+MF.getlength(line[:n]),y)
        if n<len(line): break
    if t>=TOUT:
        for k,o in enumerate(OUT):
            if t>=TOUT+0.08*k:
                y+=RH; d.text((40,y),o,font=MF,fill=(126,231,135))
                q=eout(prog(t,TOUT+0.5+0.25*k,0.35))
                if q>0:
                    w=MF.getlength(o); d.rounded_rectangle([24,y-8,24+(w+32)*q,y+RH-6],14,outline=OR,width=8)
        y+=RH; x=40
        for s,c in PROMPT: d.text((x,y),s,font=MF,fill=c); x+=MF.getlength(s)
        cur=(x,y)
    if cur and ((t%1.0)<0.55 or any(abs(t-z)<0.3 for lt in TT for z in lt)): d.rectangle([cur[0],cur[1]+6,cur[0]+CHW-4,cur[1]+RH-12],fill=(214,222,230))
    return im
def sc_term(t):
    st=T0('term'); L=layer(); head(L,t,st,'验证:问一下公共 DNS')
    im=term_img(t)
    z=eout(prog(t,TOUT+0.3,0.8)); vw=lerp(TW,1500,z); vh=vw/AR; cx=lerp(TW/2,750,z); cy=lerp(vh/2,TB+40+RH*4.3,z)
    cy=min(max(cy,vh/2),THh-vh/2); cx=min(max(cx,vw/2),TW-vw/2)
    fr=im.crop((int(cx-vw/2),int(cy-vh/2),int(cx+vw/2),int(cy+vh/2))).resize((PW,PH),Image.LANCZOS)
    a=eout(prog(t,st,0.35)); put_panel(L,fr,30*(1-a))
    q=prog(t,TOUT+0.6,0.28)
    if q>0: scaled_paste(L,tag('good','是 Cloudflare 的 NS = 生效'),540,1135,0.75+0.25*eob(q))
    return L
# ---------------- OUTRO / CTA
def sc_outro(t):
    st=T0('outro'); L=layer(); q=eobp(t,st,0.35)
    lay=layer(); d=ImageDraw.Draw(lay)
    d.rounded_rectangle([90,360,990,820],36,fill=(31,41,55),outline=(70,80,96),width=2)
    d.text((540,450),'完整步骤 + 对比表',font=F(64,True),fill=WH,anchor='mm')
    d.text((540,560),'完整图文看评论区',font=F(56,True),fill=OR,anchor='mm')
    d.text((540,660),'实测截图 · 命令 · 三家的注册条件',font=F(36),fill=GR,anchor='mm')
    d.text((540,740),'(截图里的邮箱和账号 ID 已打码)',font=F(30),fill=(110,118,129),anchor='mm')
    scaled_paste(L,lay,540,960,0.85+0.15*q)
    qq=eobp(t,wt('s16','免费'),0.3)
    if qq>0:
        lay=layer(); d=ImageDraw.Draw(lay)
        d.polygon([(540,880),(600,985),(480,985)],fill=YL); d.text((540,950),'!',font=F(60,True),fill=(17,24,39),anchor='mm')
        alpha_paste(L,lay,(0,0),min(1,qq))
        scaled_paste(L,tag('info','只拿来学习、做演示'),540,1080,0.7+0.3*qq)
    q3=eobp(t,wt('s16','别跑'),0.3)
    if q3>0: scaled_paste(L,tag('bad','别跑正式业务'),540,1200,0.7+0.3*q3)
    return L
def sc_cta(t):
    st=T0('cta'); L=layer(); d=ImageDraw.Draw(L)
    q=eobp(t,st,0.3); pul=1+0.03*math.sin(t*6)
    im=Image.new('RGBA',(460,160),(0,0,0,0)); pill(ImageDraw.Draw(im),230,80,'+ 关注',F(72,True),(17,24,39),OR,pad=(50,22))
    scaled_paste(L,im,540,500,(0.7+0.3*q)*pul)
    q2=eout(prog(t,wt('s17','下一'),0.35))
    if q2>0:
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.text((540,700),'下一条',font=F(44),fill=GR,anchor='mm')
        ld.text((540,810),'用这个域名',font=F(90,True),fill=WH,anchor='mm')
        ld.text((540,930),'把网页挂上去',font=F(90,True),fill=ORL,anchor='mm')
        alpha_paste(L,lay,(0,40*(1-q2)),q2)
    d.text((540,1120),'正切AI实验室 · Cloudflare 实操系列',font=F(32),fill=GR,anchor='mm')
    return L
FN={'hook':sc_hook,'psl':sc_psl,'fail1':sc_fail1,'fail2':sc_fail2,'fail3':sc_fail3,'kb':sc_kb,'order':sc_order,'cfadd':sc_cfadd,'cfns':sc_cfns,'kbns':sc_kbns,'term':sc_term,'active':sc_active,'outro':sc_outro,'cta':sc_cta}
# ---------------- subtitles (same style as EP01)
SUBF=F(62,True)
def sub_lines(text,maxw=14*62):
    if SUBF.getlength(text)<=maxw: return [text]
    toks=re.findall(r'[A-Za-z0-9\.\-_/:%]+|\s|.',text); best=None
    for k in range(1,len(toks)):
        a=''.join(toks[:k]).strip(); b=''.join(toks[k:]).strip()
        if not a or not b or b[0] in ',。?!、:': continue
        sc=max(SUBF.getlength(a),SUBF.getlength(b))
        if best is None or sc<best[0]: best=(sc,a,b)
    return [best[1],best[2]]
def draw_sub(base,t):
    cur=[s for s in SUBS if s['t0']<=t<s['t1']]
    if not cur: return
    s=cur[0]; lines=sub_lines(s['text']); d=ImageDraw.Draw(base)
    y0=1345-(len(lines)-1)*42
    for i,l in enumerate(lines):
        y=y0+i*84; parts=re.findall(r'[A-Za-z0-9\.\-_/:%]+(?: [A-Za-z0-9\.\-_/:%]+)*|[^A-Za-z0-9]+',l)
        tw=SUBF.getlength(l); x=540-tw/2
        for p in parts:
            col=(255,196,120) if re.match(r'[A-Za-z0-9]',p) else WH
            d.text((x,y),p,font=SUBF,fill=col,anchor='lm',stroke_width=7,stroke_fill=(10,10,14)); x+=SUBF.getlength(p)
def brand(base,t,name):
    if name=='cta': return
    ImageDraw.Draw(base).text((60,140),'正切AI实验室',font=F(30,True),fill=(200,206,214),anchor='lm')
def scene_at(t):
    for k,s in enumerate(SC):
        if s['t0']<=t<s['t1']: return k
    return len(SC)-1
def run(out,start=0,end=None,stills=None):
    N=int(math.ceil(TOTAL*FPS)); end=N if end is None else end; p=None
    if out: p=subprocess.Popen(['ffmpeg','-y','-v','error','-f','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-c:v','libx264','-preset','medium','-crf','18','-pix_fmt','yuv420p',out],stdin=subprocess.PIPE)
    last={}
    for i in range(start,end):
        t=i/FPS; k=scene_at(t); s=SC[k]
        if stills is not None and i not in stills and not (k>0 and t-s['t0']<0.24): continue
        content=FN[s['name']](t); last[k]=content
        im=bg(t); lt=t-s['t0']
        if k>0 and lt<0.24:
            if (k-1) not in last: last[k-1]=FN[SC[k-1]['name']](s['t0']-0.01)
            q=ease(lt/0.24); alpha_paste(im,last[k-1],(0,-50*q),1-q); alpha_paste(im,content,(0,60*(1-q)),q)
        else: im.alpha_composite(content)
        brand(im,t,s['name']); draw_sub(im,t)
        rgb=im.convert('RGB')
        if p: p.stdin.write(rgb.tobytes())
        if stills and i in stills: rgb.save(f'{B}/frames/f_{i:05d}.png')
        if i%300==0: print('frame',i,'/',end,flush=True)
    if p: p.stdin.close(); p.wait()
if __name__=='__main__':
    json.dump(dict(click=CLICKS,trans=[s['t0'] for s in SC[1:]]),open(f'{B}/work/sfx_events.json','w'))
    if sys.argv[1]=='stills':
        idx=set(int(float(x)*FPS) for x in sys.argv[2:]); run(None,0,max(idx)+1,idx)
    else: run(f'{B}/work/video.mp4')

ep02/src/render_tail.py

385 行 · python
ep02/src/render_tail.py
import json, sys, math, random, subprocess, re
sys.path.insert(0,'./src')
from common import *
from PIL import Image, ImageDraw, ImageFilter
B='.'; TM='./material/'
TL=json.load(open(f'{B}/work/timeline.json'))
S={s['id']:s for s in TL['segs']}; SC=TL['scenes']; SUBS=TL['subs']; TOTAL=TL['total']
def wt(sid,txt,which=0):
    ws=S[sid]['words']; hits=[w for w in ws if txt.lower() in w[2].lower()]
    if hits: return hits[min(which,len(hits)-1)][0]
    for i,w in enumerate(ws):
        if txt.lower() in ''.join(x[2] for x in ws[i:i+4]).lower(): return w[0]
    print('WARN word not found',sid,txt); return S[sid]['t0']
def scene_t(name): return next(s for s in SC if s['name']==name)
CLICKS=[]
def eobp(t,a,d):
    p=prog(t,a,d); return eob(p) if p>0 else 0.0
# ---------------- background (same look as EP01)
def make_bg():
    im=Image.new('RGBA',(W,H)); d=ImageDraw.Draw(im)
    for y in range(H):
        a=y/H; c=tuple(int(lerp(BG0[i],BG1[i],0.5+0.5*math.sin(a*math.pi))) for i in range(3)); d.line([0,y,W,y],fill=c+(255,))
    g=Image.new('RGBA',(W,H),(0,0,0,0)); gd=ImageDraw.Draw(g)
    for x in range(0,W,60): gd.line([x,0,x,H],fill=(255,255,255,10))
    for y in range(0,H,60): gd.line([0,y,W,y],fill=(255,255,255,10))
    im.alpha_composite(g); return im
BGIMG=make_bg()
def make_glow(r,col,a):
    g=Image.new('RGBA',(2*r,2*r),(0,0,0,0)); d=ImageDraw.Draw(g)
    for i in range(r,0,-4): d.ellipse([r-i,r-i,r+i,r+i],fill=col+(int(a*(1-i/r)**2),))
    return g
GLOW=make_glow(520,OR,70); GLOW2=make_glow(420,(59,130,246),50)
def bg(t):
    im=BGIMG.copy()
    im.alpha_composite(GLOW,(int(540-520+260*math.sin(t*0.21)),int(300-520+120*math.cos(t*0.17))))
    im.alpha_composite(GLOW2,(int(300-420+200*math.cos(t*0.13)),int(1500-420+150*math.sin(t*0.19))))
    return im
# ---------------- panel
PX,PY,PW,PH=40,300,1000,740
MASK=Image.new('L',(PW,PH),0); ImageDraw.Draw(MASK).rounded_rectangle([0,0,PW-1,PH-1],28,fill=255)
SHADOW=Image.new('RGBA',(PW+120,PH+120),(0,0,0,0)); ImageDraw.Draw(SHADOW).rounded_rectangle([60,70,PW+60,PH+70],34,fill=(0,0,0,170)); SHADOW=SHADOW.filter(ImageFilter.GaussianBlur(26))
def put_panel(L,img,yo=0):
    L.alpha_composite(SHADOW,(PX-60,PY-60+int(yo)))
    p=img.convert('RGBA'); p.putalpha(MASK); L.alpha_composite(p,(PX,PY+int(yo)))
    ImageDraw.Draw(L).rounded_rectangle([PX,PY+yo,PX+PW-1,PY+PH-1+yo],28,outline=(70,80,96),width=2)
def check(d,x,y,s,col,w=8): d.line([x-s*0.5,y,x-s*0.12,y+s*0.4,x+s*0.55,y-s*0.42],fill=col,width=w,joint='curve')
def cross(d,x,y,s,col,w=8): d.line([x-s/2,y-s/2,x+s/2,y+s/2],fill=col,width=w); d.line([x-s/2,y+s/2,x+s/2,y-s/2],fill=col,width=w)
def scaled_paste(L,img,cx,cy,sc,alpha=1.0):
    if sc<=0.01 or alpha<=0: return
    w,h=img.size; im=img.resize((max(1,int(w*sc)),max(1,int(h*sc))),Image.BICUBIC)
    alpha_paste(L,im,(cx-im.width/2,cy-im.height/2),alpha)
def text_img(text,font,fill=WH,stroke=0,sfill=(0,0,0)):
    b=font.getbbox(text,stroke_width=stroke); w=b[2]-b[0]+8; h=b[3]-b[1]+8
    im=Image.new('RGBA',(int(w),int(h)),(0,0,0,0)); ImageDraw.Draw(im).text((4-b[0],4-b[1]),text,font=font,fill=fill,stroke_width=stroke,stroke_fill=sfill)
    return im
def tag(kind,text,font=None):
    font=font or F(44,True); tw=font.getlength(text); w=int(tw+150); h=104
    im=Image.new('RGBA',(w,h),(0,0,0,0)); d=ImageDraw.Draw(im)
    fill,ol,ic={'bad':((127,29,29),RD,cross),'good':((20,83,45),GN,check),'info':((67,38,12),OR,None)}[kind]
    d.rounded_rectangle([4,4,w-4,h-4],48,fill=fill,outline=ol,width=3)
    if ic: ic(d,58,52,32,WH,7); d.text((100,52),text,font=font,fill=WH,anchor='lm')
    else: d.text((w/2,52),text,font=font,fill=WH,anchor='mm')
    return im
def head(L,t,st,text):
    a=eout(prog(t,st,0.35)); im=text_img(text,F(46,True),WH)
    alpha_paste(L,im,(540-im.width/2,205+16*(1-a)),a)
# ---------------- screenshot shots (masked only)
CT=63  # browser chrome height (tabs + address bar) cropped away
_cache={}
def shot(path):
    if path not in _cache:
        im=Image.open(TM+path).convert('RGB'); _cache[path]=im.crop((0,CT,im.width,im.height))
    return _cache[path]
AR=PW/PH
def view(img,cx,cy,w):
    w=min(w,img.width,img.height*AR); h=w/AR
    cx=min(max(cx,w/2),img.width-w/2); cy=min(max(cy,h/2),img.height-h/2)
    return (cx-w/2,cy-h/2,w,h)
def cam_at(keys,t):
    # keys: list of (time, cx, cy, w); eased interpolation, holds at ends; plus slow drift
    if t<=keys[0][0]: k=keys[0][1:]
    elif t>=keys[-1][0]: k=keys[-1][1:]
    else:
        for (t0,*a),(t1,*b) in zip(keys,keys[1:]):
            if t0<=t<=t1:
                q=ease((t-t0)/max(0.01,t1-t0)) if t1-t0<=1.2 else ease(min(1,(t-t0)/1.0)); k=[lerp(x,y,q) for x,y in zip(a,b)]; break
    return k
def render_shot(path,keys,t,boxes):
    img=shot(path); cx,cy,w=cam_at(keys,t); cy-=CT
    w*=1-0.025*math.sin(min(1,(t-keys[0][0])/6)*math.pi/2)  # gentle push-in
    x0,y0,vw,vh=view(img,cx,cy,w)
    frame=img.crop((int(x0),int(y0),int(x0+vw),int(y0+vh))).resize((PW,PH),Image.BICUBIC).convert('RGBA')
    s=PW/vw; d=ImageDraw.Draw(frame)
    for (ta,(bx0,by0,bx1,by1),col) in boxes:
        q=eout(prog(t,ta,0.35))
        if q<=0: continue
        by0-=CT; by1-=CT
        X0=(bx0-x0)*s-8; Y0=(by0-y0)*s-8; X1=(bx1-x0)*s+8; Y1=(by1-y0)*s+8
        cxm,cym=(X0+X1)/2,(Y0+Y1)/2; g=1.25-0.25*q
        X0,X1=cxm+(X0-cxm)*g,cxm+(X1-cxm)*g; Y0,Y1=cym+(Y0-cym)*g,cym+(Y1-cym)*g
        pul=0.5+0.5*math.sin((t-ta)*5)
        ov=Image.new('RGBA',frame.size,(0,0,0,0)); od=ImageDraw.Draw(ov)
        od.rounded_rectangle([X0-4,Y0-4,X1+4,Y1+4],16,outline=col+(int(90*q*pul),),width=14)
        od.rounded_rectangle([X0,Y0,X1,Y1],12,outline=col+(int(255*q),),width=7)
        frame.alpha_composite(ov)
    return frame
def shots_scene(t,plan,boxes,labels,headtxt,name):
    st=scene_t(name)['t0']; L=layer(); head(L,t,st,headtxt)
    # plan: list of (t_start, path, keys); crossfade 0.3 s between shots
    idx=max(i for i,p in enumerate(plan) if p[0]<=t or i==0)
    fr=render_shot(plan[idx][1],plan[idx][2],t,[(b[0],b[1],b[2]) for b in boxes if b[3]==idx])
    if idx>0 and t-plan[idx][0]<0.3:
        prev=render_shot(plan[idx-1][1],plan[idx-1][2],t,[(b[0],b[1],b[2]) for b in boxes if b[3]==idx-1])
        fr=Image.blend(prev,fr,ease((t-plan[idx][0])/0.3))
    a=eout(prog(t,st,0.35)); put_panel(L,fr,30*(1-a))
    cur=[l for l in labels if l[0]<=t]
    if cur:
        ta,kind,txt,*fo=cur[-1]; q=prog(t,ta,0.28)
        scaled_paste(L,tag(kind,txt,*fo),540,1135,0.75+0.25*eob(q))
    return L
P_DP='02-digitalplat-dpdns/masked/17-kyc-dropdown-github-oauth-only.png'
P_GH='03-github-signup-blocked/masked/01-access-temporarily-restricted.png'
P_DN='04-dnshe-email-rejected/masked/03-email-domain-rejected.png'
P_KB1='05-katabump-signup/masked/03-create-account-form.png'
P_KB2='05-katabump-signup/masked/04-security-check-captcha.png'
P_OR='06-katabump-order-kdns/masked/03-name-tangentlab-free.png'
P_CF1='07-cloudflare-add-domain-nameservers/masked/03-domain-entered.png'
P_CF2='07-cloudflare-add-domain-nameservers/masked/04-select-free-plan.png'
P_NS='07-cloudflare-add-domain-nameservers/masked/07-assigned-nameservers.png'
P_KN='08-katabump-set-ns/masked/03-nameservers-updated.png'
P_AC='09-cloudflare-active/masked/02-domain-active-protected.png'
P_EC='10-cloudflare-dns-ssl/masked/06-edge-certificates-universal-active.png'
def T0(n): return scene_t(n)['t0']
def sc_fail1(t):
    st=T0('fail1'); tb=wt('s05','必须')
    return shots_scene(t,[(st,P_DP,[(st,720,360,1000),(st+0.5,720,360,1000),(st+1.5,720,350,640),(tb-0.5,720,350,640),(tb,720,350,480)])],
        [(tb,(538,326,902,372),OR,0)],[(tb,'bad','只能用 GitHub OAuth 验证')],'坑 1 · DigitalPlat(dpdns.org)','fail1')
def sc_fail2(t):
    st=T0('fail2'); tb=wt('s06','被拦')
    return shots_scene(t,[(st,P_GH,[(st,720,420,1000),(tb-0.5,720,420,1000),(tb+0.2,720,330,560)])],
        [(tb,(610,312,830,346),RD,0)],[(tb,'bad','Access is temporarily restricted',F(38,True))],'坑 2 · 想现注册一个 GitHub','fail2')
def sc_fail3(t):
    st=T0('fail3'); ta=wt('s07','只'); tb=wt('s07','直接')
    return shots_scene(t,[(st,P_DN,[(st,880,440,1000),(ta,880,440,1000),(ta+0.6,880,390,600),(tb-0.5,880,390,600),(tb,884,460,520)])],
        [(ta+0.4,(650,372,1112,398),BL,0),(tb,(660,446,1108,482),RD,0)],[(ta+0.4,'info','只认主流公共邮箱'),(tb,'bad','域名邮箱:验证码发不出')],'坑 3 · DNSHE(us.ci / cc.cd)','fail3')
def sc_kb(t):
    st=T0('kb'); tv=wt('s08','验证码'); tn=wt('s08','名字')
    return shots_scene(t,[(st,P_KB1,[(st,720,470,900),(tn,720,470,900),(tn+0.8,720,450,560)]),(tv-0.15,P_KB2,[(tv-0.15,760,480,800),(tv+1.2,760,500,520)])],
        [(tn+0.5,(582,538,857,587),GN,0)],[(tn+0.4,'good','不要 GitHub · 不要邮箱验证')],'跑通 · KataBump(kdns.fr)','kb')
def sc_order(t):
    st=T0('order'); tf=wt('s09','Free'); te=wt('s09','注意')+0.45
    return shots_scene(t,[(st,P_OR,[(st,830,420,1000),(tf-0.5,830,420,1000),(tf,600,390,520),(te-0.9,600,390,520),(te-0.2,1010,450,560)])],
        [(tf,(459,355,543,376),GN,0),(te-0.2,(833,414,1206,484),OR,0)],[(tf,'good','.kdns.fr — Free'),(te-0.2,'info','NS 一定选 External NS')],'① 0 元下单','order')
def sc_cfadd(t):
    st=T0('cfadd'); tp=wt('s10','套餐')
    return shots_scene(t,[(st,P_CF1,[(st,810,420,1000),(st+0.8,810,270,640)]),(tp-0.2,P_CF2,[(tp-0.2,640,420,1000),(tp+0.6,420,340,520)])],
        [(st+0.7,(548,240,1080,278),OR,0),(wt('s10','Free'),(294,233,538,405),GN,1)],[(wt('s10','Free'),'good','Free 套餐 · $0')],'② Cloudflare 添加域名','cfadd')
def sc_cfns(t):
    st=T0('cfns'); tn=wt('s11','两个'); tb=wt('s11','不一样')
    return shots_scene(t,[(st,P_NS,[(st,720,400,1000),(tn-0.4,720,400,1000),(tn+0.3,610,520,440)])],
        [(tn+0.2,(488,488,728,558),OR,0)],[(tn+0.2,'info','bob / ada.ns.cloudflare.com',F(36,True)),(tb,'info','以你自己后台显示的为准')],'③ 拿到 Cloudflare 的两个 NS','cfns')
def sc_kbns(t):
    st=T0('kbns'); tf=wt('s12','填'); td=wt('s12','Delegated')
    return shots_scene(t,[(st,P_KN,[(st,780,400,1000),(tf,780,400,1000),(tf+0.6,590,436,600),(td-0.6,590,436,600),(td,800,290,560)])],
        [(tf+0.4,(356,401,814,471),OR,0),(td,(826,254,905,279),GN,0)],[(wt('s12','保存'),'info','NS1 / NS2 → 保存'),(td,'good','状态:Delegated')],'④ 回 KataBump 填 NS','kbns')
def sc_active(t):
    st=T0('active'); ta=wt('s15','Active'); tc=wt('s15','证书')
    return shots_scene(t,[(st,P_AC,[(st,700,380,1000),(st+0.9,560,300,640),(ta-0.4,560,300,640),(ta+0.2,450,265,560)]),(tc-0.3,P_EC,[(tc-0.3,820,450,1000),(tc+0.6,817,520,880)])],
        [(ta,(266,246,620,282),GN,0),(tc+0.3,(434,488,1200,552),GN,1)],[(ta-0.2,'good','改完 NS 不到 2 分钟 → Active'),(tc,'good','免费证书 · 到 2027-01-17',F(42,True))],'生效','active')
# ---------------- HOOK
def sc_hook(t):
    L=layer()
    p=eobp(t,0,0.3); sc=0.82+0.18*p
    scaled_paste(L,text_img('免费域名一大堆',F(92,True)),540,430,sc)
    q=eobp(t,wt('s01','Cloudflare')-0.1,0.3)
    if q>0: scaled_paste(L,text_img('能接 Cloudflare 的',F(84,True),OR),540,575,0.7+0.3*q); 
    q=eobp(t,wt('s01','几'),0.3)
    if q>0: scaled_paste(L,text_img('没几个',F(120,True),WH,stroke=4,sfill=(10,10,14)),540,730,0.6+0.4*q)
    items=[('DigitalPlat','dpdns.org',False,wt('s02','两家')),('DNSHE','us.ci / cc.cd',False,wt('s02','两家')+0.25),('KataBump','kdns.fr',True,wt('s02','一家'))]
    for i,(n,suf,ok,ta) in enumerate(items):
        q=prog(t,ta,0.3)
        if q<=0: continue
        y=900+i*130; lay=layer(); d=ImageDraw.Draw(lay)
        d.rounded_rectangle([140,y,940,y+108],26,fill=(20,83,45) if ok else (31,41,55),outline=GN if ok else (90,100,115),width=3)
        d.text((190,y+54),n,font=F(48,True),fill=WH,anchor='lm'); d.text((520,y+54),suf,font=M(38),fill=GR if not ok else (187,247,208),anchor='lm')
        (check if ok else cross)(d,880,y+54,40,GN if ok else RD,8)
        alpha_paste(L,lay,(60*(1-eout(q)),0),eout(q))
    return L
# ---------------- PSL
def sc_psl(t):
    st=T0('psl'); L=layer(); d=ImageDraw.Draw(L)
    head(L,t,st,'为什么很多免费域名接不进?')
    a=eout(prog(t,st+0.35,0.4))
    if a>0:
        lay=layer(); ld=ImageDraw.Draw(lay)
        f1=M(76,True); x=540-(f1.getlength('tangentlab.kdns.fr'))/2; y=470
        w1=f1.getlength('tangentlab'); w2=f1.getlength('.kdns.fr')
        ld.text((x,y),'tangentlab',font=f1,fill=WH,anchor='lm'); ld.text((x+w1,y),'.kdns.fr',font=f1,fill=OR,anchor='lm')
        ld.line([x+w1+f1.getlength('.'),y+60,x+w1+w2,y+60],fill=OR,width=6)
        ld.text((x+w1+w2/2+20,y+110),'公共后缀(PSL 名单)',font=F(34,True),fill=OR,anchor='mm')
        ld.line([x,y-60,x+w1,y-60],fill=GN,width=6); ld.text((x+w1/2,y-105),'正好多一级',font=F(36,True),fill=GN,anchor='mm')
        alpha_paste(L,lay,(0,30*(1-a)),a)
    q=eout(prog(t,wt('s03','一级'),0.35))
    if q>0:
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.rounded_rectangle([110,680,970,790],26,fill=(20,83,45),outline=GN,width=3); check(ld,170,735,34,GN,7)
        ld.text((220,735),'tangentlab.kdns.fr',font=M(44,True),fill=WH,anchor='lm'); ld.text((940,735),'免费版可接',font=F(34,True),fill=(187,247,208),anchor='rm')
        ld.rounded_rectangle([110,810,970,920],26,fill=(127,29,29),outline=RD,width=3); cross(ld,170,865,32,WH,7)
        ld.text((220,865),'a.tangentlab.kdns.fr',font=M(44,True),fill=WH,anchor='lm'); ld.text((940,865),'企业版才行',font=F(34,True),fill=(254,202,202),anchor='rm')
        alpha_paste(L,lay,(0,30*(1-q)),q)
    for i,(txt,key,sid) in enumerate([('① 后缀在 PSL 名单里','多一','s03'),('② 平台允许改 NS','NS','s04')]):
        ta=wt(sid,key)+(0.5 if i==0 else 0)
        if sid=='s03': ta=wt('s04','平台')-0.05
        q=eout(prog(t,ta+i*0.45,0.3))
        if q<=0: continue
        lay=layer(); ld=ImageDraw.Draw(lay); y=990+i*100
        ld.rounded_rectangle([110,y,970,y+84],22,fill=(31,41,55),outline=OR,width=3); ld.text((150,y+42),txt,font=F(42,True),fill=WH,anchor='lm')
        alpha_paste(L,lay,(50*(1-q),0),q)
    q=eobp(t,wt('s04','缺'),0.3)
    if q>0: scaled_paste(L,tag('bad','缺一条都白搭'),540,1230,0.7+0.3*q)
    return L
# ---------------- terminal (real DoH output, captured 2026-10-19)
TW,THh=2000,1480; MF=M(46); CHW=MF.getlength('M'); RH=72; TB=90
PROMPT=[('tangent@lab',(126,231,135)),(':',(214,222,230)),('~',(121,192,255)),('$ ',(214,222,230))]
CMD=["curl -s \\","  'https://dns.google/resolve?name=tangentlab.kdns.fr&type=NS' \\","  | jq -r '.Answer[].data'"]
OUT=['ada.ns.cloudflare.com.','bob.ns.cloudflare.com.']
def term_plan():
    rnd=random.Random(4); t=wt('s13','跑')-0.2; times=[]
    for li,line in enumerate(CMD):
        lt=[]
        for ch in line:
            lt.append(t); dt=rnd.uniform(0.5,1.4)/30
            if ch==' ': dt*=1.5
            t+=dt
        times.append(lt); t+=0.15
    return times,max(t+0.1,S['s14']['t0']-0.25)
TT,TOUT=term_plan()
for lt in TT: CLICKS.extend(lt)
def term_img(t):
    im=Image.new('RGB',(TW,THh),(13,17,23)); d=ImageDraw.Draw(im)
    d.rectangle([0,0,TW,TB],fill=(36,41,49))
    for i,c in enumerate([(255,95,87),(254,188,46),(40,200,64)]): d.ellipse([34+i*52,TB/2-15,64+i*52,TB/2+15],fill=c)
    d.text((TW/2,TB/2),'tangent@lab: ~',font=M(36),fill=(170,178,190),anchor='mm')
    y=TB+40; x=40
    for s,c in PROMPT: d.text((x,y),s,font=MF,fill=c); x+=MF.getlength(s)
    cur=None
    for li,line in enumerate(CMD):
        n=sum(1 for z in TT[li] if z<=t)
        if li>0:
            if n==0: break
            y+=RH; x=40; d.text((x,y),'> ',font=MF,fill=(110,118,129)); x+=MF.getlength('> ')
        d.text((x,y),line[:n],font=MF,fill=(214,222,230)); cur=(x+MF.getlength(line[:n]),y)
        if n<len(line): break
    if t>=TOUT:
        for k,o in enumerate(OUT):
            if t>=TOUT+0.08*k:
                y+=RH; d.text((40,y),o,font=MF,fill=(126,231,135))
                q=eout(prog(t,TOUT+0.5+0.25*k,0.35))
                if q>0:
                    w=MF.getlength(o); d.rounded_rectangle([24,y-8,24+(w+32)*q,y+RH-6],14,outline=OR,width=8)
        y+=RH; x=40
        for s,c in PROMPT: d.text((x,y),s,font=MF,fill=c); x+=MF.getlength(s)
        cur=(x,y)
    if cur and ((t%1.0)<0.55 or any(abs(t-z)<0.3 for lt in TT for z in lt)): d.rectangle([cur[0],cur[1]+6,cur[0]+CHW-4,cur[1]+RH-12],fill=(214,222,230))
    return im
def sc_term(t):
    st=T0('term'); L=layer(); head(L,t,st,'验证:问一下公共 DNS')
    im=term_img(t)
    z=eout(prog(t,TOUT+0.3,0.8)); vw=lerp(TW,1500,z); vh=vw/AR; cx=lerp(TW/2,750,z); cy=lerp(vh/2,TB+40+RH*4.3,z)
    cy=min(max(cy,vh/2),THh-vh/2); cx=min(max(cx,vw/2),TW-vw/2)
    fr=im.crop((int(cx-vw/2),int(cy-vh/2),int(cx+vw/2),int(cy+vh/2))).resize((PW,PH),Image.LANCZOS)
    a=eout(prog(t,st,0.35)); put_panel(L,fr,30*(1-a))
    q=prog(t,TOUT+0.6,0.28)
    if q>0: scaled_paste(L,tag('good','是 Cloudflare 的 NS = 生效'),540,1135,0.75+0.25*eob(q))
    return L
# ---------------- OUTRO / CTA
def sc_outro(t):
    st=T0('outro'); L=layer(); q=eobp(t,st,0.35)
    lay=layer(); d=ImageDraw.Draw(lay)
    d.rounded_rectangle([90,360,990,820],36,fill=(31,41,55),outline=(70,80,96),width=2)
    d.text((540,450),'完整步骤 + 对比表',font=F(64,True),fill=WH,anchor='mm')
    d.text((540,560),'完整图文看评论区',font=F(56,True),fill=OR,anchor='mm')
    d.text((540,660),'实测截图 · 命令 · 三家的注册条件',font=F(36),fill=GR,anchor='mm')
    d.text((540,740),'(截图里的邮箱和账号 ID 已打码)',font=F(30),fill=(110,118,129),anchor='mm')
    scaled_paste(L,lay,540,960,0.85+0.15*q)
    qq=eobp(t,wt('s16','免费'),0.3)
    if qq>0:
        lay=layer(); d=ImageDraw.Draw(lay)
        d.polygon([(540,880),(600,985),(480,985)],fill=YL); d.text((540,950),'!',font=F(60,True),fill=(17,24,39),anchor='mm')
        alpha_paste(L,lay,(0,0),min(1,qq))
        scaled_paste(L,tag('info','只拿来学习、做演示'),540,1080,0.7+0.3*qq)
    q3=eobp(t,wt('s16','别跑'),0.3)
    if q3>0: scaled_paste(L,tag('bad','别跑正式业务'),540,1200,0.7+0.3*q3)
    return L
def sc_cta(t):
    st=T0('cta'); L=layer(); d=ImageDraw.Draw(L)
    q=eobp(t,st,0.3); pul=1+0.03*math.sin(t*6)
    im=Image.new('RGBA',(460,160),(0,0,0,0)); pill(ImageDraw.Draw(im),230,80,'+ 关注',F(72,True),(17,24,39),OR,pad=(50,22))
    scaled_paste(L,im,540,500,(0.7+0.3*q)*pul)
    q2=eout(prog(t,wt('s17','下一'),0.35))
    if q2>0:
        lay=layer(); ld=ImageDraw.Draw(lay)
        ld.text((540,700),'下一条',font=F(44),fill=GR,anchor='mm')
        ld.text((540,810),'用这个域名',font=F(90,True),fill=WH,anchor='mm')
        ld.text((540,930),'把网页挂上去',font=F(90,True),fill=ORL,anchor='mm')
        alpha_paste(L,lay,(0,40*(1-q2)),q2)
    d.text((540,1120),'正切AI实验室 · Cloudflare 实操系列',font=F(32),fill=GR,anchor='mm')
    return L
FN={'hook':sc_hook,'psl':sc_psl,'fail1':sc_fail1,'fail2':sc_fail2,'fail3':sc_fail3,'kb':sc_kb,'order':sc_order,'cfadd':sc_cfadd,'cfns':sc_cfns,'kbns':sc_kbns,'term':sc_term,'active':sc_active,'outro':sc_outro,'cta':sc_cta}
# ---------------- subtitles (same style as EP01)
SUBF=F(62,True)
def sub_lines(text,maxw=14*62):
    if SUBF.getlength(text)<=maxw: return [text]
    toks=re.findall(r'[A-Za-z0-9\.\-_/:%]+|\s|.',text); best=None
    for k in range(1,len(toks)):
        a=''.join(toks[:k]).strip(); b=''.join(toks[k:]).strip()
        if not a or not b or b[0] in ',。?!、:': continue
        sc=max(SUBF.getlength(a),SUBF.getlength(b))
        if best is None or sc<best[0]: best=(sc,a,b)
    return [best[1],best[2]]
def draw_sub(base,t):
    cur=[s for s in SUBS if s['t0']<=t<s['t1']]
    if not cur: return
    s=cur[0]; lines=sub_lines(s['text']); d=ImageDraw.Draw(base)
    y0=1345-(len(lines)-1)*42
    for i,l in enumerate(lines):
        y=y0+i*84; parts=re.findall(r'[A-Za-z0-9\.\-_/:%]+(?: [A-Za-z0-9\.\-_/:%]+)*|[^A-Za-z0-9]+',l)
        tw=SUBF.getlength(l); x=540-tw/2
        for p in parts:
            col=(255,196,120) if re.match(r'[A-Za-z0-9]',p) else WH
            d.text((x,y),p,font=SUBF,fill=col,anchor='lm',stroke_width=7,stroke_fill=(10,10,14)); x+=SUBF.getlength(p)
def brand(base,t,name):
    if name=='cta': return
    ImageDraw.Draw(base).text((60,140),'正切AI实验室',font=F(30,True),fill=(200,206,214),anchor='lm')
def scene_at(t):
    for k,s in enumerate(SC):
        if s['t0']<=t<s['t1']: return k
    return len(SC)-1
def run(out,start=0,end=None,stills=None):
    N=int(math.ceil(TOTAL*FPS)); end=N if end is None else end; p=None
    if out: p=subprocess.Popen(['ffmpeg','-y','-v','error','-f','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-c:v','libx264','-preset','medium','-crf','18','-pix_fmt','yuv420p',out],stdin=subprocess.PIPE)
    last={}
    for i in range(start,end):
        t=i/FPS; k=scene_at(t); s=SC[k]
        if stills is not None and i not in stills and not (k>0 and t-s['t0']<0.24): continue
        content=FN[s['name']](t); last[k]=content
        im=bg(t); lt=t-s['t0']
        if k>0 and lt<0.24:
            if (k-1) not in last: last[k-1]=FN[SC[k-1]['name']](s['t0']-0.01)
            q=ease(lt/0.24); alpha_paste(im,last[k-1],(0,-50*q),1-q); alpha_paste(im,content,(0,60*(1-q)),q)
        else: im.alpha_composite(content)
        brand(im,t,s['name']); draw_sub(im,t)
        rgb=im.convert('RGB')
        if p: p.stdin.write(rgb.tobytes())
        if stills and i in stills: rgb.save(f'{B}/frames/f_{i:05d}.png')
        if i%300==0: print('frame',i,'/',end,flush=True)
    if p: p.stdin.close(); p.wait()

if __name__=='__main__':
    import math
    json.dump(dict(click=CLICKS,trans=[s['t0'] for s in SC[1:]]),open(f'{B}/work/sfx_events.json','w'))
    mode=sys.argv[1] if len(sys.argv)>1 else 'tail'
    if mode=='stills':
        idx=set(int(float(x)*FPS) for x in sys.argv[2:]); run(None,0,max(idx)+1,idx)
    elif mode=='tail':
        outro=next(s for s in SC if s['name']=='outro')
        warm=max(0,int((outro['t0']-0.5)*FPS)); start=int(outro['t0']*FPS); end=int(math.ceil(TOTAL*FPS))
        p=subprocess.Popen(['ffmpeg','-y','-v','error','-f','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-c:v','libx264','-preset','medium','-crf','18','-pix_fmt','yuv420p',f'{B}/work/tail.mp4'],stdin=subprocess.PIPE)
        last={}
        for i in range(warm,end):
            t=i/FPS; k=scene_at(t); s=SC[k]
            content=FN[s['name']](t); last[k]=content
            if i<start: continue
            im=bg(t); lt=t-s['t0']
            if k>0 and lt<0.24:
                if (k-1) not in last: last[k-1]=FN[SC[k-1]['name']](s['t0']-0.01)
                q=ease(lt/0.24); alpha_paste(im,last[k-1],(0,-50*q),1-q); alpha_paste(im,content,(0,60*(1-q)),q)
            else: im.alpha_composite(content)
            brand(im,t,s['name']); draw_sub(im,t)
            p.stdin.write(im.convert('RGB').tobytes())
            if i%60==0: print('ep02 tail',i,end,flush=True)
        p.stdin.close(); print('done',end-start,'rc',p.wait())
    else:
        run(f'{B}/work/video.mp4')

ep02/src/script.py

21 行 · python
ep02/src/script.py
# (id, scene, tts text, display text) — punctuation chunks must match between tts and display
SEGS = [
 ("s01","hook","免费域名一大堆,能接进 Cloudflare 的,真没几个。","免费域名一大堆,能接进 Cloudflare 的,真没几个。"),
 ("s02","hook","我挨个试了三家,两家卡住,一家跑通,零元。","我挨个试了三家,两家卡住,一家跑通,0元。"),
 ("s03","psl","先说原因。Cloudflare 免费版规定:域名必须正好比公共后缀多一级。","先说原因。Cloudflare 免费版规定:域名必须正好比公共后缀多一级。"),
 ("s04","psl","平台还得让你改 N S,缺一条都白搭。","平台还得让你改 NS,缺一条都白搭。"),
 ("s05","fail1","第一家 DigitalPlat,名字能注册,但是免费名额,必须先绑 GitHub。","第一家 DigitalPlat,名字能注册,但是免费名额,必须先绑 GitHub。"),
 ("s06","fail2","那我现注册一个 GitHub 呗?结果直接被拦了,访问受限。","那我现注册一个 GitHub 呗?结果直接被拦了,访问受限。"),
 ("s07","fail3","第二家 D N S H E,只认 QQ、Gmail 这类大邮箱,我的域名邮箱,直接被拒。","第二家 DNSHE,只认 QQ、Gmail 这类大邮箱,我的域名邮箱,直接被拒。"),
 ("s08","kb","最后是 Kata Bump 的 K D N S 点 F R。名字、邮箱、密码,过个验证码,就进去了。","最后是 KataBump 的 kdns.fr。名字、邮箱、密码,过个验证码,就进去了。"),
 ("s09","order","下单,后缀显示 Free。注意,N S 一定选 External。","下单,后缀显示 Free。注意,NS 一定选 External。"),
 ("s10","cfadd","然后去 Cloudflare,添加这个域名,套餐选最左边的 Free,零元。","然后去 Cloudflare,添加这个域名,套餐选最左边的 Free,0元。"),
 ("s11","cfns","它会分给你两个 N S,每个人都不一样,照你后台的抄。","它会分给你两个 NS,每个人都不一样,照你后台的抄。"),
 ("s12","kbns","回到 Kata Bump,填进去,保存,状态变成 Delegated。","回到 KataBump,填进去,保存,状态变成 Delegated。"),
 ("s13","term","想确认生效没,跑一行 curl,问一下 Google 的 DNS。","想确认生效没,跑一行 curl,问一下 Google 的 DNS。"),
 ("s14","term","返回的是 Cloudflare 那两个,就成了。","返回的是 Cloudflare 那两个,就成了。"),
 ("s15","active","我这边改完不到两分钟,就 Active 了,免费证书也自动签好了。","我这边改完不到两分钟,就 Active 了,免费证书也自动签好了。"),
 ("s16","outro","完整步骤和对比表,完整图文看评论区。免费域名就拿来学习,别跑正式业务。","完整步骤和对比表,完整图文看评论区。免费域名就拿来学习,别跑正式业务。"),
 ("s17","cta","关注我,下一条,用这个域名把网页挂上去。","关注我,下一条,用这个域名把网页挂上去。"),
]

ep02/src/term.py

97 行 · python
ep02/src/term.py
import random, math
from PIL import Image, ImageDraw
from common import *
CW,CH=2600,2440
FS=34; MF=M(FS); CHW=MF.getlength('M'); RH=50; TB=74; PADX=34; PADY=24
COLS=int((CW-2*PADX)//CHW)
TBG=(13,17,23); TFG=(214,222,230); TS=(110,118,129)
PROMPT=[('user@home-pc',(126,231,135)),(':',TFG),('~',(121,192,255)),('$ ',TFG)]
def logline(ts,lvl,msg):
    col={'INF':(126,231,135),'WRN':(240,200,80),'ERR':(255,123,114)}[lvl]
    return [(ts+' ',TS),(lvl+' ',col),(msg,TFG)]
class Term:
    def __init__(self,title='user@home-pc: ~',seed=1):
        self.lines=[]; self.title=title; self.rnd=random.Random(seed); self.typing=[]; self.marks=[]; self.clears=[]
    def prompt(self,t):
        self.lines.append(dict(t=t,segs=list(PROMPT),typed=None)); return t
    def type(self,t,text,cps=16):
        L=self.lines[-1]; times=[]; x=t
        for ch in text:
            times.append(x); dt=self.rnd.uniform(0.55,1.45)/cps
            if ch==' ': dt*=1.6
            if self.rnd.random()<0.06: dt+=self.rnd.uniform(0.08,0.22)
            x+=dt
        L['typed']=(text,times); self.typing+=times; return x
    def out(self,t,segs,mark=None):
        if isinstance(segs,str): segs=[(segs,TFG)]
        self.lines.append(dict(t=t,segs=segs,typed=None,mark=mark)); return t
    def clear(self,t): self.clears.append(t)
    def visible(self,t):
        c0=max([c for c in self.clears if c<=t],default=-1)
        rows=[]; cursor=None; marks=[]
        vis=[L for L in self.lines if L['t']<=t and L['t']>=c0]
        for li,L in enumerate(vis):
            segs=list(L['segs'])
            if L['typed']:
                text,times=L['typed']; n=sum(1 for x in times if x<=t); segs.append((text[:n],TFG))
            # flatten to chars with colours
            chars=[(ch,c) for s,c in segs for ch in s]
            if not chars: chars=[]
            r0=len(rows)
            for i in range(0,max(1,len(chars)),COLS): rows.append(chars[i:i+COLS])
            if L.get('mark'):
                full=''.join(ch for ch,_ in chars); m,tm=L['mark']
                k=full.find(m)
                if k>=0 and t>=tm: marks.append((r0,k,len(m),t-tm))
            if li==len(vis)-1 and L['typed'] is not None:
                cursor=(len(rows)-1,len(rows[-1]) if len(chars)%COLS or not chars else 0)
                if chars and len(chars)%COLS==0: cursor=(len(rows),0)
        if cursor is None and vis: cursor=(len(rows),0)
        return rows,cursor,marks
    def render(self,t):
        rows,cursor,marks=self.visible(t)
        maxr=int((CH-TB-PADY*2)//RH); off=max(0,len(rows)+1-maxr)
        im=Image.new('RGB',(CW,CH),TBG); d=ImageDraw.Draw(im)
        d.rectangle([0,0,CW,TB],fill=(36,41,49)); d.line([0,TB,CW,TB],fill=(22,26,32),width=2)
        for i,c in enumerate([(255,95,87),(254,188,46),(40,200,64)]): d.ellipse([28+i*44,TB/2-13,54+i*44,TB/2+13],fill=c)
        d.text((CW/2,TB/2),self.title,font=M(30),fill=(170,178,190),anchor='mm')
        for ri,row in enumerate(rows[off:]):
            y=TB+PADY+ri*RH
            x=PADX
            # group runs
            run='';rc=None;rx=x
            for ch,c in row+[(None,None)]:
                if c!=rc or ch is None:
                    if run: d.text((rx,y),run,font=MF,fill=rc)
                    run='';rc=c;rx=x
                if ch is None: break
                run+=ch; x+=CHW
        info={'off':off}
        if cursor:
            cr,cc=cursor; y=TB+PADY+(cr-off)*RH; x=PADX+cc*CHW
            typing=any(abs(t-x0)<0.35 for x0 in self.typing)
            if typing or (t%1.0)<0.55: d.rectangle([x,y+4,x+CHW-2,y+RH-6],fill=(214,222,230))
            info['cursor']=(x,y+RH/2)
        info['marks']=[]
        for (r,k,n,age) in marks:
            y=TB+PADY+(r-off)*RH; x0=PADX+k*CHW-10; x1=PADX+(k+n)*CHW+10
            a=eout(age/0.35)
            xm=x0+(x1-x0)*a
            d.rounded_rectangle([x0,y-4,xm,y+RH+2],12,outline=OR,width=6)
            info['marks'].append((x0,y-4,x1,y+RH+2))
        return im,info
class Cam:
    def __init__(self,vw=1000,vh=940):
        self.vw,self.vh=vw,vh; self.x=CW/2; self.y=CH/2; self.s=vw/CW; self.last=None
    def update(self,t,tx,ty,ts,k=5.0):
        dt=1/30 if self.last is None else t-self.last; self.last=t
        if dt<=0: return
        a=1-math.exp(-dt*k)
        self.x+=(tx-self.x)*a; self.y+=(ty-self.y)*a; self.s+=(ts-self.s)*a
    def snap(self,tx,ty,ts): self.x,self.y,self.s=tx,ty,ts
    def view(self,im):
        s=max(self.s,self.vw/CW); hw=self.vw/s/2; hh=self.vh/s/2
        x=min(max(self.x,hw),CW-hw); y=min(max(self.y,hh),CH-hh)
        box=(int(x-hw),int(y-hh),int(x+hw),int(y+hh))
        return im.crop(box).resize((self.vw,self.vh),Image.BICUBIC)

ep02/src/timeline.py

80 行 · python
ep02/src/timeline.py
import json, re, subprocess, numpy as np, sys
sys.path.insert(0,'./src'); from script import SEGS
B='.'; SR=48000
SCENE_OF={sid:sc for sid,sc,_,_ in SEGS}
GAPS={'s01':0.10,'s02':0.42,'s03':0.16,'s04':0.38,'s05':0.30,'s06':0.34,'s07':0.45,'s08':0.30,'s09':0.36,'s10':0.28,'s11':0.32,'s12':0.40,'s13':0.14,'s14':0.42,'s15':0.48,'s16':0.36}
PUN=',。?!:、;'
def load(sid):
    raw=subprocess.check_output(['ffmpeg','-v','error','-i',f'{B}/tts/{sid}.mp3','-f','f32le','-ac','1','-ar',str(SR),'-'])
    return np.frombuffer(raw,dtype=np.float32)
def norm(s): return re.sub(r'[\s%s,.?!:;\-]'%PUN,'',s).lower()
def chunks(s):
    parts=re.split('([%s])'%PUN,s); out=[]; cur=''
    for p in parts:
        if not p: continue
        if p in PUN:
            if p in '?!': cur+=p
            if cur.strip(): out.append(cur.strip())
            cur=''
        else: cur+=p
    if cur.strip(): out.append(cur.strip())
    return out
def width(s): return sum(0.55 if ord(c)<128 else 1 for c in s)
def build():
    T=json.load(open(f'{B}/tts/timing.json'))
    audio=[np.zeros(int(0.12*SR),np.float32)]; t=0.12
    segs=[]; subs=[]
    for i,(sid,_,tts,disp) in enumerate(SEGS):
        w=T[sid]['words']; a=load(sid)
        c0=max(0,w[0][0]-0.06); c1=min(len(a)/SR,w[-1][1]+0.24)
        clip=a[int(c0*SR):int(c1*SR)].copy()
        fade=int(0.02*SR); clip[-fade:]*=np.linspace(1,0,fade)
        start=t; audio.append(clip); t+=len(clip)/SR
        nxt=SEGS[i+1][0] if i+1<len(SEGS) else None
        gap=0.9 if nxt is None else GAPS[sid]
        audio.append(np.zeros(int(gap*SR),np.float32)); 
        words=[(a0-c0+start,a1-c0+start,x) for a0,a1,x in w]
        segs.append(dict(id=sid,scene=SCENE_OF[sid],t0=start,t1=t,words=words,disp=disp,tts=tts))
        t+=gap
        # subtitle chunks
        dc=chunks(disp); tc=chunks(tts); assert len(dc)==len(tc),(sid,dc,tc)
        # map tts chunk char ranges to words
        wn=[norm(x[2]) for x in words]; cum=[]; acc=0
        for x in wn: acc+=len(x); cum.append(acc)
        pos=0; times=[]
        for c in tc:
            L=len(norm(c)); a_=pos; b_=pos+L; pos=b_
            i0=next(k for k,v in enumerate(cum) if v>a_); i1=next((k for k,v in enumerate(cum) if v>=b_),len(cum)-1)
            times.append((words[i0][0],words[i1][1]))
        # merge short chunks
        merged=[]
        for txt,(a_,b_) in zip(dc,times):
            if merged and width(merged[-1][0]+txt)<=11 and not merged[-1][0].endswith('?'):
                merged[-1]=(merged[-1][0]+' '+txt,merged[-1][1],b_)
            else: merged.append((txt,a_,b_))
        for k,(txt,a_,b_) in enumerate(merged):
            subs.append(dict(text=txt,t0=a_-0.05,t1=b_,seg=sid))
    total=t
    # extend subtitle ends to next start if short gap
    for k in range(len(subs)-1):
        if subs[k+1]['t0']-subs[k]['t1']<0.45: subs[k]['t1']=subs[k+1]['t0']-0.01
        else: subs[k]['t1']+=0.15
    subs[-1]['t1']+=0.4
    voice=np.concatenate(audio)
    import wave
    pcm=(np.clip(voice,-1,1)*32767).astype(np.int16)
    with wave.open(f'{B}/work/voice.wav','wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
    scenes=[]
    for s in segs:
        if scenes and scenes[-1]['name']==s['scene']: scenes[-1]['t1']=s['t1']; scenes[-1]['segs'].append(s['id'])
        else: scenes.append(dict(name=s['scene'],t0=s['t0'],t1=s['t1'],segs=[s['id']]))
    for k in range(len(scenes)):
        scenes[k]['t0']=0 if k==0 else scenes[k-1]['t1']
    scenes[-1]['t1']=total
    json.dump(dict(total=total,segs=segs,subs=subs,scenes=scenes),open(f'{B}/work/timeline.json','w'),ensure_ascii=False,indent=1)
    return total,subs,scenes
if __name__=='__main__':
    total,subs,scenes=build(); print('total',round(total,2))
    for s in scenes: print(s['name'],round(s['t0'],2),round(s['t1'],2))
    for s in subs: print(round(s['t0'],2),round(s['t1'],2),s['text'],width(s['text']))

ep02/src/tts.py

20 行 · python
ep02/src/tts.py
import asyncio, json, edge_tts, sys, subprocess
sys.path.insert(0,'./src'); from script import SEGS
V=sys.argv[1] if len(sys.argv)>1 else 'zh-CN-YunxiNeural'; R=sys.argv[2] if len(sys.argv)>2 else '+15%'
async def one(sid,txt):
    c=edge_tts.Communicate(txt,V,rate=R,pitch='-2Hz',boundary='WordBoundary')
    words=[]; 
    with open(f'./tts/{sid}.mp3','wb') as f:
        async for ch in c.stream():
            if ch['type']=='audio': f.write(ch['data'])
            elif ch['type']=='WordBoundary': words.append((ch['offset']/1e7,(ch['offset']+ch['duration'])/1e7,ch['text']))
    return words
async def main():
    out={}
    for sid,sc,t,s in SEGS:
        w=await one(sid,t)
        d=float(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration','-of','csv=p=0',f'./tts/{sid}.mp3']))
        out[sid]={'dur':d,'words':w}; print(sid,round(d,2),len(w))
    json.dump(out,open('./tts/timing.json','w'),ensure_ascii=False,indent=1)
asyncio.run(main())

ep02/src/tts_one.py

20 行 · python
ep02/src/tts_one.py
# regenerate TTS for selected segment ids only (same voice/rate/pitch as EP01: YunxiNeural +15% -2Hz), update timing.json
import asyncio, json, sys, subprocess, edge_tts
sys.path.insert(0,'./src'); from script import SEGS
B='.'; V='zh-CN-YunxiNeural'; R='+15%'
async def one(sid,txt):
    c=edge_tts.Communicate(txt,V,rate=R,pitch='-2Hz',boundary='WordBoundary'); words=[]
    with open(f'{B}/tts/{sid}.mp3','wb') as f:
        async for ch in c.stream():
            if ch['type']=='audio': f.write(ch['data'])
            elif ch['type']=='WordBoundary': words.append((ch['offset']/1e7,(ch['offset']+ch['duration'])/1e7,ch['text']))
    return words
async def main(ids):
    T=json.load(open(f'{B}/tts/timing.json'))
    for sid,sc,t,s in SEGS:
        if sid not in ids: continue
        w=await one(sid,t); d=float(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration','-of','csv=p=0',f'{B}/tts/{sid}.mp3']))
        T[sid]={'dur':d,'words':w}; print(sid,round(d,3),len(w),[x[2] for x in w])
    json.dump(T,open(f'{B}/tts/timing.json','w'),ensure_ascii=False,indent=1)
asyncio.run(main(sys.argv[1:]))

ep03/src/audio.py

66 行 · python
ep03/src/audio.py
import json, sys, numpy as np, wave, subprocess
from scipy.signal import lfilter
LANG=sys.argv[1]
B='.'; OUTD=B if LANG=='zh' else './en'
TTSD=f'{B}/tts' if LANG=='zh' else f'{OUTD}/work/tts'
TL=json.load(open(f'{OUTD}/work/timeline.json')); SR=48000; N=int((TL['total'])*SR)
rng=np.random.default_rng(20)
def load(p):
    raw=subprocess.check_output(['ffmpeg','-v','error','-i',p,'-f','s16le','-ac','1','-ar',str(SR),'-'])
    return np.frombuffer(raw,np.int16).astype(np.float32)/32768
voice=np.zeros(N,np.float32); sfx=np.zeros(N,np.float32)
def add(buf,x,t):
    i=int(t*SR); j=min(N,i+len(x))
    if 0<=i<N: buf[i:j]+=x[:j-i]
for s in TL['segs']:
    x=load(f"{TTSD}/{s['id']}.mp3"); L=int((s['t1']-s['t0'])*SR); x=x[:L].copy()
    f=int(0.03*SR); x[-f:]*=np.linspace(1,0,f); add(voice,x,s['t0'])
# whoosh at scene changes
for s in TL['segs'][1:]:
    n=int(0.35*SR); tt=np.arange(n)/SR; env=np.sin(np.pi*tt/0.35)**2
    y=lfilter([0.08],[1,-0.92],rng.standard_normal(n)); add(sfx,(y*env*0.12).astype(np.float32),s['t0']-0.15)
# key clicks during typing windows
for s in TL['segs']:
    if s['scene'] in ('step2','step2b'):
        d=s['v1']-s['v0']; end=s['v0']+(d*0.92 if s['scene']=='step2' else d*0.8*28/50)
        t=s['v0']+0.1
        while t<end:
            n=int(0.03*SR); tt=np.arange(n)/SR
            hp=np.diff(rng.standard_normal(n),prepend=0)
            body=np.sin(2*np.pi*rng.uniform(160,260)*tt)*np.exp(-tt/0.01)
            add(sfx,((hp*np.exp(-tt/0.003)*0.5+body*0.3)*rng.uniform(0.05,0.08)).astype(np.float32),t)
            t+=rng.uniform(0.07,0.13)
# ding on success moments
for s in TL['segs']:
    if s['scene'] in ('match','active'):
        n=int(0.6*SR); tt=np.arange(n)/SR
        y=(np.sin(2*np.pi*1318.5*tt)+0.5*np.sin(2*np.pi*1975.5*tt))*np.exp(-tt/0.18)*0.06
        add(sfx,y.astype(np.float32),s['t0']+0.5)
# music: original plucked arpeggio, D major, 104 BPM, D-Bm-G-A, soft kick + shaker
bpm=104; beat=60/bpm; t=np.arange(N)/SR; mus=np.zeros(N)
def midi(m): return 440*2**((m-69)/12)
chords=[[62,66,69,74],[59,62,66,71],[55,59,62,67],[57,61,64,69]]
step=beat/2; k=0; pos=0.0
while pos<TL['total']:
    bar=int(pos/(4*beat)); ch=chords[bar%4]; pat=[0,2,1,3,2,1,3,2]
    m=ch[pat[k%8]]; n=int(0.5*SR); tt=np.arange(n)/SR
    f=midi(m+12 if (k%8)==3 else m)
    y=(np.sin(2*np.pi*f*tt)+0.3*np.sin(2*np.pi*2*f*tt)+0.1*np.sin(2*np.pi*3*f*tt))*np.exp(-tt/0.16)
    i=int(pos*SR); j=min(N,i+n); mus[i:j]+=y[:j-i]*0.35
    if k%2==0:  # bass on beats
        bn=int(beat*SR); bt=np.arange(bn)/SR; by=np.sin(2*np.pi*midi(ch[0]-24)*bt)*np.minimum(1,bt/0.01)*np.exp(-bt/0.35)
        j=min(N,i+bn); mus[i:j]+=by[:j-i]*0.45
        kn=int(0.18*SR); kt=np.arange(kn)/SR; ky=np.sin(2*np.pi*(50+90*np.exp(-kt/0.03))*kt)*np.exp(-kt/0.07)
        j=min(N,i+kn); mus[i:j]+=ky[:j-i]*0.5
    sn=int(0.05*SR); sy=np.diff(rng.standard_normal(sn),prepend=0)*np.exp(-np.arange(sn)/SR/0.012)*0.06
    j=min(N,i+sn); mus[i:j]+=sy[:j-i]
    pos+=step; k+=1
mus=lfilter([0.35],[1,-0.65],mus)
mus=mus/np.max(np.abs(mus))*0.11
fi=int(0.2*SR); fo=int(1.2*SR); mus[:fi]*=np.linspace(0,1,fi); mus[-fo:]*=np.linspace(1,0,fo)
mix=voice+sfx+mus.astype(np.float32)
def save(p,x):
    pcm=(np.clip(x,-1,1)*32767).astype(np.int16)
    with wave.open(p,'wb') as f: f.setnchannels(1);f.setsampwidth(2);f.setframerate(SR);f.writeframes(pcm.tobytes())
save(f'{OUTD}/work/mix_raw.wav',mix); save(f'{OUTD}/work/music.wav',mus); print('ok')

ep03/src/cover.py

52 行 · python
ep03/src/cover.py
import sys
sys.argv=['x','zh']
exec(open('./src/render.py').read().split('# ---------- scenes ----------')[0].replace("LANG=sys.argv[1]","LANG=sys.argv[1]"))
from PIL import Image, ImageDraw
CR=(244,239,228); INK=(20,24,22); G2=(22,163,74)
def term_card(w):
    im=term_frame(200)
    im=im.resize((w,int(w*298/1120)),Image.LANCZOS); return card(im,26)
def big_result(w):
    im=term_frame(200).resize((w,int(w*73/500)),Image.LANCZOS,box=(0,167,500,240)); return card(im,26)
def make(lang,out,out169):
    globals()['LANG']=lang; _tcache.clear()
    # vertical
    c=Image.new('RGBA',(1080,1920),CR+(255,)); d=ImageDraw.Draw(c)
    for y in range(0,1920,48): d.line([0,y,1080,y],fill=(234,228,214),width=2)
    t=term_card(980); shadow_paste(c,t,(50,280))
    r=big_result(980); shadow_paste(c,r,(50,280+t.size[1]+50))
    d=ImageDraw.Draw(c)
    yb=280+t.size[1]+50+r.size[1]+40
    if lang=='zh':
        d.rounded_rectangle([50,yb,50+F(54).getlength('✓ 两个 NS 都是 Cloudflare')+60,yb+90],45,fill=G2)
        d.text((80,yb+45),'✓ 两个 NS 都是 Cloudflare',font=F(54),fill=WH,anchor='lm')
        d.text((50,1060),'改完 NS 必做',font=F(64),fill=G2,anchor='lm')
        d.text((50,1200),'一条命令',font=F(150),fill=INK,anchor='lm')
        d.rectangle([50,1320,1030,1430],fill=G2)
        d.text((70,1375),'验证接入 Cloudflare',font=F(100),fill=WH,anchor='lm')
        d.text((50,1520),'0元 · 系统自带 curl · 几秒出结果',font=F(56,False),fill=(70,80,74),anchor='lm')
        d.text((50,1780),'正切AI实验室',font=F(40),fill=(110,118,112),anchor='lm')
    else:
        d.rounded_rectangle([50,yb,50+F(50).getlength('✓ Both nameservers: Cloudflare')+60,yb+90],45,fill=G2)
        d.text((80,yb+45),'✓ Both nameservers: Cloudflare',font=F(50),fill=WH,anchor='lm')
        d.text((50,1060),'Do this after changing NS',font=F(60),fill=G2,anchor='lm')
        d.text((50,1200),'One command',font=F(136),fill=INK,anchor='lm')
        d.rectangle([50,1320,1030,1430],fill=G2)
        d.text((70,1375),'Is it on Cloudflare?',font=F(92),fill=WH,anchor='lm')
        d.text((50,1520),'Free · built-in curl · seconds',font=F(54,False),fill=(70,80,74),anchor='lm')
    c.convert('RGB').save(out)
    if out169:
        c=Image.new('RGBA',(1920,1080),CR+(255,)); d=ImageDraw.Draw(c)
        for y in range(0,1080,48): d.line([0,y,1920,y],fill=(234,228,214),width=2)
        t=term_card(900); shadow_paste(c,t,(960,250)); r=big_result(900); shadow_paste(c,r,(960,250+t.size[1]+40))
        d=ImageDraw.Draw(c)
        d.text((80,230),'改完 NS 必做',font=F(64),fill=G2,anchor='lm')
        d.text((80,390),'一条命令',font=F(160),fill=INK,anchor='lm')
        d.rectangle([80,500,880,620],fill=G2); d.text((100,560),'验证接入 Cloudflare',font=F(84),fill=WH,anchor='lm')
        d.text((80,720),'0元 · 系统自带 curl · 几秒出结果',font=F(56,False),fill=(70,80,74),anchor='lm')
        d.text((80,960),'正切AI实验室',font=F(40),fill=(110,118,112),anchor='lm')
        c.convert('RGB').save(out169)
make('zh','./cover.png','./cover_169.png')
make('en','./en/cover.png',None)
print('ok')

ep03/src/mux.sh

17 行 · bash
ep03/src/mux.sh
set -e
D=$1
cd $D/work
gainpass() { ffmpeg -hide_banner -i $1 -af ebur128=framelog=quiet -f null - 2>&1 | grep " I:" | tail -1 | awk '{print $2}'; }
I1=$(gainpass mix_raw.wav); G=$(python3 -c "print(-14-($I1))")
ffmpeg -y -v error -i mix_raw.wav -af "volume=${G}dB,alimiter=limit=0.84:attack=3:release=50:level=disabled" -ar 48000 mix1.wav
I2=$(gainpass mix1.wav); G2=$(python3 -c "print(-14-($I2))")
ffmpeg -y -v error -i mix1.wav -af "volume=${G2}dB,alimiter=limit=0.84:attack=3:release=50:level=disabled" -ar 48000 mix2.wav
I3=$(gainpass mix2.wav); G3=$(python3 -c "print(-14-($I3))")
ffmpeg -y -v error -i mix2.wav -af "volume=${G3}dB,alimiter=limit=0.84:attack=3:release=50:level=disabled" -ar 48000 mix3.wav
I4=$(gainpass mix3.wav); G4=$(python3 -c "print(-14-($I4))")
ffmpeg -y -v error -i mix3.wav -af "volume=${G4}dB" -ar 48000 mix.wav
echo "raw $I1 -> $I2 -> $I3 -> $(gainpass mix.wav)"
ffmpeg -y -v error -i video.mp4 -i mix.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 -shortest -movflags +faststart ../final.mp4
cd ..; ffprobe -v error -show_entries format=duration:stream=codec_name,width,height,r_frame_rate -of compact final.mp4
ffmpeg -hide_banner -i final.mp4 -af ebur128=peak=true:framelog=quiet -f null - 2>&1 | grep -E " I:| Peak:|LRA:"

ep03/src/render.py

361 行 · python
ep03/src/render.py
import sys, json, math, subprocess, glob, re
sys.path.insert(0,'./src')
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from script import ZH, EN
LANG=sys.argv[1]
W,H,FPS=1080,1920,30
B='.'
OUTD=B if LANG=='zh' else './en'
TTSD=f'{B}/tts' if LANG=='zh' else f'{OUTD}/work/tts'
SEGS=ZH if LANG=='zh' else EN
CJK='/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'; CJKB='/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc'
_fc={}
def F(s,b=True):
    k=(s,b)
    if k not in _fc: _fc[k]=ImageFont.truetype(CJKB if b else CJK,s,index=2)
    return _fc[k]
MONO=ImageFont.truetype('/usr/share/fonts/truetype/IBM Plex Mono/IBMPlexMono-SemiBold.ttf',40)
BG=(12,20,17); BG2=(20,32,27); GN=(34,197,94); GNL=(134,239,172); WH=(246,248,247); GR=(150,165,158); AM=(251,191,36); RD=(248,113,113)
def ease(x): x=max(0,min(1,x)); return x*x*(3-2*x)
def eout(x): x=max(0,min(1,x)); return 1-(1-x)**3
def prog(t,a,d): return max(0.0,min(1.0,(t-a)/d))
# ---------- timeline ----------
T=json.load(open(f'{TTSD}/timing.json'))
tl=[]; t=0.05; GAP=0.10
for sid,sc,txt,sub in SEGS:
    w=T[sid]['words']; sp=(w[-1][1]+0.08) if w else T[sid]['dur']
    sp=min(sp,T[sid]['dur'])
    tl.append(dict(id=sid,scene=sc,t0=t,t1=t+sp,sub=sub,words=w,dur=T[sid]['dur'])); t+=sp+GAP
TOTAL=round(t+0.55,2)
# scene spans (visual) : from seg start to next seg start
for i,s in enumerate(tl):
    s['v0']=0 if i==0 else s['t0']; s['v1']=tl[i+1]['t0'] if i+1<len(tl) else TOTAL
json.dump(dict(total=TOTAL,segs=[{k:v for k,v in s.items() if k!='words'} for s in tl]),open(f'{OUTD}/work/timeline.json','w'),ensure_ascii=False,indent=1)
# ---------- subtitles ----------
SUBF=F(64 if LANG=='zh' else 58); MAXW=940
def wrap(text):
    if LANG=='zh':
        if SUBF.getlength(text)<=MAXW: return [text]
        OV={'一条命令确认域名已经交给 Cloudflare':['一条命令确认域名','已经交给 Cloudflare'],'终端里用 curl 向 Google 公共 DNS 查 NS 记录':['终端里用 curl 向','Google 公共 DNS 查 NS 记录']}
        if text in OV: return OV[text]
        cand=[k+1 for k,ch in enumerate(text[:-1]) if ch in ',;:' and SUBF.getlength(text[:k+1])<=MAXW and SUBF.getlength(text[k+1:])<=MAXW]
        if cand:
            k=min(cand,key=lambda k:abs(k-len(text)/2)); return [text[:k].strip(),text[k:].strip()]
        # split near middle at best point
        best=None
        for k in range(1,len(text)):
            a,b=text[:k],text[k:]
            if re.match(r'[A-Za-z0-9]',text[k-1]) and re.match(r'[A-Za-z0-9]',text[k]): continue
            if b[:1] in ',。;:、」': continue
            sc=max(SUBF.getlength(a),SUBF.getlength(b))
            if best is None or sc<best[0]: best=(sc,a,b)
        return [best[1].strip(),best[2].strip()]
    if SUBF.getlength(text)<=MAXW: return [text]
    ws=text.split(' '); best=None
    for k in range(1,len(ws)):
        a=' '.join(ws[:k]); b=' '.join(ws[k:]); sc=max(SUBF.getlength(a),SUBF.getlength(b))
        if best is None or sc<best[0]: best=(sc,a,b)
    if best[0]<=MAXW: return [best[1],best[2]]
    return [best[1],best[2],'X']
def chunks(text):
    if LANG=='zh':
        parts=[p for p in re.split(r'(?<=[,;:。])',text) if p.strip()]
    else:
        out=[]
        for sent in [p for p in re.split(r'(?<=[.?!])\s',text) if p.strip()]:
            if len(wrap(sent))<=2: out.append(sent); continue
            subs=[p for p in re.split(r'(?<=[,:])\s',sent) if p.strip()]
            cur=''
            for p in subs:
                c=(cur+' '+p).strip()
                if len(wrap(c))<=2: cur=c
                else:
                    out.append(cur); cur=p
            out.append(cur)
        res=[]
        for c in out:
            if len(wrap(c))>2:
                w=c.split(' '); h=len(w)//2; res+=[' '.join(w[:h]),' '.join(w[h:])]
            else: res.append(c)
        m=[]
        for c in res:
            if m and len(m[-1])<10 and len(wrap(m[-1]+' '+c))<=2: m[-1]=m[-1]+' '+c
            else: m.append(c)
        return m
    out=[]; cur=''
    lim=20 if LANG=='zh' else 44
    for p in parts:
        if len(cur)+len(p)<=lim: cur+=('' if LANG=='zh' or not cur else ' ')+p
        else:
            if cur: out.append(cur)
            cur=p
    if cur: out.append(cur)
    out=[re.sub(r'[,。;]$','',c).strip() for c in out]
    res=[]
    for c in out:
        if LANG!='zh' and len(wrap(c))>2:
            w=c.split(' '); h=len(w)//2; res+= [' '.join(w[:h]),' '.join(w[h:])]
        else: res.append(c)
    return res
SUBS=[]
for s in tl:
    cs=chunks(s['sub']); n=sum(len(c) for c in cs); x=s['t0']; L=s['t1']-s['t0']
    for c in cs:
        d=L*len(c)/n; SUBS.append(dict(t0=x,t1=x+d+(GAP if c==cs[-1] else 0),text=c,lines=wrap(c))); x+=d
for s in SUBS:
    assert len(s['lines'])<=2, s
def draw_sub(base,t):
    cur=[s for s in SUBS if s['t0']<=t<s['t1']]
    if not cur: return
    ls=cur[0]['lines']; d=ImageDraw.Draw(base)
    lh=SUBF.size+18; y0=1480-(len(ls)-1)*lh/2
    for i,l in enumerate(ls):
        d.text((540,y0+i*lh),l,font=SUBF,fill=WH,anchor='mm',stroke_width=8,stroke_fill=(0,0,0))
# ---------- assets ----------
TF=sorted(glob.glob(f'{B}/rec/tf/*.png'))
_tcache={}
def term_frame(i):
    i=max(0,min(len(TF)-1,int(i)))
    if i in _tcache: return _tcache[i]
    im=Image.open(TF[i]).convert('RGB').crop((0,0,1072,262))
    # mask the resolver hostname/path (row 1, cols 3..30) as it gets typed
    reg=im.crop((44,38,505,76)).convert('L')
    bb=reg.point(lambda v:255 if v>90 else 0).getbbox()
    if bb:
        d=ImageDraw.Draw(im); x1=44+bb[2]+3
        d.rounded_rectangle([44,39,x1,75],10,fill=(22,101,52))
        if x1>440:
            lab='向 Google 公共 DNS 查询' if LANG=='zh' else 'Google Public DNS'
            d.text(((44+x1)/2,57),lab,font=F(26),fill=WH,anchor='mm')
    canvas=Image.new('RGB',(1072+48,262+36),(13,17,23)); canvas.paste(im,(24,18))
    if len(_tcache)>40: _tcache.clear()
    _tcache[i]=canvas; return canvas
TERM_FINAL=term_frame(200)
def card(im,radius=28,border=GN,bw=0):
    w,h=im.size; m=Image.new('L',(w,h),0); ImageDraw.Draw(m).rounded_rectangle([0,0,w-1,h-1],radius,fill=255)
    out=Image.new('RGBA',(w,h)); out.paste(im.convert('RGBA'),(0,0),m)
    if bw: ImageDraw.Draw(out).rounded_rectangle([0,0,w-1,h-1],radius,outline=border,width=bw)
    return out
def shadow_paste(base,im,xy):
    w,h=im.size; sh=Image.new('RGBA',(w+80,h+80),(0,0,0,0))
    ImageDraw.Draw(sh).rounded_rectangle([40,52,w+40,h+52],30,fill=(0,0,0,150)); sh=sh.filter(ImageFilter.GaussianBlur(18))
    base.alpha_composite(sh,(int(xy[0]-40),int(xy[1]-40))); base.alpha_composite(im,(int(xy[0]),int(xy[1])))
SC_NS=Image.open('./material/07-cloudflare-add-domain-nameservers/masked/07-assigned-nameservers.png').convert('RGB').crop((440,140,1000,630))
SC_ACT=Image.open('./material/09-cloudflare-active/masked/02-domain-active-protected.png').convert('RGB').crop((246,126,1076,440))
def kb(img,box0,box1,x,outw,outh):
    # ken burns: interpolate crop box, resize to out
    e=ease(x); b=[box0[k]+(box1[k]-box0[k])*e for k in range(4)]
    return img.resize((outw,outh),Image.LANCZOS,box=tuple(b))
def bgbase():
    im=Image.new('RGBA',(W,H),BG+(255,)); d=ImageDraw.Draw(im)
    for y in range(0,H,60): d.line([0,y,W,y],fill=(18,29,24),width=1)
    for x in range(0,W,60): d.line([x,0,x,H],fill=(18,29,24),width=1)
    return im
BGIMG=bgbase()
def badge(base,n,title,a=1.0):
    d=ImageDraw.Draw(base); s=eout(a)
    x0,y0=56,150; sz=int(170*(0.6+0.4*s))
    d.rounded_rectangle([x0,y0,x0+sz,y0+sz],34,fill=GN)
    d.text((x0+sz/2,y0+sz/2-6),str(n),font=F(int(130*(0.6+0.4*s))),fill=(8,30,16),anchor='mm')
    tf=F(64) if LANG=='zh' else F(52)
    lines=title.split('\n')
    for i,l in enumerate(lines):
        d.text((x0+200,y0+85+(i-(len(lines)-1)/2)*72),l,font=tf,fill=WH,anchor='lm')
STEPT={'zh':{1:'记下分给你的两个 NS',2:'终端里查 NS 记录',3:'对答案'},
       'en':{1:'Note your 2 nameservers',2:'Query NS in a terminal',3:'Compare the answers'}}[LANG]
def hl(d,box,a,col=GN,w=7):
    if a<=0: return
    x0,y0,x1,y1=box; xm=x0+(x1-x0)*eout(a)
    d.rounded_rectangle([x0,y0,xm,y1],14,outline=col,width=w)
def term_zoom(i,box,outw):
    im=term_frame(i); bw=box[2]-box[0]; bh=box[3]-box[1]; oh=int(outw*bh/bw)
    return im.resize((outw,oh),Image.LANCZOS,box=box)
# ---------- scenes ----------
def sc_hook(base,lt,dur,gt):
    d=ImageDraw.Draw(base)
    # final result: zoom on answers
    z=1+0.06*lt/dur
    cx,cy=250,204; hw=250/z; hh=35/z
    im=term_frame(200).resize((1000,int(1000*(2*hh)/(2*hw))),Image.LANCZOS,box=(cx-hw,cy-hh,cx+hw,cy+hh))
    full=card(term_frame(200).resize((1000,int(1000*298/1120)),Image.LANCZOS),24,GN,4)
    shadow_paste(base,full,(40,600))
    zc=card(im,24,GN,6); shadow_paste(base,zc,(40,940))
    d=ImageDraw.Draw(base)
    d.text((1000,1120),'✓',font=F(90),fill=GN,anchor='mm') if False else None
    if LANG=='zh':
        d.text((60,200),'先看效果',font=F(150),fill=GN,anchor='lm',stroke_width=4,stroke_fill=(0,0,0))
        d.text((60,355),'一条命令确认域名',font=F(84),fill=WH,anchor='lm')
        d.text((60,465),'已经交给 Cloudflare',font=F(84),fill=WH,anchor='lm')
    else:
        d.text((60,190),'Watch this first:',font=F(104),fill=GN,anchor='lm',stroke_width=3,stroke_fill=(0,0,0))
        d.text((60,330),'1 command proves your',font=F(74),fill=WH,anchor='lm')
        d.text((60,430),'domain is on Cloudflare',font=F(74),fill=WH,anchor='lm')
    # check pill
    a=prog(lt,0.4,0.4)
    if a>0:
        lab='✓ 两个 NS 都是 Cloudflare' if LANG=='zh' else '✓ Both nameservers: Cloudflare'
        f=F(48); w=f.getlength(lab)+60
        d.rounded_rectangle([540-w/2,1215,540+w/2,1300],42,fill=GN)
        d.text((540,1257),lab,font=f,fill=(8,30,16),anchor='mm')
def sc_plan(base,lt,dur,gt):
    d=ImageDraw.Draw(base)
    if LANG=='zh':
        d.text((60,220),'一共 3 步',font=F(140),fill=WH,anchor='lm')
        items=['记下 Cloudflare 分的 NS','一条命令查询','对答案']; pills=['0 元','系统自带 curl','几秒出结果']
    else:
        d.text((60,220),'Just 3 steps',font=F(124),fill=WH,anchor='lm')
        items=['Note your nameservers','Run one command','Compare answers']; pills=['Free','Built-in curl','Seconds']
    x=60
    for i,p in enumerate(pills):
        a=prog(lt,0.1+0.15*i,0.3)
        if a<=0: continue
        f=F(46); w=f.getlength(p)+56
        d.rounded_rectangle([x,370,x+w,450],40,outline=GN,width=4,fill=BG2)
        d.text((x+w/2,410),p,font=f,fill=GNL,anchor='mm'); x+=w+24
    for i,it in enumerate(items):
        a=prog(lt,0.5+0.55*i,0.35)
        if a<=0: continue
        y=560+i*250; off=(1-eout(a))*300
        d.rounded_rectangle([60+off,y,1020+off,y+200],30,fill=BG2,outline=(40,70,55),width=3)
        d.rounded_rectangle([90+off,y+30,230+off,y+170],26,fill=GN)
        d.text((160+off,y+96),str(i+1),font=F(100),fill=(8,30,16),anchor='mm')
        d.text((270+off,y+100),it,font=F(60 if LANG=='zh' else 54),fill=WH,anchor='lm')
def sc_step1(base,lt,dur,gt):
    badge(base,1,STEPT[1],prog(lt,0,0.35))
    x=lt/dur
    # NS rows in SC_NS coords: bob ~ (94..250, 345..370)-> crop space
    if x<0.45: im=kb(SC_NS,(0,0,560,490),(20,60,540,450),x/0.45,1000,875)
    else: im=kb(SC_NS,(20,60,540,450),(40,345,420,435),(x-0.45)/0.3,1000,int(1000*90/380))
    c=card(im,26,GN,4); y=430 if x<0.45 else 700
    shadow_paste(base,c,(40,y))
    if x>=0.8:
        d=ImageDraw.Draw(base); lab='每人都不一样,照自己后台抄' if LANG=='zh' else 'Yours will be different'
        f=F(50); w=f.getlength(lab)+60
        d.rounded_rectangle([540-w/2,1060,540+w/2,1150],44,fill=AM); d.text((540,1105),lab,font=f,fill=(40,25,0),anchor='mm')
def sc_step2(base,lt,dur,gt):
    badge(base,2,STEPT[2],prog(lt,0,0.35))
    i=40+(102-40)*min(1,lt/(dur*0.92))
    z=1.0+0.18*ease(prog(lt,dur*0.5,dur*0.5))
    w=int(1000*z); im=term_frame(i).resize((w,int(w*298/1120)),Image.LANCZOS)
    if z>1: im=im.crop((0,0,1000,im.size[1]))
    shadow_paste(base,card(im,24,GN,4),(40,640))
    d=ImageDraw.Draw(base)
    lab='向 Google 公共 DNS 查询 NS' if LANG=='zh' else 'Ask Google Public DNS for NS'
    f=F(48); d.text((540,560),lab,font=f,fill=GNL,anchor='mm')
def sc_step2b(base,lt,dur,gt):
    badge(base,2,STEPT[2],1)
    i=102+(152-102)*min(1,lt/(dur*0.8))
    # zoom on jq line and results
    im=term_zoom(i,(0,112,700,260),1000)
    shadow_paste(base,card(im,24,GN,4),(40,640))
    d=ImageDraw.Draw(base)
    lab='jq 只取答案(可选)' if LANG=='zh' else 'jq keeps only the answers (optional)'
    d.text((540,560),lab,font=F(48),fill=GNL,anchor='mm')
def sc_step3(base,lt,dur,gt):
    badge(base,3,STEPT[3],prog(lt,0,0.35))
    im=term_zoom(200,(10,167,480,240),1000); c=card(im,24,GN,4)
    shadow_paste(base,c,(40,620)); d=ImageDraw.Draw(base)
    # highlight "cloudflare.com" part in both lines: in crop coords chars: ada.ns. = 12 chars
    sx=1000/470; cw=16.67  # terminal char width (canvas has +24 offset)
    for k,(row_y,pre) in enumerate([(169,12),(206,11)]):
        x0=(27+pre*cw-10-4)*sx+40; x1=(27+(pre+14)*cw-10+4)*sx+40
        y0=620+(row_y-167)*sx-2; y1=620+(row_y+36-167)*sx+2
        hl(d,(x0,y0,x1,y1),prog(lt,0.6+0.3*k,0.4),AM,7)
    if lt>1.4:
        lab='结尾都是 Cloudflare ✓' if LANG=='zh' else 'Both end in Cloudflare ✓'
        f=F(54); w=f.getlength(lab)+60
        d.rounded_rectangle([540-w/2,1120,540+w/2,1215],46,fill=GN); d.text((540,1167),lab,font=f,fill=(8,30,16),anchor='mm')
def sc_match(base,lt,dur,gt):
    badge(base,3,STEPT[3],1)
    a=card(term_zoom(200,(10,167,480,240),1000),24,GN,4); shadow_paste(base,a,(40,430))
    b=card(SC_NS.resize((1000,int(1000*90/380)),Image.LANCZOS,box=(40,345,420,435)),24,GN,4)
    yb=430+a.size[1]+150; off=(1-eout(prog(lt,0.0,0.4)))*600
    shadow_paste(base,b,(40+off,yb))
    d=ImageDraw.Draw(base)
    ym=430+a.size[1]+75
    lt1='终端结果' if LANG=='zh' else 'Terminal'; lt2='Cloudflare 后台' if LANG=='zh' else 'Dashboard'
    if lt>0.5:
        lab='= 一模一样 ✓' if LANG=='zh' else '= exact match ✓'
        f=F(60); w=f.getlength(lab)+70
        d.rounded_rectangle([540-w/2,ym-52,540+w/2,ym+52],50,fill=GN); d.text((540,ym),lab,font=f,fill=(8,30,16),anchor='mm')
    d.text((60,400),lt1,font=F(40),fill=GR,anchor='ls'); d.text((60+off,yb-14),lt2,font=F(40),fill=GR,anchor='ls')
def sc_active(base,lt,dur,gt):
    x=lt/dur
    im=kb(SC_ACT,(0,0,830,314),(0,40,520,237),x,1000,380)
    c=card(im,26,GN,4); shadow_paste(base,c,(40,600))
    d=ImageDraw.Draw(base)
    if LANG=='zh':
        d.text((60,250),'交接成功',font=F(130),fill=GN,anchor='lm'); d.text((60,410),'Cloudflare 已激活',font=F(72),fill=WH,anchor='lm')
    else:
        d.text((60,250),'Handoff done',font=F(116),fill=GN,anchor='lm'); d.text((60,410),'Cloudflare is Active',font=F(72),fill=WH,anchor='lm')
    a=prog(lt,0.5,0.3)
    if a>0:
        f=F(64); lab='Active ✓'; w=f.getlength(lab)+70
        d.rounded_rectangle([1020-w,1040,1020,1140],50,fill=GN); d.text((1020-w/2,1090),lab,font=f,fill=(8,30,16),anchor='mm')
TIPS={'zh':[('NS 生效要时间','官方:最多 24 小时,查不到过会儿再查'),('查自己的完整域名','别查上一级后缀,查到的是平台的 NS'),('jq 是可选的','没装 jq,直接看返回的 JSON')],
      'en':[('NS changes take time','Up to 24 hours per Cloudflare docs'),('Query your full domain','Not the parent suffix'),('jq is optional','No jq? Read the raw JSON')]}[LANG]
def sc_tips(base,gt,which):
    d=ImageDraw.Draw(base)
    d.rounded_rectangle([56,150,226,320],34,fill=AM); d.text((141,229),'!',font=F(130),fill=(40,25,0),anchor='mm')
    d.text((256,235),'踩坑提醒' if LANG=='zh' else 'Watch out',font=F(100 if LANG=='zh' else 90),fill=AM,anchor='lm')
    s9=[s for s in tl if s['scene']=='tip1'][0]; s10=[s for s in tl if s['scene']=='tip2'][0]
    mid=s10['t0']+(s10['t1']-s10['t0'])*0.5
    act=0 if gt<s10['t0'] else (1 if gt<mid else 2)
    if s9['t0']+2.4<=gt<s10['t0']:
        a=eout(prog(gt,s9['t0']+2.4,0.3)); big='最多 24 小时' if LANG=='zh' else 'Up to 24 h'
        sub_='Cloudflare 官方文档:改完 NS 最多等 24 小时' if LANG=='zh' else 'Per Cloudflare docs, after changing NS'
        d.text((540,880-(1-a)*80),big,font=F(150),fill=AM,anchor='mm')
        d.text((540,1040),sub_,font=F(46,False),fill=GR,anchor='mm')
    for i,(h1,h2) in enumerate(TIPS):
        appear=s9['t0']+0.15 if i==0 else (s10['t0']-0.1 if i==1 else mid-0.2)
        a=prog(gt,appear,0.35)
        if a<=0: continue
        y=420+i*290; off=(1-eout(a))*400; on=(i==act)
        d.rounded_rectangle([60+off,y,1020+off,y+250],30,fill=(48,38,12) if on else BG2,outline=AM if on else (60,70,62),width=5 if on else 3)
        d.text((100+off,y+80),f'{i+1}. {h1}',font=F(62 if LANG=='zh' else 56),fill=AM if on else WH,anchor='lm')
        d.text((100+off,y+175),h2,font=F(44 if LANG=='zh' else 44,False),fill=WH if on else GR,anchor='lm')
def sc_outro(base,lt,dur,gt):
    d=ImageDraw.Draw(base)
    im=card(term_zoom(200,(10,167,480,240),1000).resize((840,int(840*73/470)),Image.LANCZOS),22,GN,4)
    shadow_paste(base,im,(120,300))
    if LANG=='zh':
        d.text((540,640),'完整步骤看评论区',font=F(100),fill=WH,anchor='mm')
        a=prog(lt,1.2,0.4)
        if a>0:
            d.rounded_rectangle([110,780,970,1000],34,fill=BG2,outline=GN,width=4)
            d.text((540,850),'合集「Cloudflare 与自托管」',font=F(56),fill=GNL,anchor='mm')
            d.text((540,935),'下一条:把网页挂上这个域名',font=F(52,False),fill=WH,anchor='mm')
    else:
        d.text((540,640),'Full steps in the comments',font=F(76),fill=WH,anchor='mm')
        a=prog(lt,1.2,0.4)
        if a>0:
            d.rounded_rectangle([90,780,990,1000],34,fill=BG2,outline=GN,width=4)
            d.text((540,850),'Cloudflare & Self-Hosting series',font=F(54),fill=GNL,anchor='mm')
            d.text((540,935),'Next: a web page on this domain',font=F(50,False),fill=WH,anchor='mm')
    outro_payoff(base,lt)
def outro_payoff(base,lt):
    a=prog(lt,3.3,0.4)
    if a<=0: return
    im=card(SC_ACT.resize((760,int(760*200/520)),Image.LANCZOS,box=(0,90,520,290)),22,GN,4)
    shadow_paste(base,im,(160,1040+(1-eout(a))*120))
SCN={'hook':sc_hook,'plan':sc_plan,'step1':sc_step1,'step2':sc_step2,'step2b':sc_step2b,'step3':sc_step3,'match':sc_match,'active':sc_active,'outro':sc_outro}
def frame(gt):
    base=BGIMG.copy()
    s=[x for x in tl if x['v0']<=gt<x['v1']] or [tl[-1]]; s=s[0]; lt=gt-s['v0']; dur=s['v1']-s['v0']
    if s['scene'] in ('tip1','tip2'): sc_tips(base,gt,s['scene'])
    else: SCN[s['scene']](base,lt,dur,gt)
    draw_sub(base,gt)
    # flash/zoom-in transition at scene start
    if s['v0']>0 and lt<0.12:
        a=1-lt/0.12; ov=Image.new('RGBA',(W,H),(255,255,255,int(60*a))); base.alpha_composite(ov)
    return base.convert('RGB')
if __name__=='__main__':
    if len(sys.argv)>2:
        for x in sys.argv[2:]:
            frame(float(x)).save(f'{OUTD}/frames/f_{x}.png')
        sys.exit()
    N=int(TOTAL*FPS)
    p=subprocess.Popen(['ffmpeg','-y','-v','error','-f','rawvideo','-pix_fmt','rgb24','-s',f'{W}x{H}','-r',str(FPS),'-i','-','-c:v','libx264','-preset','medium','-crf','18','-pix_fmt','yuv420p',f'{OUTD}/work/video.mp4'],stdin=subprocess.PIPE)
    for k in range(N): p.stdin.write(frame(k/FPS).tobytes())
    p.stdin.close(); p.wait(); print('total',TOTAL,'frames',N)

ep03/src/script.py

3 行 · python
ep03/src/script.py
ZH=[('s01', 'hook', '先看效果,一条命令确认域名已经交给 Cloudflare。', '先看效果,一条命令确认域名已经交给 Cloudflare'), ('s02', 'plan', '0元,系统自带的 curl 就能查,一共三步。', '0元,系统自带的 curl 就能查,一共三步'), ('s03', 'step1', '第一步,记下 Cloudflare 分给你的两个 NS,每人都不一样。', '第一步,记下 Cloudflare 分给你的两个 NS,每人都不一样'), ('s04', 'step2', '第二步,终端里用 curl 向 Google 公共 DNS 查 NS 记录。', '第二步,终端里用 curl 向 Google 公共 DNS 查 NS 记录'), ('s05', 'step2b', '接个 jq,只留答案。', '接个 jq,只留答案'), ('s06', 'step3', '第三步,对答案:两个结尾都是 Cloudflare,', '第三步,对答案:两个结尾都是 Cloudflare'), ('s07', 'match', '而且和后台一模一样,就是交接成功。', '而且和后台一模一样,就是交接成功'), ('s08', 'active', '回到 Cloudflare,状态已经 Active。', '回到 Cloudflare,状态已经 Active'), ('s09', 'tip1', '踩坑提醒:NS 生效要时间,官方说最多 24 小时,查不到过会儿再查。', '踩坑提醒:NS 生效要时间,官方说最多 24 小时,查不到过会儿再查'), ('s10', 'tip2', '要查自己的完整域名,别查上一级后缀;没装 jq,看原始 JSON 也行。', '要查自己的完整域名,别查上一级后缀;没装 jq,看原始 JSON 也行'), ('s11', 'outro', '完整步骤看评论区。合集,Cloudflare 与自托管,下一条把网页挂上这个域名。', '完整步骤看评论区。合集「Cloudflare 与自托管」,下一条把网页挂上这个域名')]
EN=[('s01', 'hook', 'Watch this first: one command proves your domain is now on Cloudflare.', 'Watch this first: one command proves your domain is now on Cloudflare'), ('s02', 'plan', 'Free, uses the curl already on your computer, three steps.', 'Free, uses the curl already on your computer, three steps'), ('s03', 'step1', 'Step one: note the two nameservers Cloudflare assigned you. Everyone gets different ones.', 'Step one: note the two nameservers Cloudflare assigned you. Everyone gets different ones'), ('s04', 'step2', 'Step two: in a terminal, use curl to ask Google Public DNS for your NS records.', 'Step two: in a terminal, use curl to ask Google Public DNS for your NS records'), ('s05', 'step2b', 'Pipe into jq to keep just the answers.', 'Pipe into jq to keep just the answers'), ('s06', 'step3', 'Step three: compare. Both answers end in Cloudflare,', 'Step three: compare. Both answers end in Cloudflare'), ('s07', 'match', 'and they match your dashboard exactly. Handoff done.', 'and they match your dashboard exactly. Handoff done'), ('s08', 'active', "Back in Cloudflare, it's already Active.", "Back in Cloudflare, it's already Active"), ('s09', 'tip1', 'Heads up: nameserver changes take time. Cloudflare says up to 24 hours. If it fails, check later.', 'Heads up: nameserver changes take time. Cloudflare says up to 24 hours. If it fails, check later'), ('s10', 'tip2', 'Query your own full domain, not the parent suffix. No jq? Read the raw JSON.', 'Query your own full domain, not the parent suffix. No jq? Read the raw JSON'), ('s11', 'outro', 'Full steps in the comments. Next in the Cloudflare and Self-Hosting series: a web page on this domain.', 'Full steps in the comments. Next in the Cloudflare & Self-Hosting series: a web page on this domain')]

ep03/src/tts.py

23 行 · python
ep03/src/tts.py
import asyncio, json, edge_tts, sys, subprocess
sys.path.insert(0,'./src'); from script import ZH,EN
lang=sys.argv[1]; SEGS=ZH if lang=='zh' else EN
V='zh-CN-YunxiNeural' if lang=='zh' else 'en-US-AndrewNeural'; R='+20%' if lang=='zh' else '+12%'; P='-2Hz' if lang=='zh' else '+0Hz'
OUT='./tts' if lang=='zh' else './en/work/tts'
import os; os.makedirs(OUT,exist_ok=True)
async def one(sid,txt):
    c=edge_tts.Communicate(txt,V,rate=R,pitch=P,boundary='WordBoundary'); words=[]
    with open(f'{OUT}/{sid}.mp3','wb') as f:
        async for ch in c.stream():
            if ch['type']=='audio': f.write(ch['data'])
            elif ch['type']=='WordBoundary': words.append((ch['offset']/1e7,(ch['offset']+ch['duration'])/1e7,ch['text']))
    return words
async def main():
    out={}
    for sid,sc,t,s in SEGS:
        w=await one(sid,t)
        d=float(subprocess.check_output(['ffprobe','-v','error','-show_entries','format=duration','-of','csv=p=0',f'{OUT}/{sid}.mp3']))
        out[sid]={'dur':d,'words':w}; print(sid,round(d,2))
    json.dump(out,open(f'{OUT}/timing.json','w'),ensure_ascii=False,indent=1)
    print('total',sum(v['dur'] for v in out.values()))
asyncio.run(main())

tiktok-en/mkscript.py

14 行 · python
tiktok-en/mkscript.py
import json,sys,importlib.util
B,title,notes=sys.argv[1],sys.argv[2],sys.argv[3]
spec=importlib.util.spec_from_file_location('s',f'{B}/src/script.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
T=json.load(open(f'{B}/work/timeline.json'));seg={s['id']:s for s in T['segs']}
disp={a:d for a,_,_,d in m.SEGS}
f=lambda t:f"{int(t//60):02d}:{t%60:05.2f}"
o=[title,f"Voice: edge-tts en-US-AndrewNeural, rate -5%, default pitch | Total {T['total']:.1f} s | 1080x1920 30fps | Brand: TangentLab",""]
for sc in T['scenes']:
    o.append(f"[{f(sc['t0'])} – {f(sc['t1'])}] {sc['name']}")
    for i in sc['segs']: o.append(f"  {f(seg[i]['t0'])}  "+' '.join(x.strip() for x in disp[i].split('|')))
    o.append('')
o+=["Notes:",notes]
open(f'{B}/script.txt','w').write('\n'.join(o)+'\n')

tiktok-en/mux.sh

8 行 · bash
tiktok-en/mux.sh
set -e
cd $1
python3 src/audio.py >/dev/null
ffmpeg -y -v error -i work/voice.wav -i work/sfx.wav -i work/music.wav -filter_complex "[0:a][1:a][2:a]amix=inputs=3:normalize=0:duration=longest,alimiter=limit=0.6:attack=4:release=60:level=disabled,loudnorm=I=-15.3:TP=-1.5:LRA=7[a]" -map "[a]" -ar 48000 -ac 1 work/mix.wav
ffmpeg -y -v error -i work/video.mp4 -i work/mix.wav -filter_complex "[1:a]volume=1.9dB,alimiter=limit=0.89:level=disabled[a]" -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 -shortest -movflags +faststart final.mp4
ffprobe -v error -show_entries format=duration,size:stream=codec_name,width,height,r_frame_rate -of compact final.mp4
ffmpeg -hide_banner -i final.mp4 -af ebur128=framelog=quiet -f null - 2>&1 | grep " I:"

tiktok-en/sheet.py

10 行 · python
tiktok-en/sheet.py
import sys,glob
from PIL import Image
src,out=sys.argv[1],sys.argv[2]
fs=sorted(glob.glob(src+'/*.png'))
for k in range(0,len(fs),5):
    sh=Image.new('RGB',(5*432,768),'white')
    for j,f in enumerate(fs[k:k+5]): sh.paste(Image.open(f).convert('RGB').resize((432,768)),(j*432,0))
    sh.save(f'{out}{k//5}.png')
print(len(fs))

tiktok-en/subs_en.py

29 行 · python
tiktok-en/subs_en.py
import re
from PIL import ImageDraw
KEY={'cloudflare','cloudflared','https','cname','dns','ns','github','katabump','dnshe','digitalplat','free','external','delegated','active','curl','ingress','ssl','gmail','outlook','google','ip','url'}
def make(F,WH,SUBS):
    SUBF=F(60,True); MAXW=900
    def lines_of(text):
        if SUBF.getlength(text)<=MAXW: return [text]
        w=text.split(' '); best=None
        for k in range(1,len(w)):
            a=' '.join(w[:k]); b=' '.join(w[k:])
            sc=max(SUBF.getlength(a),SUBF.getlength(b))
            if best is None or sc<best[0]: best=(sc,a,b)
        return [best[1],best[2]]
    def is_hl(tok):
        core=tok.strip('.,?!:;')
        return (bool(re.search(r'[\./_:0-9]|--',core)) and len(core)>1) or core.lower() in KEY
    def draw_sub(base,t):
        cur=[s for s in SUBS if s['t0']<=t<s['t1']]
        if not cur: return
        s=cur[0]; ls=lines_of(s['text']); d=ImageDraw.Draw(base)
        y0=1345-(len(ls)-1)*40
        for i,l in enumerate(ls):
            y=y0+i*80; x=540-SUBF.getlength(l)/2; toks=l.split(' ')
            for j,tk in enumerate(toks):
                part=tk+(' ' if j<len(toks)-1 else '')
                d.text((x,y),tk,font=SUBF,fill=(255,196,120) if is_hl(tk) else WH,anchor='lm',stroke_width=7,stroke_fill=(10,10,14))
                x+=SUBF.getlength(part)
    return draw_sub,lines_of,SUBF