Add FIFTEEN–LOVE, a 19-second umpire-scored spot
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2de45510a3
commit
da09b55525
3 files changed
+108
-30
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=358"></script>
|
||||
<script type="module" src="src/main.js?v=359"></script>
|
||||
</body>
|
||||
</html>
|
||||
+79
-29
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
</style></head><body>
|
||||
|
||||
<section id="t_a"><div class="c say" style="top:700px">Every old club has its traditions.</div></section>
|
||||
@@ -74,4 +86,20 @@ section:target { display: block; }
|
||||
<img class="logo" src="../../assets/logo-v2.png" style="width:700px; top:700px">
|
||||
<div class="c say" style="top:1170px; font-size:62px">Lifetime membership now open.</div>
|
||||
</section>
|
||||
<section id="sb_15"><div class="sb">
|
||||
<div class="row"><i></i><span class="who">St. Mordred's</span><span class="pts">15</span></div>
|
||||
<div class="row you"><span></span><span class="who">You</span><span class="pts">0</span></div>
|
||||
</div></section>
|
||||
<section id="sb_30"><div class="sb">
|
||||
<div class="row"><i></i><span class="who">St. Mordred's</span><span class="pts">30</span></div>
|
||||
<div class="row you"><span></span><span class="who">You</span><span class="pts">0</span></div>
|
||||
</div></section>
|
||||
<section id="sb_40"><div class="sb">
|
||||
<div class="row"><i></i><span class="who">St. Mordred's</span><span class="pts">40</span></div>
|
||||
<div class="row you"><span></span><span class="who">You</span><span class="pts">0</span></div>
|
||||
</div></section>
|
||||
<section id="sb_game"><div class="sb">
|
||||
<div class="row"><i></i><span class="who">St. Mordred's</span><span class="pts game">GAME</span></div>
|
||||
<div class="row you"><span></span><span class="who">You</span><span class="pts">0</span></div>
|
||||
</div></section>
|
||||
</body></html>
|
||||
Reference in new issue
Block a user