diff --git a/index.html b/index.html index f030934..34f5301 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/cut.py b/tools/trailer/cut.py index eb4e770..564f794 100644 --- a/tools/trailer/cut.py +++ b/tools/trailer/cut.py @@ -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) diff --git a/tools/trailer/frame.html b/tools/trailer/frame.html index b284669..3f0082f 100644 --- a/tools/trailer/frame.html +++ b/tools/trailer/frame.html @@ -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; }
Every old club has its traditions.
@@ -74,4 +86,20 @@ section:target { display: block; }
Lifetime membership now open.
+
+
St. Mordred's15
+
You0
+
+
+
St. Mordred's30
+
You0
+
+
+
St. Mordred's40
+
You0
+
+
+
St. Mordred'sGAME
+
You0
+