import asyncio, base64, json, os, subprocess, sys, shutil, wave import numpy as np from PIL import Image from playwright.async_api import async_playwright sys.path.insert(0, '/home/claude/afterlight/film') from edl import EDL ROOT = '/home/claude/afterlight' OUT = f'{ROOT}/film/build' os.makedirs(OUT, exist_ok=True) FPS = 24 idx = json.load(open(f'{ROOT}/archive/sheet_index.json')) cat = {x['id']: x for x in json.load(open(f'{ROOT}/archive/repo_catalog.json'))} ONLY = set(int(a) for a in sys.argv[1:]) if len(sys.argv) > 1 else None def run(cmd): r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode: print(' '.join(cmd)); print(r.stderr[-1500:]); raise SystemExit(1) def seg_len(s): return s['dur'] / s.get('slow', 1.0) if s['kind'] == 'clip' else s['dur'] def write_png(dataurl, path): open(path, 'wb').write(base64.b64decode(dataurl.split(',', 1)[1])) def frames_to_mp4(d, n, path): run(['ffmpeg', '-v', 'error', '-y', '-framerate', str(FPS), '-i', f'{d}/%05d.jpg', '-frames:v', str(n), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p', path]) def kenburns(png, dur, move, path): im = Image.open(f'{ROOT}/{png}').convert('RGB').resize((1280, 720)) d = f'{OUT}/kb'; shutil.rmtree(d, ignore_errors=True); os.makedirs(d) n = int(round(dur * FPS)) for i in range(n): u = i / max(1, n - 1); u = u * u * (3 - 2 * u) if move == 'in': z = 1.0 + 0.08 * u; cx, cy = 640, 360 else: z = 1.08; cx = 640 + 40 - 80 * u; cy = 360 w, h = 1280 / z, 720 / z box = (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2) im.crop(tuple(int(v) for v in box)).resize((1280, 720), Image.LANCZOS).save(f'{d}/{i:05d}.jpg', quality=92) frames_to_mp4(d, n, path) async def main(): async with async_playwright() as p: b = await p.chromium.launch() pg = await b.new_page(viewport={'width': 1280, 'height': 720}) await pg.goto(f'file://{ROOT}/film/inserts.html'); await pg.wait_for_timeout(800) await pg.evaluate('window.fontsReady.then(()=>document.fonts.ready).then(()=>true)') segs = []; labels = {} for i, s in enumerate(EDL): L = seg_len(s); base = f'{OUT}/seg{i:03d}' segs.append(base + '_final.mp4') if s.get('label'): labels[s['label']] = base + '_v.mp4' if ONLY is not None and i not in ONLY and os.path.exists(base + '_final.mp4'): continue k = s['kind']; prov = '' if s.get('label'): labels[s['label']] = base + '_v.mp4' if k == 'clip': cid = idx[s['key']]; src = f'{ROOT}/archive/clips/{cid}.mp4'; film = cat[cid]['film'] slow = s.get('slow', 1.0) run(['ffmpeg', '-v', 'error', '-y', '-ss', str(s['inp']), '-i', src, '-t', str(s['dur']), '-vf', f'setpts=PTS/{slow},scale=-2:720:flags=lanczos,pad=1280:720:(ow-iw)/2:0:color=0x09090a,fps={FPS},format=yuv420p', '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-t', f'{L:.3f}', base + '_v.mp4']) film = ''.join(ch for ch in film if ch.isascii() and ch.isprintable()).replace(' ',' ') prov = ('margin', (f'PHOTOGRAPHED · STAGED STARFIELD, 1900s FILM · {film[:40]} · movingimagearchive.com via CINEOSIS LAB' if s.get('staged') else f'PHOTOGRAPHED · {film[:46]} · movingimagearchive.com via CINEOSIS LAB')) s['_film'] = film; s['_id'] = cid elif k == 'cold': run(['ffmpeg', '-v', 'error', '-y', '-ss', str(s['inp']), '-i', f'{ROOT}/the-cold-hand.mp4', '-t', str(s['dur']), '-vf', f'scale=1280:720,fps={FPS},format=yuv420p', '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', base + '_v.mp4']) prov = ('corner', 'INVENTED · The Cold Hand (AFTERLIGHT case 04)') elif k == 'grab': src = labels[s['src']] run(['ffmpeg', '-v', 'error', '-y', '-ss', str(s['at']), '-i', src, '-frames:v', '1', base + '_g.png']) os.makedirs(f'{OUT}/grabs', exist_ok=True); shutil.copy(base + '_g.png', f"{OUT}/grabs/{s['src']}.png") kenburns(os.path.relpath(base + '_g.png', ROOT), L, 'in', base + '_v.mp4') elif k == 'still': kenburns(s['png'], L, s.get('move', 'in'), base + '_v.mp4') prov = ('corner', 'INVENTED · AFTERLIGHT keyframe') else: d = f'{OUT}/fr'; shutil.rmtree(d, ignore_errors=True); os.makedirs(d) n = int(round(L * FPS)) for f in range(n): t = f / FPS if k == 'card': du = await pg.evaluate('([a,b,t,d])=>frameCard(a,b,t,d)', [s['text'], s['title'], t, L]) else: du = await pg.evaluate('([n,t])=>frameAnim(n,t)', [s['name'], t]) open(f'{d}/{f:05d}.jpg', 'wb').write(base64.b64decode(du.split(',', 1)[1])) frames_to_mp4(d, n, base + '_v.mp4') if k == 'anim' and s['name'] not in ('title', 'end'): prov = ('corner', 'INVENTED · drawn insert') # overlays inputs = ['-i', base + '_v.mp4']; filt = []; last = '0:v'; j = 1 if prov: tp = base + '_tag.png'; write_png(await pg.evaluate('([a,b])=>tagPNG(a,b)', [prov[1], prov[0]]), tp) inputs += ['-loop', '1', '-t', f'{L:.3f}', '-i', tp] filt.append(f'[{last}][{j}:v]overlay=0:0[o{j}]'); last = f'o{j}'; j += 1 for (txt, a, e) in s.get('slips', []): sp = f'{base}_slip{j}.png'; write_png(await pg.evaluate('([t,p])=>slipPNG(t,p)', [txt, s.get('slip_pos','bottom')]), sp) inputs += ['-loop', '1', '-t', f'{L:.3f}', '-i', sp] filt.append(f'[{j}:v]format=rgba,fade=t=in:st={a}:d=0.35:alpha=1,fade=t=out:st={max(a, e - 0.35)}:d=0.35:alpha=1[s{j}]') filt.append(f'[{last}][s{j}]overlay=0:0[o{j}]'); last = f'o{j}'; j += 1 if filt: run(['ffmpeg', '-v', 'error', '-y', *inputs, '-filter_complex', ';'.join(filt), '-map', f'[{last}]', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', str(FPS), '-t', f'{L:.3f}', base + '_final.mp4']) else: shutil.copy(base + '_v.mp4', base + '_final.mp4') print(i, k, s.get('key', s.get('name', s.get('title', ''))), f'{L:.1f}s', flush=True) await b.close() with open(f'{OUT}/list.txt', 'w') as f: for p in segs: f.write(f"file '{p}'\n") run(['ffmpeg', '-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', f'{OUT}/list.txt', '-c', 'copy', f'{OUT}/picture.mp4']) # provenance log json.dump([{k: v for k, v in s.items() if not k.startswith('__')} for s in EDL], open(f'{OUT}/edl_resolved.json', 'w'), indent=1, ensure_ascii=False) asyncio.run(main())