From 5fba837eadfdcf8a3f9b2c4a2d2310de9b785397 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Mon, 5 Oct 2026 10:56:50 +0200 Subject: [PATCH] Tighten the trailer to 1:30 and fix its two audio faults Cut 3 of LIFETIME MEMBERS: the intro music no longer runs into Reg's first line, Ambrose finishes "...still waiting for a result" (line ends are now measured off each take instead of the QA transcript), every shot is tighter, and the black title cards are set larger. Cut 2 stays on disk beside it as the reference. Co-Authored-By: Claude Opus 5.5 --- index.html | 2 +- tools/trailer/SCRIPT.md | 7 +++++ tools/trailer/cut.py | 65 +++++++++++++++++++++++----------------- tools/trailer/frame.html | 14 ++++----- tools/trailer/qa.py | 13 ++++++++ 5 files changed, 65 insertions(+), 36 deletions(-) diff --git a/index.html b/index.html index 9751559..bf8948d 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 eaf398a..8a4b027 100644 --- a/tools/trailer/SCRIPT.md +++ b/tools/trailer/SCRIPT.md @@ -69,3 +69,10 @@ still in promo/trailer/clips if a 15-second cutdown ever wants them. - QA listening (`qa.py`, Gemini): trust it on spoken lines and on mix complaints, NOT on its timestamps for music (they drift up to 1.5x) and not on transcripts of non-speech audio (it invents words from footsteps). + +## Cut 2 → cut 3 (Oliver, 2026-10-05) — 1:45 → 1:30, cut 2 kept as the reference +- The intro music ran 3 s into Reg's "Fertiliser": it now fades out under "How did it start?". +- Ambrose lost the end of "...result": cut 2 trusted the QA transcript's 6.6 s; the take's speech + 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). diff --git a/tools/trailer/cut.py b/tools/trailer/cut.py index 1756c8a..c9e7fec 100644 --- a/tools/trailer/cut.py +++ b/tools/trailer/cut.py @@ -23,35 +23,38 @@ game = lambda n: os.path.join(ROOT, 'assets', 'audio', n) # 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) +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. 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)), + ('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.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)), + ('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)), ] # every segment is a whole number of frames, or the audio drifts a sub-frame per cut @@ -63,9 +66,15 @@ for name, _, _, dur, _ in EDL: TOTAL = t # music/effects: (file, src_in, dur, at, gain dB, fade_in, fade_out) +# 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 +o_in = max(0.0, OPEN_TURN - start['title']) +o_at = max(0.0, start['title'] - OPEN_TURN) 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), + (aud('m_open.mp3'), o_in, start['q1'] + 1.2 - o_at, o_at, -11, 2.0, 3.4), # gone mid-question + (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"), @@ -181,7 +190,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.mp4') + out = os.path.join(T, 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) diff --git a/tools/trailer/frame.html b/tools/trailer/frame.html index 1245b7c..b284669 100644 --- a/tools/trailer/frame.html +++ b/tools/trailer/frame.html @@ -13,8 +13,8 @@ section:target { display: block; } .c { position: absolute; left: 0; right: 0; text-align: center; } /* black cards: one line of quiet Baskerville, the documentary's voice */ -.say { font: italic 400 62px/1.25 Baskerville, serif; letter-spacing: .005em; padding: 0 110px; } -.q { font: italic 400 66px/1.2 Baskerville, serif; } +.say { font: italic 400 88px/1.18 Baskerville, serif; letter-spacing: .005em; padding: 0 80px; text-wrap: balance; } +.q { font: italic 400 96px/1.15 Baskerville, serif; padding: 0 80px; text-wrap: balance; } .q::before, .q::after { content: ''; display: block; width: 46px; height: 2px; background: var(--gold); margin: 0 auto 34px; opacity: .8; } .q::after { margin: 34px auto 0; } @@ -39,8 +39,8 @@ section:target { display: block; } .px { font: 400 34px/1.5 Silkscreen, monospace; letter-spacing: .18em; margin-left: .18em; } -
Every old club has its traditions.
-
Some of them refuse to die.
+
Every old club has its traditions.
+
Some of them refuse to die.
@@ -57,8 +57,8 @@ section:target { display: block; }
EST. 1877
-
How did it start?
-
And the challengers?
+
How did it start?
+
And the challengers?
Reginald MossHead Groundskeeper1962 –
Doris PennyStrawberries & Cream1971 –
@@ -72,6 +72,6 @@ section:target { display: block; }
-
Lifetime membership now open.
+
Lifetime membership now open.
diff --git a/tools/trailer/qa.py b/tools/trailer/qa.py index 1154fc6..14f6ddc 100644 --- a/tools/trailer/qa.py +++ b/tools/trailer/qa.py @@ -3,6 +3,7 @@ the soundtrack (an audio model hears it so the cut does not rest on guesswork). uv run --with requests python tools/trailer/qa.py [frames=6] + uv run --with requests python tools/trailer/qa.py --ask "" # any file, any question """ import base64, json, os, subprocess, sys, tempfile @@ -11,6 +12,18 @@ import requests sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from gen import API, H, OUT +def ask(media, question, model=os.environ.get('QA_LISTENER', 'google/gemini-2.5-pro')): + wav = tempfile.mktemp(suffix='.wav') + subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', media, '-ac', '1', '-ar', '16000', wav], check=True) + r = requests.post(f'{API}/chat/completions', headers=H(), json={'model': model, 'messages': [{'role': 'user', 'content': [ + {'type': 'text', 'text': question}, + {'type': 'input_audio', 'input_audio': {'data': base64.b64encode(open(wav, 'rb').read()).decode(), 'format': 'wav'}}]}]}, + timeout=600) + return r.json()['choices'][0]['message']['content'] if r.ok else r.text[:500] + + +if sys.argv[1] == '--ask': + sys.exit(print(ask(sys.argv[2], sys.argv[3]))) clip = sys.argv[1] n = int(sys.argv[2]) if len(sys.argv) > 2 else 6 name = os.path.splitext(os.path.basename(clip))[0]