| 1 | import json, os, subprocess, shlex |
| 2 | |
| 3 | ROOT = "/shared/projects/SteppingApp/promo" |
| 4 | SHOTS = f"{ROOT}/shots" |
| 5 | CLIPS = f"{ROOT}/build/clips" |
| 6 | os.makedirs(CLIPS, exist_ok=True) |
| 7 | FPS = 30 |
| 8 | W, H = 1920, 1080 |
| 9 | def pick(paths): |
| 10 | for p in paths: |
| 11 | if os.path.exists(p): |
| 12 | return p |
| 13 | return paths[-1] |
| 14 | MONO = pick([ |
| 15 | "/usr/share/fonts/truetype/liberation2/LiberationMono-Bold.ttf", |
| 16 | "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", |
| 17 | "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", |
| 18 | ]) |
| 19 | SANS = pick([ |
| 20 | "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf", |
| 21 | "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", |
| 22 | ]) |
| 23 | GOLD = "0xC8A24C" |
| 24 | BONE = "0xF2ECE0" |
| 25 | STAGE = "0x0F0E0C" |
| 26 | |
| 27 | meta = json.load(open(f"{ROOT}/voiceover/scene_times.json")) |
| 28 | ss = meta["scene_start"] |
| 29 | total = meta["total"] |
| 30 | TAIL = 1.6 |
| 31 | |
| 32 | order = ["s1","s2","s3","s4","s5","s6","s7","s8","s9","s10"] |
| 33 | cfg = { |
| 34 | "s1": ("hero.png","wide","STEPPING / DIVINE NINE STEP CULTURE"), |
| 35 | "s2": ("cities.png","wide","CHOOSE YOUR CITY"), |
| 36 | "s3": ("city.png","wide","THE LINE-UP / UPCOMING SHOWS"), |
| 37 | "s4": ("event.png","wide","EVERY SHOW, IN DETAIL"), |
| 38 | "s5": ("yard.png","tall","THE YARD / THE COMMUNITY"), |
| 39 | "s6": ("profile.png","wide","PROFILES / WHO'S WHO"), |
| 40 | "s7": ("route.png","tall","THE ROUTE / PLAN THE TRIP"), |
| 41 | "s8": ("mobile.png","phone","RIGHT IN YOUR PHONE"), |
| 42 | "s9": ("hero.png","wide","MORE ON THE WAY"), |
| 43 | "s10": (None,"end",""), |
| 44 | } |
| 45 | |
| 46 | def dur(i): |
| 47 | s = order[i] |
| 48 | if i < len(order)-1: |
| 49 | return round(ss[order[i+1]] - ss[s], 3) |
| 50 | return round(total - ss[s] + TAIL, 3) |
| 51 | |
| 52 | def esc(t): |
| 53 | return t.replace(":", "\\:").replace("'", "’").replace(",", "\\,") |
| 54 | |
| 55 | def caption(text): |
| 56 | if not text: |
| 57 | return "" |
| 58 | t = esc(text) |
| 59 | return (f",drawtext=fontfile={MONO}:text='{t}':fontcolor={GOLD}:fontsize=30" |
| 60 | f":x=88:y=h-96:box=1:boxcolor={STAGE}@0.55:boxborderw=22") |
| 61 | |
| 62 | def run(cmd): |
| 63 | r = subprocess.run(cmd, shell=True, capture_output=True, text=True) |
| 64 | if r.returncode != 0: |
| 65 | print("FFMPEG ERR:\n", r.stderr[-1500:]) |
| 66 | raise SystemExit(1) |
| 67 | |
| 68 | clip_files = [] |
| 69 | for i, s in enumerate(order): |
| 70 | img, kind, cap = cfg[s] |
| 71 | d = dur(i) |
| 72 | out = f"{CLIPS}/{s}.mp4" |
| 73 | frames = int(round(d*FPS)) |
| 74 | fin = "fade=t=in:st=0:d=0.4,fade=t=out:st=%.3f:d=0.4" % max(d-0.4, 0) |
| 75 | if kind == "wide": |
| 76 | z = f"scale=2400:-1,zoompan=z='min(zoom+0.00035,1.06)':d={frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{H}:fps={FPS}" |
| 77 | vf = f"{z},{fin}{caption(cap)}" |
| 78 | run(f"ffmpeg -y -loop 1 -t {d} -i {shlex.quote(SHOTS+'/'+img)} -vf \"{vf}\" -r {FPS} -c:v libx264 -pix_fmt yuv420p -an {out}") |
| 79 | elif kind == "tall": |
| 80 | vf = f"scale={W}:-1,crop={W}:{H}:0:'(ih-{H})*t/{d}',{fin}{caption(cap)}" |
| 81 | run(f"ffmpeg -y -loop 1 -t {d} -i {shlex.quote(SHOTS+'/'+img)} -vf \"{vf}\" -r {FPS} -c:v libx264 -pix_fmt yuv420p -an {out}") |
| 82 | elif kind == "phone": |
| 83 | fc = (f"color={STAGE}:s={W}x{H}:d={d}:r={FPS}[bg];" |
| 84 | f"[0:v]scale=-1:1000[ph];" |
| 85 | f"[bg][ph]overlay=(W-w)/2:(H-h)/2,{fin}{caption(cap)}") |
| 86 | run(f"ffmpeg -y -loop 1 -t {d} -i {shlex.quote(SHOTS+'/'+img)} -filter_complex \"{fc}\" -r {FPS} -c:v libx264 -pix_fmt yuv420p -an {out}") |
| 87 | else: |
| 88 | vf = (f"drawtext=fontfile={SANS}:text='STEPPING':fontcolor={BONE}:fontsize=120:x=(w-text_w)/2:y=(h-text_h)/2-40," |
| 89 | f"drawtext=fontfile={MONO}:text='BUILT FOR THE CULTURE':fontcolor={GOLD}:fontsize=30:x=(w-text_w)/2:y=(h/2)+70," |
| 90 | f"fade=t=in:st=0:d=0.5") |
| 91 | run(f"ffmpeg -y -f lavfi -i color={STAGE}:s={W}x{H}:d={d}:r={FPS} -vf \"{vf}\" -c:v libx264 -pix_fmt yuv420p -an {out}") |
| 92 | clip_files.append(out) |
| 93 | print(f" {s} {d}s -> {out}") |
| 94 | |
| 95 | with open(f"{CLIPS}/list.txt","w") as f: |
| 96 | for c in clip_files: |
| 97 | f.write(f"file '{c}'\n") |
| 98 | run(f"ffmpeg -y -f concat -safe 0 -i {CLIPS}/list.txt -c:v libx264 -pix_fmt yuv420p -r {FPS} {ROOT}/build/video_silent.mp4") |
| 99 | |
| 100 | vlen = round(total + TAIL, 3) |
| 101 | fadest = round(total - 0.3, 3) |
| 102 | af = (f"[0:a]aresample=44100,aformat=channel_layouts=stereo,loudnorm=I=-16:TP=-1.5:LRA=11,apad=whole_dur={vlen},asplit=2[vo][key];" |
| 103 | f"[1:a]atrim=0:{vlen},asetpts=N/SR/TB,aresample=44100,aformat=channel_layouts=stereo,volume=0.22,afade=t=in:st=0:d=2,afade=t=out:st={fadest}:d=2.0[m];" |
| 104 | f"[m][key]sidechaincompress=threshold=0.035:ratio=9:attack=15:release=350:makeup=1[md];" |
| 105 | f"[vo][md]amix=inputs=2:duration=longest:normalize=0,alimiter=limit=0.97[out]") |
| 106 | run(f"ffmpeg -y -i {ROOT}/voiceover/stepping_vo.wav -i {ROOT}/assets/music_inspired.mp3 " |
| 107 | f"-filter_complex \"{af}\" -map \"[out]\" -ar 44100 -ac 2 -c:a aac -b:a 192k {ROOT}/build/audio_final.m4a") |
| 108 | |
| 109 | run(f"ffmpeg -y -i {ROOT}/build/video_silent.mp4 -i {ROOT}/build/audio_final.m4a " |
| 110 | f"-map 0:v -map 1:a -c:v copy -c:a copy -shortest {ROOT}/build/stepping_walkthrough.mp4") |
| 111 | print("DONE -> build/stepping_walkthrough.mp4 (", vlen, "s )") |