engine/base.css
@font-face{font-family:'Geist';src:url(data:font/woff2;base64,__GEIST__) format('woff2');font-weight:100 900}
@font-face{font-family:'Geist Mono';src:url(data:font/woff2;base64,__GEISTMONO__) format('woff2');font-weight:100 900}
html,body{margin:0;background:#E9E7E2;overflow:hidden}
html.alpha,html.alpha body{background:transparent}
body.preview{height:100vh;background:#1b1b1b}
html:not(.alpha) body.preview{background:#E9E7E2}
#wrap{position:relative;transform-origin:50% 50%}
body.preview #wrap{position:fixed;left:50%;top:calc(50% - 10px)}
#stage{position:relative;overflow:hidden;font-family:'Geist',system-ui,sans-serif;-webkit-font-smoothing:antialiased;color:#0B0B0B;font-feature-settings:"tnum" 1}
#world{position:absolute;left:0;top:0;transform-origin:0 0}
#shape{position:absolute;overflow:hidden;isolation:isolate;box-shadow:0 1px 2px rgba(20,18,14,.06),0 10px 30px -8px rgba(20,18,14,.16)}
html.alpha #shape{box-shadow:none}
.L{position:absolute;width:0;height:0;transform-origin:0 0}
.a{position:absolute}
.row{position:absolute;display:flex;align-items:center;white-space:nowrap}
.blend{mix-blend-mode:difference}
.mono{font-family:'Geist Mono',ui-monospace,monospace}
svg{display:block;overflow:visible}
#cursor{position:absolute;left:0;top:0;height:auto;transform-origin:3px 3px;filter:drop-shadow(0 2px 3px rgba(0,0,0,.25));z-index:10}
engine/beats.js
// NODE_PATH=motion/node_modules node beats.js dist/clip.html out/sheet.png t1 t2 ... -> contact sheet of stills at those times
const {chromium}=require('playwright');const path=require('path');const os=require('os');const {execFileSync}=require('child_process');const fs=require('fs');
(async()=>{const [html,out,...ts]=process.argv.slice(2);
if(!html||!out||!ts.length){console.error('usage: node beats.js clip.html sheet.png t1 [t2 ...]');process.exit(2);}
const b=await chromium.launch();const p=await b.newPage({viewport:{width:1920,height:1080}});const errs=[];p.on('pageerror',e=>errs.push(e.message));
await p.goto('file://'+path.resolve(html)+'?render');await p.evaluate(()=>document.fonts.ready);
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'beats-'));const alpha=await p.evaluate(()=>{const a=document.documentElement.classList.contains('alpha');if(a){document.documentElement.style.background='#000';document.body.style.background='#000';}return a;});
try{
for(let i=0;i<ts.length;i++){await p.evaluate(t=>seek(t),+ts[i]);await p.screenshot({path:path.join(dir,`${String(i).padStart(3,'0')}.png`)});}
await b.close();
const cols=Math.min(4,ts.length),rows=Math.ceil(ts.length/cols);
execFileSync('ffmpeg',['-loglevel','error','-y','-i',path.join(dir,'%03d.png'),'-vf',`scale=640:-1,tile=${cols}x${rows}:padding=4:color=white`,'-frames:v','1',out],{stdio:'inherit'});
}finally{fs.rmSync(dir,{recursive:true,force:true});}
if(errs.length){console.error('ERR',errs);process.exit(1);}})().catch(e=>{console.error(e);process.exit(1);});
engine/build.py
"""Assemble clip fragments + engine + fonts into self-contained HTML files.
usage: python3 build.py <out_dir> clips/01-name.html [clips/02-name.html ...]"""
import base64, re, sys, pathlib
E = pathlib.Path(__file__).resolve().parent
b64 = lambda p: base64.b64encode(open(p, 'rb').read()).decode()
CURSOR = '<svg id="cursor" viewBox="0 0 40 56"><path d="M3 3 L3 41 L12.5 32 L19 47 L25.5 44.2 L19.2 29.8 L32 29.8 Z" fill="#0B0B0B" stroke="#fff" stroke-width="2.6" stroke-linejoin="round"/></svg>'
def build(src, dst):
frag = open(src, encoding='utf-8').read()
m = re.search(r'<title>(.*?)</title>', frag); title = m.group(1) if m else pathlib.Path(src).stem
css = ''.join(re.findall(r'<style>(.*?)</style>', frag, re.S))
slot = lambda n: (re.search(rf'<div data-slot="{n}">(.*?)</div><!--/{n}-->', frag, re.S) or [None, ''])[1]
js = ''.join(re.findall(r'<script>(.*?)</script>', frag, re.S))
base = open(E/'base.css').read().replace('__GEIST__', b64(E/'fonts/Geist-Variable.woff2')).replace('__GEISTMONO__', b64(E/'fonts/GeistMono-Medium.woff2'))
html = (f'<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><style>{base}{css}</style></head><body>\n'
f'<div id="wrap"><div id="stage"><div id="world">{slot("world")}<div id="shape">{slot("shape")}</div>{slot("over")}</div>{CURSOR}</div></div>\n'
f'<script>{open(E/"motion.js").read()}</script><script>{js}</script></body></html>')
open(dst, 'w', encoding='utf-8').write(html)
if __name__ == '__main__':
out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)
for s in sys.argv[2:]:
d = out/(pathlib.Path(s).stem + '.html'); build(s, d); print('built', d)
engine/fonts/OFL-Geist.txt
Copyright (c) 2023 Vercel, in collaboration with basement.studio
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
engine/motion.js
/* motion-kit engine: one shape that never cuts.
Every style is a pure function of time inside seek(t). No CSS transitions, no timers. */
(function(){
const M = window.M = {};
const clamp = M.clamp = (x,a=0,b=1)=>Math.min(b,Math.max(a,x));
M.lerp = (a,b,u)=>a+(b-a)*u;
const eo = M.eo = x=>{x=clamp(x);return 1-Math.pow(1-x,3)};
const eio = M.eio = x=>{x=clamp(x);return x*x*x*(x*(6*x-15)+10)};
// closed-form spring step response 0 -> 1 (zero initial velocity)
const S = M.S = (tau,w,z)=>{
if(tau<=0) return 0;
if(!isFinite(w)) return 1;
if(z<1){const wd=w*Math.sqrt(1-z*z);return 1-Math.exp(-z*w*tau)*(Math.cos(wd*tau)+z*w/wd*Math.sin(wd*tau));}
if(z>1+1e-6){const q=w*Math.sqrt(z*z-1),r1=-z*w+q,r2=-z*w-q;return 1-(r2*Math.exp(r1*tau)-r1*Math.exp(r2*tau))/(r2-r1);} // overdamped
return 1-Math.exp(-w*tau)*(1+w*tau);
};
M.MORPH=[15,0.84]; M.FAST=[27,0.86]; M.SLOW=[12.5,0.9]; M.SOFT=[10,0.95]; M.CAM=[7.5,1]; M.INSTANT=[Infinity,1];
// a value that changes target many times = the sum of one spring per change.
// period>0 and a closed loop adds the previous cycle's residue so t=period == t=0.
M.track = (v0,keys,def=M.MORPH,period=0)=>{
let prev=v0; const ks=[];
for(const k of keys){const d=k[1]-prev; prev=k[1]; const sp=k[2]||def; if(d!==0) ks.push([k[0],d,sp[0],sp[1]]);}
const loop = period>0 && Math.abs(prev-v0)<1e-9;
return t=>{let v=v0; for(const k of ks){v+=k[1]*S(t-k[0],k[2],k[3]); if(loop) v+=k[1]*(S(t+period-k[0],k[2],k[3])-1);} return v;};
};
const hex = M.hex = h=>[1,3,5].map(i=>parseInt(h.slice(i,i+2),16));
M.ctrack = (h0,keys,def=[20,1],period=0)=>{
const c0=hex(h0); const tr=[0,1,2].map(i=>M.track(c0[i],keys.map(k=>[k[0],hex(k[1])[i],k[2]]),def,period));
return t=>`rgb(${tr.map(f=>Math.round(clamp(f(t),0,255))).join(',')})`;
};
// step helper: pick the latest value whose time has passed
M.step = (v0,keys)=>t=>{let v=v0; for(const k of keys) if(t>=k[0]) v=k[1]; return v;};
// content swap: exit blurs out fast; enter waits, then blurs in (own timings -> no overlap)
M.vis = (t,tin,tout,o={})=>{
const din=o.din??0.07, lin=o.lin??0.26, lout=o.lout??0.12, bl=o.blur??12;
const a = tin==null?1:eo((t-tin-din)/lin);
const b = tout==null?0:eo((t-tout)/lout);
return {o:a*(1-b), blur:(1-a)*bl+b*bl*0.8, s:(0.94+0.06*a)*(1-0.03*b), a, b};
};
M.apply = (el,v,extra='')=>{
if(v.o<0.002){el.style.display='none';return false;}
el.style.display=''; el.style.opacity=v.o.toFixed(4);
el.style.filter=v.blur>0.05?`blur(${v.blur.toFixed(2)}px)`:'none';
el.style.transform=`scale(${v.s.toFixed(4)})${extra}`;
return true;
};
M.setText = (el,s)=>{ if(el._t!==s){ el.textContent=s; el._t=s; } };
// one icon set, 24-grid, stroke normalised so every icon has the same world stroke width
M.IC = {
arrow:['M5 12h14','M13 5l7 7-7 7'], check:['M20 6 9 17l-5-5'], x:['M18 6 6 18','M6 6l12 12'], plus:['M5 12h14','M12 5v14'],
folder:['M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'],
terminal:['m4 17 6-6-6-6','M12 19h8'], file:['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z','M14 2v4a2 2 0 0 0 2 2h4','M8 13h8','M8 17h5'],
pencil:['M21.17 6.81a1 1 0 0 0-3.99-3.99L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z','m15 5 4 4'],
coin:['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z','M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8','M12 18V6'],
clock:['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z','M12 6v6l4 2'],
chip:['M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z','M9 9h6v6H9z','M9 1v3','M15 1v3','M9 20v3','M15 20v3','M20 9h3','M20 14h3','M1 9h3','M1 14h3'],
sparkle:['M9.94 15.5A2 2 0 0 0 8.5 14.06l-6.14-1.58a.5.5 0 0 1 0-.96L8.5 9.94A2 2 0 0 0 9.94 8.5l1.58-6.14a.5.5 0 0 1 .96 0L14.06 8.5A2 2 0 0 0 15.5 9.94l6.14 1.58a.5.5 0 0 1 0 .96L15.5 14.06a2 2 0 0 0-1.44 1.44l-1.58 6.14a.5.5 0 0 1-.96 0z'],
castle:['M3 21V9l3-1v2h3V7l3-2 3 2v3h3V8l3 1v12','M3 21h18','M10 21v-4a2 2 0 0 1 4 0v4','M6 13h1','M17 13h1'],
search:['M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z','m21 21-4.3-4.3'],
};
M.icon = (name,size,color,sw=2.2)=>`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="${(sw*24/size).toFixed(3)}" stroke-linecap="round" stroke-linejoin="round">${M.IC[name].map(d=>`<path d="${d}"/>`).join('')}</svg>`;
// cursor path: keyframes [t,x,y] in world coords, eased between keys with a slight human arc
M.path = (CK)=>t=>{
if(t<=CK[0][0]) return {x:CK[0][1],y:CK[0][2]};
const L=CK[CK.length-1]; if(t>=L[0]) return {x:L[1],y:L[2]};
let i=0; while(t>=CK[i+1][0]) i++;
const [t0,x0,y0]=CK[i],[t1,x1,y1]=CK[i+1];
const u=eio((t-t0)/(t1-t0)), dx=x1-x0, dy=y1-y0, arc=Math.sin(Math.PI*u)*0.06;
return {x:x0+dx*u-dy*arc, y:y0+dy*u+dx*arc};
};
M.presses = (clicks=[],drags=[])=>{
const k=[];
for(const c of clicks){k.push([c-0.07,1,[45,1]],[c+0.035,0,[22,0.72]]);}
for(const [a,b] of drags){k.push([a-0.04,1,[45,1]],[b,0,[22,0.72]]);}
k.sort((a,b)=>a[0]-b[0]); return M.track(0,k);
};
// time at which a monotonic function first crosses a threshold (precomputed at 1ms)
M.crossTimes = (f,t0,t1,ths)=>ths.map(th=>{for(let t=t0;t<=t1;t+=0.001) if(f(t)>=th) return t; return Infinity;});
/* scene runner
cfg = { W,H, center:[x,y], bg:'#hex'|null(alpha), SH:{name:{w,h,r,bg,cam}}, start:'name', SEQ:[[t,'name'],...],
layers:[{el,tin,tout,anchor:'c'|'t'|'l',o,update(t,g)}], cursor:{keys,clicks,drags,size}, shapePress:[t],
intro:t|null, geom(t,g) -> g, extra(t,g), T } */
M.scene = (cfg)=>{
const $=id=>document.getElementById(id);
const {W,H}=cfg; const [CX,CY]=cfg.center||[W/2,H/2];
const stage=$('stage'), world=$('world'), shape=$('shape'), cur=$('cursor');
stage.style.width=W+'px'; stage.style.height=H+'px'; $('wrap').style.width=W+'px'; $('wrap').style.height=H+'px';
stage.style.background=cfg.bg||'transparent';
if(!cfg.bg) document.documentElement.classList.add('alpha');
const P0=cfg.SH[cfg.start], SEQ=cfg.SEQ;
const sp=cfg.spring||M.MORPH;
const tr=k=>M.track(P0[k],SEQ.map(([t,n])=>[t,cfg.SH[n][k]]),sp);
const shW=tr('w'), shH=tr('h'), shR=tr('r');
const shBG=M.ctrack(P0.bg,SEQ.map(([t,n])=>[t,cfg.SH[n].bg]),[20,1]);
const cam=M.track(P0.cam,SEQ.map(([t,n])=>[t,cfg.SH[n].cam]),cfg.camSpring||M.CAM);
const press=M.track(0,(cfg.shapePress||[]).flatMap(c=>[[c-0.07,1,[45,1]],[c+0.035,0,[22,.72]]]));
const intro=cfg.intro===undefined?0.02:cfg.intro;
const cpath=cfg.cursor?M.path(cfg.cursor.keys):null, cpress=cfg.cursor?M.presses(cfg.cursor.clicks,cfg.cursor.drags):null;
if(cfg.cursor) cur.style.width=(cfg.cursor.size||46)+'px'; else cur.style.display='none';
const layers=(cfg.layers||[]).map(L=>({...L,node:$(L.el)}));
const seek=(t)=>{
let g={t, w:shW(t), h:shH(t), r:shR(t), bg:shBG(t), s:cam(t), cx:0, cy:0, fx:0, fy:0, sc:1-0.035*press(t), op:1};
if(intro!=null){const a=S(t-intro,13,0.78); g.sc*=0.55+0.45*a; g.op=clamp((t-intro)/0.12);}
g.cursor=cpath?cpath(t):null;
if(cfg.geom) g=cfg.geom(t,g)||g;
g.r=Math.min(g.r,g.h/2,g.w/2);
world.style.transform=`translate(${(CX-g.s*g.fx).toFixed(3)}px,${(CY-g.s*g.fy).toFixed(3)}px) scale(${g.s.toFixed(5)})`;
const st=shape.style;
st.left=(g.cx-g.w/2).toFixed(3)+'px'; st.top=(g.cy-g.h/2).toFixed(3)+'px';
st.width=g.w.toFixed(3)+'px'; st.height=g.h.toFixed(3)+'px'; st.borderRadius=g.r.toFixed(3)+'px';
st.background=g.bg; st.transform=`scale(${g.sc.toFixed(5)})`; st.opacity=g.op.toFixed(4);
for(const L of layers){
const v=M.vis(t,L.tin,L.tout,L.o||{});
if(M.apply(L.node,v)){
const an=L.anchor||'c';
L.node.style.left=(an==='l'?0:g.w/2).toFixed(3)+'px';
L.node.style.top=(an==='t'?0:g.h/2).toFixed(3)+'px';
if(L.update) L.update(t,g,v);
}
}
if(cfg.extra) cfg.extra(t,g);
if(cpath){
const c=g.cursor, sx=CX+g.s*(c.x-g.fx), sy=CY+g.s*(c.y-g.fy);
cur.style.transform=`translate(${(sx-3).toFixed(2)}px,${(sy-3).toFixed(2)}px) scale(${(1-0.13*cpress(t)).toFixed(4)})`;
}
};
window.seek=seek; window.DURATION=cfg.T;
// preview mode: loop in the browser, scaled to fit
const RENDER=location.search.includes('render');
document.body.classList.add(RENDER?'render':'preview');
seek(0);
if(!RENDER){
const fit=()=>{const k=Math.min(innerWidth/W,(innerHeight-30)/H);const wr=$('wrap');wr.style.transform=`translate(-50%,-50%) scale(${k})`;};
fit(); addEventListener('resize',fit);
const t0=performance.now();
const loop=()=>{seek(((performance.now()-t0)/1000)%(cfg.T+0.8));requestAnimationFrame(loop);};
requestAnimationFrame(loop);
}
return seek;
};
})();
engine/render.js
// NODE_PATH=motion/node_modules node render.js dist/clip.html out/clip.(mp4|mov) [fps, e.g. 30000/1001 | 25 | 60]
// 4 subframes per frame across a 180° shutter, blended with ffmpeg tmix. Alpha clips -> ProRes 4444 .mov
const {chromium}=require('playwright');const {spawn}=require('child_process');const path=require('path');
(async()=>{
const [html,out,fpsArg]=process.argv.slice(2); const FPS=!fpsArg?30000/1001:(fpsArg.includes('/')?fpsArg.split('/')[0]/fpsArg.split('/')[1]:+fpsArg);
const b=await chromium.launch();const p=await b.newPage({viewport:{width:1920,height:1080}});
const errs=[];p.on('pageerror',e=>errs.push(e.message));
await p.goto('file://'+path.resolve(html)+'?render');await p.evaluate(()=>document.fonts.ready);
const info=await p.evaluate(()=>({T:window.DURATION,W:document.getElementById('stage').offsetWidth,H:document.getElementById('stage').offsetHeight,alpha:document.documentElement.classList.contains('alpha')}));
await p.setViewportSize({width:info.W,height:info.H});
const N=Math.round(info.T*FPS), K=4, sub=1/(FPS*2*K);
const fr=(fpsArg&&fpsArg.includes('/'))?[fpsArg,`${fpsArg.split('/')[0]*4}/${fpsArg.split('/')[1]}`]:(FPS===30000/1001?['30000/1001','120000/1001']:[String(FPS),String(FPS*4)]);
const vf=`format=gbrap,tmix=frames=4:weights='1 1 1 1',select='eq(mod(n\\,4)\\,3)',setpts=N/(${fr[0]})/TB`;
const enc=info.alpha?['-c:v','prores_ks','-profile:v','4','-pix_fmt','yuva444p10le','-vendor','apl0']:['-c:v','libx264','-crf','14','-preset','medium','-pix_fmt','yuv420p','-movflags','+faststart'];
const ff=spawn('ffmpeg',['-loglevel','error','-y','-f','image2pipe','-framerate',fr[1],'-c:v','png','-i','-','-vf',vf,'-r',fr[0],...enc,out]);
ff.stderr.on('data',d=>process.stderr.write(d));
// if ffmpeg dies, stop feeding it instead of waiting forever for 'drain'
let ffCode=null;const closed=new Promise(r=>ff.on('close',c=>{ffCode=c;r();}));ff.stdin.on('error',()=>{});
for(let f=0;f<N&&ffCode===null;f++){for(let k=0;k<K&&ffCode===null;k++){const t=Math.max(0,f/FPS+(k-1.5)*sub);await p.evaluate(t=>seek(t),t);
const buf=await p.screenshot({type:'png',omitBackground:info.alpha});
if(!ff.stdin.write(buf)) await Promise.race([new Promise(r=>ff.stdin.once('drain',r)),closed]);}}
ff.stdin.end();await closed;await b.close();
if(ffCode!==0){console.error('ffmpeg failed (exit '+ffCode+'), '+out+' is incomplete');process.exit(1);}
console.log('rendered',out,N,'frames');
if(errs.length){console.error('page errors while rendering:\n'+errs.join('\n'));process.exit(1);}
})().catch(e=>{console.error(e);process.exit(1);});
examples/opus-aoe2/01-opus-drop.html
<title>01 Opus drop</title>
<style>
.lvl{position:absolute;top:-18px;width:200px;text-align:center;color:#fff;font-size:30px;font-weight:500}
</style>
<div data-slot="shape">
<div class="a" id="pill"></div>
<div class="L" id="Lopus"><div class="row" style="transform:translate(-50%,-50%);gap:16px;color:#fff;font-size:48px;font-weight:600;letter-spacing:-.02em"><span id="icSpark"></span>Opus 5.5<span id="badge" style="display:inline-block;background:#FF5A1F;color:#fff;font-size:22px;font-weight:600;letter-spacing:0;border-radius:999px;padding:6px 14px">New</span></div></div>
<div class="L" id="Laoe">
<div class="a" style="left:-358px;top:-98px;width:196px;height:196px;border-radius:38px;background:#FF5A1F"><div class="a" id="icCastle" style="left:38px;top:34px"></div></div>
<div class="a" style="left:-126px;top:-66px;font-size:54px;font-weight:600;letter-spacing:-.025em;white-space:nowrap">Age of Empires II</div>
<div class="a" style="left:-124px;top:6px;font-size:25px;color:#8A8782;white-space:nowrap">Browser replica · built by Opus 5.5</div>
</div>
<div class="L blend" id="Lseg">
<div class="lvl" style="left:-436px">Low</div><div class="lvl" style="left:-212px">Medium</div><div class="lvl" style="left:12px">High</div><div class="lvl" style="left:236px">Max</div>
</div>
</div><!--/shape-->
<script>
const $=id=>document.getElementById(id);
$('icSpark').innerHTML=M.icon('sparkle',40,'#FF5A1F');
$('icCastle').innerHTML=M.icon('castle',120,'#fff',4.2);
const badge=M.track(0,[[0.63,1,[16,0.7]]]);
const pL=M.track(-442,[[4.5,-218,M.SLOW],[4.85,6,M.SLOW],[5.3,230,M.SLOW]]);
const pR=M.track(-230,[[4.5,-6,M.FAST],[4.85,218,M.FAST],[5.3,442,M.FAST]]);
M.scene({
W:1920,H:1080,bg:'#E9E7E2',T:6.2,
SH:{opus:{w:440,h:120,r:60,bg:'#0B0B0B',cam:1.9}, aoe:{w:840,h:300,r:44,bg:'#FFFFFF',cam:1.45}, seg:{w:920,h:124,r:62,bg:'#FFFFFF',cam:1.55}},
start:'opus', SEQ:[[2.17,'aoe'],[4.06,'seg']],
layers:[
{el:'Lopus',tin:null,tout:2.17,update:(t)=>{const b=badge(t);$('badge').style.transform=`scale(${Math.max(0,b).toFixed(4)})`;$('badge').style.opacity=Math.min(1,b*1.5);}},
{el:'Laoe',tin:2.17,tout:4.06},
{el:'Lseg',tin:4.06,tout:null},
],
cursor:{size:44,clicks:[4.5,4.85,5.3],keys:[[0,520,320],[1.2,520,320],[1.9,300,130],[3.7,300,130],[4.36,-102,20],[4.56,-102,20],[4.78,122,20],[4.92,122,20],[5.2,346,20]]},
extra:(t,g)=>{
const v=M.vis(t,4.06,null,{din:0.05,lin:0.2}); const ps=$('pill').style;
if(v.o<0.002){ps.display='none';return;}
const L=pL(t)+g.w/2, R=pR(t)+g.w/2;
Object.assign(ps,{display:'',left:L+'px',width:Math.max(0,R-L)+'px',top:'12px',height:(g.h-24)+'px',borderRadius:((g.h-24)/2)+'px',background:'#0B0B0B',opacity:v.o,filter:v.blur>0.05?`blur(${v.blur}px)`:'none'});
}
});
</script>
examples/opus-aoe2/02-pip-builds.html
<title>02 PiP builds</title>
<style>
.ml{color:#8A8782}
.lane{position:absolute;left:-510px;width:1020px;height:90px}
.lane .nm{position:absolute;left:0;top:26px;font-size:30px;font-weight:600;letter-spacing:-.01em}
.lane .tr{position:absolute;left:190px;top:38px;width:620px;height:14px;border-radius:7px;background:#E2DFD9}
.lane .fl{position:absolute;left:190px;top:38px;height:14px;border-radius:7px;background:#0B0B0B}
.lane .stt{position:absolute;right:0;top:30px;font-size:22px;color:#8A8782;width:170px;text-align:right}
.col{position:absolute;top:0;width:320px}
.col .hd{position:absolute;left:0;top:0;display:flex;gap:12px;align-items:center;font-size:28px;font-weight:600}
.col .bar{position:absolute;bottom:0;width:52px;border-radius:12px 12px 6px 6px;background:#0B0B0B}
.col .xl{position:absolute;width:80px;text-align:center;font-size:17px;color:#8A8782}
.seg4{position:absolute;width:95px;text-align:center;top:12px;color:#fff;font-size:20px;font-weight:500}
</style>
<div data-slot="world">
<div class="a" id="tab" style="left:-150px;top:-136px;width:130px;height:44px;border-radius:14px;background:#E14A12"></div>
<div class="a" id="fname" style="left:-200px;top:132px;width:400px;text-align:center;color:#fff;font-size:30px;font-weight:500"></div>
</div><!--/world-->
<div data-slot="shape">
<div class="L" id="Lfolder"><div class="a" style="left:-150px;top:-60px;width:300px;height:170px;border-radius:0 0 26px 26px;background:rgba(255,255,255,.14)"></div></div>
<div class="L" id="Lterm">
<div class="a" style="left:-506px;top:22px;display:flex;gap:10px"><i style="width:14px;height:14px;border-radius:50%;background:#D6D3CE"></i><i style="width:14px;height:14px;border-radius:50%;background:#D6D3CE"></i><i style="width:14px;height:14px;border-radius:50%;background:#D6D3CE"></i></div>
<div class="a mono ml" style="left:-200px;top:17px;width:400px;text-align:center;font-size:19px">aoe2-low — claude</div>
<div class="a" style="left:-530px;top:58px;width:1060px;height:1.5px;background:#E2DFD9"></div>
<div class="a" style="left:-490px;top:82px;width:980px;height:76px;border:1.5px solid #E2DFD9;border-radius:14px;box-sizing:border-box"></div>
<div class="row" style="left:-466px;top:104px;gap:12px;font-size:24px;font-weight:500"><span style="color:#FF5A1F;font-size:28px">✻</span>Welcome to Claude Code</div>
<div class="a mono" style="left:-490px;top:196px;width:960px;font-size:26px;line-height:40px"><span class="ml">> </span><span id="typed"></span><span id="caret" style="display:inline-block;width:12px;height:28px;background:#0B0B0B;vertical-align:-5px;margin-left:2px"></span></div>
<div class="row" id="building" style="left:-490px;top:356px;gap:12px;font-size:25px;font-weight:500"><span id="star" style="color:#FF5A1F;font-size:30px;display:inline-block">✻</span><span>Building…</span><span class="ml mono" id="elapsed" style="font-size:22px">0:00</span></div>
<div class="a mono ml" style="left:-490px;top:560px;font-size:21px">Opus 5.5</div>
<div class="a" id="effort" style="left:120px;top:546px;width:380px;height:52px;border-radius:26px;background:#E9E6E0">
<div class="a" id="effPill" style="top:4px;height:44px;border-radius:22px;background:#0B0B0B"></div>
<div class="a blend" style="left:0;top:0;width:380px;height:52px"><div class="seg4" style="left:0">Low</div><div class="seg4" style="left:95px">Med</div><div class="seg4" style="left:190px">High</div><div class="seg4" style="left:285px">Max</div></div>
</div>
</div>
<div class="L" id="Llanes">
<div class="a ml" style="left:-510px;top:40px;font-size:24px;white-space:nowrap">Same prompt · one build per reasoning level</div>
<div class="lane" id="ln0" style="top:110px"><div class="nm">Low</div><div class="tr"></div><div class="fl"></div><div class="stt mono"></div></div>
<div class="lane" id="ln1" style="top:220px"><div class="nm">Medium</div><div class="tr"></div><div class="fl"></div><div class="stt mono"></div></div>
<div class="lane" id="ln2" style="top:330px"><div class="nm">High</div><div class="tr"></div><div class="fl"></div><div class="stt mono"></div></div>
<div class="lane" id="ln3" style="top:440px"><div class="nm">Max</div><div class="tr"></div><div class="fl"></div><div class="stt mono"></div></div>
</div>
<div class="L" id="Lmet">
<div class="a ml" style="left:-510px;top:40px;font-size:24px;white-space:nowrap">Measured for every build</div>
<div class="col" id="c0" style="left:-510px;top:110px"><div class="hd"><span class="ic"></span>Cost</div></div>
<div class="col" id="c1" style="left:-160px;top:110px"><div class="hd"><span class="ic"></span>Time</div></div>
<div class="col" id="c2" style="left:190px;top:110px"><div class="hd"><span class="ic"></span>Tokens</div></div>
</div>
</div><!--/shape-->
<script>
const $=id=>document.getElementById(id);
const ACC='#FF5A1F';
// word times (clip-local = video time - 6.75)
const PROMPT='Build a faithful replica of Age of Empires II that runs in the browser: villagers, resources, buildings and an AI opponent.';
const TY0=3.5, TY1=4.75;
// effort control: High -> Low on "low"
const SEGX=i=>i*95;
const eL=M.track(SEGX(2)+4,[[5.75,SEGX(0)+4,M.FAST]]), eR=M.track(SEGX(3)-4,[[5.75,SEGX(1)-4,M.SLOW]]);
// lanes
const DUR=[3.0,4.2,5.4,6.8], LS=7.85;
const prog=(i,t)=>M.clamp((t-LS-0.12*i)/DUR[i]);
const PULSE=[8.75,9.12,9.32,9.73];
// metrics
const COLT=[12.8,13.55,14.65], REL=[[.22,.4,.66,1],[.25,.44,.7,1],[.2,.38,.64,1]];
const ICN=['coin','clock','chip'];
for(let c=0;c<3;c++){
const col=$('c'+c); col.querySelector('.ic').innerHTML=M.icon(ICN[c],34,'#0B0B0B');
['Low','Med','High','Max'].forEach((n,i)=>{col.insertAdjacentHTML('beforeend',`<div class="bar" id="b${c}${i}" style="left:${i*72}px;top:0"></div><div class="xl" style="left:${i*72-14}px;top:420px">${n}</div>`);});
}
const barH=[0,1,2].map(c=>[0,1,2,3].map(i=>M.track(0,[[COLT[c]+0.12+0.07*i,REL[c][i]*300,[13,0.82]]])));
M.scene({
W:1920,H:1080,bg:null,T:15.75,center:[1220,540],intro:0.31,
SH:{folder:{w:300,h:220,r:26,bg:'#FF5A1F',cam:1.6}, term:{w:1060,h:640,r:26,bg:'#F4F2EE',cam:1.05},
lanes:{w:1100,h:600,r:28,bg:'#F4F2EE',cam:1.05}, met:{w:1100,h:620,r:28,bg:'#FFFFFF',cam:1.05}},
start:'folder', SEQ:[[2.38,'term'],[7.69,'lanes'],[11.25,'met']],
layers:[
{el:'Lfolder',tin:0.31,tout:2.38,o:{din:0}},
{el:'Lterm',tin:2.38,tout:7.69,anchor:'t',update:(t)=>{
const n=Math.round(M.clamp((t-TY0)/(TY1-TY0))*PROMPT.length); M.setText($('typed'),PROMPT.slice(0,n));
const typing=t>TY0-0.1&&t<TY1+0.1; $('caret').style.opacity=(typing||t>6.4||Math.floor(t*2.2)%2==0)?(t>6.4?0:1):0;
const bv=M.vis(t,6.42,null,{din:0,lin:0.2}); $('building').style.opacity=bv.o; $('building').style.filter=bv.blur>0.05?`blur(${bv.blur}px)`:'none';
$('star').style.transform=`rotate(${(t*240)%360}deg)`; M.setText($('elapsed'),'0:0'+Math.max(0,Math.floor((t-6.45)*2)).toString().slice(-1));
const L=eL(t),R=eR(t); Object.assign($('effPill').style,{left:L+'px',width:(R-L)+'px'});
}},
{el:'Llanes',tin:7.69,tout:11.25,anchor:'t',update:(t)=>{
for(let i=0;i<4;i++){
const ln=$('ln'+i), v=M.vis(t,7.69+0.1*i,null,{din:0.12,lin:0.24});
ln.style.opacity=v.o; ln.style.filter=v.blur>0.05?`blur(${v.blur}px)`:'none'; ln.style.transform=`translateY(${(1-v.a)*14}px)`;
const p=prog(i,t); ln.querySelector('.fl').style.width=(620*p)+'px';
M.setText(ln.querySelector('.stt'),p>=1?'done ✓':(p>0?Math.round(p*100)+'%':'queued'));
const k=Math.exp(-Math.pow((t-PULSE[i]-0.12)/0.16,2)); ln.querySelector('.nm').style.color=`rgb(${Math.round(11+(255-11)*k)},${Math.round(11+(90-11)*k)},${Math.round(11+(31-11)*k)})`;
}
}},
{el:'Lmet',tin:11.25,tout:null,anchor:'t',update:(t)=>{
for(let c=0;c<3;c++){
const col=$('c'+c), v=M.vis(t,COLT[c],null,{din:0,lin:0.22});
col.style.opacity=v.o; col.style.filter=v.blur>0.05?`blur(${v.blur}px)`:'none';
for(let i=0;i<4;i++){const h=Math.max(0,barH[c][i](t)); Object.assign($('b'+c+i).style,{height:h+'px',top:(400-h)+'px',background:i===0?ACC:'#0B0B0B'});}
}
}},
],
cursor:{size:44,clicks:[2.18,2.32,5.75],keys:[[0,456,362],[1.3,456,362],[1.95,40,30],[2.5,40,30],[5.0,60,140],[5.55,172,262],[5.95,172,262],[6.7,380,380]]},
extra:(t,g)=>{
const v=M.vis(t,0.31,2.38,{din:0}); const top=g.cy-g.h/2, left=g.cx-g.w/2;
Object.assign($('tab').style,{display:v.o<0.002?'none':'',opacity:v.o,left:(left+12)+'px',top:(top-34)+'px',transform:`scale(${g.sc})`,transformOrigin:`${g.w/2-12}px ${g.h/2+34}px`});
const nv=M.vis(t,0.85,2.38,{din:0}); const nm='aoe2-low'; const n=Math.round(M.clamp((t-0.95)/0.5)*nm.length);
Object.assign($('fname').style,{display:nv.o<0.002?'none':'',opacity:nv.o,top:(g.h/2+26)+'px'}); M.setText($('fname'),nm.slice(0,n)||' ');
}
});
</script>
examples/opus-aoe2/03-effort-slider.html
<title>03 Effort slider</title>
<style>.blk{position:absolute;background:#0B0B0B;transform-origin:50% 100%}</style>
<div data-slot="shape">
<div class="L" id="Lcard">
<div class="a" style="left:-450px;top:40px;font-size:26px;color:#8A8782;white-space:nowrap">Same prompt · different effort</div>
<div class="a" id="scene" style="left:0;top:0"></div>
<div class="a" style="left:-330px;top:400px;width:660px;height:2px;background:#E2DFD9"></div>
<div class="a" style="left:-400px;top:470px;width:800px;height:12px;border-radius:6px;background:#E6E3DE"></div>
<div class="a" id="sfill" style="left:-400px;top:470px;height:12px;border-radius:6px;background:#0B0B0B"></div>
<div class="a" id="knob" style="top:458px;width:36px;height:36px;border-radius:50%;background:#fff;border:4px solid #0B0B0B;box-sizing:border-box"></div>
<div class="a" style="left:-400px;top:506px;font-size:24px;font-weight:500">Low</div>
<div class="a" style="left:300px;top:506px;width:100px;text-align:right;font-size:24px;font-weight:500">Max</div>
</div>
</div><!--/shape-->
<script>
const $=id=>document.getElementById(id);
// castle parts: [threshold, x0, y0, x1, y1, color, radius]
const P=[[0,-180,300,180,400,'#0B0B0B','6px'],[0.18,-36,336,36,401,'#FFFFFF','36px 36px 0 0'],[0.34,-240,220,-150,400,'#0B0B0B','6px'],[0.48,150,220,240,400,'#0B0B0B','6px'],
[0.6,-70,190,70,300,'#0B0B0B','6px'],[0.72,-240,196,-218,220,'#0B0B0B','3px'],[0.74,-206,196,-184,220,'#0B0B0B','3px'],[0.76,-172,196,-150,220,'#0B0B0B','3px'],
[0.78,150,196,172,220,'#0B0B0B','3px'],[0.8,184,196,206,220,'#0B0B0B','3px'],[0.82,218,196,240,220,'#0B0B0B','3px'],
[0.88,-3,110,3,190,'#0B0B0B','2px'],[0.92,3,112,52,138,'#FF5A1F','2px 8px 8px 2px'],[0.95,-206,260,-184,292,'#FF5A1F','11px 11px 2px 2px'],[0.97,184,260,206,292,'#FF5A1F','11px 11px 2px 2px']];
$('scene').innerHTML=P.map((p,i)=>`<div class="blk" id="p${i}" style="left:${p[1]}px;top:${p[2]}px;width:${p[3]-p[1]}px;height:${p[4]-p[2]}px;background:${p[5]};border-radius:${p[6]}"></div>`).join('');
const KX=[[0,760,470],[0.9,760,470],[1.85,-396,476],[2.2,-396,476],[3.1,400,476],[3.4,400,476],[3.8,470,560]];
const cur=M.path(KX);
const val=t=>t<2.15?0:M.clamp((Math.min(cur(t).x,cur(3.3).x)+400)/800);
const CT=M.crossTimes(val,0,4,P.map(p=>p[0])).map(x=>Math.max(x,0.3));
M.scene({
W:1920,H:1080,bg:'#E9E7E2',T:3.8,intro:0.05,
SH:{card:{w:1000,h:600,r:40,bg:'#FFFFFF',cam:1.35}}, start:'card', SEQ:[],
layers:[{el:'Lcard',tin:0.05,tout:null,anchor:'t',o:{din:0.05},update:(t)=>{
const v=val(t), kx=-400+800*v;
$('sfill').style.width=(kx+400)+'px'; $('knob').style.left=(kx-18)+'px';
$('knob').style.transform=`scale(${1+0.18*M.track(0,[[2.1,1,[30,.8]],[3.3,0,[20,.8]]])(t)})`;
P.forEach((p,i)=>{const a=M.S(t-CT[i],17,0.62); const el=$('p'+i); el.style.transform=`scaleY(${Math.max(0,a).toFixed(4)})`; el.style.opacity=t<CT[i]?0:1;});
}}],
cursor:{size:44,drags:[[2.15,3.3]],keys:KX},
});
</script>
examples/opus-aoe2/04-master-prompt.html
<title>04 Master prompt</title>
<style>
.sec{position:absolute;left:-330px;width:660px;height:120px}
.sec .bd{position:absolute;left:0;top:0;width:40px;height:40px;border-radius:12px;display:flex;align-items:center;justify-content:center}
.sec .tt{position:absolute;left:58px;top:4px;font-size:27px;font-weight:600;white-space:nowrap}
.sk{position:absolute;height:14px;border-radius:7px;background:#E6E3DE}
.dl{position:absolute;left:-230px;height:12px;border-radius:6px;background:#DCD8D1}
</style>
<div data-slot="world">
<div class="a" id="ghost" style="left:10px;top:-60px;width:700px;height:120px;border-radius:60px;border:3px dashed #BDB8B0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;font-size:34px;font-weight:500;color:#A29E97">Opus 5.5 · Low</div>
</div><!--/world-->
<div data-slot="shape">
<div class="L" id="Lnotes">
<div class="row" style="left:-330px;top:40px;gap:14px;font-size:34px;font-weight:600;letter-spacing:-.02em"><span id="icPen"></span>Build notes</div>
<div class="sec" id="s0" style="top:124px"><div class="bd" style="background:#0B0B0B"></div><div class="tt">Liked</div><div class="sk" style="left:58px;top:56px"></div><div class="sk" style="left:58px;top:84px"></div></div>
<div class="sec" id="s1" style="top:262px"><div class="bd" style="background:#0B0B0B"></div><div class="tt">Didn't like</div><div class="sk" style="left:58px;top:56px"></div><div class="sk" style="left:58px;top:84px"></div></div>
<div class="sec" id="s2" style="top:400px"><div class="bd" style="background:#FF5A1F"></div><div class="tt">Missing</div><div class="sk" style="left:58px;top:56px"></div><div class="sk" style="left:58px;top:84px"></div></div>
</div>
<div class="L" id="Ldoc">
<div class="row mono" style="left:-230px;top:38px;gap:12px;font-size:25px"><span id="icFile"></span>master-prompt.md</div>
<div class="a" style="left:-300px;top:96px;width:600px;height:1.5px;background:#EAE7E2"></div>
<div id="lines"></div>
</div>
<div class="L" id="Lchip"><div class="row mono" style="transform:translate(-50%,-50%);gap:12px;color:#fff;font-size:26px"><span id="icFile2"></span>master-prompt.md</div></div>
<div class="L" id="Lsess"><div class="row" style="transform:translate(-50%,-50%);gap:18px;color:#fff;font-size:40px;font-weight:600;letter-spacing:-.02em"><span id="icSpark"></span>Opus 5.5<span id="lowChip" style="font-size:26px;font-weight:600;letter-spacing:0;border-radius:999px;padding:6px 16px">Low</span><span style="font-size:22px;font-weight:500;letter-spacing:0;color:#9A9791" class="mono">+ master-prompt.md</span></div></div>
</div><!--/shape-->
<script>
const $=id=>document.getElementById(id);
$('icPen').innerHTML=M.icon('pencil',32,'#0B0B0B'); $('icFile').innerHTML=M.icon('file',30,'#0B0B0B'); $('icFile2').innerHTML=M.icon('file',30,'#fff'); $('icSpark').innerHTML=M.icon('sparkle',38,'#FF5A1F');
['check','x','plus'].forEach((n,i)=>$('s'+i).querySelector('.bd').innerHTML=M.icon(n,24,'#fff',3));
const SEC=[1.8,2.12,3.68], SKW=[[520,380],[460,300],[500,340]];
const LW=[430,380,440,300,0,260,420,400,350,0,300,410,240];
$('lines').innerHTML=LW.map((w,i)=>`<div class="dl" id="d${i}" style="top:${130+i*42}px;height:${i==0?20:12}px;${i==0?'background:#FF5A1F;':''}"></div>`).join('');
const KX=[[0,760,460],[1.2,760,460],[1.9,330,250],[6.2,330,250],[6.75,0,30],[6.95,0,30],[7.55,360,20],[7.85,360,20],[8.5,470,170]];
const cur=M.path(KX);
const fxT=M.track(0,[[6.95,180,M.SOFT],[7.6,360,M.SOFT]]);
const lowOn=M.track(0,[[8.31,1,[18,.8]]]);
M.scene({
W:1920,H:1080,bg:'#E9E7E2',T:10,intro:0.15,
SH:{notes:{w:780,h:560,r:36,bg:'#FFFFFF',cam:1.3}, doc:{w:600,h:720,r:28,bg:'#FFFFFF',cam:1.2},
chip:{w:420,h:96,r:48,bg:'#0B0B0B',cam:1.5}, sess:{w:720,h:120,r:60,bg:'#0B0B0B',cam:1.75}},
start:'notes', SEQ:[[5.1,'doc'],[6.9,'chip'],[7.8,'sess']],
layers:[
{el:'Lnotes',tin:0.15,tout:5.1,anchor:'t',o:{din:0.05},update:(t)=>{
for(let i=0;i<3;i++){const s=$('s'+i),v=M.vis(t,SEC[i],null,{din:0,lin:0.22}); s.style.opacity=v.o; s.style.filter=v.blur>0.05?`blur(${v.blur}px)`:'none'; s.style.transform=`translateY(${(1-v.a)*12}px)`;
s.querySelectorAll('.sk').forEach((k,j)=>k.style.width=(SKW[i][j]*M.eio((t-SEC[i]-0.15-0.18*j)/0.45))+'px');}
}},
{el:'Ldoc',tin:5.1,tout:6.9,anchor:'t',update:(t)=>{LW.forEach((w,i)=>$('d'+i).style.width=(w*M.eio((t-5.35-0.075*i)/0.3))+'px');}},
{el:'Lchip',tin:6.9,tout:7.8,o:{din:0.04}},
{el:'Lsess',tin:7.8,tout:null,update:(t)=>{const k=M.clamp(lowOn(t)); const c=$('lowChip').style; c.background=`rgba(255,90,31,${k})`; c.color='#fff'; c.boxShadow=`inset 0 0 0 2px rgba(255,255,255,${0.35*(1-k)})`; c.transform=`scale(${1+0.12*Math.sin(Math.PI*M.clamp((t-8.31)/0.3))})`;}},
],
cursor:{size:44,drags:[[6.9,7.8]],keys:KX},
geom:(t,g)=>{g.fx=fxT(t); if(t>=6.9){const c=t<7.8?cur(t):cur(7.8); g.cx=c.x; g.cy=c.y-30; if(t>=7.8){g.cx=360; g.cy=M.lerp(c.y-30,0,M.S(t-7.8,15,0.84));}} return g;},
extra:(t,g)=>{const v=M.vis(t,6.95,7.8,{din:0,lin:0.2,lout:0.15}); const s=$('ghost').style; s.display=v.o<0.002?'none':''; s.opacity=v.o; s.transform=`scale(${v.s})`;}
});
</script>
examples/opus-aoe2/05-low-vs-max.html
<title>05 Low vs Max</title>
<style>
.inf{position:absolute;left:-310px;width:620px;height:64px;display:flex;align-items:center;gap:16px;font-size:25px;font-weight:500}
.inf .ib{width:40px;height:40px;border-radius:12px;background:#F1EFEB;display:flex;align-items:center;justify-content:center}
.side{position:absolute;top:0;width:440px}
.side .nm{font-size:48px;font-weight:600;letter-spacing:-.025em;white-space:nowrap}
.side .sub{margin-top:8px;font-size:22px;color:#8A8782;white-space:nowrap}
.side .trk{position:absolute;top:170px;left:0;width:440px;height:18px;border-radius:9px;background:#EDEAE5}
.side .fil{position:absolute;top:170px;left:0;height:18px;border-radius:9px;background:#0B0B0B}
.side .lb{position:absolute;top:130px;left:0;font-size:20px;color:#8A8782}
</style>
<div data-slot="shape">
<div class="L" id="Lsess"><div class="row" style="transform:translate(-50%,-50%);gap:18px;color:#fff;font-size:40px;font-weight:600;letter-spacing:-.02em"><span id="icSpark"></span>Opus 5.5<span style="font-size:26px;font-weight:600;letter-spacing:0;border-radius:999px;padding:6px 16px;background:#FF5A1F">Low</span></div></div>
<div class="L" id="Lctx">
<div class="row" style="left:-310px;top:40px;gap:14px;font-size:30px;font-weight:600"><span id="icSpark2"></span>Opus 5.5<span style="font-size:20px;border-radius:999px;padding:4px 12px;background:#FF5A1F;color:#fff">Low</span><span style="font-size:22px;color:#8A8782;font-weight:500">· given up front</span></div>
<div class="inf" id="i0" style="top:110px"><span class="ib"></span><span class="mono" style="font-size:23px">master-prompt.md</span></div>
<div class="inf" id="i1" style="top:184px"><span class="ib"></span>What worked</div>
<div class="inf" id="i2" style="top:258px"><span class="ib"></span>What didn't</div>
<div class="inf" id="i3" style="top:332px"><span class="ib"></span>What was missing</div>
</div>
<div class="L" id="Lvs">
<div class="side" style="left:-540px;top:80px"><div class="nm">Low</div><div class="sub">+ master prompt</div><div class="lb">Result</div><div class="trk"></div><div class="fil" id="fL" style="background:#FF5A1F"></div></div>
<div class="side" style="left:100px;top:80px"><div class="nm" id="rn">Max</div><div class="sub">default prompt</div><div class="lb">Result</div><div class="trk"></div><div class="fil" id="fR"></div></div>
<div class="a" id="vs" style="left:-48px;top:112px;width:96px;height:96px;border-radius:50%;background:#0B0B0B;color:#fff;font-size:34px;font-weight:600;display:flex;align-items:center;justify-content:center"><span id="vsT">vs</span></div>
</div>
</div><!--/shape-->
<script>
const $=id=>document.getElementById(id);
$('icSpark').innerHTML=M.icon('sparkle',38,'#FF5A1F'); $('icSpark2').innerHTML=M.icon('sparkle',30,'#FF5A1F');
['file','check','x','plus'].forEach((n,i)=>$('i'+i).querySelector('.ib').innerHTML=M.icon(n,24,'#0B0B0B'));
const IT=[0.95,1.22,1.5,1.82];
const fR=M.track(0,[[3.2,0.86,[11,0.9]]]), fL=M.track(0,[[4.05,0.5,[11,0.9]],[4.75,0.86,[9,0.86]]]);
const q=M.track(0,[[6.68,1,[16,0.7]]]);
M.scene({
W:1920,H:1080,bg:'#E9E7E2',T:7.2,intro:0.02,
SH:{sess:{w:560,h:120,r:60,bg:'#0B0B0B',cam:1.8}, ctx:{w:720,h:440,r:36,bg:'#FFFFFF',cam:1.4}, vs:{w:1240,h:330,r:40,bg:'#FFFFFF',cam:1.2}},
start:'sess', SEQ:[[0.54,'ctx'],[2.67,'vs']],
layers:[
{el:'Lsess',tin:null,tout:0.54},
{el:'Lctx',tin:0.54,tout:2.67,anchor:'t',update:(t)=>{for(let i=0;i<4;i++){const e=$('i'+i),v=M.vis(t,IT[i],null,{din:0,lin:0.2}); e.style.opacity=v.o; e.style.filter=v.blur>0.05?`blur(${v.blur}px)`:'none'; e.style.transform=`translateX(${(1-v.a)*-18}px)`;}}},
{el:'Lvs',tin:2.67,tout:null,anchor:'t',update:(t)=>{
$('fL').style.width=(440*Math.max(0,fL(t)))+'px'; $('fR').style.width=(440*Math.max(0,fR(t)))+'px';
M.setText($('rn'),t<6.17?'Max':t<6.6?'High':'High / Max');
const k=q(t); const vs=$('vs').style; vs.background=k>0.5?'#FF5A1F':'#0B0B0B'; M.setText($('vsT'),t<6.68?'vs':'?'); vs.transform=`scale(${1+0.25*Math.sin(Math.PI*M.clamp((t-6.68)/0.35))})`;
}},
],
cursor:{size:44,keys:[[0,900,520],[5.2,900,520],[6.0,60,160],[7.2,60,160]]},
});
</script>
examples/opus-aoe2/06-chapter.html
<title>06 Chapter</title>
<div data-slot="shape">
<div class="L" id="Lnext"><div class="row" style="transform:translate(-50%,-50%);gap:12px;color:#fff;font-size:32px;font-weight:500">Up next<span id="icA"></span></div></div>
<div class="L" id="Lch">
<div class="a" style="left:-300px;top:-86px;font-size:150px;line-height:150px;font-weight:600;letter-spacing:-.05em;color:#FF5A1F">01</div>
<div class="a" style="left:-68px;top:-62px;font-size:60px;font-weight:600;letter-spacing:-.03em;color:#fff;white-space:nowrap">First build</div>
<div class="a" style="left:-66px;top:16px;font-size:28px;color:#9A9791;white-space:nowrap">Opus 5.5 · Low effort</div>
</div>
</div><!--/shape-->
<script>
document.getElementById('icA').innerHTML=M.icon('arrow',30,'#fff');
M.scene({
W:1920,H:1080,bg:'#E9E7E2',T:2.9,intro:0.08,
SH:{next:{w:260,h:92,r:46,bg:'#0B0B0B',cam:2.0}, ch:{w:820,h:300,r:48,bg:'#0B0B0B',cam:1.5}},
start:'next', SEQ:[[0.95,'ch']],
layers:[{el:'Lnext',tin:0.08,tout:0.95,o:{din:0.03}},{el:'Lch',tin:0.95,tout:null}],
});
</script>
examples/opus-aoe2/plan.json
{
"title": "Opus 5.5 · Age of Empires II",
"video": "work/source.mp4",
"fps": "30000/1001",
"clips": [
{"id":"01","title":"Opus 5.5 drop","line":"“Opus 5.5 just came out … a replica of Age of Empires II against every reasoning level.”","in":0.30,"out":6.75,"kind":"full","file":"out/01-opus-drop_0m00s30.mp4"},
{"id":"02","title":"Folder → Claude Code → four builds → cost, time, tokens","line":"“I created a blank folder … opened up Claude Code, gave it this prompt, and set Opus 5.5 to low …”","in":6.75,"out":22.5,"kind":"panel","file":"out/02-pip-builds_0m06s75.mov"},
{"id":"03","title":"Effort slider builds the castle","line":"“The results are gonna vary quite a lot between the low effort and the max effort.”","in":25.8,"out":29.6,"kind":"full","file":"out/03-effort-slider_0m25s80.mp4"},
{"id":"04","title":"Notes → master prompt → Low session","line":"“Taking notes of all the things that I liked, didn’t like … one more master prompt …”","in":32.4,"out":42.3,"kind":"full","file":"out/04-master-prompt_0m32s40.mp4"},
{"id":"05","title":"Low + master prompt vs Max","line":"“Could I get outstanding results from the low that will rival … a high or a max?”","in":42.3,"out":49.4,"kind":"full","file":"out/05-low-vs-max_0m42s30.mp4"},
{"id":"06","title":"Chapter card · First build","line":"“Okay, let’s get into the first build.”","in":52.4,"out":53.92,"kind":"full","file":"out/06-chapter_0m52s40.mp4"}
],
"notes": ["The typed prompt and the cost/time/tokens bars are illustrative (relative size only)."]
}
README.md
# motion-broll
**Motion-graphic B-roll for your videos, made by Claude Code.** Give it a video and a transcript and it plans, animates and renders clips timed to your words. Each clip is one continuous shape that keeps morphing (pill → card → terminal → chart) and never cuts, with a cursor driving every change.
<p>
<img src="../../docs/demo-panel.gif" width="49%" alt="A transparent panel animating beside a picture-in-picture shot of the speaker">
<img src="../../docs/demo-cutaway.gif" width="49%" alt="A full-frame cutaway: build notes turning into a master prompt that is dragged into a session">
</p>
## What you get
You run `/motion-broll`, answer a few questions, approve a plan, and get back:
- **The clips**, named by where they go on your timeline (`04-master-prompt_0m32s40.mp4`). Full-frame cutaways are MP4. Panels that sit in empty space next to you are transparent ProRes 4444 `.mov`.
- **A preview render** of your video with the clips cut in.
- **`compare.html`**: original vs. with motion graphics, synced, as side by side, stacked or wipe.
- **`viewer.html`**: step through the clips one by one.
- **`TIMING.md`**: every clip with its in/out point and the line it covers.
The preview is for review. For your final cut, drop the clips into your own editor.
## Install
```
npx skills add Barty-Bart/motion-graphics
```
That installs the skills from this repo (pick `motion-broll`, `object-separation`, or both). It works in Claude Code and other agents that read skills.
Or copy a skill's folder from `skills/` into your project's `.claude/skills/` (or `~/.claude/skills/` to use it everywhere).
**Requirements for motion-broll:** Node 18+, Python 3, and ffmpeg (with the ProRes encoder, standard in Homebrew builds). On first run the skill installs Playwright and Chromium into a local `motion/` folder.
## Use it
```
/motion-broll
```
Then point it at your video and transcript (an SRT from your editor, Descript or YouTube works). It will:
1. **Inspect the footage.** It reads resolution, frame rate and layout, including picture-in-picture sections and whether the box changes size.
2. **Estimate word timings** from your transcript.
3. **Plan the clips** in a table and wait for your OK. For each clip it chooses a full-frame cutaway, a transparent panel in empty space, or nothing, and tells you why.
4. **Build each clip**, check stills on the key words, and fix what's off.
5. **Render** with motion blur at your video's frame rate, then make the preview and the comparison pages.
It never invents numbers or results. Bars show relative size and text uses skeleton lines until you give it the real figures.
## How it works
- Every frame is a pure function of time. Springs are closed-form step responses, and a value that changes target many times is the sum of one spring per change. There are no CSS transitions or timers, so any frame can be rendered on its own.
- Clips are small HTML files on a shared engine ([`engine/motion.js`](engine/motion.js)). Headless Chromium captures 4 sub-frames per frame across a 180° shutter, and ffmpeg blends them into motion blur.
- The worked example in [`examples/opus-aoe2/`](examples/opus-aoe2/) is the six clips from the demo above.
## Licence
MIT. Geist fonts: SIL Open Font License. Icon paths adapted from Lucide (ISC).
reference/engine-api.md
# Writing a clip
A clip is a small HTML fragment in `motion/clips/NN-name.html`. `engine/build.py` wraps it with the engine, fonts and a cursor into one self-contained page, where `seek(t)` draws the frame at time `t`. Every style is a pure function of `t`: no CSS transitions, no timers, no state carried between frames. That is what makes frame-by-frame rendering with motion blur possible.
## Fragment skeleton
```html
<title>04 Master prompt</title>
<style>/* clip-specific CSS */</style>
<div data-slot="world"> <!-- optional: elements beside the shape (a drop target, a folder tab) --> </div><!--/world-->
<div data-slot="shape">
<!-- one layer per state; .L layers are anchored inside the shape -->
<div class="L" id="Lnotes"> … </div>
<div class="L" id="Ldoc"> … </div>
</div><!--/shape-->
<script>
M.scene({
W:1920, H:1080, T:10, // frame size (match the video) and clip length in seconds
bg:'#E9E7E2', // canvas colour; null = transparent (panel clip → render to .mov)
center:[960,540], // screen point the camera centres on (move it for panels)
intro:0.15, // shape pops in at this time; null = already on screen
SH:{ // the states the one shape morphs through
notes:{w:780,h:560,r:36,bg:'#FFFFFF',cam:1.3},
doc:{w:600,h:720,r:28,bg:'#FFFFFF',cam:1.2},
},
start:'notes', SEQ:[[5.1,'doc']], // [clip-local time, state] – put each on its word
layers:[
{el:'Lnotes', tin:0.15, tout:5.1, anchor:'t'}, // anchor 'c' centre (default), 't' top-centre, 'l' left-centre
{el:'Ldoc', tin:5.1, tout:null, anchor:'t', update:(t,g)=>{ /* per-frame content, e.g. typing */ }},
],
cursor:{size:44, clicks:[2.3], drags:[[6.9,7.8]], keys:[[0,760,460],[1.2,760,460],[1.9,330,250]]},
geom:(t,g)=>g, // optional: change geometry (e.g. follow the cursor while dragged, g.fx/g.fy camera focus)
extra:(t,g)=>{}, // optional: per-frame work outside layers (world elements, indicators)
});
</script>
```
Coordinates are **world pixels**. The shape is centred at world (0,0) unless `geom` moves it (`g.cx`, `g.cy`). The camera draws world → screen as `center + cam × (world − focus)`. Choose `cam` per state so the state fills the frame: roughly 1.2–1.6 for cards, 1.8–2.2 for pills. The cursor keeps a constant size on screen.
Layer content is positioned relative to its anchor, e.g. `left:-330px; top:40px` inside an `anchor:'t'` layer = 330px left of centre, 40px below the shape's top edge. Top-anchored content rides the top edge when the shape grows.
## Engine API (`window.M`)
| Call | What it does |
|---|---|
| `M.track(v0, [[t, value, spring?], …])` | A value that changes target many times: the sum of one closed-form spring per change. Returns `t => value`. |
| `M.ctrack('#hex', [[t,'#hex'], …])` | The same for colours. Returns `t => 'rgb(...)'`. |
| `M.MORPH`, `M.FAST`, `M.SLOW`, `M.SOFT`, `M.CAM`, `M.INSTANT` | Spring presets `[stiffness ω, damping ζ]`. FAST/SLOW for leading/trailing edges; INSTANT for invisible resets. |
| `M.vis(t, tin, tout, {din, lin, lout})` | Content swap: exit blurs out fast, enter waits `din` then blurs in over `lin`. Returns `{o, blur, s, a, b}`. |
| `M.apply(el, v)` | Applies a `vis` result to an element (opacity, blur, scale). |
| `M.path([[t,x,y], …])` | Cursor or any point path, eased between keys with a slight human arc. |
| `M.crossTimes(f, t0, t1, thresholds)` | When a rising value first crosses each threshold. Use it to trigger springs from a dragged value. |
| `M.icon(name, size, colour, strokeW?)` | Icon from the set in `M.IC`, with stroke normalised so all icons match. |
| `M.setText(el, s)` | Set text only when it changed (cheap per frame). |
| `M.eo`, `M.eio`, `M.clamp`, `M.lerp`, `M.S` | Easing and spring helpers. |
Icons in `M.IC`: arrow, check, x, plus, folder, terminal, file, pencil, coin, clock, chip, sparkle, castle, search. Add more as 24-grid stroke paths (e.g. from Lucide, ISC licence).
## Patterns (see `examples/opus-aoe2/`)
| Pattern | How | Example |
|---|---|---|
| Pill → card → control | SH states + layers with tin/tout | 01 |
| Liquid indicator | Two `M.track`s for left/right edges; the edge moving forward uses `M.FAST`, the trailing one `M.SLOW` | 01 (levels), 02 (effort) |
| Typing | `PROMPT.slice(0, n)` with `n` from time, and a caret | 02 |
| Rows or bars appearing on words | Per-row `M.vis(t, wordTime, …)` + width or height from a spring | 02, 04, 05 |
| Direct manipulation | While the cursor is held, the value comes from its position; on release it springs from where it was | 03 (slider) |
| Drag and drop | `geom` sets `g.cx/cy` from the cursor while held; a world-slot drop target; `g.fx` moves the camera focus | 04 |
| Transparent panel | `bg:null`, `center` in the empty area, light shapes | 02 |
## Interaction vocabulary
Map what the speaker says onto these:
- primary action (pill + click)
- progress (loader, bars, lanes)
- confirmation (check, toast)
- live status (island)
- detail card with a drag
- slider with rubber band
- toggle
- tabs
- chart with tooltip
- search and filter list
- file → drag → drop target
- terminal typing
- side-by-side comparison
- chapter card
## plan.json
```json
{
"title": "Opus 5.5 · Age of Empires II",
"video": "work/source.mp4",
"fps": "30000/1001",
"clips": [
{"id":"01","title":"Opus 5.5 drop","line":"“Opus 5.5 just came out …”","in":0.30,"out":6.75,"kind":"full","file":"out/01-opus-drop_0m00s30.mp4"},
{"id":"02","title":"Folder → Claude Code → four builds","line":"“I created a blank folder …”","in":6.75,"out":22.5,"kind":"panel","file":"out/02-pip-builds_0m06s75.mov"}
],
"notes": ["The typed prompt and the cost/time/tokens bars are illustrative."]
}
```
Paths are relative to `plan.json`. `out` is when the clip leaves the timeline; if the clip is shorter, the composite holds its last frame.
scripts/composite.py
"""Preview composite: lay the rendered clips over the source video (hard cuts, original audio).
usage: python3 composite.py motion/plan.json motion/out/preview.mp4
Clips shorter than their slot hold their last frame. Audio is re-encoded to AAC so any source muxes into MP4. Transparent .mov clips composite with alpha."""
import json, sys, subprocess, pathlib
P = json.load(open(sys.argv[1])); base = pathlib.Path(sys.argv[1]).parent; out = sys.argv[2]
rel = lambda f: str((base/f).resolve()) if not pathlib.Path(f).is_absolute() else f
cmd = ['ffmpeg', '-loglevel', 'error', '-y', '-i', rel(P['video'])]; fc = []; last = '0:v'
for i, c in enumerate(P['clips'], 1):
cmd += ['-i', rel(c['file'])]
fc.append(f"[{i}:v]format=yuva444p,tpad=stop_mode=clone:stop_duration={c['out']-c['in']:.3f},setpts=PTS-STARTPTS+{c['in']}/TB[c{i}]")
fc.append(f"[{last}][c{i}]overlay=enable='between(t,{c['in']},{c['out']})':eof_action=pass[v{i}]"); last = f'v{i}'
fc.append(f'[{last}]format=yuv420p[v]')
cmd += ['-filter_complex', ';'.join(fc), '-map', '[v]', '-map', '0:a?', '-c:v', 'libx264', '-crf', '18', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', '-shortest', out]
subprocess.run(cmd, check=True); print('wrote', out)
scripts/inspect_video.py
"""Inspect a talking-head video: specs, a contact sheet, and regions that are empty (e.g. a picture-in-picture layout).
usage: python3 inspect_video.py video.mp4 outdir"""
import sys, json, subprocess, numpy as np, os
v, out = sys.argv[1], sys.argv[2]; os.makedirs(out, exist_ok=True)
p = json.loads(subprocess.run(['ffprobe','-v','error','-print_format','json','-show_streams','-show_format',v],capture_output=True,text=True).stdout)
vs = [s for s in p['streams'] if s['codec_type']=='video'][0]
W, H = int(vs['width']), int(vs['height']); fps = vs['r_frame_rate']; dur = float(p['format']['duration'])
subprocess.run(['ffmpeg','-loglevel','error','-y','-i',v,'-vf',f'fps=1/{max(1,dur/16):.2f},scale=480:-1,tile=4x4:padding=4','-frames:v','1',f'{out}/contact.png'])
raw = subprocess.run(['ffmpeg','-loglevel','error','-i',v,'-vf','fps=4,scale=96:54,format=gray','-f','rawvideo','-'],capture_output=True).stdout
fr = np.frombuffer(raw,np.uint8).reshape(-1,54,96)
# a column band that is near-black for a stretch = free space for an overlay panel.
# an all-black frame (fade, intro/outro) has no subject: its own 'black' layout, not 'pip'
def layout(f):
if f.max()<=20: return 'black'
return 'pip' if f[:,60:].mean()<8 or f[:,:36].mean()<8 else 'full'
sections=[]; prev=None; start=0
for i,f in enumerate(fr):
lay=layout(f)
if lay!=prev:
if prev is not None: sections.append({'from':start/4,'to':i/4,'layout':prev})
start=i; prev=lay
sections.append({'from':start/4,'to':len(fr)/4,'layout':prev})
# track the subject box through each PiP section; flag any resize (a panel sized for one box can collide with another)
for s in sections:
if s['layout']!='pip': continue
boxes=[]
for i in range(int(s['from']*4),int(s['to']*4)):
f=fr[i]; cols=np.where(f.max(0)>20)[0]; rows=np.where(f.max(1)>20)[0]
if len(cols)==0: continue
boxes.append((i/4,[int(cols.min()*W/96),int(rows.min()*H/54),int((cols.max()+1)*W/96),int((rows.max()+1)*H/54)]))
if not boxes: continue
s['subject_box']=[min(b[1][0] for b in boxes),min(b[1][1] for b in boxes),max(b[1][2] for b in boxes),max(b[1][3] for b in boxes)]
changes=[]; ref=boxes[0][1]
for t,b in boxes:
if max(abs(b[k]-ref[k]) for k in range(4))>2*W/96: changes.append({'at':t,'box':b}); ref=b
s['box_changes']=changes
if changes: print(f"WARNING: subject box changes size/position inside PiP section {s['from']}-{s['to']}s at {[c['at'] for c in changes]}. Keep panels clear of the largest box, or ask the user whether the change is intended.")
info={'width':W,'height':H,'fps':fps,'duration':dur,'sections':sections}
json.dump(info,open(f'{out}/video.json','w'),indent=1); print(json.dumps(info,indent=1))
scripts/make_pages.py
"""Write viewer.html (step through clips) and compare.html (original vs preview, synced) next to the renders.
usage: python3 make_pages.py motion/plan.json motion/out/preview.mp4"""
import json, sys, pathlib, os, subprocess, html, urllib.parse
S = pathlib.Path(__file__).resolve().parent.parent / 'templates'
plan_p = pathlib.Path(sys.argv[1]); P = json.load(open(plan_p)); base = plan_p.parent
preview = pathlib.Path(sys.argv[2]).resolve(); out = preview.parent
rel = lambda f: os.path.relpath((base / f).resolve(), out)
fmt = lambda t: (lambda cs: f"{cs//6000}:{cs%6000/100:05.2f}")(round(t*100)) # round first: 59.999 -> 1:00.00, not 0:60.00
probe = lambda f, e: subprocess.run(['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries', e, '-of', 'csv=p=0', str(f)], capture_output=True, text=True).stdout.strip()
js = lambda o: json.dumps(o, ensure_ascii=False).replace('</', '<\\/') # safe inside <script>
title = P.get('title', 'Video'); title_h = html.escape(title)
def web_src(c):
f = (base / c['file']).resolve()
if f.suffix.lower() == '.mov': # browsers can't play ProRes: make an on-black preview for the page
pv = out / (f.stem + '_preview.mp4')
size = probe(f, 'stream=width,height').replace(',', 'x') or '1920x1080'
subprocess.run(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', f'color=black:s={size}', '-i', str(f), '-filter_complex', '[0][1]overlay=shortest=1',
'-c:v', 'libx264', '-crf', '20', '-pix_fmt', 'yuv420p', str(pv)], check=True)
return pv.name
return os.path.relpath(f, out)
clips = [{'n': c['id'], 't': c['title'], 'tc': f"{fmt(c['in'])} – {fmt(c['out'])}", 'q': c.get('line', ''), 'f': pathlib.Path(c['file']).name,
'src': web_src(c), **({'alpha': True} if c.get('kind') == 'panel' else {})} for c in P['clips']]
clips.insert(0, {'n': '▶', 'full': True, 't': 'Full preview · your video with every clip', 'tc': 'whole video', 'q': 'Composite for review. For the final cut, place the clips in your editor.', 'f': preview.name, 'src': preview.name})
v = open(S / 'viewer.html').read().replace('/*CLIPS*/[]', js(clips)).replace('/*TITLE*/', title_h).replace('/*SUB*/', f"{len(P['clips'])} clips + full preview")
open(out / 'viewer.html', 'w').write(v)
dur = float(subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', str(preview)], capture_output=True, text=True).stdout.strip() or 0)
marks = [[c['in'], c['out'], c['id'], c['title']] for c in P['clips']]
c = (open(S / 'compare.html').read().replace('/*DUR*/0', f'{dur:.2f}').replace('/*MARKS*/[]', js(marks))
.replace('/*ORIG*/', urllib.parse.quote(rel(P['video']))).replace('/*BROLL*/', urllib.parse.quote(preview.name)).replace('/*TITLE*/', title_h)
.replace('/*SUB*/', 'Left or top is the original. Right or bottom has the motion graphics cut in.'))
open(out / 'compare.html', 'w').write(c); print('wrote', out / 'viewer.html', 'and', out / 'compare.html')
# TIMING.md for the editor
rows = ['| File | In | Out | Treatment | Covers the line |', '|---|---|---|---|---|']
for c in P['clips']:
rows.append(f"| {pathlib.Path(c['file']).name} | {fmt(c['in'])} | {fmt(c['out'])} | {'transparent panel' if c.get('kind') == 'panel' else 'full-frame cutaway'} | {c.get('line', '')} |")
notes = P.get('notes', [])
open(out / 'TIMING.md', 'w').write(f"# Motion graphics for {title}\n\nEach file name ends with its timeline in-point (`0m32s40` = 0:32.40). Transparent panels are ProRes 4444 .mov files: put them on a track above the video.\n\n" + '\n'.join(rows) + ('\n\nNotes\n' + '\n'.join('- ' + n for n in notes) if notes else '') + '\n')
print('wrote', out / 'TIMING.md')
scripts/setup.sh
#!/usr/bin/env bash
# One-time setup in the user's project. Installs Playwright + Chromium into ./motion/node_modules.
set -e
MOTION_DIR="${1:-./motion}"
mkdir -p "$MOTION_DIR"/{clips,dist,out,work}
missing=()
command -v node >/dev/null || missing+=("node (https://nodejs.org)")
command -v ffmpeg >/dev/null || missing+=("ffmpeg (macOS: brew install ffmpeg)")
command -v python3 >/dev/null || missing+=("python3")
if [ ${#missing[@]} -gt 0 ]; then echo "Missing: ${missing[*]}"; exit 1; fi
python3 -c "import numpy" 2>/dev/null || python3 -m pip install --user numpy || python3 -m pip install --break-system-packages numpy
if [ ! -d "$MOTION_DIR/node_modules/playwright" ]; then
# a package.json here stops npm from installing into a parent project
[ -f "$MOTION_DIR/package.json" ] || echo '{"private":true}' > "$MOTION_DIR/package.json"
(cd "$MOTION_DIR" && npm install --silent playwright && npx playwright install chromium)
fi
ffmpeg -hide_banner -encoders 2>/dev/null | grep -q prores_ks || echo "Note: your ffmpeg has no prores_ks encoder; transparent panel clips will fail."
echo "Ready. Clips go in $MOTION_DIR/clips, renders in $MOTION_DIR/out."
scripts/words.py
"""SRT/VTT -> estimated word timestamps (spreads each cue across its characters; ~0.2s accuracy).
If faster-whisper is installed, prefer real word timestamps from the audio.
usage: python3 words.py transcript.srt|transcript.vtt > words.txt"""
import re, sys
def ts(x): # SRT hh:mm:ss,mmm or VTT [hh:]mm:ss.mmm
return sum(float(v)*60**i for i,v in enumerate(reversed(x.strip().replace(',','.').split(':'))))
cues=0
for blk in re.split(r'\n\s*\n',open(sys.argv[1],encoding='utf-8-sig').read().strip()):
L=blk.strip().split('\n')
i=next((i for i,l in enumerate(L[:2]) if '-->' in l),None) # SRT has an index line first, VTT may not
if i is None: continue # WEBVTT header, NOTE/STYLE blocks
a,b=[ts(x.split()[0]) for x in L[i].split('-->')] # drop VTT cue settings after the end time
words=re.sub(r'<[^>]*>','',' '.join(L[i+1:])).split() # strip <i>, <c>, <00:00:01.500> tags
if not words: continue
n=sum(len(w)+1 for w in words); c=0; out=[]
for w in words: out.append(f'{a+(b-a)*c/n:.2f}:{w}'); c+=len(w)+1
print(' '.join(out)); cues+=1
if not cues: sys.exit(f'words.py: no cues found in {sys.argv[1]} (expected SRT or VTT)')
SKILL.md
---
name: motion-broll
description: Turn a talking-head video and its transcript into motion-graphic B-roll timed to the speaker's words, in a continuous one-shape style (one element that morphs and never cuts, driven by a cursor, spring motion). Use when someone gives a video (and ideally a transcript) and asks for motion graphics, B-roll, animated cutaways or overlays.
---
# Motion B-roll
You make motion-graphic clips for a creator's video. Every clip is **one shape that never cuts**: it morphs its size, corners and colour from state to state while its content swaps with a short blur. A cursor drives the changes with real clicks and drags, and every change lands on a spoken word.
The output is a set of clips the creator drops into their editor. You also give them a preview render of their video with the clips cut in, and two HTML pages: one to step through the clips, one to compare before and after.
Everything you need is in this skill folder:
- `engine/`: the motion engine (`motion.js`), the page shell (`base.css`), `build.py` (clip → self-contained HTML), `render.js` (frame-by-frame render with motion blur), `beats.js` (contact sheets of stills), fonts (Geist, OFL).
- `scripts/`: `setup.sh`, `inspect_video.py`, `words.py`, `composite.py`, `make_pages.py`.
- `reference/engine-api.md`: how to write a clip. **Read it before writing your first clip.**
- `examples/opus-aoe2/`: six finished clips for a real 54-second video. Use them as the quality bar and as starting points.
Below, `$SKILL` means this skill's folder. Work in a `motion/` folder in the user's project.
## 1. Set up (first run only)
```bash
bash $SKILL/scripts/setup.sh ./motion
```
This checks for Node, Python 3 and ffmpeg, and installs Playwright + Chromium into `motion/node_modules`. Run every engine script with `NODE_PATH=./motion/node_modules`.
## 2. Interview
Use the AskUserQuestion tool; keep it to one round. You need:
- **The video.** A path. Copy or reference it as `motion/work/source.*`.
- **The transcript.** An SRT or VTT with timestamps is best. With plain text only, get word timestamps from the audio (`pip install faster-whisper`) or ask for an SRT.
- **Density:**
- Light: 2–4 clips per minute, only the strongest moments.
- Medium (default): 4–7 per minute.
- Heavy: most lines covered, with short gaps of face.
- **Look:**
- The default palette: warm gray canvas, black and white components, one orange accent.
- Or their brand: colours, and optionally a font. Ask them to drop a logo, screenshots or brand colours into `motion/inputs/`, and read those.
- **Anything to avoid or include.** Lines to leave on their face, real numbers or names to show, product screenshots to recreate.
Skip any question the user already answered.
## 3. Inspect the footage
```bash
python3 $SKILL/scripts/inspect_video.py motion/work/source.mp4 motion/work
python3 $SKILL/scripts/words.py transcript.srt > motion/work/words.txt # .vtt works too
```
Look at `motion/work/contact.png` yourself. `video.json` gives resolution, fps and layout sections:
- **full:** the speaker fills the frame.
- **pip:** the speaker is in a box and the rest is empty. It includes the box position and a warning if the box changes size or moves.
- **black:** the whole frame is black (a fade, intro or outro).
Clips must match the video's resolution and fps. Word times from an SRT or VTT are estimates (±0.2s); treat them as such.
## 4. Plan, and get approval before any code
Show a table with one row per clip:
| # | In–Out | The line it covers | What the shape does, on which words | Treatment | Why |
**Creative direction: decide how much of the frame each clip takes.** This is the most important call; make it per clip and give the reason.
- **Full-frame cutaway.** The frame is all speaker, and the line describes something to *see*: a product, a process, a comparison, a number, a chapter change.
- Cut away for the length of the idea (3–10s), then give the face back.
- Don't cut away in the first second of the hook or on personal, emotional or opinion lines.
- Leave at least ~2s of face between cutaways.
- **Panel in the empty space.** The edit already leaves room: a PiP box, a split, a plain region.
- Render a transparent clip (`bg:null`) centred in the empty area, sized to stay clear of the speaker's box at every moment.
- It can run long (15s+) as one continuous morph, because the speaker stays on screen.
- If `inspect_video.py` reports the box changing, ask whether that's intended, then size for the largest box or split the panel at the change.
- **Nothing.** The line is about the speaker, or it's too close to another clip.
**Content rules:**
- Show the subject's real objects: the folder, the terminal, the file, the product, the result. Pull them from the words. Map each idea onto an interaction from the vocabulary in `reference/engine-api.md` (pill + click, progress, check, status island, card with a drag, slider, toggle, tabs, chart + tooltip, search/filter, file drag-and-drop, terminal typing, side-by-side comparison, chapter card).
- **Never invent numbers, quotes, prices or results.** Use relative bars, skeleton text lines or labels taken from the transcript, and list what is illustrative. Put real figures in only when the user gives them.
- One idea per clip. A continuous stretch (a whole PiP section) can be one long clip with several states.
- Pace to speech, not music: one change per spoken beat, about 0.4–1.2s apart.
Wait for the user to approve or edit the plan.
## 5. Build
Write one fragment per clip in `motion/clips/NN-name.html`, following `reference/engine-api.md` and the examples. Then build:
```bash
python3 $SKILL/engine/build.py motion/dist motion/clips/*.html
```
Put each state change on its word: clip-local time = word time − clip in-point.
## 6. Check stills on the key words
```bash
NODE_PATH=./motion/node_modules node $SKILL/engine/beats.js motion/dist/NN-name.html motion/work/NN.png 0.4 1.2 2.1 …
```
Pick the times where each state has settled and a couple of mid-morph moments. Look at every sheet yourself. Fix anything cramped, clipped, unreadable or off-word, or where the cursor leaves the frame. Then check once more. Panel clips render on black in the sheet so you can judge them.
## 7. Render
```bash
NODE_PATH=./motion/node_modules node $SKILL/engine/render.js motion/dist/NN-name.html motion/out/NN-name_0m32s40.mp4 30000/1001
```
- Use the video's fps.
- Panel clips (`bg:null`) must be written to `.mov`, which gives ProRes 4444 with alpha.
- Name each file by its timeline in-point (`0m32s40` = 0:32.40).
- Rendering takes 4 subframes per frame, so it is slower than real time. Run two clips in parallel on a multi-core machine.
## 8. Deliver
Write `motion/plan.json` (schema in `reference/engine-api.md`), then:
```bash
python3 $SKILL/scripts/composite.py motion/plan.json motion/out/preview.mp4
python3 $SKILL/scripts/make_pages.py motion/plan.json motion/out/preview.mp4
```
The user gets, all in `motion/out/`:
- the clips
- `TIMING.md`: file, in, out, the line it covers, the treatment, and what is illustrative
- `preview.mp4`: their video with the clips cut in, hard cuts, original audio
- `viewer.html`: step through the clips
- `compare.html`: original vs preview, synced, as side by side, stacked or wipe
Say plainly that the preview is for review: for the final cut they place the clips in their own editor, where they can nudge timing, add transitions and mix audio.
## Style defaults
The user's brand overrides these.
- **Colour:** canvas `#E9E7E2`, ink `#0B0B0B`, white components, one accent `#FF5A1F`. Panel clips on dark footage use light shapes (`#F4F2EE`).
- **Type and icons:** Geist for UI text, Geist Mono for code, file names and terminals. One icon set with one stroke weight (`M.icon`).
- **Motion:** springs with a tiny overshoot at most. Leading and trailing edges ride different springs so indicators stretch. The camera zooms so each state fills the frame.
- **Banned:** bouncy easing, particles, glows, gradients on UI chrome, mixed icon strokes, dead time, anything that looks like a template, made-up data.
## Gotchas
- Never put `will-change` on anything the camera scales; text renders blurry.
- Text swapping inside a morphing container needs its own enter and exit timing (`M.vis` din/lin/lout), or the old and new text overlap.
- `mix-blend-mode: difference` labels must sit in a layer that has the blend mode itself. A filtered parent isolates them.
- Items that slide under a highlight need `M.FAST` springs, or the highlight row sits empty for a moment.
- Keep the cursor inside the frame at every camera zoom, including during morphs.
- Word estimates from an SRT drift within a cue; put important changes on the first or last word of a cue when you can.
- A clip ends by holding its last state; the composite holds the last frame if its slot is longer.
templates/compare.html
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>/*TITLE*/ before and after</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap">
<style>
:root{--bg:#E9E7E2;--panel:#FFFFFF;--ink:#0B0B0B;--muted:#7D7A74;--line:#DAD6CF;--soft:#F3F1ED;--acc:#FF5A1F;--stage:#0B0B0B;
--sans:'Geist',system-ui,-apple-system,sans-serif;--mono:'Geist Mono',ui-monospace,monospace}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#141412;--panel:#1D1C1A;--ink:#F2F0EC;--muted:#9C9891;--line:#34322E;--soft:#262522;color-scheme:dark}}
:root[data-theme="dark"]{--bg:#141412;--panel:#1D1C1A;--ink:#F2F0EC;--muted:#9C9891;--line:#34322E;--soft:#262522;color-scheme:dark}
body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.5}
.wrap{max-width:1400px;margin:0 auto;padding-inline:20px;padding-block:24px 44px;display:grid;gap:16px}
header{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px 20px}
h1{margin:0;font-size:22px;font-weight:600;letter-spacing:-.02em;text-wrap:balance}
.sub{color:var(--muted);font-size:14px}
.seg{display:inline-flex;background:var(--soft);border:1px solid var(--line);border-radius:12px;padding:3px;gap:2px}
.seg button{font:inherit;font-size:14px;font-weight:500;color:var(--ink);background:transparent;border:0;border-radius:9px;padding:6px 13px;cursor:pointer}
.seg button[aria-pressed="true"]{background:var(--ink);color:var(--bg)}
button:focus-visible,input:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
.views{display:grid;gap:14px}
.views.side{grid-template-columns:1fr 1fr}
.views.stack{grid-template-columns:minmax(0,960px);justify-content:center}
@media (max-width:760px){.views.side{grid-template-columns:1fr}}
.card{background:var(--panel);border-radius:16px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.05),0 12px 30px -14px rgba(0,0,0,.2)}
.lab{display:flex;align-items:center;gap:8px;padding:10px 14px;font-size:14px;font-weight:600}
.dot{width:9px;height:9px;border-radius:50%;background:var(--muted)}
.card.after .dot{background:var(--acc)}
.screen{position:relative;aspect-ratio:16/9;background:var(--stage);max-width:100%}
video{position:absolute;inset:0;width:100%;height:100%;display:block}
.wipe{display:none}
.views.wipe-mode .card.before,.views.wipe-mode .card.after{display:none}
.views.wipe-mode{grid-template-columns:minmax(0,1100px);justify-content:center}
.views.wipe-mode .wipe{display:block}
#wTop{clip-path:inset(0 0 0 50%)}
.handle{position:absolute;top:0;bottom:0;width:3px;background:#fff;left:50%;transform:translateX(-1.5px);box-shadow:0 0 0 1px rgba(0,0,0,.2);pointer-events:none}
.handle::after{content:"";position:absolute;top:50%;left:50%;width:34px;height:34px;border-radius:50%;background:#fff;transform:translate(-50%,-50%);box-shadow:0 2px 8px rgba(0,0,0,.3)}
.wl{position:absolute;top:10px;font-size:12px;font-weight:600;color:#fff;background:rgba(0,0,0,.55);border-radius:999px;padding:3px 10px}
.bar{background:var(--panel);border-radius:16px;padding:14px 16px;display:grid;gap:12px}
.ctl{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px}
.btn{font:inherit;font-size:14px;font-weight:500;color:var(--bg);background:var(--ink);border:0;border-radius:10px;padding:8px 16px;cursor:pointer;min-width:84px}
.btn.ghost{background:var(--soft);color:var(--ink);border:1px solid var(--line)}
.time{font-family:var(--mono);font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
.now{font-size:14px;font-weight:500}
.now b{color:var(--acc);font-weight:600}
.track{position:relative;height:44px;cursor:pointer;touch-action:none}
.rail{position:absolute;left:0;right:0;top:26px;height:6px;border-radius:3px;background:var(--soft)}
.fill{position:absolute;left:0;top:26px;height:6px;border-radius:3px;background:var(--ink)}
.mk{position:absolute;top:0;height:20px;border-radius:6px;background:color-mix(in srgb,var(--acc) 18%,transparent);border:1px solid color-mix(in srgb,var(--acc) 55%,transparent);font-family:var(--mono);font-size:11px;display:flex;align-items:center;justify-content:center;color:var(--ink);overflow:hidden;box-sizing:border-box}
.mk.on{background:var(--acc);color:#fff;border-color:var(--acc)}
.knob{position:absolute;top:20px;width:18px;height:18px;border-radius:50%;background:var(--ink);transform:translateX(-9px);box-shadow:0 0 0 3px var(--panel)}
.foot{font-size:13px;color:var(--muted);max-width:80ch;margin:0}
</style>
<div class="wrap">
<header>
<div><h1>Before and after · motion B-roll</h1><div class="sub">/*SUB*/</div></div>
<div class="seg" role="group" aria-label="Layout">
<button type="button" data-mode="side" aria-pressed="true">Side by side</button>
<button type="button" data-mode="stack" aria-pressed="false">Stacked</button>
<button type="button" data-mode="wipe-mode" aria-pressed="false">Wipe</button>
</div>
</header>
<div class="views side" id="views">
<div class="card before"><div class="lab"><span class="dot"></span>Original</div><div class="screen"><video id="vA" src="/*ORIG*/" muted playsinline preload="auto"></video></div></div>
<div class="card after"><div class="lab"><span class="dot"></span>With motion B-roll</div><div class="screen"><video id="vB" src="/*BROLL*/" muted playsinline preload="auto"></video></div></div>
<div class="card wipe"><div class="lab"><span class="dot"></span>Drag across the picture to compare</div>
<div class="screen" id="wScreen" style="touch-action:none;cursor:ew-resize">
<video id="wBot" src="/*ORIG*/" muted playsinline preload="auto"></video>
<video id="wTop" src="/*BROLL*/" muted playsinline preload="auto"></video>
<span class="wl" style="left:10px">Original</span><span class="wl" style="right:10px">B-roll</span>
<div class="handle" id="handle"></div>
</div>
</div>
</div>
<div class="bar">
<div class="ctl">
<button class="btn" id="play" type="button">Play</button>
<button class="btn ghost" id="snd" type="button">Sound on</button>
<span class="time" id="time">0:00.0</span>
<span class="now" id="now">Speaker only</span>
</div>
<div class="track" id="track" role="slider" aria-label="Seek" tabindex="0" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
<div class="rail"></div><div class="fill" id="fill"></div><div id="marks"></div><div class="knob" id="knob"></div>
</div>
</div>
<p class="foot">Orange blocks on the timeline are where an animation is on screen. Click one to jump to it. Arrow keys skip 2 seconds, space plays and pauses.</p>
</div>
<script>
const D=/*DUR*/0;
const CL=/*MARKS*/[];
const $=id=>document.getElementById(id);
const views=$('views'); let mode='side';
const master=()=>mode==='wipe-mode'?$('wTop'):$('vB');
const followers=()=>mode==='wipe-mode'?[$('wBot')]:[$('vA')];
const all=['vA','vB','wBot','wTop'].map($);
$('marks').innerHTML=CL.map((c,i)=>`<div class="mk" data-i="${i}" style="left:${c[0]/D*100}%;width:${(c[1]-c[0])/D*100}%">${c[2]}</div>`).join('');
function fmt(t){const m=Math.floor(t/60),s=t-m*60;return m+':'+(s<10?'0':'')+s.toFixed(1);}
function seek(t){t=Math.max(0,Math.min(D-0.05,t));all.forEach(v=>{try{v.currentTime=t}catch(e){}});paint(t);}
function paint(t){
$('fill').style.width=(t/D*100)+'%';$('knob').style.left=(t/D*100)+'%';$('time').textContent=fmt(t)+' / '+fmt(D);$('track').setAttribute('aria-valuenow',t.toFixed(1));
const c=CL.findIndex(c=>t>=c[0]&&t<c[1]);$('now').innerHTML=c<0?'Speaker only':`<b>${CL[c][2]}</b> · ${CL[c][3]}`;
document.querySelectorAll('.mk').forEach(m=>m.classList.toggle('on',+m.dataset.i===c));
}
function tick(){const m=master();const t=m.currentTime;for(const f of followers()){if(Math.abs(f.currentTime-t)>0.08){try{f.currentTime=t}catch(e){}} if(m.paused!==f.paused){m.paused?f.pause():f.play().catch(()=>{});}}paint(t);requestAnimationFrame(tick);}
requestAnimationFrame(tick);
function setPlay(p){const m=master();if(p){m.play().catch(()=>{});followers().forEach(f=>f.play().catch(()=>{}));}else{all.forEach(v=>v.pause());}$('play').textContent=p?'Pause':'Play';}
$('play').onclick=()=>setPlay(master().paused);
master().addEventListener('ended',()=>setPlay(false));
all.forEach(v=>v.addEventListener('ended',()=>{if(v===master()){setPlay(false);seek(0);}}));
let sound=false;function applySound(){all.forEach(v=>v.muted=true);master().muted=!sound;$('snd').textContent=sound?'Sound off':'Sound on';}
$('snd').onclick=()=>{sound=!sound;applySound();};
document.querySelectorAll('.seg button').forEach(b=>b.onclick=()=>{
const t=master().currentTime, playing=!master().paused; all.forEach(v=>v.pause());
mode=b.dataset.mode; views.className='views '+mode; document.querySelectorAll('.seg button').forEach(x=>x.setAttribute('aria-pressed',String(x===b)));
seek(t); applySound(); if(playing) setPlay(true);
});
// scrubbing
const tr=$('track');let drag=false;
const tAt=e=>{const r=tr.getBoundingClientRect();return (e.clientX-r.left)/r.width*D;};
tr.addEventListener('pointerdown',e=>{const mk=e.target.closest('.mk');if(mk){seek(CL[+mk.dataset.i][0]+0.05);return;}drag=true;tr.setPointerCapture(e.pointerId);seek(tAt(e));});
tr.addEventListener('pointermove',e=>{if(drag)seek(tAt(e));});
tr.addEventListener('pointerup',()=>drag=false);
document.addEventListener('keydown',e=>{if(e.key===' '){e.preventDefault();$('play').click();}else if(e.key==='ArrowRight'){seek(master().currentTime+2);}else if(e.key==='ArrowLeft'){seek(master().currentTime-2);}});
// wipe
const ws=$('wScreen');let wd=false;
function setWipe(e){const r=ws.getBoundingClientRect();const p=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width));$('wTop').style.clipPath=`inset(0 0 0 ${p*100}%)`;$('handle').style.left=(p*100)+'%';}
ws.addEventListener('pointerdown',e=>{wd=true;ws.setPointerCapture(e.pointerId);setWipe(e);});
ws.addEventListener('pointermove',e=>{if(wd)setWipe(e);});
ws.addEventListener('pointerup',()=>wd=false);
applySound();paint(0);
</script>
</html>templates/viewer.html
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>/*TITLE*/ B-roll</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap">
<style>
:root{
--bg:#E9E7E2; --panel:#FFFFFF; --ink:#0B0B0B; --muted:#7D7A74; --line:#DEDAD3; --soft:#F3F1ED; --acc:#FF5A1F; --stage:#D9D6D0;
--sans:'Geist',system-ui,-apple-system,sans-serif; --mono:'Geist Mono',ui-monospace,monospace;
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#141412;--panel:#1D1C1A;--ink:#F2F0EC;--muted:#9C9891;--line:#34322E;--soft:#262522;--stage:#0B0B0A;color-scheme:dark}}
:root[data-theme="dark"]{--bg:#141412;--panel:#1D1C1A;--ink:#F2F0EC;--muted:#9C9891;--line:#34322E;--soft:#262522;--stage:#0B0B0A;color-scheme:dark}
body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.5}
.wrap{max-width:1180px;margin:0 auto;padding-inline:20px;padding-block:28px 48px;display:grid;gap:20px}
header{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:8px 20px}
h1{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0;text-wrap:balance}
.sub{color:var(--muted);font-size:14px}
.main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:20px;align-items:start}
@media (max-width:860px){.main{grid-template-columns:1fr}}
.player{background:var(--panel);border-radius:18px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.05),0 12px 32px -14px rgba(0,0,0,.18)}
.screen{position:relative;aspect-ratio:16/9;background:var(--stage);max-width:100%}
video{position:absolute;inset:0;width:100%;height:100%;display:block}
.info{padding:16px 18px 18px;display:grid;gap:10px}
.meta{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center}
.tc{font-family:var(--mono);font-size:13px;background:var(--soft);border-radius:8px;padding:3px 9px;font-variant-numeric:tabular-nums}
.tag{font-size:12px;font-weight:500;color:#fff;background:var(--acc);border-radius:999px;padding:2px 9px}
.title{font-size:19px;font-weight:600;letter-spacing:-.015em;margin:0}
.quote{margin:0;color:var(--muted);font-size:15px;max-width:65ch}
.file{font-family:var(--mono);font-size:12.5px;color:var(--muted);overflow-wrap:anywhere}
.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
button{font:inherit;font-size:14px;font-weight:500;color:var(--ink);background:var(--soft);border:1px solid var(--line);border-radius:10px;padding:7px 13px;cursor:pointer}
button:hover{border-color:var(--muted)}
button:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
button.primary{background:var(--ink);color:var(--bg);border-color:var(--ink)}
.spacer{flex:1}
.hint{font-size:12.5px;color:var(--muted)}
.list{display:grid;gap:8px;margin:0;padding:0;list-style:none}
.item button{width:100%;display:grid;grid-template-columns:34px minmax(0,1fr);gap:10px;align-items:start;text-align:left;background:var(--panel);border:1px solid transparent;border-radius:14px;padding:11px 12px}
.item button[aria-current="true"]{border-color:var(--ink)}
.num{font-family:var(--mono);font-size:13px;width:34px;height:34px;border-radius:10px;background:var(--soft);display:flex;align-items:center;justify-content:center}
.item button[aria-current="true"] .num{background:var(--acc);color:#fff}
.it{font-weight:600;font-size:14.5px;letter-spacing:-.01em}
.iq{font-size:13px;color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.itc{font-family:var(--mono);font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
.foot{font-size:13px;color:var(--muted);max-width:75ch}
@media (prefers-reduced-motion: reduce){video{animation:none}}
</style>
<div class="wrap">
<header>
<h1>/*TITLE*/ · B-roll</h1>
<span class="sub">/*SUB*/</span>
</header>
<div class="main">
<section class="player" aria-label="Clip player">
<div class="screen"><video id="vid" playsinline muted autoplay loop preload="auto"></video></div>
<div class="info">
<div class="meta"><span class="tc" id="tc"></span><button type="button" id="snd" style="padding:3px 10px;font-size:13px">Sound on</button><span class="tag" id="tag" hidden>Transparent overlay</span></div>
<h2 class="title" id="title"></h2>
<p class="quote" id="quote"></p>
<div class="file" id="file"></div>
<div class="controls">
<button id="prev" type="button">← Prev</button>
<button id="play" type="button" class="primary">Pause</button>
<button id="next" type="button">Next →</button>
<span class="spacer"></span>
<label class="hint"><input type="checkbox" id="auto"> Play all in order</label>
</div>
</div>
</section>
<ol class="list" id="list"></ol>
</div>
<p class="foot">The timecode is where each clip goes in your edit. Transparent panel clips are shown here on black; in your edit they sit in the empty area of the shot. Arrow keys switch clips and space pauses.</p>
</div>
<script>
const CLIPS=/*CLIPS*/[];
const $=id=>document.getElementById(id);
const vid=$('vid'), list=$('list');
let cur=0, touched=false; addEventListener('pointerdown',()=>{touched=true;},{capture:true}); addEventListener('keydown',()=>{touched=true;},{capture:true});
list.innerHTML=CLIPS.map((c,i)=>`<li class="item"><button type="button" data-i="${i}"><span class="num">${c.n}</span><span><div class="it">${c.t}</div><div class="itc">${c.tc}</div><div class="iq">${c.q}</div></span></button></li>`).join('');
function show(i){
cur=(i+CLIPS.length)%CLIPS.length; const c=CLIPS[cur];
$('tc').textContent=c.tc; $('title').textContent=c.n+' · '+c.t; $('quote').textContent=c.q; $('file').textContent=c.f; $('tag').hidden=!c.alpha;
vid.loop=!$('auto').checked; vid.muted=!(c.full&&touched); vid.src=c.src; vid.play().catch(()=>{}); $('play').textContent='Pause'; $('snd').hidden=!c.full; $('snd').textContent=vid.muted?'Sound on':'Sound off';
list.querySelectorAll('button').forEach(b=>b.setAttribute('aria-current',String(+b.dataset.i===cur)));
}
list.addEventListener('click',e=>{const b=e.target.closest('button[data-i]'); if(b) show(+b.dataset.i);});
$('snd').onclick=()=>{vid.muted=!vid.muted;$('snd').textContent=vid.muted?'Sound on':'Sound off';};
$('prev').onclick=()=>show(cur-1); $('next').onclick=()=>show(cur+1);
$('play').onclick=()=>{if(vid.paused){vid.play();$('play').textContent='Pause';}else{vid.pause();$('play').textContent='Play';}};
$('auto').onchange=()=>{vid.loop=!$('auto').checked; if($('auto').checked){vid.currentTime=0;vid.play();}};
vid.addEventListener('ended',()=>{if($('auto').checked) show(cur+1);});
document.addEventListener('keydown',e=>{if(e.target.tagName==='INPUT')return; if(e.key==='ArrowRight'){show(cur+1);e.preventDefault();} else if(e.key==='ArrowLeft'){show(cur-1);e.preventDefault();} else if(e.key===' '){$('play').click();e.preventDefault();}});
show(0);
</script>
</html>