Add MEMBERSHIP, the club's own 1980s VHS advert

cut.py --cut ad: a plummy announcer (one gpt-audio read per line, ad.sh)
sells St. Mordred's over the creepiest takes with yellow 80s supers, a
PLAY/STOP VCR read-out, two testimonials from the real lines and the small
print read at speed, until the game's umpire says "Quiet." and the jingle
dies. A VHS grade (chroma smear, scanlines, tape wobble) is the cut's own.
gen.py learns a voice command; one new silent Doris take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-05 11:46:31 +02:00
1 parent da09b55525
commit 806a739c6f
7 files changed
+170 -11

No files matched your search

+15
View File
@@ -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.
+26
View File
@@ -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 <line>... (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
+1
View File
@@ -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
}
+56 -5
View File
@@ -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
+47
View File
@@ -4,6 +4,7 @@
--screenshot=<out>.png file://.../frame.html#<id> (cut.py does this)
Everything is drawn on transparent; cut.py lays it over picture or black. -->
<html><head><meta charset="utf-8">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT@1,9..144,900,100&family=VT323&display=block">
<style>
@font-face { font-family: Silkscreen; src: url(../../assets/fonts/Silkscreen-Regular.ttf); }
:root { --paper: #ece6d6; --gold: #c9a45a; --shade: 0 2px 22px rgba(0,0,0,.8), 0 0 4px rgba(0,0,0,.6); }
@@ -49,6 +50,27 @@ section:target { display: block; }
.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; }
/* MEMBERSHIP: the club's own 1980s advert — supers, VCR read-out, testimonials, the end frame */
.osd { position: absolute; left: 78px; top: 214px; font: 400 80px/1 VT323, monospace; color: #f4f4f0;
letter-spacing: .06em; text-shadow: 0 0 8px rgba(255,255,255,.55), 3px 3px 0 rgba(0,0,0,.55); }
.su { position: absolute; left: 64px; right: 64px; top: 1170px; transform: rotate(-3deg); transform-origin: left center;
font: italic 900 96px/.98 Fraunces, serif; font-variation-settings: 'SOFT' 100, 'opsz' 144; text-transform: uppercase;
color: #ffe14a; -webkit-text-stroke: 12px #3b1300; paint-order: stroke fill; letter-spacing: -.005em;
text-shadow: 0 10px 0 #2a0d00, 0 16px 34px rgba(0,0,0,.55); text-wrap: balance; }
.su.s { font-size: 80px; }
.su small { display: block; margin-top: 14px; font: italic 900 40px/1 Fraunces, serif; color: #fff;
-webkit-text-stroke: 7px #3b1300; text-transform: none; }
.adbg { position: absolute; inset: 0; background: radial-gradient(120% 80% at 50% 40%, #1f5a37 0%, #0c2b19 55%, #04120a 100%); }
.adbg::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(90deg, rgba(255,255,255,.035) 0 60px, transparent 60px 120px); }
.tm { position: absolute; left: 64px; top: 1250px; font: italic 900 70px/1 Fraunces, serif; font-variation-settings: 'SOFT' 100;
color: #fff; -webkit-text-stroke: 9px #3b1300; paint-order: stroke fill; text-shadow: 0 8px 0 #2a0d00; }
.tm em { display: block; margin-top: 10px; font-size: 44px; font-style: italic; color: #ffe14a; }
.burst { position: absolute; width: 520px; height: 520px; left: 50%; top: 1020px; margin-left: -260px; transform: rotate(-9deg);
background: #e3262b; clip-path: polygon(50% 0%,57% 13%,71% 4%,72% 19%,88% 15%,83% 30%,98% 33%,88% 45%,100% 55%,86% 62%,94% 77%,78% 78%,79% 94%,65% 87%,57% 100%,49% 88%,37% 98%,34% 83%,19% 90%,20% 74%,4% 72%,14% 59%,0% 49%,13% 40%,4% 26%,20% 25%,17% 9%,33% 14%,40% 1%);
display: flex; align-items: center; justify-content: center; text-align: center; }
.burst span { font: italic 900 58px/1 Fraunces, serif; color: #ffe14a; text-transform: uppercase; -webkit-text-stroke: 6px #5a0b0b; paint-order: stroke fill; padding: 0 70px; }
.fine { position: absolute; left: 70px; right: 70px; top: 1560px; font: 400 23px/1.3 Helvetica, Arial, sans-serif; color: rgba(255,255,255,.85); text-align: center; }
</style></head><body>
<section id="t_a"><div class="c say" style="top:700px">Every old club has its traditions.</div></section>
@@ -102,4 +124,29 @@ section:target { display: block; }
<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>
<section id="osd_play"><div class="osd">PLAY &#9654;</div></section>
<section id="osd_stop"><div class="osd">STOP &#9632;</div></section>
<section id="su_grass"><div class="su s">The finest grass in the county</div></section>
<section id="su_staff"><div class="su ">Dedicated ground staff</div></section>
<section id="su_tonic"><div class="su ">Our own lawn tonic!</div></section>
<section id="su_berries"><div class="su ">Strawberries &amp; cream</div></section>
<section id="su_fresh"><div class="su ">Served fresh daily!*<small>*fresh-ish</small></div></section>
<section id="su_welcome"><div class="su ">A warm welcome</div></section>
<section id="su_officials"><div class="su s">Officials who never miss a thing</div></section>
<section id="su_since"><div class="su ">Umpiring since 1908</div></section>
<section id="su_fixture"><div class="su s">Our longest-running fixture</div></section>
<section id="ad_members"><div class="adbg"></div><div class="c su" style="top:820px; left:80px; right:80px; transform:rotate(-3deg)">What our members say</div></section>
<section id="tm_doris"><div class="tm">&ldquo;Doris&rdquo;<em>Member since 1971</em></div></section>
<section id="tm_reg"><div class="tm">&ldquo;Reg&rdquo;<em>Member since 1962</em></div></section>
<section id="ad_club"><div class="adbg"></div>
<svg class="crest" style="top:300px" width="190" height="240" viewBox="0 0 150 190" fill="none" stroke="#e8c46a" stroke-width="3.6">
<g transform="rotate(-32 75 112)"><ellipse cx="75" cy="66" rx="30" ry="40"/><path d="M75 106v64M66 106l9 14 9-14"/></g>
<g transform="rotate(32 75 112)"><ellipse cx="75" cy="66" rx="30" ry="40"/><path d="M75 106v64M66 106l9 14 9-14"/></g>
<circle cx="75" cy="16" r="11" fill="#e8c46a" stroke="none"/></svg>
<div class="c su" style="top:600px; left:40px; right:40px; transform:rotate(-3deg); font-size:128px">St. Mordred's</div>
<div class="c club" style="top:790px; font-size:34px; color:#f4ecd6">LAWN TENNIS CLUB &middot; EST. 1877</div>
<div class="burst"><span>Lifetime membership now open!</span></div>
</section>
<section id="ad_fine"><div class="fine">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.</div></section>
</body></html>
+24 -5
View File
@@ -4,6 +4,7 @@
uv run --with requests --with pillow python tools/trailer/gen.py still <name> "<prompt>" [ref.png ...]
uv run --with requests --with pillow python tools/trailer/gen.py clip <name> <model> <secs> <first.png> "<prompt>" [last.png]
uv run --with requests --with pillow python tools/trailer/gen.py music <name> "<prompt>"
uv run --with requests --with pillow python tools/trailer/gen.py voice <name> "<style>" "<script>" [voice]
Everything lands in promo/trailer/ (gitignored). Each call also writes <name>.json beside its
output with the exact request, so a take can be re-rolled or compared later.
@@ -85,9 +86,9 @@ def clip(name, model, secs, first, prompt, last=None, res='1080p', audio=True):
print(out, len(v.content), 'bytes', f'{time.time() - t0:.0f}s', flush=True)
def music(name, prompt, model='google/lyria-3-pro-preview'):
req = {'model': model, 'messages': [{'role': 'user', 'content': prompt}],
'modalities': ['audio', 'text'], 'stream': True}
def music(name, prompt, model='google/lyria-3-pro-preview', messages=None, **extra):
req = {'model': model, 'messages': messages or [{'role': 'user', 'content': prompt}],
'modalities': ['audio', 'text'], 'stream': True, **extra}
r = requests.post(f'{API}/chat/completions', headers=H(), json=req, stream=True, timeout=900)
if r.status_code != 200:
sys.exit(f'{name}: HTTP {r.status_code} {r.text[:600]}')
@@ -106,18 +107,36 @@ def music(name, prompt, model='google/lyria-3-pro-preview'):
text.append(d['content'])
if not chunks:
sys.exit(f'{name}: no audio. text: {"".join(text)[:400]}')
out = os.path.join(OUT, 'audio', f'{name}.{fmt}')
open(out, 'wb').write(base64.b64decode(''.join(chunks)))
data = base64.b64decode(''.join(chunks))
if (extra.get('audio') or {}).get('format') == 'pcm16': # streamed speech is raw 24 kHz mono
import wave
out = os.path.join(OUT, 'audio', f'{name}.wav')
with wave.open(out, 'wb') as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(24000); w.writeframes(data)
else:
out = os.path.join(OUT, 'audio', f'{name}.{fmt}')
open(out, 'wb').write(data)
note(out, req)
print(out, ''.join(text)[:300], flush=True)
def voice(name, style, script, voice_id='ballad', model='openai/gpt-audio'):
"""A read of SCRIPT, word for word, in STYLE (an announcer, a whisper...)."""
music(name, None, model, messages=[
{'role': 'system', 'content': 'You are a professional voice actor in a recording booth. Perform the '
'user message as a script: say exactly those words, in that order, nothing added, nothing left out, '
'no greeting, no comment. Style: ' + style},
{'role': 'user', 'content': script}], audio={'voice': voice_id, 'format': 'pcm16'})
if __name__ == '__main__':
a = sys.argv[1:]
if a[:1] == ['still']:
still(a[1], a[2], a[3:])
elif a[:1] == ['clip']:
clip(a[1], a[2], a[3], a[4], a[5], a[6] if len(a) > 6 else None)
elif a[:1] == ['voice']:
voice(a[1], a[2], a[3], *a[4:5])
elif a[:1] == ['music']:
music(a[1], a[2], *a[3:4])
else: