Files
splatter-tennis/tools/trailer/cut.py
T
wuwiandClaude Opus 5.5 d388004eac Add the LIFETIME MEMBERS trailer pipeline
A vertical live-action mockumentary trailer: the dead staff of St. Mordred's
(the game's bosses) each tell a different origin story, and the film ends on
"Quiet, please." / "Play." / splat. tools/trailer/ holds the whole pipeline:
stills and takes through the OpenRouter key (gen.py, stills.sh, clips.sh),
the frame graphics in HTML/CSS (frame.html), a QA listener (qa.py), the
music sectioner (segment.py) and the edit (cut.py). Media lands in the
gitignored promo/trailer/. SCRIPT.md is the screenplay and the cut notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:27:13 +02:00

205 lines
11 KiB
Python

#!/usr/bin/env python3
"""The edit. EDL below is the whole film: picture segments in order, then music and effects placed
against segment starts. Renders frame cards (frame.html via headless Chrome), one intermediate per
segment, concatenates, mixes, grades, and writes promo/trailer/lifetime-members.mp4.
python3 tools/trailer/cut.py # cards + segments + mix
python3 tools/trailer/cut.py --mix # reuse segments, redo concat/mix/grade only
"""
import os, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
T = os.path.join(ROOT, 'promo', 'trailer')
B = os.path.join(T, 'build')
CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
FPS, W, H = 24, 1080, 1920
clip = lambda n: os.path.join(T, 'clips', n + '.mp4')
card = lambda n: os.path.join(T, 'cards', n + '.png')
aud = lambda n: os.path.join(T, 'audio', n)
game = lambda n: os.path.join(ROOT, 'assets', 'audio', n)
# (name, source clip or None for black, in, dur, opts)
# ov: [(card, t_in, t_out)] faded overlays · fi/fo: picture fade from/to black · gain: clip audio dB
# mute: drop the clip's own audio · dlg: a spoken line — its gain is MEASURED so every voice in the
# film sits at DLG LUFS (eq: lift a voice out of loud ambience first)
L3 = (0.7, 4.8)
DLG = -20.0
QUIET = 2.3 # seconds into s_amb where "please" ends and the music is cut
EDL = [
('gates', 'gates_v1', 0.0, 7.0, dict(fi=1.2, gain=-2, afi=1.0)),
('court', 'court_v1', 0.0, 5.0, dict(gain=-4)),
('board', 'board_v1', 0.0, 5.5, dict(fo=0.5, gain=-3, afo=0.5)),
('trad', None, 0, 5.2, dict(ov=[('t_a', 0.3, 4.8), ('t_b', 2.3, 4.8)])),
('title', None, 0, 4.0, dict(ov=[('title', 0.2, 3.5)])),
('q1', None, 0, 2.3, dict(ov=[('q_start', 0.15, 2.0)])),
('reg', 'reg_v1.keep', 0.0, 7.3, dict(ov=[('lt_reg', *L3)], dlg=True)),
('tin', 'tin_v1', 0.0, 3.0, dict(gain=-6)),
('doris', 'doris_v1', 0.0, 8.0, dict(ov=[('lt_doris', *L3)], dlg=True)),
('cream', 'cream_v1', 0.0, 3.2, dict(gain=-6)),
('clive', 'clive_v1', 0.0, 6.3, dict(ov=[('lt_clive', *L3)], dlg=True)),
('bylaws', 'bylaws_v1', 0.0, 3.0, dict(gain=-6)),
('ambrose', 'ambrose_v1', 0.0, 7.0, dict(ov=[('lt_ambrose', *L3)], dlg=True)),
('archive', 'archive_v1', 0.0, 5.0, dict(fo=0.5, gain=-4, afo=0.5)),
('q2', None, 0, 2.3, dict(ov=[('q_chall', 0.15, 2.0)])),
('s_doris', 'stare_doris_v1', 0.6, 3.4, dict(dlg=True)),
('s_clive', 'stare_clive_v2', 0.8, 3.2, dict(gain=-6)),
# "Quiet, please." — and the score obeys: it stops dead on the end of "please" (see MUSIC)
('s_amb', 'stare_ambrose_v1', 0.0, 3.2, dict(dlg=True, afo=0.9)),
('stands', 'stands_close_v2', 0.9, 3.5, dict(gain=-17)), # the heads creak, barely
('play', 'ambrose_night_v2', 0.0, 2.0, dict(dlg=True)),
('splat', None, 0, 1.6, dict()),
('logo', None, 0, 4.6, dict(ov=[('logo', 0.25, 4.6)], fo=0.6)),
('stinger', 'reg_dawn_v1', 0.0, 5.4, dict(fi=0.4, afi=0.35, dlg=(0.0, 2.6), lift=2, eq=True)),
('end', None, 0, 4.2, dict(ov=[('end', 0.5, 4.2)], fo=1.0)),
]
# every segment is a whole number of frames, or the audio drifts a sub-frame per cut
EDL = [(n, s, i, round(d * FPS) / FPS, o) for n, s, i, d, o in EDL]
start, t = {}, 0.0
for name, _, _, dur, _ in EDL:
start[name] = t
t += dur
TOTAL = t
# music/effects: (file, src_in, dur, at, gain dB, fade_in, fade_out)
MUSIC = [
(aud('m_open.mp3'), 0.0, 30.5, start['gates'] + 1.5, -11, 2.0, 2.5),
(aud('cue_a.mp3'), 35.0, 38.6, start['reg'], -20, 1.5, 2.0),
# the drone sits low under Doris, then swells through Clive's eyes so the shush has something to stop
(aud('cue_b.mp3'), 0.0, start['s_amb'] + QUIET - start['q2'], start['q2'], -17, 1.0, 0.01,
f"'if(lt(t,{start['s_clive'] - start['q2']:.2f}),1,min(2.4,1+(t-{start['s_clive'] - start['q2']:.2f})*0.6))':eval=frame"),
(aud('cue_a.mp3'), 68.5, 7.0, start['logo'] + 0.2, -18, 0.8, 3.0),
(game('tennis-applaus.wav'), 0.0, 4.1, start['end'] + 0.2, -11, 0.3, 1.8),
]
SFX = [
(aud('splat.wav'), 0.0, 4.5, start['splat'], -6, 0.0, 1.5),
]
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode:
sys.exit(f'FAILED: {" ".join(cmd)[:400]}\n{r.stderr[-1500:]}')
def cards():
page = 'file://' + os.path.join(ROOT, 'tools', 'trailer', 'frame.html')
for c in sorted({o[0] for *_, op in EDL for o in op.get('ov', [])}):
run([CHROME, '--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1',
f'--window-size={W},{H}', '--default-background-color=00000000', '--virtual-time-budget=1500',
f'--screenshot={card(c)}', f'{page}#{c}'])
def splat():
"""The impact: the game's own headshot splat on top of a synthesized trailer boom (a sub drop
and a noise transient into a long tail), so the cut to black lands like a body hitting the floor."""
run(['ffmpeg', '-v', 'error', '-y',
'-f', 'lavfi', '-i', "aevalsrc='0.9*sin(2*PI*(30+40*exp(-6*t))*t)*exp(-1.4*t)':s=48000:d=4.5",
'-f', 'lavfi', '-i', "anoisesrc=d=4.5:c=brown:r=48000:a=0.7",
'-i', game('headshot-1.wav'),
'-filter_complex',
'[1]afade=out:st=0:d=0.35:curve=exp,lowpass=f=900[n];'
'[2]aresample=48000,volume=1.0,aecho=0.8:0.6:60|130:0.35|0.2[s];'
# dense short reflections, not long discrete echoes: a 0.9 s repeat read as a click
'[0][n][s]amix=inputs=3:normalize=0,aecho=0.8:0.7:37|61|97|141|193:0.35|0.3|0.25|0.2|0.15,'
'aformat=channel_layouts=stereo,volume=0.7,alimiter=limit=0.7:attack=1:release=80',
aud('splat.wav')])
def lufs(src, tin, dur, eq=''):
r = subprocess.run(['ffmpeg', '-hide_banner', '-ss', f'{tin:.3f}', '-t', f'{dur:.3f}', '-i', clip(src),
'-af', eq + 'ebur128', '-f', 'null', '-'], capture_output=True, text=True).stderr
return float(r[r.rindex('I:'):].split()[1])
def segment(i, name, src, tin, dur, op):
out = os.path.join(B, f'{i:02d}_{name}.mov')
cmd = ['ffmpeg', '-v', 'error', '-y']
if src:
cmd += ['-ss', f'{tin:.3f}', '-t', f'{dur:.3f}', '-i', clip(src)]
else:
cmd += ['-f', 'lavfi', '-t', f'{dur:.3f}', '-i', f'color=black:s={W}x{H}:r={FPS}',
'-f', 'lavfi', '-t', f'{dur:.3f}', '-i', 'anullsrc=r=48000:cl=stereo']
first_ov = 1 if src else 2
for c, *_ in op.get('ov', []):
cmd += ['-loop', '1', '-t', f'{dur:.3f}', '-framerate', str(FPS), '-i', card(c)]
f = [f'[0:v]scale={W}:{H}:flags=lanczos,setsar=1,fps={FPS},format=yuv420p[v0]']
v = 'v0'
for k, (c, a, b) in enumerate(op.get('ov', [])):
j = first_ov + k
fo = min(0.5, max(0.05, dur - b))
f.append(f'[{j}:v]format=rgba,fade=in:st={a}:d=0.5:alpha=1,fade=out:st={b - fo:.3f}:d={fo:.3f}:alpha=1[o{k}]')
f.append(f'[{v}][o{k}]overlay=0:0:format=auto[v{k + 1}]')
v = f'v{k + 1}'
vf = []
if op.get('fi'): vf.append(f'fade=in:st=0:d={op["fi"]}')
if op.get('fo'): vf.append(f'fade=out:st={dur - op["fo"]:.3f}:d={op["fo"]}')
f.append(f'[{v}]' + (','.join(vf) or 'null') + ',format=yuv420p[v]')
a_in = '0:a' if src else '1:a'
eq = 'highpass=f=120,equalizer=f=3000:t=q:w=1.2:g=4,' if op.get('eq') else '' # pull a voice out of its ambience
g = -60 if op.get('mute') else op.get('gain', 0)
if op.get('dlg'):
w = op['dlg'] if isinstance(op['dlg'], tuple) else (0, dur) # measure only where the line is
g = round(DLG + op.get('lift', 0) - lufs(src, tin + w[0], w[1] - w[0], eq), 1)
print(f' {name:8s} dialogue gain {g:+.1f} dB')
f.append(f'[{a_in}]aresample=48000,aformat=channel_layouts=stereo,{eq}volume={g}dB,'
f'afade=in:d={op.get("afi", 0.02)},afade=out:st={dur - op.get("afo", 0.04):.3f}:d={op.get("afo", 0.04)},'
f'apad,atrim=0:{dur:.3f}[a]')
cmd += ['-filter_complex', ';'.join(f), '-map', '[v]', '-map', '[a]', '-t', f'{dur:.3f}',
'-c:v', 'libx264', '-crf', '12', '-preset', 'medium', '-pix_fmt', 'yuv420p', '-r', str(FPS),
'-c:a', 'pcm_s16le', out]
run(cmd)
return out
def mix(parts):
lst = os.path.join(B, 'list.txt')
open(lst, 'w').write(''.join(f"file '{p}'\n" for p in parts))
cat = os.path.join(B, 'picture.mov')
run(['ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', lst, '-c', 'copy', cat])
cmd = ['ffmpeg', '-v', 'error', '-y', '-i', cat]
f, labels = [], ['[0:a]']
for k, (src, a, d, at, g, fi, fo, *auto) in enumerate(MUSIC + SFX):
cmd += ['-i', src]
auto = f'volume={auto[0]},' if auto else ''
f.append(f'[{k + 1}:a]atrim={a}:{a + d},asetpts=PTS-STARTPTS,aresample=48000,aformat=channel_layouts=stereo,'
f'{auto}volume={g}dB,afade=in:d={fi},afade=out:st={max(0, d - fo):.3f}:d={fo},'
f'adelay={int(at * 1000)}|{int(at * 1000)}[m{k}]')
labels.append(f'[m{k}]')
f.append(''.join(labels) + f'amix=inputs={len(labels)}:normalize=0:duration=first[mx]')
# measure pass: the same graph, audio only, printing the loudnorm stats
meas = subprocess.run(['ffmpeg', '-hide_banner', '-v', 'info'] + cmd[3:] + ['-filter_complex', ';'.join(f) + ';[mx]loudnorm=I=-14:TP=-1.0:LRA=20:print_format=json[a]',
'-map', '[a]', '-f', 'null', '-'], capture_output=True, text=True).stderr
import json
m = json.loads(meas[meas.rindex('{'):meas.rindex('}') + 1])
f.append(f'[mx]loudnorm=I=-14:TP=-1.0:LRA=20:linear=true:measured_I={m["input_i"]}:measured_TP={m["input_tp"]}:'
f'measured_LRA={m["input_lra"]}:measured_thresh={m["input_thresh"]}:offset={m["target_offset"]},'
'aresample=48000[a]')
print('loudness in', m['input_i'], 'LUFS, LRA', m['input_lra'])
# one grade over everything, so seventeen takes from two models read as one film:
# a touch of contrast, a little less saturation, a soft vignette, and live 35mm-ish grain
f.append('[0:v]eq=contrast=1.04:saturation=0.9:gamma=0.98,vignette=angle=PI/5.5,'
'noise=c0s=5:c0f=t+u,format=yuv420p[v]')
out = os.path.join(T, 'lifetime-members.mp4')
cmd += ['-filter_complex', ';'.join(f), '-map', '[v]', '-map', '[a]',
# grain is the costliest thing to encode: tune for it and cap the rate (~14 Mb/s keeps it
# postable everywhere; uncapped crf 16 came out at 36 Mb/s, 480 MB for 2 minutes)
'-c:v', 'libx264', '-crf', '18', '-tune', 'grain', '-maxrate', '14M', '-bufsize', '28M',
'-preset', 'slow', '-profile:v', 'high', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '320k', '-ar', '48000', '-movflags', '+faststart', out]
run(cmd)
print(out, f'{TOTAL:.1f}s')
if __name__ == '__main__':
os.makedirs(B, exist_ok=True)
if '--mix' in sys.argv:
parts = [os.path.join(B, f'{i:02d}_{e[0]}.mov') for i, e in enumerate(EDL)]
else:
cards(); splat()
parts = [segment(i, *e) for i, e in enumerate(EDL)]
mix(parts)
for n in ('reg', 'q2', 's_amb', 'play', 'splat', 'logo', 'stinger', 'end'):
print(f'{n:8s} {start[n]:6.2f}s')