Product video (and the assets around it), end to end
You are producing a shippable product asset — a polished video, a set of
exact-size stills, or both — from a live web page, with the vos CLI.
Everything is data: you write a flow script, the CLI records and plans, you
tune JSON, you re-render. Never re-record to fix pacing or zooms — edit
doc.json and render again. Quality bar: references/taste.md — follow
its quality loop and judge stills multimodally.
Setup
npm i -D @vosjs/cli
One command, one package: @vosjs/cli is the open source (MIT) vos
binary with the engine verbs, the take pipeline used here (record / plan /
frames / render on screen recordings) and the vos.so platform verbs. (Until
0.9 the take pipeline shipped separately as @vosso/vos-plugin; a project
that still lists it can drop it.) Requirements:
- A Chromium: system Chrome is found automatically; otherwise
npx playwright install chromiumor pointVOS_BROWSER_PATHat one. Exit code 3 means no browser was found. - Network at render time: the render page loads three/mediabunny from esm.sh. Recording also needs to reach the target URL. Fully offline sandboxes cannot render — say so instead of shipping nothing.
Conventions: logs → stderr, results → stdout; --json streams NDJSON ending
with {"event":"done",…}; exit codes 0 ok / 1 error / 2 usage or strict
failure / 3 no browser.
Step 0 — pick the destination (it decides everything)
| Destination | Viewport | Output | Extras |
|---|---|---|---|
| Landing-page clip (hero/section embed) | 2560×1440 (footage-native 2K) | webm → VP9 re-encode + poster | silent loop; see references/destinations.md |
| Launch video (PH, social, store promo) | 2560×1440 or 1280×720 | mp4 (--format mp4, needs system Chrome) | music bed via doc.audio |
| Launch-kit stills (store screenshots, tiles, OG) | sized to the asset | frames --frame <t> --size WxH PNGs from the same take | one take → every asset |
| Quick demo (issue, PR, chat) | 1280×720 | webm, defaults | speed over polish; drafts acceptable here ONLY |
Per-channel dimensions and byte budgets: references/destinations.md.
The core loop (every destination)
-
Explore the target page with your own tools (fetch HTML / Playwright). Identify the 3–6 moments that tell ONE story. Collect STABLE selectors (
a[href='…'], ids, roles — not nth-child chains). Stage the content like a set: the script must leave the product in the state a proud screenshot would show — labels typed, real-looking data, the feature mid-story. An empty canvas records fast and demos nothing, and no downstream composition rescues it.Verified the feature with agent-browser already? Keep that walk and skip the second script. agent-browser's
--jsonresult does not say what ran (scrollanswers{scrolled:true}), so wrap each call so the command rides beside its result, then convert:ab() { agent-browser "$@" --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const r=JSON.parse(s);process.stdout.write(JSON.stringify({command:process.argv.slice(1),...r})+"\n")})' -- "$@" >> steps.jsonl; } ab open https://target.example; ab snapshot -i -u; ab click @e27 ab wait 800; ab snapshot -i # the page changed: refs renumbered ab fill @e2 query; ab press Enter vos actions from-agent-browser steps.jsonl --out actions.json(a whole path run as one
agent-browser batch … --json > steps.jsonlalready has that shape). The log issteps.jsonlin the directory you stand in, so walk from one directory. Refs resolve through the lastsnapshot -ibefore them, and they RENUMBER after a navigation and again inside a dialog: re-snapshot after anything that changes the page and read the refs before you name one (-ugives links their href). Click the control, do not press its shortcut: ⌘K opens the dialog for a human, but a keystroke is the one step the recorder cannot replay. Whatever it cannot follow (a shortcut key, a drag, a secondopen) is NAMED in the output, never dropped: read the notes, write those steps by hand, then record. -
Write
actions.json:{ "url": "https://target.example", "viewport": { "width": 1280, "height": 720 }, "steps": [ { "do": "wait", "ms": 800 }, { "do": "hover", "selector": "a[href='/pricing']", "ms": 700 }, { "do": "click", "selector": "#cta" }, { "do": "wait", "ms": 1500 }, { "do": "scroll", "dy": 400 }, { "do": "move", "x": 640, "y": 320 }, { "do": "wait", "ms": 900 } ] }Verbs:
waithoverclicktypescrollmovedrag(drag = real edits:{do:'drag', selector|x,y, tx, ty, ms}— slide a range input, drag a canvas element, move a timeline clip). Pacing IS the zoom plan: openwait ≥700ms; hover what matters 700–900ms (dwells become zooms); 1200–2000ms after navigations; end settled. Route the cursor away from hover-triggered menus (taste.md, flow rules). Check withvos validate actions.json. -
Record:
vos record --actions actions.json --out take --strict --json--strictalways: skipped selector / networkidle timeout → exit 2 withskipped[]in the done event. A skip means the flow is broken — fix it, never ship around it. The take auto-encodes and auto-plans. (vos create --actions actions.json out.webm --strictis the one-shot record+render verb — fine for a quick first pass, but THIS skill's loop reviews frames before rendering, so prefer the separate verbs here.) -
Tune
doc.json(JSON Schema ships in the@vosjs/clinpm package:schema/doc.schema.json):zoom:[{in, out, level, cx, cy, source}], SOURCE seconds; levels 1.4–2.8;cx/cyNORMALIZED [0..1] (0.5,0.5 = center) — NOT pixels; set"source": "manual"on spans you touch (survives re-plan).segments(trims) ·speed(rate0.1–16) ·frame.*·cursor.tilt:[{in, out, rx, ry, source}], SOURCE seconds — the 3D card leans to the pose while active, returns to rest between. DEGREES (±5..18 reads premium): +rx = top edge closer, +ry = left edge closer (lean toward a right-side focus = negativery). Spans ≥ 0.8s; pair with zoom moments (same in/out chains the moves), one pose change per ~5s beat."source": "manual"on spans you touch;tiltStyle: "subtle"|"medium"|"strong"records the auto wand.frame.backgroundMedia: a video loop / image behind the card —{"kind":"video","key":"/bg.webm","duration":10,"dim":0.2}.key= a file dropped in the take dir ("/bg.webm") or a media URL; video needsduration(OUTPUT-anchored modulo loop);dim0..1 scrim. Ambience, not a subject — dim it behind dense UI.audio: OUTPUT-anchored clips;keymay be a file dropped into the take dir ("/music.mp3"); gain/fades/loop. Muxed on full renders (Opus/AAC);--rangestays silent; forces single-flight.- export:
{"resolution": "720p|1080p|2k|4k", "fps": 30}— never above the footage (validate warns). Thenvos validate take --json— lints must pass.
-
Look before you render (the taste.md quality loop):
vos frames take --at-zooms --times 0,25%,50%,75%,100% --json→ judge every still against taste.md, zoom apexes hardest.- Iterate: edit doc.json →
vos render take check.webm --range a..b --draft(seconds, half res — never ship drafts) → re-frame the changed region. - Trying a presentation? Use a flag, not a scratch script.
render/framestake doc overrides —--set <path>=<value>(repeatable; JSON-or-string),--frame <macos|windows|minimal|none>(render),--background <url>— that patch the doc in memory (doc.json untouched) and are lint-gated. Sovos frames take --frame 2.0 --set frame.browserBar.kind=mac-light --set tilt[0].rx=8previews a framed, tilted card without touching the file.
-
Final render:
vos render take out.webm --json(or--format mp4). Re-frame the final (frames --at-zooms) against taste.md before declaring done. Renders are deterministic — only your edits change the output.
6b. Human review round (when the ask involves one): vos open take
serves the take into the studio — your doc.json edits arrive intact and
every zoom span is draggable.
- Package for the destination:
references/destinations.md.
Launch kit (one take → every store asset)
The launch-kit skill owns this destination: it establishes the release,
loops every channel against channel-specs.json, verifies each artifact
against its spec, writes the kit.json manifest and pushes labelled for
the release. The mechanics it loops are this skill's:
vos frames take --frame <t> --size WxH per still spec, the mp4 render for
video. Follow it when the ask is a release, not one video.
Gotchas
- A WebGL-heavy page (a shader background, a 3D canvas) paints BLACK under
headless Chromium's software GL, and the recording has no way to say so:
the take looks right except for a dead canvas. Record such pages with
VOS_BROWSER_PATHpointing at system Chrome (a real GPU), and check the digest's sheet for the canvas before cutting. - Render time ≈ 1.5× real-time at 1080p (a 12.5s take ≈ 19s; ~5s fixed
startup);
--parallel Npays off on takes ≳30s (ignored when audio rides); 2K ≈ 2× per-frame cost. Recording is always real-time. - Footage resolution = viewport size — decide 2K at RECORD time, from the
DESTINATION's specs (a 720p take cannot honestly fill a 1080p video
spec). Coordinate steps (
x/y/drag) are VIEWPORT pixels: a viewport change means scaling every coordinate; selectors survive. vos plan takeregenerates onlysource:"auto"spans; manual spans survive.- Take dirs:
frames/is a deletable encode intermediate (~1GB at 2K);recording.webmis the re-render source — keep it. - More failure modes:
references/troubleshooting.md.
Avoid (the traps that shipped)
- A zoom that opens before the click it frames:
validatenames it ("points beside what was clicked"); start the span after the click, or aim at it. - A focus point in pixels:
cx/cyare fractions of the frame. - A
typeverb's field click as a zoom target: the field's centre is empty; frame the text, and open the span after the click. - Routing the cursor through a hover-triggered menu between beats.
- A first frame or a last frame that cannot stand alone as a poster.
- A frozen opening: a static landing page records as freeze-then-bang; trim it or speed it, the story opens near the money shot.
- A store screenshot cut from the composed frame: real UX is the page, full
bleed (
deliverdoes this; a text-heavy page also wants a store-size take). - A
.pngname onvos still: it writes WebP; convert, andvos validate <kit.json>reads the bytes.