#!/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')