diff --git a/index.html b/index.html index 34f5301..e47d5d8 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 23e1f55..f513a7f 100644 --- a/tools/trailer/SCRIPT.md +++ b/tools/trailer/SCRIPT.md @@ -85,3 +85,18 @@ challengers?" → Doris / Clive's eyes / "Quiet, please." / "Play." → splat → 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. + +## FIFTEEN–LOVE (2026-10-05) — 19 s, `cut.py --cut score` +No interview audio: the game's own recorded umpire. "Spectators are reminded to remain seated, +especially the deceased" over the stands → a ball struck on every cut, a love game called against +the viewer, one member's stare per point under a broadcast score bug (ST. MORDRED'S 15/30/40/GAME, +YOU 0) → "Game. Set." on Ambrose, "Massacre." on black, splat → "This match is still in progress. +I have not been told otherwise." over the logo, applause. + +## MEMBERSHIP (2026-10-05) — 63 s, `cut.py --cut ad` +The club recruits you: a 1989 VHS advert. Announcer reads (`ad.sh`, openai/gpt-audio, one read per +line so each sits on its shot; spell the club "Saint Mor-dreds" or it is swallowed), yellow Fraunces +supers, PLAY/STOP VCR read-out, two "testimonials" from the real takes, small print read at speed, +then the game's umpire: "Quiet." — and the jingle dies. VHS grade: hot colour, chroma smear, +scanlines, light tape wobble (more than d=0.012 reads as a doubled voice). doris_silent is the one +new take: an advert cannot have a member's lips moving under the announcer. diff --git a/tools/trailer/ad.sh b/tools/trailer/ad.sh new file mode 100644 index 0000000..0b6d1d1 --- /dev/null +++ b/tools/trailer/ad.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# MEMBERSHIP: the club's own 1980s recruitment advert. Every announcer line is its own read, so each +# can sit on its own shot. usage: sh tools/trailer/ad.sh ... (parallel) +cd "$(dirname "$0")/../.." +V="uv run -q --with requests --with pillow python tools/trailer/gen.py voice" +STYLE="An over-enthusiastic 1980s British television advert announcer: a warm, plummy, upper-class southern English accent, a big beaming smile in the voice, bouncy, salesy, slightly too cheerful." +FAST="A 1980s British television advert announcer reading the legal small print at the very end of an advert: extremely fast, flat, barely breathing, every word still intelligible, upper-class southern English accent." +line() { + case $1 in + v01) $V v01 "$STYLE" "Looking for a tennis club with real tradition?" ;; + v02) $V v02 "$STYLE" "Since eighteen seventy-seven, Saint Mor-dreds has offered the finest grass courts in the county..." ;; + v03) $V v03 "$STYLE" "...lovingly tended by our dedicated ground staff..." ;; + v04) $V v04 "$STYLE" "...with our very own lawn tonic!" ;; + v05) $V v05 "$STYLE" "Treat yourself to strawberries and cream..." ;; + v06) $V v06 "$STYLE" "...served fresh daily!" ;; + v07) $V v07 "$STYLE" "Join a warm, welcoming community of members..." ;; + v08) $V v08 "$STYLE" "...with officials who never miss a thing!" ;; + v09) $V v09 "$STYLE" "Our umpire has presided over every match since nineteen-oh-eight..." ;; + v10) $V v10 "$STYLE" "...including our longest-running fixture!" ;; + v11) $V v11 "$STYLE" "But don't just take our word for it!" ;; + v12) $V v12 "$STYLE" "Saint Mor-dreds Lawn Tennis Club. Lifetime membership... now open!" ;; + v13) $V v13 "$FAST" "Membership is for life. Membership does not end with life. Members may not leave the grounds. Strawberries are not fresh. Terms and conditions apply." ;; + *) echo "unknown line $1" ;; + esac +} +for n in "$@"; do line "$n" & done; wait diff --git a/tools/trailer/clips.sh b/tools/trailer/clips.sh index a027d51..ecaea94 100644 --- a/tools/trailer/clips.sh +++ b/tools/trailer/clips.sh @@ -32,6 +32,7 @@ take() { serve) c serve 4 serve "Slow motion. The tennis ball floats at the apex of the toss in the floodlight glare; the young woman's racket swings up through the frame toward the ball; behind the net the zombies lurch forward. Audio: a deep slow-motion whoosh, a heartbeat, rising tension." ;; reg_dawn) c reg_dawn 6 reg_dawn "Static medium shot. The zombie groundskeeper dabs the red stain on the white line with his handkerchief, glances up at the camera and says cheerfully, in a slow, gravelly old English countryside accent: \"Lovely day for it.\" Then he carries on dabbing. Audio: only his voice, early morning birdsong, a distant sprinkler." ;; stands_close) c stands_close 6 stands_close "Static camera. For the first two seconds the dead spectators sit perfectly still, all looking to the left toward the court. Then, in perfect unison, every single head in the stand turns ninety degrees, slowly and mechanically, until every face is looking directly into the camera lens, cloudy eyes wide. They hold the stare, motionless. Nobody speaks, nobody else moves. Audio: silence, faint wind, the creak of old wood as the heads turn, no voices at all." ;; + doris_silent) c doris_silent 6 doris "Static camera, advert-style. The zombie strawberry seller silently arranges a punnet of strawberries on the counter, then looks up straight into the camera lens and holds a wide, polite, slightly too long smile. Her mouth stays closed: she does not speak at all. Audio: birdsong, bunting flapping, no voices." ;; *) echo "unknown take $1" ;; esac } diff --git a/tools/trailer/cut.py b/tools/trailer/cut.py index 564f794..339a04d 100644 --- a/tools/trailer/cut.py +++ b/tools/trailer/cut.py @@ -6,6 +6,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 --cut ad # MEMBERSHIP: the club's own 1980s VHS recruitment advert 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. """ @@ -97,8 +98,30 @@ SCORE = [ ('massacre', None, 0, 1.6, dict()), ('logo', None, 0, 6.2, dict(ov=[('logo', 0.3, 6.2)], fo=0.7)), ] +# MEMBERSHIP: the club recruits YOU — a 1980s VHS advert. A plummy announcer and yellow supers sell +# the club over the creepiest shots we have; two "testimonials" use the real lines; the small print is +# read at speed; then the game's umpire says "Quiet." and the jingle dies on the spot. +su = lambda c, a, dur: [(c, a, dur, 0.2)] +AD = [ + ('open', 'gates_v1', 0.0, 3.4, dict(fi=0.3, gain=-10, ov=[('osd_play', 0.0, 1.9, 0)])), + ('court', 'court_v1', 0.0, 5.4, dict(gain=-12, ov=su('su_grass', 0.6, 5.4))), + ('reg', 'stare_reg_v1', 0.0, 3.9, dict(gain=-12, ov=su('su_staff', 0.4, 3.9))), + ('tin', 'tin_v1', 0.0, 2.7, dict(gain=-12, ov=su('su_tonic', 0.3, 2.7))), + ('doris', 'doris_silent_v1', 0.6, 4.4, dict(gain=-14, ov=su('su_berries', 0.4, 4.4))), + ('cream', 'cream_v1', 0.3, 2.5, dict(gain=-12, ov=su('su_fresh', 0.3, 2.5))), + ('stands', 'stands_close_v2', 0.6, 4.6, dict(gain=-14, ov=su('su_welcome', 0.5, 4.6))), + ('clive', 'stare_clive_v2', 1.0, 3.0, dict(gain=-14, ov=su('su_officials', 0.3, 3.0))), + ('umpire', 'ambrose_night_v1', 2.1, 1.9, dict(gain=-14, ov=su('su_since', 0.3, 1.9))), + ('board', 'board_v1', 1.0, 3.3, dict(gain=-12, ov=[('su_since', 0, 3.3, 0)])), + ('archive', 'archive_v1', 0.0, 3.6, dict(gain=-12, ov=su('su_fixture', 0.3, 3.6))), + ('members', None, 0, 3.0, dict(ov=[('ad_members', 0, 3.0, 0)])), + ('doris_t', 'stare_doris_v1', 0.7, 2.8, dict(dlg=True, ov=[('tm_doris', 0.2, 2.8, 0.15)])), + ('reg_t', 'reg_dawn_v1', 1.4, 2.5, dict(dlg=(0.3, 1.9), eq=True, ov=[('tm_reg', 0.2, 2.5, 0.15)])), + ('club', None, 0, 12.6, dict(ov=[('ad_club', 0, 12.6, 0), ('ad_fine', 5.9, 12.6, 0.2)])), + ('logo', None, 0, 3.6, dict(ov=[('logo', 0, 3.6, 0), ('osd_stop', 2.3, 3.6, 0)])), +] CUTS = {'full': (FULL, f'lifetime-members-cut{CUT}.mp4'), 'short': (SHORT_EDL, 'lifetime-members-short.mp4'), - 'score': (SCORE, 'fifteen-love.mp4')} + 'score': (SCORE, 'fifteen-love.mp4'), 'ad': (AD, 'membership.mp4')} EDL, OUT_NAME = CUTS[NAME] # every segment is a whole number of frames, or the audio drifts a sub-frame per cut @@ -155,7 +178,24 @@ def score_sound(start): return music, sfx -MUSIC, SFX = (score_sound if NAME == 'score' else doc_sound)(start) +def ad_sound(start): + vo = lambda n, at, src=0.0: (aud(n + '.wav'), src, 9.0, at, 'dlg', 0.0, 0.08) + duck = f"'if(between(t,{start['doris_t']:.2f},{start['club']:.2f}),0.35,1)':eval=frame" # under the testimonials + music = [ + (aud('ad_long.mp3'), 0.0, start['logo'] + 0.06, 0.0, -14, 0.25, 0.01, duck), # dies on "Quiet." + ] + sfx = [ + vo('v01', start['open'] + 0.35), vo('v02', start['court'] + 0.1, 0.1), vo('v03', start['reg'] + 0.35), + vo('v04', start['tin'] + 0.1), vo('v05', start['doris'] + 0.3), vo('v06', start['cream'] + 0.15, 0.45), + vo('v07', start['stands'] + 0.3, 0.1), vo('v08', start['clive'] + 0.15, 1.1), vo('v09', start['umpire'] + 0.15), + vo('v10', start['archive'] + 0.15), vo('v11', start['members'] + 0.3), vo('v12', start['club'] + 0.4), + vo('v13', start['club'] + 6.1), + (game('quiet_dark.m4a'), 0.0, 2.3, start['logo'] + 0.05, 'dlg', 0.0, 0.05), + ] + return music, sfx + + +MUSIC, SFX = {'score': score_sound, 'ad': ad_sound}.get(NAME, doc_sound)(start) def run(cmd): @@ -168,7 +208,7 @@ 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', [])}): 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'--window-size={W},{H}', '--default-background-color=00000000', '--virtual-time-budget=6000', f'--screenshot={card(c)}', f'{page}#{c}']) @@ -268,8 +308,19 @@ def mix(parts): print('loudness in', m['input_i'], 'LUFS, LRA', m['input_lra']) # one grade over everything, so seventeen takes from two models read as one film: # 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]') + if NAME == 'ad': + # VHS, 1989: hotter colour, chroma bleeding sideways, soft, noisy, scanlines, a little tape wow + lines = os.path.join(B, 'scanlines.png') + run(['magick', '-size', '1x4', 'xc:none', '-fill', 'rgba(0,0,0,0.20)', '-draw', 'point 0,3', lines + '.pat.png']) + run(['magick', '-size', f'{W}x{H}', f'tile:{lines}.pat.png', lines]) + cmd += ['-loop', '1', '-framerate', str(FPS), '-t', f'{TOTAL:.3f}', '-i', lines] + f.append(f'[0:v]eq=contrast=1.07:saturation=1.22:gamma=1.03,chromashift=cbh=5:crh=-5:edge=smear,' + f'boxblur=1:1:4:1,noise=c0s=9:c1s=7:c2s=7:c0f=t+u:c1f=t+u:c2f=t+u,vignette=angle=PI/5[vv];' + f'[vv][{len(MUSIC + SFX) + 1}:v]overlay=0:0,format=yuv420p[v]') + f[-2] = f[-2].replace('aresample=48000[a]', 'lowpass=f=12500,vibrato=f=0.6:d=0.012,aresample=48000[a]') + else: + 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, 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 diff --git a/tools/trailer/frame.html b/tools/trailer/frame.html index 3f0082f..d1c02a8 100644 --- a/tools/trailer/frame.html +++ b/tools/trailer/frame.html @@ -4,6 +4,7 @@ --screenshot=.png file://.../frame.html# (cut.py does this) Everything is drawn on transparent; cut.py lays it over picture or black. --> +
Every old club has its traditions.
@@ -102,4 +124,29 @@ section:target { display: block; }
St. Mordred'sGAME
You0
+
PLAY ▶
+
STOP ■
+
The finest grass in the county
+
Dedicated ground staff
+
Our own lawn tonic!
+
Strawberries & cream
+
Served fresh daily!**fresh-ish
+
A warm welcome
+
Officials who never miss a thing
+
Umpiring since 1908
+
Our longest-running fixture
+
What our members say
+
“Doris”Member since 1971
+
“Reg”Member since 1962
+
+ + + + +
St. Mordred's
+
LAWN TENNIS CLUB · EST. 1877
+
Lifetime membership now open!
+
+
Membership is for life. Membership does not end with life. Members may not leave the grounds. +Strawberries are not fresh. Lawn tonic is not for human consumption. Line calls are final. Terms and conditions apply.
diff --git a/tools/trailer/gen.py b/tools/trailer/gen.py index fed520b..07105ba 100644 --- a/tools/trailer/gen.py +++ b/tools/trailer/gen.py @@ -4,6 +4,7 @@ uv run --with requests --with pillow python tools/trailer/gen.py still "" [ref.png ...] uv run --with requests --with pillow python tools/trailer/gen.py clip "" [last.png] uv run --with requests --with pillow python tools/trailer/gen.py music "" + uv run --with requests --with pillow python tools/trailer/gen.py voice "