From da09b555252c26c525446b45633b2816a71c36f3 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Mon, 5 Oct 2026 11:28:28 +0200 Subject: [PATCH] =?UTF-8?q?Add=20FIFTEEN=E2=80=93LOVE,=20a=2019-second=20u?= =?UTF-8?q?mpire-scored=20spot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- index.html | 2 +- tools/trailer/cut.py | 108 ++++++++++++++++++++++++++++----------- tools/trailer/frame.html | 28 ++++++++++ 3 files changed, 108 insertions(+), 30 deletions(-) diff --git a/index.html b/index.html index f030934..34f5301 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/tools/trailer/cut.py b/tools/trailer/cut.py index eb4e770..564f794 100644 --- a/tools/trailer/cut.py +++ b/tools/trailer/cut.py @@ -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) diff --git a/tools/trailer/frame.html b/tools/trailer/frame.html index b284669..3f0082f 100644 --- a/tools/trailer/frame.html +++ b/tools/trailer/frame.html @@ -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; }
Every old club has its traditions.
@@ -74,4 +86,20 @@ section:target { display: block; }
Lifetime membership now open.
+
+
St. Mordred's15
+
You0
+
+
+
St. Mordred's30
+
You0
+
+
+
St. Mordred's40
+
You0
+
+
+
St. Mordred'sGAME
+
You0
+