Files
wuwiandClaude Opus 5.5 806a739c6f 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>
2026-10-05 11:46:31 +02:00

345 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 # 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.
"""
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')
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
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.6, 4.6)
DLG = -20.0
QUIET = 2.3 # seconds into s_amb where "please" ends and the music is cut
CUT = 3 # cut 2 (1:45) is kept beside it as lifetime-members-cut2.mp4, the reference
# Line ends below are MEASURED off each take's speech-band envelope, not off the QA transcript
# (its timestamps drift): cut 2 lost the end of Ambrose's "...result" by trusting 6.6 s; it is 7.2.
FULL = [
('gates', 'gates_v1', 0.0, 5.6, dict(fi=1.0, gain=-2, afi=0.8)),
('court', 'court_v1', 0.0, 4.0, dict(gain=-4)),
('board', 'board_v1', 0.0, 4.6, dict(fo=0.5, gain=-3, afo=0.5)),
('trad', None, 0, 4.4, dict(ov=[('t_a', 0.25, 4.0), ('t_b', 1.8, 4.0)])),
('title', None, 0, 3.4, dict(ov=[('title', 0.15, 3.0)])),
('q1', None, 0, 1.9, dict(ov=[('q_start', 0.1, 1.7)])),
('reg', 'reg_v1.keep', 0.0, 7.1, dict(ov=[('lt_reg', *L3)], dlg=True)),
('tin', 'tin_v1', 0.0, 2.2, dict(gain=-6)),
('doris', 'doris_v1', 0.0, 7.6, dict(ov=[('lt_doris', *L3)], dlg=True)),
('cream', 'cream_v1', 0.4, 2.2, dict(gain=-6)),
('clive', 'clive_v1', 0.0, 6.1, dict(ov=[('lt_clive', *L3)], dlg=True)),
('bylaws', 'bylaws_v1', 0.0, 2.2, dict(gain=-6)),
('ambrose', 'ambrose_v1', 0.0, 7.9, dict(ov=[('lt_ambrose', *L3)], dlg=True, afo=0.5)),
('archive', 'archive_v1', 0.0, 3.8, dict(fo=0.5, gain=-4, afo=0.5)),
('q2', None, 0, 1.9, dict(ov=[('q_chall', 0.1, 1.7)])),
('s_doris', 'stare_doris_v1', 0.7, 2.7, dict(dlg=True)),
('s_clive', 'stare_clive_v2', 1.0, 2.8, 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.0, dict(dlg=True, afo=0.8)),
('stands', 'stands_close_v2', 0.9, 3.0, dict(gain=-17)), # the heads creak, barely
('play', 'ambrose_night_v2', 0.0, 1.9, dict(dlg=True)),
('splat', None, 0, 1.2, dict()),
('logo', None, 0, 3.8, dict(ov=[('logo', 0.2, 3.8)], fo=0.5)),
('stinger', 'reg_dawn_v1', 0.6, 3.6, dict(fi=0.4, afi=0.35, dlg=(1.0, 2.7), lift=2, eq=True)),
('end', None, 0, 3.6, dict(ov=[('end', 0.4, 3.6)], fo=0.9)),
]
# THE CUTDOWN: one line per witness, cut INSIDE their takes at words.py's word timestamps
# (each cut re-checked by transcribing the segment itself). No title, no B-roll, no stands.
lt = lambda who, dur: [(f'lt_{who}', 0.2, dur - 0.15)]
SHORT_EDL = [
('gates', 'gates_v1', 0.0, 2.3, dict(fi=0.5, gain=-2, afi=0.4)),
('q1', None, 0, 1.4, dict(ov=[('q_start', 0.05, 1.3)])),
# "Fertiliser. Formula Seven."
('reg', 'reg_v1.keep', 0.0, 3.25, dict(ov=lt('reg', 3.25), dlg=True, afo=0.15)),
# "It wasn't the cream. The cream was just... old."
('doris', 'doris_v1', 1.75, 3.5, dict(ov=lt('doris', 3.5), dlg=True, afi=0.08, afo=0.15)),
# "Lifetime membership. Nobody ever defined... lifetime."
('clive', 'clive_v1', 1.6, 4.7, dict(ov=lt('clive', 4.7), dlg=True, afi=0.08, afo=0.12)), # "lifetime" ends at 6.0
# "Nineteen-eleven. Fifth set. No tie-break."
('ambrose', 'ambrose_v1', 0.0, 4.4, dict(ov=lt('ambrose', 4.4), dlg=True, afo=0.25)),
('q2', None, 0, 1.4, dict(ov=[('q_chall', 0.05, 1.3)])),
('s_doris', 'stare_doris_v1', 0.75, 2.5, dict(dlg=True)),
('s_clive', 'stare_clive_v2', 1.5, 2.0, dict(gain=-6)),
('s_amb', 'stare_ambrose_v1', 0.0, 2.8, dict(dlg=True, afo=0.7)),
('play', 'ambrose_night_v2', 0.0, 1.6, dict(dlg=True)),
('splat', None, 0, 0.9, dict()),
('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)),
]
# 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)),
]
# 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'), '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
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 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")
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
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):
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=6000',
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=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])
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, *fd) in enumerate(op.get('ov', [])):
j = first_ov + k
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"]}')
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 ''
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}]')
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
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
# 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, s in start.items():
print(f'{n:8s} {s:6.2f}s')