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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-05 10:56:50 +02:00
1 parent d388004eac
commit 5fba837ead
5 files changed
+65 -36

No files matched your search

+1 -1
View File
@@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=356"></script>
<script type="module" src="src/main.js?v=357"></script>
</body>
</html>
+7
View File
@@ -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).
+37 -28
View File
@@ -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)
+7 -7
View File
@@ -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; }
</style></head><body>
<section id="t_a"><div class="c say" style="top:820px">Every old club has its traditions.</div></section>
<section id="t_b"><div class="c say" style="top:960px">Some of them refuse to die.</div></section>
<section id="t_a"><div class="c say" style="top:700px">Every old club has its traditions.</div></section>
<section id="t_b"><div class="c say" style="top:1010px">Some of them refuse to die.</div></section>
<section id="title">
<svg class="crest" width="150" height="190" viewBox="0 0 150 190" fill="none" stroke="#c9a45a" stroke-width="3.2">
@@ -57,8 +57,8 @@ section:target { display: block; }
<div class="c est">EST. 1877</div>
</section>
<section id="q_start"><div class="c q" style="top:880px">How did it start?</div></section>
<section id="q_chall"><div class="c q" style="top:880px">And the challengers?</div></section>
<section id="q_start"><div class="c q" style="top:850px">How did it start?</div></section>
<section id="q_chall"><div class="c q" style="top:790px">And the challengers?</div></section>
<section id="lt_reg"><div class="lt"><i></i><b>Reginald Moss</b><span>Head Groundskeeper</span><em>1962 –</em></div></section>
<section id="lt_doris"><div class="lt"><i></i><b>Doris Penny</b><span>Strawberries &amp; Cream</span><em>1971 –</em></div></section>
@@ -72,6 +72,6 @@ section:target { display: block; }
<section id="end">
<img class="logo" src="../../assets/logo-v2.png" style="width:700px; top:700px">
<div class="c say" style="top:1170px; font-size:50px">Lifetime membership now open.</div>
<div class="c say" style="top:1170px; font-size:62px">Lifetime membership now open.</div>
</section>
</body></html>
+13
View File
@@ -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 <clip.mp4> [frames=6]
uv run --with requests python tools/trailer/qa.py --ask <media> "<question>" # 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]