diff --git a/index.html b/index.html index bf8948d..f030934 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/SCRIPT.md b/tools/trailer/SCRIPT.md index 8a4b027..23e1f55 100644 --- a/tools/trailer/SCRIPT.md +++ b/tools/trailer/SCRIPT.md @@ -76,3 +76,12 @@ still in promo/trailer/clips if a 15-second cutdown ever wants them. envelope says 7.2. Every line end in the EDL is now measured off the take, not the transcript. - Tighter everywhere (B-roll 2.2 s, cold open 14 s, shorter cards and logo); the black cards' text is bigger (88/96 px Baskerville, balanced wrapping). + +## The short (2026-10-05) — 36 s, `cut.py --short`, beside cut 2 and cut 3 +Gates → "How did it start?" → one line per witness, cut inside the takes (Reg: "Fertiliser. Formula +Seven." · Doris: "It wasn't the cream. The cream was just… old." · Clive: "Lifetime membership. +Nobody ever defined… lifetime." · Ambrose: "1911. Fifth set. No tie-break.") → "And the +challengers?" → Doris / Clive's eyes / "Quiet, please." / "Play." → splat → Reg: "Lovely day for it." +→ end card. No title, no B-roll, no stands. Cut points come from `words.py` (local Whisper word +timestamps) and every cut segment is re-transcribed to prove no word was clipped — Whisper's word +ENDS still run early (Clive's "lifetime" ends at 6.0 s, not 5.66), so check the envelope too. diff --git a/tools/trailer/cut.py b/tools/trailer/cut.py index c9e7fec..eb4e770 100644 --- a/tools/trailer/cut.py +++ b/tools/trailer/cut.py @@ -3,14 +3,17 @@ 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 # 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 --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') -B = os.path.join(T, 'build') +SHORT = '--short' in sys.argv +B = os.path.join(T, 'build', 'short' if SHORT else 'full') CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' FPS, W, H = 24, 1080, 1920 @@ -29,7 +32,7 @@ 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. -EDL = [ +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)), @@ -57,6 +60,31 @@ EDL = [ ('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)), +] +EDL = SHORT_EDL if SHORT else FULL + # 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 @@ -70,15 +98,17 @@ TOTAL = t # 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 -o_in = max(0.0, OPEN_TURN - start['title']) -o_at = max(0.0, start['title'] - OPEN_TURN) +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, start['q1'] + 1.2 - o_at, o_at, -11, 2.0, 3.4), # gone mid-question + (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['logo'] + 0.2, -18, 0.8, 3.0), + (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 = [ @@ -190,7 +220,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, f'lifetime-members-cut{CUT}.mp4') + out = os.path.join(T, 'lifetime-members-short.mp4' if SHORT else f'lifetime-members-cut{CUT}.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) @@ -209,5 +239,5 @@ if __name__ == '__main__': 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') + for n, s in start.items(): + print(f'{n:8s} {s:6.2f}s') diff --git a/tools/trailer/words.py b/tools/trailer/words.py new file mode 100644 index 0000000..6d4432b --- /dev/null +++ b/tools/trailer/words.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Word timestamps for takes, from a local Whisper — the cutdown cuts INSIDE lines, and the QA +listener's timestamps drift too much for that. + + uv run --with faster-whisper python tools/trailer/words.py ... (take names in promo/trailer/clips) +""" +import os, subprocess, sys +import numpy as np +from faster_whisper import WhisperModel + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +m = WhisperModel('small.en', compute_type='int8') +for name in sys.argv[1:]: + # decode with ffmpeg ourselves: faster-whisper's PyAV path breaks on newer av releases + src = name if os.path.exists(name) else os.path.join(ROOT, 'promo', 'trailer', 'clips', name + '.mp4') + raw = subprocess.run(['ffmpeg', '-v', 'error', '-i', src, + '-ac', '1', '-ar', '16000', '-f', 's16le', '-'], capture_output=True).stdout + segs, _ = m.transcribe(np.frombuffer(raw, np.int16).astype(np.float32) / 32768, word_timestamps=True) + print(name + ':', ' '.join(f'{w.word.strip()}[{w.start:.2f}-{w.end:.2f}]' for s in segs for w in s.words))