Add FIFTEEN–LOVE, a 19-second umpire-scored spot

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>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-05 11:28:28 +02:00
1 parent 2de45510a3
commit da09b55525
3 files changed
+108 -30

No files matched your search

+79 -29
View File
@@ -5,6 +5,7 @@ segment, concatenates, mixes, grades, and writes promo/trailer/lifetime-members.
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.
"""
@@ -12,8 +13,8 @@ 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')
SHORT = '--short' in sys.argv
B = os.path.join(T, 'build', 'short' if SHORT else 'full')
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
@@ -83,7 +84,22 @@ SHORT_EDL = [
('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)),
]
EDL = SHORT_EDL if SHORT else FULL
# 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]
@@ -93,27 +109,53 @@ for name, _, _, dur, _ in EDL:
t += dur
TOTAL = t
# music/effects: (file, src_in, dur, at, gain dB, fade_in, fade_out)
# 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")
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),
]
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):
@@ -124,7 +166,7 @@ def run(cmd):
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', [])}):
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}'])
@@ -146,8 +188,8 @@ def splat():
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),
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])
@@ -165,11 +207,17 @@ def segment(i, name, src, tin, dur, op):
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', [])):
for k, (c, a, b, *fd) 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}]')
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"]}')
@@ -202,6 +250,8 @@ def mix(parts):
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}]')
@@ -220,7 +270,7 @@ def mix(parts):
# 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-short.mp4' if SHORT else f'lifetime-members-cut{CUT}.mp4')
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)
+28
View File
@@ -37,6 +37,18 @@ section:target { display: block; }
/* the game's own voice at the end: its logo and its pixel face */
.logo { position: absolute; left: 50%; transform: translateX(-50%); }
.px { font: 400 34px/1.5 Silkscreen, monospace; letter-spacing: .18em; margin-left: .18em; }
/* FIFTEEN–LOVE: a broadcast score bug, the club serving, the viewer receiving */
.sb { position: absolute; left: 64px; top: 250px; min-width: 470px; padding: 18px 26px 18px 22px;
background: linear-gradient(90deg, rgba(10,26,17,.9), rgba(10,26,17,.78)); border-left: 4px solid var(--gold);
box-shadow: 0 6px 30px rgba(0,0,0,.45); font-family: 'Gill Sans', sans-serif; color: var(--paper); }
.sb .row { display: grid; grid-template-columns: 22px 1fr 96px; align-items: center; height: 54px; }
.sb .row + .row { border-top: 1px solid rgba(201,164,90,.28); }
.sb .who { font: 600 27px/1 'Gill Sans', sans-serif; letter-spacing: .16em; text-transform: uppercase; }
.sb .pts { font: 400 44px/1 'Gill Sans', sans-serif; text-align: right; font-variant-numeric: tabular-nums; color: var(--gold); }
.sb .you .who, .sb .you .pts { color: rgba(236,230,214,.62); }
.sb i { width: 12px; height: 12px; border-radius: 50%; background: #d8e04a; box-shadow: 0 0 8px rgba(216,224,74,.6); }
.sb .game { font: 600 34px/1 'Gill Sans', sans-serif; letter-spacing: .2em; }
</style></head><body>
<section id="t_a"><div class="c say" style="top:700px">Every old club has its traditions.</div></section>
@@ -74,4 +86,20 @@ section:target { display: block; }
<img class="logo" src="../../assets/logo-v2.png" style="width:700px; top:700px">
<div class="c say" style="top:1170px; font-size:62px">Lifetime membership now open.</div>
</section>
<section id="sb_15"><div class="sb">
<div class="row"><i></i><span class="who">St. Mordred's</span><span class="pts">15</span></div>
<div class="row you"><span></span><span class="who">You</span><span class="pts">0</span></div>
</div></section>
<section id="sb_30"><div class="sb">
<div class="row"><i></i><span class="who">St. Mordred's</span><span class="pts">30</span></div>
<div class="row you"><span></span><span class="who">You</span><span class="pts">0</span></div>
</div></section>
<section id="sb_40"><div class="sb">
<div class="row"><i></i><span class="who">St. Mordred's</span><span class="pts">40</span></div>
<div class="row you"><span></span><span class="who">You</span><span class="pts">0</span></div>
</div></section>
<section id="sb_game"><div class="sb">
<div class="row"><i></i><span class="who">St. Mordred's</span><span class="pts game">GAME</span></div>
<div class="row you"><span></span><span class="who">You</span><span class="pts">0</span></div>
</div></section>
</body></html>