diff --git a/.gitignore b/.gitignore index 3318b0c..eb68be7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ assets/_gen/ assets/_refcheck/ assets/__pycache__/ promo/clips/ +promo/trailer/ # the Capacitor shell. ios/ IS committed (it is the project, and Xcode settings live in it) — # ios/.gitignore already keeps the copied web payload, Pods and Xcode's per-user junk out of it. diff --git a/index.html b/index.html index ed478fc..9751559 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 new file mode 100644 index 0000000..eaf398a --- /dev/null +++ b/tools/trailer/SCRIPT.md @@ -0,0 +1,71 @@ +# LIFETIME MEMBERS — the Splatter Tennis trailer + +Vertical 9:16, English, live-action mockumentary ("What We Do in the Shadows" meets a BBC sports +documentary). Lightly funny, properly eerie. The dead of ST. MORDRED'S LAWN TENNIS CLUB (est. 1877) +are interviewed about how it all started — and every one of them tells a DIFFERENT origin story. +The costumes are the game's bosses (Groundskeeper, Line Judge, Chair Umpire, Strawberry Seller, +The Champion), so the film introduces the cast before anyone has played a point. + +Pipeline: stills `gen.py still` (gemini-3-pro-image) -> clips `gen.py clip` (Veo 3.1, native +dialogue + ambience) -> score `gen.py music` (Lyria 3) -> frame graphics in HTML/CSS +(`frame.html`, rendered by headless Chrome) -> `cut.py` assembles with ffmpeg. + +## The cast + +| Who | Look (= the game's boss sprite) | Origin story | +|---|---|---| +| REGINALD MOSS, Head Groundskeeper, 1962 – | hulking, bottle-green club blazer with crest, olive work trousers | the fertiliser, FORMULA 7 | +| DORIS PENNY, Strawberries & Cream, 1971 – | white paper soda-jerk cap, stained apron, tray of strawberries, cream jugs | "it wasn't the cream" | +| CLIVE BARROW, Line Judge, 1974 – | huge man, navy/white striped polo, navy cap, dark glasses he never removes | the bylaws: lifetime membership | +| AMBROSE PIKE, Chair Umpire, 1908 – | gaunt, cream linen blazer, panama hat, navy tie, gooseneck mic | 1911, fifth set, no tie-break | +| THE CHAMPION | bandaged skull, white polo with gold trim, tarnished trophy | never speaks | + +## Shot list + +COLD OPEN +1. GATES — dawn fog, wrought-iron gates, sign ST. MORDRED'S LAWN TENNIS CLUB · EST. 1877. Slow push in. Crows. Somewhere in the fog: pok… pok… a rally. Nobody is playing. +2. COURT — immaculate stripes in fog, a sprinkler ticking. Far baseline: a figure standing perfectly still. +3. HONOURS BOARD — gilt names, 1877, 1878 … the last line: 1911 — MATCH IN PROGRESS. +CARD "Every old club has its traditions." / "Some of them refuse to die." → TITLE "LIFETIME MEMBERS" + +THE INTERVIEWS — card: "HOW DID IT START?" +4. REG (potting shed): "Fertiliser. Formula Seven. Lovely green… very persistent." +5. B — a rusty FORMULA 7 tin on dark soil; the grass around it slowly leans toward camera. +6. DORIS (strawberry kiosk): "People say it was the cream. It wasn't the cream. The cream was just… old." +7. B — a cream jug on a doily; the surface of the cream ripples by itself. +8. CLIVE (umpires' room): "It's in the bylaws. Lifetime membership. Nobody ever defined 'lifetime'." +9. B — a leather-bound rule book, a finger traces: MEMBERSHIP SHALL BE FOR LIFE. +10. AMBROSE (high in his chair, fog): "Nineteen-eleven. Fifth set. No tie-break." … "We're still waiting for a result." +11. B — black-and-white archive: two gentlemen in long whites at dusk, scoreboard 112 – 112. + +THE TURN — card: "AND THE CHALLENGERS?" (the camera IS the challenger: every stare is at you) +12. DORIS, into the lens: "They never stay long." +13. CLIVE pulls his sunglasses down: milky-white dead eyes. Silent. +14. AMBROSE at the mic: "Quiet, please." — and the score obeys: it stops dead on "please". +15. THE STANDS, in silence: every head bows, then rises to stare into the lens. +16. AMBROSE, now at night under floodlights: "Play." +17. CUT TO BLACK: SPLAT. +LOGO — SPLATTER TENNIS. "MATCH IN PROGRESS" (the honours board's 1911 line). + +STINGER +18. REG on his knees at dawn, dabbing red off the white baseline with a handkerchief. Looks up: "Lovely day for it." +END — logo, "Lifetime membership now open.", the game's own polite applause. + +## Cut 1 → cut 2 (Oliver, 2026-10-03) +Cut 1 ended on a floodlit match — a living player walks on, the Champion sets down his trophy, a +slow-motion serve. Oliver: it "does not really fit … seems odd", conclude earlier on the +challengers / "they never stay long" / "Play". Why it was odd: it switched genre from mockumentary +to action trailer at the climax, and it was where the generated footage looked most generic. The +ending now stays inside the documentary form. The takes (lights, challenger, champion, serve) are +still in promo/trailer/clips if a 15-second cutdown ever wants them. + +## Sound rules that cost a render each +- Every segment is a whole number of frames, or the audio drifts a sub-frame per cut. +- Dialogue gain is MEASURED (`dlg`), never guessed: every line lands at DLG LUFS. +- Loudness is two-pass `loudnorm ... linear=true` to -14 LUFS / -1 dBTP. One-pass loudnorm is a + compressor and pumped the silence before "Play." back up. +- No long discrete echoes on an impact: a 0.9 s repeat read as a click on the logo. +- Encode with `-tune grain` and a 14 Mb/s cap: the grain made an uncapped crf-16 file 480 MB. +- QA listening (`qa.py`, Gemini): trust it on spoken lines and on mix complaints, NOT on its + timestamps for music (they drift up to 1.5x) and not on transcripts of non-speech audio + (it invents words from footsteps). diff --git a/tools/trailer/clips.sh b/tools/trailer/clips.sh new file mode 100644 index 0000000..a027d51 --- /dev/null +++ b/tools/trailer/clips.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# The takes. Each is one image-to-video call off its still; dialogue and ambience come from the model. +# usage: sh tools/trailer/clips.sh ... (parallel; TAKE=v2 to keep earlier takes) +cd "$(dirname "$0")/../.." +M=${MODEL:-google/veo-3.1-fast} +T=${TAKE:-v1} +D=promo/trailer/stills +END="Cinematic 35mm film look, consistent with the first frame. No music. No subtitles, no captions, no on-screen text." +c() { uv run -q --with requests --with pillow python tools/trailer/gen.py clip "$1_$T" "$M" "$2" "$D/$3.png" "$4 $END" ; } +take() { + case $1 in + gates) c gates 8 gates "Very slow, smooth dolly forward through the open iron gates into the fog. A crow shifts on the gate and one takes off, flapping away into the mist. Audio: cold wind, crows cawing, gravel underfoot; far away inside the fog, the hollow pok... pok... pok... of a tennis rally, though nobody is there." ;; + court) c court 6 court "Locked-off static shot. The sprinkler ticks and sprays its arc. The distant figure at the far baseline stays perfectly still, then very slowly turns its head toward the camera. Audio: the tick-tick-tick of the sprinkler, hissing water, otherwise silence." ;; + board) c board 6 board "Very slow push-in toward the last line of the honours board, 1911 MATCH IN PROGRESS. The lettering stays sharp, legible and unchanged. The pale man standing in the dark doorway slowly steps backwards into the darkness and disappears. Audio: a slow grandfather clock ticking, a creaking floorboard." ;; + reg) c reg 8 reg "Documentary talking-head interview, static camera on a tripod with a barely perceptible slow push-in. The zombie groundskeeper turns the single blade of grass in his fingers, then lifts his cloudy eyes just off camera toward the interviewer and says, in a slow, gravelly, deadpan old English countryside accent: \"Fertiliser. Formula Seven.\" He pauses and looks fondly back down at the blade of grass. \"Lovely green. Very... persistent.\" Audio: only his voice, the quiet room tone of a wooden shed, faint rain on the roof, one distant crow." ;; + tin) c tin 4 tin "Macro, static camera. The luminous green sludge slowly oozes out of the tin into the soil; the grass blades around it curl toward it like fingers; the dead hand's fingers twitch once. Audio: a slow wet squelch, a deep low rumble." ;; + doris) c doris 8 doris "Documentary talking-head interview, static camera on a tripod. The zombie strawberry seller smiles politely just off camera at the interviewer and says, in a warm, chirpy but slightly hollow old London accent: \"People say it was the cream.\" Short pause, the smile stays. \"It wasn't the cream.\" Her smile slowly freezes. \"The cream was just... old.\" Audio: only her voice, morning birdsong, bunting flapping softly in the wind." ;; + cream) c cream 4 cream "Static macro. The surface of the cream in the jug slowly ripples on its own, as if something beneath it took a breath. The fly takes off. Audio: the buzz of the fly, then silence." ;; + clive) c clive 8 clive "Documentary talking-head interview, static camera on a tripod. The zombie line judge, arms folded, says just off camera to the interviewer, in a pompous, nasal, utterly certain English accent: \"It's in the bye-laws. Lifetime membership.\" He leans forward slightly. \"Nobody ever defined... lifetime.\" Audio: only his voice, a ticking wall clock, quiet room tone." ;; + bylaws) c bylaws 4 bylaws "Static overhead. The dead grey fingertip slowly slides along the page underlining the words MEMBERSHIP SHALL BE FOR LIFE; the printed lettering stays sharp and unchanged. Audio: dry paper rustle, a fingernail scraping paper." ;; + ambrose) c ambrose 8 ambrose "Low-angle static shot. The ancient zombie chair umpire leans to the gooseneck microphone and speaks in a thin, ancient, precise upper-class English voice, amplified through an old public-address system with a faint echo across the empty court: \"Nineteen-eleven. Fifth set. No tie-break.\" A long pause, he does not blink. \"We are still waiting for a result.\" Audio: only his amplified voice and its echo, fog-muffled silence, one distant crow." ;; + archive) c archive 6 archive "Flickering hand-cranked 1911 film footage at a jerky 16 frames per second, black and white, scratches, dust and gate weave. The two gaunt gentlemen trade one slow, exhausted rally; one of them sways as if falling asleep standing. The spectators in deckchairs do not move. Audio: only the rattle of an old film projector." ;; + stare_reg) c stare_reg 4 reg "Static camera. The zombie groundskeeper slowly lifts his head and stares directly into the camera lens, unblinking, silent, completely still. Audio: room tone, rain on the roof, nothing else." ;; + stare_doris) c stare_doris 4 doris "Static camera. The zombie strawberry seller turns her head to look straight into the camera lens and says sweetly, in her old London accent: \"They never stay long.\" Then she keeps smiling. Audio: only her voice and soft wind." ;; + stare_clive) c stare_clive 4 clive "Static camera, slow push-in. The zombie line judge looks straight into the lens and, with one finger, slowly pulls his small round dark sunglasses down his nose, revealing two completely blank, milky-white dead eyes with no irises and no pupils, like boiled eggs. He does not blink. Nobody speaks. Audio: room tone, a clock ticking." ;; + stare_ambrose) c stare_ambrose 4 ambrose "Static low-angle camera. The ancient chair umpire leans to the gooseneck microphone and says, slowly and firmly, through an old echoing public-address system: \"Quiet, please.\" Audio: only his amplified voice and its echo dying away into the fog." ;; + lights) c lights 6 lights "Static wide shot. One after another, the remaining floodlight banks switch on with heavy electrical clunks, each tower blasting beams through the fog in turn, until the whole court is lit. The two figures on the court stand motionless. Audio: four heavy relay clunks, a rising electrical hum." ;; + stands) c stands 6 stands "Very slow push-in. The dead spectators sit perfectly still and silent. Then, all at the same moment, every head in the stand slowly turns to look directly at the camera. Audio: silence, faint wind, one wooden creak." ;; + challenger) c challenger 8 challenger "Steadicam tracking behind the young woman as she walks to the baseline, stops, and bounces the tennis ball twice on the grass. Across the net, the shambling figures emerge from the fog and come closer. Audio: footsteps on wet grass, two crisp ball bounces, low groans in the fog, the hum of the floodlights." ;; + champion) c champion 8 champion "Static wide shot. The towering zombie champion walks slowly out of the fog toward the net, stops, carefully sets the tarnished trophy down on the grass beside the net post, then raises his old wooden racket and points it straight at the camera. Audio: heavy slow footsteps, the metallic clink of the trophy on the ground, a low rasping growl." ;; + ambrose_night) c ambrose_night 4 ambrose_night "Static low-angle close shot. The ancient chair umpire leans into the microphone and says one single word, slowly, clearly and crisply enunciated, in a thin, dry, ancient upper-class English voice, with a gentle echo from the stadium speakers: \"Play.\" The word is clean and intelligible, not distorted, not whispered. Audio: only that one word and its soft echo, then the low hum of the floodlights." ;; + 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." ;; + *) echo "unknown take $1" ;; + esac +} +for n in "$@"; do take "$n" & done; wait diff --git a/tools/trailer/cut.py b/tools/trailer/cut.py new file mode 100644 index 0000000..1756c8a --- /dev/null +++ b/tools/trailer/cut.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""The edit. EDL below is the whole film: picture segments in order, then music and effects placed +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 --mix # reuse segments, redo concat/mix/grade only +""" +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') +CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +FPS, W, H = 24, 1080, 1920 + +clip = lambda n: os.path.join(T, 'clips', n + '.mp4') +card = lambda n: os.path.join(T, 'cards', n + '.png') +aud = lambda n: os.path.join(T, 'audio', n) +game = lambda n: os.path.join(ROOT, 'assets', 'audio', n) + +# (name, source clip or None for black, in, dur, opts) +# ov: [(card, t_in, t_out)] faded overlays · fi/fo: picture fade from/to black · gain: clip audio dB +# mute: drop the clip's own audio · dlg: a spoken line — its gain is MEASURED so every voice in the +# film sits at DLG LUFS (eq: lift a voice out of loud ambience first) +L3 = (0.7, 4.8) +DLG = -20.0 +QUIET = 2.3 # seconds into s_amb where "please" ends and the music is cut +EDL = [ + ('gates', 'gates_v1', 0.0, 7.0, dict(fi=1.2, gain=-2, afi=1.0)), + ('court', 'court_v1', 0.0, 5.0, dict(gain=-4)), + ('board', 'board_v1', 0.0, 5.5, dict(fo=0.5, gain=-3, afo=0.5)), + ('trad', None, 0, 5.2, dict(ov=[('t_a', 0.3, 4.8), ('t_b', 2.3, 4.8)])), + ('title', None, 0, 4.0, dict(ov=[('title', 0.2, 3.5)])), + ('q1', None, 0, 2.3, dict(ov=[('q_start', 0.15, 2.0)])), + ('reg', 'reg_v1.keep', 0.0, 7.3, dict(ov=[('lt_reg', *L3)], dlg=True)), + ('tin', 'tin_v1', 0.0, 3.0, dict(gain=-6)), + ('doris', 'doris_v1', 0.0, 8.0, dict(ov=[('lt_doris', *L3)], dlg=True)), + ('cream', 'cream_v1', 0.0, 3.2, dict(gain=-6)), + ('clive', 'clive_v1', 0.0, 6.3, dict(ov=[('lt_clive', *L3)], dlg=True)), + ('bylaws', 'bylaws_v1', 0.0, 3.0, dict(gain=-6)), + ('ambrose', 'ambrose_v1', 0.0, 7.0, dict(ov=[('lt_ambrose', *L3)], dlg=True)), + ('archive', 'archive_v1', 0.0, 5.0, dict(fo=0.5, gain=-4, afo=0.5)), + ('q2', None, 0, 2.3, dict(ov=[('q_chall', 0.15, 2.0)])), + ('s_doris', 'stare_doris_v1', 0.6, 3.4, dict(dlg=True)), + ('s_clive', 'stare_clive_v2', 0.8, 3.2, dict(gain=-6)), + # "Quiet, please." — and the score obeys: it stops dead on the end of "please" (see MUSIC) + ('s_amb', 'stare_ambrose_v1', 0.0, 3.2, dict(dlg=True, afo=0.9)), + ('stands', 'stands_close_v2', 0.9, 3.5, dict(gain=-17)), # the heads creak, barely + ('play', 'ambrose_night_v2', 0.0, 2.0, dict(dlg=True)), + ('splat', None, 0, 1.6, dict()), + ('logo', None, 0, 4.6, dict(ov=[('logo', 0.25, 4.6)], fo=0.6)), + ('stinger', 'reg_dawn_v1', 0.0, 5.4, dict(fi=0.4, afi=0.35, dlg=(0.0, 2.6), lift=2, eq=True)), + ('end', None, 0, 4.2, dict(ov=[('end', 0.5, 4.2)], fo=1.0)), +] + +# 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 +for name, _, _, dur, _ in EDL: + start[name] = t + t += dur +TOTAL = t + +# music/effects: (file, src_in, dur, at, gain dB, fade_in, fade_out) +MUSIC = [ + (aud('m_open.mp3'), 0.0, 30.5, start['gates'] + 1.5, -11, 2.0, 2.5), + (aud('cue_a.mp3'), 35.0, 38.6, 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), + (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 run(cmd): + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode: + sys.exit(f'FAILED: {" ".join(cmd)[:400]}\n{r.stderr[-1500:]}') + + +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'--screenshot={card(c)}', f'{page}#{c}']) + + +def splat(): + """The impact: the game's own headshot splat on top of a synthesized trailer boom (a sub drop + and a noise transient into a long tail), so the cut to black lands like a body hitting the floor.""" + run(['ffmpeg', '-v', 'error', '-y', + '-f', 'lavfi', '-i', "aevalsrc='0.9*sin(2*PI*(30+40*exp(-6*t))*t)*exp(-1.4*t)':s=48000:d=4.5", + '-f', 'lavfi', '-i', "anoisesrc=d=4.5:c=brown:r=48000:a=0.7", + '-i', game('headshot-1.wav'), + '-filter_complex', + '[1]afade=out:st=0:d=0.35:curve=exp,lowpass=f=900[n];' + '[2]aresample=48000,volume=1.0,aecho=0.8:0.6:60|130:0.35|0.2[s];' + # dense short reflections, not long discrete echoes: a 0.9 s repeat read as a click + '[0][n][s]amix=inputs=3:normalize=0,aecho=0.8:0.7:37|61|97|141|193:0.35|0.3|0.25|0.2|0.15,' + 'aformat=channel_layouts=stereo,volume=0.7,alimiter=limit=0.7:attack=1:release=80', + 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), + '-af', eq + 'ebur128', '-f', 'null', '-'], capture_output=True, text=True).stderr + return float(r[r.rindex('I:'):].split()[1]) + + +def segment(i, name, src, tin, dur, op): + out = os.path.join(B, f'{i:02d}_{name}.mov') + cmd = ['ffmpeg', '-v', 'error', '-y'] + if src: + cmd += ['-ss', f'{tin:.3f}', '-t', f'{dur:.3f}', '-i', clip(src)] + else: + cmd += ['-f', 'lavfi', '-t', f'{dur:.3f}', '-i', f'color=black:s={W}x{H}:r={FPS}', + '-f', 'lavfi', '-t', f'{dur:.3f}', '-i', 'anullsrc=r=48000:cl=stereo'] + first_ov = 1 if src else 2 + for c, *_ in op.get('ov', []): + 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', [])): + 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}]') + v = f'v{k + 1}' + vf = [] + if op.get('fi'): vf.append(f'fade=in:st=0:d={op["fi"]}') + if op.get('fo'): vf.append(f'fade=out:st={dur - op["fo"]:.3f}:d={op["fo"]}') + f.append(f'[{v}]' + (','.join(vf) or 'null') + ',format=yuv420p[v]') + a_in = '0:a' if src else '1:a' + eq = 'highpass=f=120,equalizer=f=3000:t=q:w=1.2:g=4,' if op.get('eq') else '' # pull a voice out of its ambience + g = -60 if op.get('mute') else op.get('gain', 0) + if op.get('dlg'): + w = op['dlg'] if isinstance(op['dlg'], tuple) else (0, dur) # measure only where the line is + g = round(DLG + op.get('lift', 0) - lufs(src, tin + w[0], w[1] - w[0], eq), 1) + print(f' {name:8s} dialogue gain {g:+.1f} dB') + f.append(f'[{a_in}]aresample=48000,aformat=channel_layouts=stereo,{eq}volume={g}dB,' + f'afade=in:d={op.get("afi", 0.02)},afade=out:st={dur - op.get("afo", 0.04):.3f}:d={op.get("afo", 0.04)},' + f'apad,atrim=0:{dur:.3f}[a]') + cmd += ['-filter_complex', ';'.join(f), '-map', '[v]', '-map', '[a]', '-t', f'{dur:.3f}', + '-c:v', 'libx264', '-crf', '12', '-preset', 'medium', '-pix_fmt', 'yuv420p', '-r', str(FPS), + '-c:a', 'pcm_s16le', out] + run(cmd) + return out + + +def mix(parts): + lst = os.path.join(B, 'list.txt') + open(lst, 'w').write(''.join(f"file '{p}'\n" for p in parts)) + cat = os.path.join(B, 'picture.mov') + run(['ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', lst, '-c', 'copy', cat]) + cmd = ['ffmpeg', '-v', 'error', '-y', '-i', cat] + f, labels = [], ['[0:a]'] + 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 '' + 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}]') + labels.append(f'[m{k}]') + f.append(''.join(labels) + f'amix=inputs={len(labels)}:normalize=0:duration=first[mx]') + # measure pass: the same graph, audio only, printing the loudnorm stats + meas = subprocess.run(['ffmpeg', '-hide_banner', '-v', 'info'] + cmd[3:] + ['-filter_complex', ';'.join(f) + ';[mx]loudnorm=I=-14:TP=-1.0:LRA=20:print_format=json[a]', + '-map', '[a]', '-f', 'null', '-'], capture_output=True, text=True).stderr + import json + m = json.loads(meas[meas.rindex('{'):meas.rindex('}') + 1]) + f.append(f'[mx]loudnorm=I=-14:TP=-1.0:LRA=20:linear=true:measured_I={m["input_i"]}:measured_TP={m["input_tp"]}:' + f'measured_LRA={m["input_lra"]}:measured_thresh={m["input_thresh"]}:offset={m["target_offset"]},' + 'aresample=48000[a]') + 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]') + out = os.path.join(T, 'lifetime-members.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) + '-c:v', 'libx264', '-crf', '18', '-tune', 'grain', '-maxrate', '14M', '-bufsize', '28M', + '-preset', 'slow', '-profile:v', 'high', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '320k', '-ar', '48000', '-movflags', '+faststart', out] + run(cmd) + print(out, f'{TOTAL:.1f}s') + + +if __name__ == '__main__': + os.makedirs(B, exist_ok=True) + if '--mix' in sys.argv: + parts = [os.path.join(B, f'{i:02d}_{e[0]}.mov') for i, e in enumerate(EDL)] + else: + 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') diff --git a/tools/trailer/frame.html b/tools/trailer/frame.html new file mode 100644 index 0000000..1245b7c --- /dev/null +++ b/tools/trailer/frame.html @@ -0,0 +1,77 @@ + + + + + +
Every old club has its traditions.
+
Some of them refuse to die.
+ +
+ + + + + + + +
LIFETIME
+
MEMBERS
+
+
ST. MORDRED'S LAWN TENNIS CLUB
+
EST. 1877
+
+ +
How did it start?
+
And the challengers?
+ +
Reginald MossHead Groundskeeper1962 –
+
Doris PennyStrawberries & Cream1971 –
+
Clive BarrowLine Judge1974 –
+
Ambrose PikeChair Umpire1908 –
+ + + +
+ +
Lifetime membership now open.
+
+ diff --git a/tools/trailer/gen.py b/tools/trailer/gen.py new file mode 100644 index 0000000..fed520b --- /dev/null +++ b/tools/trailer/gen.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""The trailer's generator: stills, clips and score through the OpenRouter key in the repo's .env. + + 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 "" + +Everything lands in promo/trailer/ (gitignored). Each call also writes .json beside its +output with the exact request, so a take can be re-rolled or compared later. +""" +import base64, json, os, sys, time + +import requests + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.path.join(ROOT, 'promo', 'trailer') +API = 'https://openrouter.ai/api/v1' +IMG_MODEL = os.environ.get('TRAILER_IMG', 'google/gemini-3-pro-image') + + +def key(): + for line in open(os.path.join(ROOT, '.env')): + if line.startswith('OPENROUTER_API_KEY='): + return line.split('=', 1)[1].strip().strip('"').strip("'") + sys.exit('no OPENROUTER_API_KEY in .env') + + +H = lambda: {'Authorization': f'Bearer {key()}', 'Content-Type': 'application/json'} + + +def u(p): + ext = 'jpeg' if p.lower().endswith(('.jpg', '.jpeg')) else 'png' + return f'data:image/{ext};base64,' + base64.b64encode(open(p, 'rb').read()).decode() + + +def note(path, req): + req = {k: v for k, v in req.items() if k not in ('input_references', 'frame_images')} + json.dump(req, open(os.path.splitext(path)[0] + '.json', 'w'), indent=1) + + +def still(name, prompt, refs=(), ar='9:16', res='2K'): + req = {'model': IMG_MODEL, 'prompt': prompt, 'resolution': res, 'aspect_ratio': ar} + if refs: + req['input_references'] = [{'type': 'image_url', 'image_url': {'url': u(r)}} for r in refs] + r = requests.post(f'{API}/images', headers=H(), json=req, timeout=1800) + if r.status_code != 200: + sys.exit(f'{name}: HTTP {r.status_code} {r.text[:400]}') + d = (r.json().get('data') or [{}])[0] + b = d.get('b64_json') or d.get('image_url', {}).get('url', '').split(',')[-1] + if not b: + sys.exit(f'{name}: no image returned {r.text[:400]}') + out = os.path.join(OUT, 'stills', name + '.png') + open(out, 'wb').write(base64.b64decode(b)) + note(out, dict(req, refs=list(refs))) + print(out, flush=True) + + +def clip(name, model, secs, first, prompt, last=None, res='1080p', audio=True): + frames = [{'type': 'image_url', 'image_url': {'url': u(first)}, 'frame_type': 'first_frame'}] + if last: + frames.append({'type': 'image_url', 'image_url': {'url': u(last)}, 'frame_type': 'last_frame'}) + req = {'model': model, 'prompt': prompt, 'duration': int(secs), 'aspect_ratio': '9:16', + 'resolution': res, 'generate_audio': audio, 'frame_images': frames} + r = requests.post(f'{API}/videos', headers=H(), json=req, timeout=300) + if r.status_code not in (200, 201, 202): + sys.exit(f'{name}: HTTP {r.status_code} {r.text[:600]}') + job = r.json() + poll = job.get('polling_url') or f"{API}/videos/{job['id']}" + t0 = time.time() + while True: + time.sleep(10) + s = requests.get(poll, headers=H(), timeout=60).json() + st = s.get('status') + if st in ('completed', 'succeeded'): + break + if st in ('failed', 'error', 'cancelled'): + sys.exit(f'{name}: {st} {json.dumps(s)[:600]}') + if time.time() - t0 > 1500: + sys.exit(f'{name}: timed out, job {job.get("id")}') + urls = s.get('unsigned_urls') or s.get('urls') or [o.get('url') for o in s.get('output', []) if isinstance(o, dict)] + out = os.path.join(OUT, 'clips', name + '.mp4') + v = requests.get(urls[0], headers=H(), timeout=300) # the "unsigned" URL still wants auth + open(out, 'wb').write(v.content) + note(out, dict(req, first=first, last=last, cost=s.get('usage') or s.get('cost'))) + 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} + 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]}') + chunks, fmt, text = [], 'mp3', [] + for line in r.iter_lines(): + if not line.startswith(b'data: ') or line == b'data: [DONE]': + continue + ev = json.loads(line[6:]) + for ch in ev.get('choices', []): + d = ch.get('delta', {}) + a = d.get('audio') or {} + if a.get('data'): + chunks.append(a['data']) + fmt = a.get('format', fmt) + if d.get('content'): + 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))) + note(out, req) + print(out, ''.join(text)[:300], flush=True) + + +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] == ['music']: + music(a[1], a[2], *a[3:4]) + else: + sys.exit(__doc__) diff --git a/tools/trailer/qa.py b/tools/trailer/qa.py new file mode 100644 index 0000000..1154fc6 --- /dev/null +++ b/tools/trailer/qa.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Look at and listen to a take: a contact sheet of N frames, plus a transcript and a description of +the soundtrack (an audio model hears it so the cut does not rest on guesswork). + + uv run --with requests python tools/trailer/qa.py [frames=6] +""" +import base64, json, os, subprocess, sys, tempfile + +import requests + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from gen import API, H, OUT + +clip = sys.argv[1] +n = int(sys.argv[2]) if len(sys.argv) > 2 else 6 +name = os.path.splitext(os.path.basename(clip))[0] +dur = float(subprocess.check_output(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', + '-of', 'csv=p=0', clip]).decode()) +info = subprocess.check_output(['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,width,height,r_frame_rate', + '-of', 'compact', clip]).decode().strip() +print(f'{name}: {dur:.2f}s {info}') +sheet = os.path.join(OUT, 'qa', name + '.jpg') +os.makedirs(os.path.dirname(sheet), exist_ok=True) +tmp = tempfile.mkdtemp() +for i in range(n): + t = dur * (i + 0.5) / n + subprocess.run(['ffmpeg', '-v', 'error', '-y', '-ss', f'{t:.2f}', '-i', clip, '-frames:v', '1', + '-vf', 'scale=-2:640', f'{tmp}/{i:02d}.jpg'], check=True) +subprocess.run(['magick', *sorted(f'{tmp}/{f}' for f in os.listdir(tmp)), '+append', sheet], check=True) +print('sheet', sheet) +if 'audio' not in info: + sys.exit('no audio stream') +wav = f'{tmp}/a.wav' +subprocess.run(['ffmpeg', '-v', 'error', '-y', '-i', clip, '-ac', '1', '-ar', '16000', wav], check=True) +r = requests.post(f'{API}/chat/completions', headers=H(), json={ + 'model': os.environ.get('QA_MODEL', 'google/gemini-2.5-flash'), + 'messages': [{'role': 'user', 'content': [ + {'type': 'text', 'text': 'Transcribe every spoken word in this clip verbatim with rough timestamps. ' + 'Then, in two short lines: describe the voice (accent, age, texture) and list every non-speech ' + 'sound you hear (music? ambience? effects?).'}, + {'type': 'input_audio', 'input_audio': {'data': base64.b64encode(open(wav, 'rb').read()).decode(), + 'format': 'wav'}}]}]}, timeout=300) +print(r.json()['choices'][0]['message']['content'] if r.ok else r.text[:500]) diff --git a/tools/trailer/segment.py b/tools/trailer/segment.py new file mode 100644 index 0000000..7898e33 --- /dev/null +++ b/tools/trailer/segment.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Cut the Lyria cues into their sections. Lyria writes medleys (three moods per cue, joined by +hard cuts), so the score is EDITED from sections rather than used whole. Novelty on a spectral +self-similarity curve finds the joins; each section is then written out as its own wav for audition. + + uv run --with numpy python tools/trailer/segment.py promo/trailer/audio/cue_a.mp3 ... +""" +import os, subprocess, sys +import numpy as np + +SR, HOP = 22050, 11025 # half-second frames +for f in sys.argv[1:]: + raw = subprocess.run(['ffmpeg', '-v', 'error', '-i', f, '-ac', '1', '-ar', str(SR), '-f', 's16le', '-'], + capture_output=True).stdout + x = np.frombuffer(raw, np.int16).astype(float) / 32768 + n = len(x) // HOP + fr = x[:n * HOP].reshape(n, HOP) * np.hanning(HOP) + spec = np.abs(np.fft.rfft(fr, axis=1)) + edges = np.geomspace(40, 10000, 25) + bins = np.fft.rfftfreq(HOP, 1 / SR) + bands = np.stack([spec[:, (bins >= a) & (bins < b)].mean(1) for a, b in zip(edges, edges[1:])], 1) + feat = np.log(bands + 1e-6) + feat = (feat - feat.mean(0)) / (feat.std(0) + 1e-6) + k = 4 # 2 s either side + nov = np.zeros(n) + for i in range(k, n - k): + nov[i] = np.linalg.norm(feat[i - k:i].mean(0) - feat[i:i + k].mean(0)) + cuts = [0] + for i in np.argsort(-nov): + if nov[i] < 2.2: break + if all(abs(i - c) >= 12 for c in cuts) and n - i >= 8: + cuts.append(i) + cuts = sorted(cuts) + [n] + rms = 20 * np.log10(np.sqrt((fr ** 2).mean(1)) + 1e-9) + base = os.path.splitext(f)[0] + for j, (a, b) in enumerate(zip(cuts, cuts[1:])): + out = f'{base}.s{j}.wav' + subprocess.run(['ffmpeg', '-v', 'error', '-y', '-ss', str(a / 2), '-t', str((b - a) / 2), '-i', f, out], check=True) + print(f'{os.path.basename(out)} {a/2:6.1f}-{b/2:6.1f}s rms {rms[a:b].mean():5.1f} dB') diff --git a/tools/trailer/stills.sh b/tools/trailer/stills.sh new file mode 100644 index 0000000..0e70daf --- /dev/null +++ b/tools/trailer/stills.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# The keyframes. Every still is one gen.py call; LOOK is the shared camera/grade/makeup law. +# usage: sh tools/trailer/stills.sh ... (runs the named ones in parallel) +cd "$(dirname "$0")/../.." +G="uv run -q --with requests --with pillow python tools/trailer/gen.py still" +D=promo/trailer/stills +LOOK="Vertical 9:16 cinematic film still from a prestige British sports documentary, live action, photographic. Shot on ARRI Alexa with vintage anamorphic lenses, shallow depth of field, fine 35mm film grain, muted teal-and-amber grade, deep shadows. The undead are real actors in high-end practical prosthetic zombie makeup, as in a big-budget horror comedy: mottled grey-green skin, sunken cheeks, dark veins at the temples, cloudy pale eyes, cracked lips, decayed but not gory. No captions, no subtitles, no watermark, no lower-third graphics, no added text." +INT="Documentary talking-head interview framing, medium close-up from mid-chest up, the subject slightly off-centre, eyeline just off camera toward an unseen interviewer, mouth clearly visible and closed, background in soft focus but rich in detail." +shot() { + case $1 in + reg) $G reg "$LOOK $INT Reginald, the head groundskeeper of an old English lawn tennis club, the same man and the same potting shed as in the reference photo: hulking, dead for decades, grey-green mottled skin, a torn left ear, cloudy pale-yellow eyes, thin wisps of grey hair. Faded bottle-green club blazer with an embroidered gold crest, frayed white shirt, striped club tie. He holds a tiny pair of nail scissors and a single blade of grass up near his chest, inspecting it with enormous tenderness. Soft-focus background: wooden rakes, shelves of rusty tins labelled FORMULA 7 LAWN TONIC, a foggy window with a glimpse of a striped grass court. Grey window light from the left, a warm bulb behind." $D/test_live.png ;; + doris) $G doris "$LOOK $INT Doris, the strawberries-and-cream seller of an old English lawn tennis club, a dead woman who looks about fifty-five: grey-green mottled skin, a slightly crooked jaw, cloudy pale eyes, grey hair in a neat bun under a white paper soda-jerk cap. A white apron over a pink-and-white striped dress; the apron is stained red, ambiguously strawberry juice. She stands behind the counter of an old-fashioned wooden strawberry kiosk with a faded red-and-white striped awning; punnets of glossy red strawberries and white china cream jugs on the counter in front of her. Soft-focus background: the club grounds in morning fog, sagging bunting. She smiles politely, a little too long." ;; + clive) $G clive "$LOOK $INT Clive, the line judge of an old English lawn tennis club, a very large, heavy-set dead man: grey-green jowly skin, a navy-and-white horizontally striped polo shirt, a navy cap, small round dark sunglasses that he never takes off, even indoors. He sits in the club's old wood-panelled officials' room: blazers on pegs, faded framed team photographs, stacked tennis ball tins, a green-shaded desk lamp. Arms folded over his belly, chin raised, utterly certain of himself." ;; + ambrose) $G ambrose "$LOOK Low-angle medium shot looking up at him. Ambrose, the chair umpire of an old English lawn tennis club, a gaunt and very old dead man: grey-green parchment skin stretched over the skull, deeply sunken eyes, a thin white moustache, cream linen blazer, navy tie, a panama hat with a navy band. He sits high up in a tall white-painted wooden umpire's chair beside a grass tennis court at dusk, thick fog behind him, an old chrome gooseneck microphone in front of his mouth. He looks down toward the camera with severe dignity, mouth closed." ;; + gates) $G gates "$LOOK Wide establishing shot at dawn in thick fog: tall black wrought-iron gates of an old English lawn tennis club, half open, ivy climbing the brick pillars, an ornate iron arch across the top spelling ST. MORDRED'S LAWN TENNIS CLUB in iron letters, a small brass plaque on the pillar reading EST. 1877, three crows perched on the gate. A gravel drive leads into the fog, where the faint shapes of grass courts and a Victorian clubhouse with a clock tower dissolve. Nobody in sight. Cold blue-grey light." ;; + court) $G court "$LOOK Wide shot at dawn: thick fog over an immaculate grass tennis court with perfect light-and-dark mowing stripes, crisp white lines, a sagging net. Tall portrait frame, upright, the same orientation as the reference image (which only shows the orientation and colour grade, not the content): the court recedes from the bottom edge of the frame up toward a foggy horizon in the upper third, the net crosses the frame horizontally in the middle, the sky is at the top. Camera at standing height at the near baseline looking down the length of the empty court. A small old brass impulse sprinkler sits on the grass at the lower left edge throwing a fine arc of water. Nothing else in the foreground: no people, no hands, no faces near the camera. Far away at the opposite baseline, small and barely visible in the fog, the dark silhouette of ONE figure in tennis whites stands perfectly still, head slightly tilted, a racket hanging from one hand. That distant figure is the only person in the image. Unsettling, quiet, cold." $D/gates.png ;; + board) $G board "$LOOK Interior, dim Victorian clubhouse corridor, a shaft of dusty light. Close shot of a dark mahogany honours board with gold-leaf lettering. Heading: GENTLEMEN'S SINGLES. Rows below, in gilt: 1907 C. P. DIXON-HALE / 1908 E. G. MERRIWEATHER / 1909 L. H. BRAITHWAITE / 1910 R. F. ASHWORTH / 1911 MATCH IN PROGRESS. Every row below 1911 is empty bare wood. Cobwebs in the corner, a wilted carnation tucked into the frame, dust on the ledge. All lettering exactly as written, correctly spelled." ;; + tin) $G tin "$LOOK Extreme macro close-up at ground level on dark soil at night: a rusty, dented vintage tin with a faded label reading FORMULA 7 LAWN TONIC lies on its side, a thick luminous green sludge seeping out into the earth. The grass blades around it are unnaturally vivid green and a few are curled toward the tin like fingers. A single hard side light, low fog creeping over the soil." ;; + cream) $G cream "$LOOK Macro close-up: a white china cream jug on a lace doily on a weathered wooden counter, filled to the brim with thick, faintly yellowed cream, one glossy strawberry beside it, a single fly on the rim. Flat grey morning light, shallow depth of field, very still and quietly unsettling." ;; + bylaws) $G bylaws "$LOOK Close-up from above: an old leather-bound rule book lies open on a desk under a green banker's lamp. The yellowed page is typeset in ornate Victorian type: heading BYE-LAWS OF ST. MORDRED'S LAWN TENNIS CLUB, and below it, in bold, Rule 1. MEMBERSHIP SHALL BE FOR LIFE. A thick grey-green dead index finger with a dirty fingernail rests just under the words. Warm lamp light, deep shadows. All lettering exactly as written, correctly spelled." ;; + archive) $G archive "Vertical 9:16 black-and-white archival film still from 1911, heavy silver grain, scratches, dust, gate weave, vignette, slightly overexposed highlights. Two gentlemen in long white flannel trousers and long-sleeved shirts, gaunt and exhausted, play lawn tennis at dusk on a grass court with wooden rackets. Behind them a large hand-painted wooden scoreboard reads FIFTH SET and 112 - 112. In the foreground a few spectators in straw boaters and long dresses are asleep in deckchairs, cobwebs between two of them. Long shadows. Authentic period photograph, no modern elements, no captions." ;; + lights) $G lights "$LOOK Night. Wide low-angle shot: a grass tennis court in thick fog, four tall old lattice-steel floodlight towers with banks of round lamps. The nearest bank has just switched on and blasts hard white beams through the fog; the others are still dark. Ominous, cinematic, silent." ;; + stands) $G stands "$LOOK Tall portrait frame, upright, the same orientation as the reference image (which only shows the orientation and grade, not the content): the people sit upright with heads toward the top of the frame and feet toward the bottom, the rows of the stand are horizontal lines stacked one above another from the bottom of the frame to the top. Night, floodlit, shot from courtside looking up at a steep old wooden spectator stand beside an English tennis court, every seat filled with dead spectators in their Sunday best, straw boaters, blazers, summer dresses, one lady holding a parasol, sitting perfectly upright with hands folded in their laps, silent, all heads turned the same way toward the court, cloudy eyes. Fog drifts between the rows, harsh light from above. Eerie and darkly comic, like a very polite audience at a funeral." $D/challenger.png ;; + challenger) $G challenger "$LOOK Night, floodlit, fog. Over-the-shoulder hero shot from behind and slightly low: a living young woman tennis player in crisp all-white kit, a white headband and a long dark ponytail, a racket in her right hand and a yellow tennis ball in her left, walking onto the near baseline of a grass court. Across the net, in the fog, the faint silhouettes of many figures shambling closer. Floodlight beams cut through the haze. Lonely, heroic, tense." ;; + champion) $G champion "$LOOK Night, floodlit, fog, wide shot across the net of a grass tennis court: on the far side a towering, muscular dead tennis champion steps out of the fog toward the net. Grey-green skin, his skull wrapped in old yellowed bandages with one cloudy eye showing, a dirty white polo shirt with gold trim and white shorts, a large tarnished silver trophy cup in one hand and an old wooden racket in the other. Strong backlight from the floodlights, a long shadow reaching toward camera, shambling dead figures behind him in the haze." ;; + ambrose_night) $G ambrose_night "$LOOK The same man in the same tall white umpire's chair as in the reference image, but now at night under hard floodlights, thick fog glowing behind him. Medium close-up, low angle: he leans toward the old chrome gooseneck microphone, lips close to it, mouth closed, solemn. Cream linen blazer, navy tie, panama hat." $D/ambrose.png ;; + serve) $G serve "$LOOK The same living young woman as in the reference image (white headband, long dark ponytail, crisp white tennis dress, healthy living skin, NOT a zombie). Night: low angle from behind and below her, looking up past her raised left arm at a yellow tennis ball hanging at the apex of a serve toss, a racket cocked behind the head, a floodlight flaring directly behind the ball, fog and glittering droplets in the beam. Slow-motion feeling, the moment before impact." $D/challenger.png ;; + reg_dawn) $G reg_dawn "$LOOK The same man as in the reference image, the zombie groundskeeper in the bottle-green club blazer, now outdoors at dawn in light fog on a grass tennis court. Medium shot: he kneels on the white baseline, carefully dabbing a large splash of dark red off the white line with a folded white handkerchief; a small tin of white line paint and a brush beside his knee. He has just glanced up toward the camera, polite, mouth closed." $D/reg.png ;; + stands_close) $G stands_close "$LOOK Night, floodlit, fog. Medium shot from courtside, slightly low, of three rows of an old wooden spectator stand, upright portrait framing with the people sitting upright: about nine dead spectators in their Sunday best, straw boaters, a cloche hat, a blazer with a carnation, a lace collar, one lady holding a closed parasol, all sitting perfectly upright with hands folded, faces clearly visible, grey-green skin and cloudy eyes, all of them looking to the left toward the court in profile. Silent, eerie, darkly comic. Same people, clothes and grade as the reference image." $D/stands.png ;; + *) echo "unknown still $1" ;; + esac +} +for n in "$@"; do shot "$n" & done; wait