cut.py --cut score: no interview audio, only the game's own recorded umpire. "Spectators are reminded to remain seated, especially the deceased", then a love game called against the viewer, one member's stare per point under a broadcast score bug, "Game. Set. Massacre." on black, and "This match is still in progress" over the logo. cut.py now holds named cuts, each with its own build folder and output file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
294 lines
17 KiB
Python
294 lines
17 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 # cut 3, the full film: cards + segments + mix
|
||
python3 tools/trailer/cut.py --short # the ~35 s social cutdown, from the same takes
|
||
python3 tools/trailer/cut.py --cut score # FIFTEEN–LOVE: the game's umpire scores a love game against you
|
||
python3 tools/trailer/cut.py --mix # reuse segments, redo concat/mix/grade only
|
||
Every cut keeps its own build folder and output file; nothing overwrites an earlier version.
|
||
"""
|
||
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')
|
||
NAME = sys.argv[sys.argv.index('--cut') + 1] if '--cut' in sys.argv else 'short' if '--short' in sys.argv else 'full'
|
||
B = os.path.join(T, 'build', NAME)
|
||
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.6, 4.6)
|
||
DLG = -20.0
|
||
QUIET = 2.3 # seconds into s_amb where "please" ends and the music is cut
|
||
CUT = 3 # cut 2 (1:45) is kept beside it as lifetime-members-cut2.mp4, the reference
|
||
# Line ends below are MEASURED off each take's speech-band envelope, not off the QA transcript
|
||
# (its timestamps drift): cut 2 lost the end of Ambrose's "...result" by trusting 6.6 s; it is 7.2.
|
||
FULL = [
|
||
('gates', 'gates_v1', 0.0, 5.6, dict(fi=1.0, gain=-2, afi=0.8)),
|
||
('court', 'court_v1', 0.0, 4.0, dict(gain=-4)),
|
||
('board', 'board_v1', 0.0, 4.6, dict(fo=0.5, gain=-3, afo=0.5)),
|
||
('trad', None, 0, 4.4, dict(ov=[('t_a', 0.25, 4.0), ('t_b', 1.8, 4.0)])),
|
||
('title', None, 0, 3.4, dict(ov=[('title', 0.15, 3.0)])),
|
||
('q1', None, 0, 1.9, dict(ov=[('q_start', 0.1, 1.7)])),
|
||
('reg', 'reg_v1.keep', 0.0, 7.1, dict(ov=[('lt_reg', *L3)], dlg=True)),
|
||
('tin', 'tin_v1', 0.0, 2.2, dict(gain=-6)),
|
||
('doris', 'doris_v1', 0.0, 7.6, dict(ov=[('lt_doris', *L3)], dlg=True)),
|
||
('cream', 'cream_v1', 0.4, 2.2, dict(gain=-6)),
|
||
('clive', 'clive_v1', 0.0, 6.1, dict(ov=[('lt_clive', *L3)], dlg=True)),
|
||
('bylaws', 'bylaws_v1', 0.0, 2.2, dict(gain=-6)),
|
||
('ambrose', 'ambrose_v1', 0.0, 7.9, dict(ov=[('lt_ambrose', *L3)], dlg=True, afo=0.5)),
|
||
('archive', 'archive_v1', 0.0, 3.8, dict(fo=0.5, gain=-4, afo=0.5)),
|
||
('q2', None, 0, 1.9, dict(ov=[('q_chall', 0.1, 1.7)])),
|
||
('s_doris', 'stare_doris_v1', 0.7, 2.7, dict(dlg=True)),
|
||
('s_clive', 'stare_clive_v2', 1.0, 2.8, 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.0, dict(dlg=True, afo=0.8)),
|
||
('stands', 'stands_close_v2', 0.9, 3.0, dict(gain=-17)), # the heads creak, barely
|
||
('play', 'ambrose_night_v2', 0.0, 1.9, dict(dlg=True)),
|
||
('splat', None, 0, 1.2, dict()),
|
||
('logo', None, 0, 3.8, dict(ov=[('logo', 0.2, 3.8)], fo=0.5)),
|
||
('stinger', 'reg_dawn_v1', 0.6, 3.6, dict(fi=0.4, afi=0.35, dlg=(1.0, 2.7), lift=2, eq=True)),
|
||
('end', None, 0, 3.6, dict(ov=[('end', 0.4, 3.6)], fo=0.9)),
|
||
]
|
||
|
||
# THE CUTDOWN: one line per witness, cut INSIDE their takes at words.py's word timestamps
|
||
# (each cut re-checked by transcribing the segment itself). No title, no B-roll, no stands.
|
||
lt = lambda who, dur: [(f'lt_{who}', 0.2, dur - 0.15)]
|
||
SHORT_EDL = [
|
||
('gates', 'gates_v1', 0.0, 2.3, dict(fi=0.5, gain=-2, afi=0.4)),
|
||
('q1', None, 0, 1.4, dict(ov=[('q_start', 0.05, 1.3)])),
|
||
# "Fertiliser. Formula Seven."
|
||
('reg', 'reg_v1.keep', 0.0, 3.25, dict(ov=lt('reg', 3.25), dlg=True, afo=0.15)),
|
||
# "It wasn't the cream. The cream was just... old."
|
||
('doris', 'doris_v1', 1.75, 3.5, dict(ov=lt('doris', 3.5), dlg=True, afi=0.08, afo=0.15)),
|
||
# "Lifetime membership. Nobody ever defined... lifetime."
|
||
('clive', 'clive_v1', 1.6, 4.7, dict(ov=lt('clive', 4.7), dlg=True, afi=0.08, afo=0.12)), # "lifetime" ends at 6.0
|
||
# "Nineteen-eleven. Fifth set. No tie-break."
|
||
('ambrose', 'ambrose_v1', 0.0, 4.4, dict(ov=lt('ambrose', 4.4), dlg=True, afo=0.25)),
|
||
('q2', None, 0, 1.4, dict(ov=[('q_chall', 0.05, 1.3)])),
|
||
('s_doris', 'stare_doris_v1', 0.75, 2.5, dict(dlg=True)),
|
||
('s_clive', 'stare_clive_v2', 1.5, 2.0, dict(gain=-6)),
|
||
('s_amb', 'stare_ambrose_v1', 0.0, 2.8, dict(dlg=True, afo=0.7)),
|
||
('play', 'ambrose_night_v2', 0.0, 1.6, dict(dlg=True)),
|
||
('splat', None, 0, 0.9, dict()),
|
||
('stinger', 'reg_dawn_v1', 1.4, 2.5, dict(fi=0.3, afi=0.25, afo=0.35, dlg=(0.3, 1.9), lift=2, eq=True)),
|
||
('end', None, 0, 3.0, dict(ov=[('end', 0.3, 3.0)], fo=0.8)),
|
||
]
|
||
|
||
# FIFTEEN–LOVE: no interview audio at all. The game's own recorded umpire scores a love game against
|
||
# the viewer while one member after another stares into the lens; a broadcast score bug keeps count.
|
||
# A ball is struck on every cut, every call lands on a face, "Massacre." lands on black.
|
||
SCORE = [
|
||
('stands', 'stands_close_v2', 0.6, 4.3, dict(fi=0.4, afi=0.3, gain=-14)),
|
||
('reg', 'stare_reg_v1', 2.2, 1.8, dict(gain=-14, ov=[('sb_15', 0.35, 1.8, 0.15)])),
|
||
('doris', 'stare_doris_v1', 2.65, 1.35, dict(mute=True, ov=[('sb_15', 0, 0.2, 0), ('sb_30', 0.2, 1.35, 0.1)])),
|
||
('clive', 'stare_clive_v2', 1.6, 2.4, dict(gain=-14, ov=[('sb_30', 0, 0.3, 0), ('sb_40', 0.3, 2.4, 0.1)])),
|
||
('ambrose', 'ambrose_night_v1', 2.2, 1.8, dict(gain=-14, ov=[('sb_40', 0, 0.3, 0), ('sb_game', 0.3, 1.8, 0.1)])),
|
||
('massacre', None, 0, 1.6, dict()),
|
||
('logo', None, 0, 6.2, dict(ov=[('logo', 0.3, 6.2)], fo=0.7)),
|
||
]
|
||
CUTS = {'full': (FULL, f'lifetime-members-cut{CUT}.mp4'), 'short': (SHORT_EDL, 'lifetime-members-short.mp4'),
|
||
'score': (SCORE, 'fifteen-love.mp4')}
|
||
EDL, OUT_NAME = CUTS[NAME]
|
||
|
||
# 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 or 'dlg' = measured to DLG, fade_in, fade_out[, volume expr])
|
||
# m_open turns a corner at 21.0 s (segment.py's join): that turn lands on the title card, and the
|
||
# whole cue has faded out under the question card, before Reg opens his mouth (cut 2 ran it 3 s into
|
||
# his line — Oliver: "the music from the intro is overlapping the voice")
|
||
def doc_sound(start):
|
||
OPEN_TURN = 21.0
|
||
anchor = start.get('title', start['q1']) # the cutdown has no title: the turn lands on the question
|
||
o_in = max(0.0, OPEN_TURN - anchor)
|
||
o_at = max(0.0, anchor - OPEN_TURN)
|
||
o_dur = (start['q1'] + 1.2 if 'title' in start else start['reg'] - 0.2) - o_at
|
||
MUSIC = [
|
||
(aud('m_open.mp3'), o_in, o_dur, o_at, -11, min(2.0, o_dur / 3), min(3.4, o_dur / 2)), # gone before Reg speaks
|
||
(aud('cue_a.mp3'), 35.0, start['q2'] - start['reg'], 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.get('logo', start['stinger']) + 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),
|
||
]
|
||
return MUSIC, SFX
|
||
|
||
|
||
def score_sound(start):
|
||
ump = lambda n, at: (game(n + '.m4a'), 0.0, 9.0, at, 'dlg', 0.0, 0.05)
|
||
a = start['ambrose']
|
||
massacre = a + 0.3 + 2.12 # "Game. Set. ... Massacre." — the third word lands on black
|
||
music = [
|
||
(aud('cue_b.mp3'), 0.0, massacre + 0.4, 0.0, -19, 1.2, 0.01), # the drone stops on the word
|
||
(game('tennis-applaus.wav'), 0.0, 4.1, start['logo'] + 4.3, -14, 0.3, 1.0), # after "otherwise."
|
||
]
|
||
sfx = [
|
||
ump('ump_spectators', start['stands'] + 0.35),
|
||
ump('fifteen_love', start['reg'] + 0.35),
|
||
ump('thirty_love', start['doris'] + 0.2),
|
||
ump('forty_love', start['clive'] + 0.3),
|
||
ump('game_set_massacre', a + 0.3),
|
||
ump('ump_still_a_match', start['logo'] + 0.5),
|
||
(aud('splat.wav'), 0.0, 4.5, massacre + 0.42, -6, 0.0, 1.5),
|
||
] + [(game('tennis-opponent.wav'), 0.0, 0.2, start[n] - 0.06, -10, 0.0, 0.01) # the point is played
|
||
for n in ('reg', 'doris', 'clive', 'ambrose')]
|
||
return music, sfx
|
||
|
||
|
||
MUSIC, SFX = (score_sound if NAME == 'score' else doc_sound)(start)
|
||
|
||
|
||
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=0.0, dur=60.0, eq=''):
|
||
r = subprocess.run(['ffmpeg', '-hide_banner', '-ss', f'{tin:.3f}', '-t', f'{dur:.3f}', '-i', src if os.path.exists(src) else 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, *fd) in enumerate(op.get('ov', [])):
|
||
j = first_ov + k
|
||
fd = fd[0] if fd else 0.5 # 0: a hard switch (the score bug ticking over)
|
||
if fd:
|
||
fo = 0 if b >= dur - 1e-6 else min(fd, max(0.05, dur - b)) # held to the cut: no fade-out
|
||
f.append(f'[{j}:v]format=rgba,fade=in:st={a}:d={fd}:alpha=1'
|
||
+ (f',fade=out:st={b - fo:.3f}:d={fo:.3f}:alpha=1' if fo else '') + f'[o{k}]')
|
||
f.append(f'[{v}][o{k}]overlay=0:0:format=auto[v{k + 1}]')
|
||
else:
|
||
f.append(f'[{j}:v]format=rgba[o{k}]')
|
||
f.append(f"[{v}][o{k}]overlay=0:0:format=auto:enable='between(t,{a},{b})'[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 ''
|
||
if g == 'dlg':
|
||
g = round(DLG - lufs(src), 1)
|
||
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, OUT_NAME)
|
||
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, s in start.items():
|
||
print(f'{n:8s} {s:6.2f}s')
|