Add a 36-second cutdown of the trailer
cut.py --short builds a social-length version from the same takes: one line per witness cut inside their takes, then the challengers beat, "Quiet, please", "Play", the splat and Reg's stinger. Each cut keeps its own build folder and output file, so cut 2, cut 3 and the short all stay. words.py gives local Whisper word timestamps for cutting inside lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fba837ead
commit
2de45510a3
4 files changed
+69
-11
No files matched your search
+1
-1
@@ -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=357"></script>
|
||||
<script type="module" src="src/main.js?v=358"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
+40
-10
@@ -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')
|
||||
@@ -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|file>... (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))
|
||||
Reference in new issue
Block a user