@vosjs/cli 0.77.1 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,13 +72,15 @@ A **take** is a directory: the recording of a scripted browser flow, its exact c
72
72
 
73
73
  ```bash
74
74
  vos create --actions actions.json out.webm --strict # one shot: record, auto-plan, render
75
+ vos look https://your-app.example # SEE the page first: look.png + every control with a stable selector
75
76
  vos record --actions actions.json --out take --dry-run # rehearse the script first: which selectors resolve, their rects, in seconds
76
77
  vos record --actions actions.json --out take --strict # drive the page, record it with an exact cursor track, plan the cut
77
78
  vos digest take # SEE the recording before cutting: moments, frames, crops (an agent's eyes)
78
79
  # … edit take/doc.json (zoom spans, trims, speed, overlays) by hand or by agent …
79
- vos frames take --at-zooms --at-moments # PNG stills: contact sheet, every zoom apex, every moment
80
+ vos frames take --at-zooms --at-moments # PNG stills: every zoom apex on every media (each checked against its zoom-off twin), every moment
80
81
  vos render take check.webm --range 4..8 --draft # spot-check an edit in seconds (half res, low bitrate)
81
82
  vos render take out.webm # the polished render
83
+ vos check-video take/out.webm # the file itself: facts, scene changes, loudness, first and last frames
82
84
  vos frames take --frame 2.5 --size 1280x800 # one frame at an exact size, to check a moment
83
85
  vos open take # hand the take to the studio; a human drags every span
84
86
  ```
@@ -107,24 +109,43 @@ vos ingest loom.mp4 --as take --out take # a screen demo with no
107
109
 
108
110
  What a trace cannot give is named, never guessed: agent-browser's own log carries neither timestamps nor coordinates (measured), so stamp `ts` and a `point` per command when you keep one, or replay the walk with `vos actions from-agent-browser` and `vos record` instead. `--offset <ms>` shifts a trace whose zero is not the video's; `--viewport WxH` names the coordinate space when the video was scaled. A headed capture with the real pointer baked in shows two cursors under a trace's drawn one; turn the drawn cursor off in `doc.json` for those.
109
111
 
112
+ **Several takes, one film.** `vos join` cuts takes (and plain video files) into one take, in argument order, and the result is one document to edit, render and push like any other:
113
+
114
+ ```bash
115
+ vos join intro-take settings-take outro.mp4 --out film --captions
116
+ ```
117
+
118
+ The first input is the primary: its frame, cursor style, export, sound and cut come along as they are. Each next input becomes a media of the document (`m1`, `m2`, …): its footage and sidecars are copied under `film/m1/…`, its segments are appended (so the output plays the inputs one after the other), every source-anchored span it carries (`zoom`, `speed`, `tilt`, `camMotion`, `freeze`, `rejected`) is tagged with its media and renamed `m1-…`, and its layers move to where its footage now starts. A layer it pinned to one of its steps keeps its moment through an `anchor` to that step (a pin places a layer on the primary's footage only, so the pin itself is dropped, and the done line says so); its sound clips stay behind. A layer that starts after an input's footage ends (an end card, a closing title) is that input's ENDING: every input's ending but the last one's is dropped, named in a note, because it would land over the next clip; the last input's stays at the end of the film. A plain video file becomes a media with no cursor track, a `meta` probed from the file, a full-bleed card of its own (`inset` 0, no corners, no shadow) and one segment over its whole length; its own sound is part of what it is, so it plays as an audio clip keyed to the copied file (`<id>-sound`, starting where the file plays, trimmed to its clip, a short fade at each end), and `--mute <file>` leaves it out. A file whose audio Node cannot decode (AAC) is copied as it is and the join says so in one line; the render reads it in the browser. The joined document must pass `vos validate` before a byte is written. `--out` is refused when it exists, unless `--force` names a take directory to replace; `--out` may not overlap an input. `--captions` lands every step's `caption` (from `meta.steps` or each take's `actions.json`) as a caption anchored to its step, in the house style, marked `from: "steps"` so a second run replaces only those; each ends with the clip it starts in (never over the next input's footage, and a step in the last second of its clip gets none), a step a caption is already anchored to is left alone, and an input none of whose steps carries a caption is named. `vos plan <take> --captions` does the same for a single take (`--captions none` keeps its meaning: the plan proposes none).
119
+
120
+ **Music timed to the film.** `vos score` places ONE audio clip, id `score`, so that the strongest swell of a track lands on the film's payoff:
121
+
122
+ ```bash
123
+ vos score film --track fresh-focus --at m1:share --dry-run # a catalog slug, a step on media m1: print the plan and the envelope
124
+ vos score film --track ./theme.wav --at 12 --gain 0.4 # a local file (copied into film/music/), output seconds
125
+ ```
126
+
127
+ The track is decoded (mediabunny in Node for PCM; a compressed track in the headless browser) and measured as a short-term loudness curve, a 1 s window every 0.5 s: K-weighted LUFS (BS.1770) for mono and stereo audio, RMS dBFS otherwise, and the output says which. Its strongest swell is the largest sustained rise to a peak (at least 3 dB, holding within 3 dB of the peak for 1.5 s), measured once the track has established its level: a rise starts no earlier than 5 s after the track first comes within 12 dB of its median level, and from no lower than that, so a fade in from silence at the top is the track starting, never its swell; it lands where it ARRIVES, within 1 dB of the peak. `--at` is where it should land: a step (its id, `step:<index>` by its index, `<media>:<step>` for another media's), resolved through the cut exactly as an anchored caption is, or output seconds. The clip's `in` is set so the swell plays there, `start` is 0, `out` is `in` plus the film's length, with `--gain` (0.5), `--fade-out` (1.5 s) and, whenever the track is entered mid-song (`in` > 0), a 0.8 s fade-in (`--fade-in` sets it, 0 for none); when the track cannot both hold the swell there and cover the film, the clamp is said in words. Without `--at` the track starts at its first moment above its median level. A second run replaces the clip; `--dry-run` writes nothing and prints the swell, the offset and the curve as one line of blocks (`--json` carries every point).
128
+
110
129
  **No browser here?** A cloud agent, a CI runner or a chat host records on vos.so instead: the same script goes up, the fleet records, plans and lands the take as a private vos with its doc and its digest, and the take comes home into `--out` with its media, the directory every other verb reads.
111
130
 
112
131
  ```bash
113
132
  vos record --hosted --actions actions.json --out take # needs a credential (vos login, or VOS_API_KEY); a public URL, or a preview behind --header
114
133
  ```
115
134
 
116
- What travels: the script, the viewport, every `--header` value (a preview's bypass token), and the value of every `{ "env": "NAME" }` a `setup` step reads, from this shell. What never travels: a session (`--hosted` refuses `--session` and `--storage-state`, because none of a person's ever reaches vos.so). A page behind a sign-in fails on the fleet in words naming the local ladder, exit 4; a private-network URL is refused before a job exists. The fleet records up to ten minutes. `--dry-run` stays a local act.
117
-
118
- | Verb | Flags |
119
- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
120
- | `record` | `--actions <file>` (or positional) `--url` `--out take` `--strict` `--dry-run` `--allow-wall` `--keep-frames` `--storage-state <file>` or `--session <name>` `--header name=value`... `--browser-arg=<switch>`... `--max-duration <s>` `--background <slug\|url\|none>` `--hosted` (record on vos.so; with `--title` `--folder <id>`) |
121
- | `create` | The `record` flags plus the render flags (`--width` `--height` `--fps` `--format` `--parallel` `--draft` `--frame` `--set`), no `--range`. With `--strict` an incomplete recording exits 2 before anything is rendered |
122
- | `plan` | `--fresh` (discard the current plan) `--reuse` `--from <doc.json>` (defaults to `<take>/doc.prev.json`) `--style <doc.json\|take\|vosId>` `--with <doc.json\|take\|vosId>[@end\|@start\|@step:<id>\|@<seconds>]` (a template, repeatable) `--background` `--motion` (re-propose the motion) `--headline` `--kicker` `--launch` `--brand` `--music` `--entrance` `--transitions slide\|fade\|scale\|none` `--end-card on\|none\|<ref>` `--captions` `--clicks` `--release` |
123
- | `digest` | `--out <take>/digest` `--full 960` `--crop 640` (image long edges, the token budget) `--no-frames` `--transcript <file>` (Whisper-shaped segments merged as `said`) `--style <ref>` (report a reference document's style fields) |
124
- | `frames` | `--times 0,25%,50%,75%,100%` (the default selector, output seconds or percent) `--frame <t>` `--at-zooms` `--at-moments` `--size WxH` `--out <take>/stills` `--background` `--set …`; writes `stills.json` |
125
- | `render` | `--width` `--height` `--fps` `--format webm\|mp4` `--parallel N` (1..16 sessions) `--range a..b` (output seconds; keeps its audio) `--draft` `--frame <kind>` `--background` `--set …`; `out` defaults to `<take>/out.<format>` |
126
- | `ingest` | `<video>` (positional) `--cursor <trace.zip\|steps.jsonl\|cursor.csv>` `--out take` `--offset <ms>` `--viewport WxH` `--background <slug\|url\|none>`; a `.zip` is a Playwright trace, `.jsonl`/`.json` are records (or a Playwright `.trace` by its lines), `.csv` is `t,x,y,type` |
127
- | `open` | `--studio <url>` (the studio; `https://vos.so` by default, `VOS_ORIGIN` moves it) `--print` (print the URL, do not launch a browser) |
135
+ What travels: the script, the viewport, every `--header` value (a preview's bypass token), and the value of every `{ "env": "NAME" }` a `setup` step reads, from this shell. What never travels: a session (`--hosted` refuses `--session` and `--storage-state`, because none of a person's ever reaches vos.so). A page behind a sign-in fails on the fleet in words naming the local ladder, exit 4; a private-network URL is refused before a job exists. The fleet records up to ten minutes. `--dry-run` stays a local act. A script with a guarded step or a `download` is refused on `--hosted` in words: nobody can confirm a step that spends on the fleet, and a hosted take has no `outputs/` to keep a file in.
136
+
137
+ | Verb | Flags |
138
+ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
139
+ | `record` | `--actions <file>` (or positional) `--url` `--out take` `--strict` `--dry-run` `--allow-wall` `--keep-frames` `--storage-state <file>` or `--session <name>` `--header name=value`... `--confirm <id>`... `--browser-arg=<switch>`... `--max-duration <s>` `--background <slug\|url\|none>` `--hosted` (record on vos.so; with `--title` `--folder <id>`) |
140
+ | `create` | The `record` flags plus the render flags (`--width` `--height` `--fps` `--format` `--parallel` `--draft` `--frame` `--set`), no `--range`. With `--strict` an incomplete recording exits 2 before anything is rendered |
141
+ | `plan` | `--fresh` (discard the current plan) `--reuse` `--from <doc.json>` (defaults to `<take>/doc.prev.json`) `--style <doc.json\|take\|vosId>` `--with <doc.json\|take\|vosId>[@end\|@start\|@step:<id>\|@<seconds>]` (a template, repeatable) `--background` `--motion` (re-propose the motion) `--headline` `--kicker` `--launch` `--brand` `--music` `--entrance` `--transitions slide\|fade\|scale\|none` `--end-card on\|none\|<ref>` `--captions` (bare: the step captions, anchored; `none`: no beat captions) `--clicks` `--release` |
142
+ | `digest` | `--out <take>/digest` `--full 960` `--crop 640` (image long edges, the token budget) `--no-frames` `--transcript <file>` (Whisper-shaped segments merged as `said`) `--style <ref>` (report a reference document's style fields) |
143
+ | `frames` | `--times 0,25%,50%,75%,100%` (the default selector, output seconds or percent) `--frame <t>` `--at-zooms` `--at-moments` `--size WxH` `--out <take>/stills` `--background` `--set …` `--strict`; writes `stills.json` |
144
+ | `render` | `--width` `--height` `--fps` `--format webm\|mp4` `--parallel N` (1..16 sessions) `--range a..b` (output seconds; keeps its audio) `--draft` `--frame <kind>` `--background` `--set …`; `out` defaults to `<take>/out.<format>` |
145
+ | `ingest` | `<video>` (positional) `--cursor <trace.zip\|steps.jsonl\|cursor.csv>` `--out take` `--offset <ms>` `--viewport WxH` `--background <slug\|url\|none>`; a `.zip` is a Playwright trace, `.jsonl`/`.json` are records (or a Playwright `.trace` by its lines), `.csv` is `t,x,y,type` |
146
+ | `join` | `<take> <take\|file.mp4>...` (positional, in order) `--out <dir>` `--force` (replace a take directory) `--captions` (land the step captions, anchored) `--mute <file>`... (leave a file's own sound out) |
147
+ | `score` | `<take>` `--track <slug\|file>` `--at <step id\|step:<index>\|media:step\|seconds>` `--gain 0..1` (0.5) `--fade-in <s>` (0.8 when entered mid-song) `--fade-out <s>` (1.5) `--dry-run` |
148
+ | `open` | `--studio <url>` (the studio; `https://vos.so` by default, `VOS_ORIGIN` moves it) `--print` (print the URL, do not launch a browser) |
128
149
 
129
150
  **The wall check.** Once the first navigation settles, and before a frame is captured, `record`, `create` and `--dry-run` ask whether the recorder landed where it was sent. A take that met a sign-in instead is refused with exit 4 and a sentence (`asked for /dashboard, landed on /login: no session for app.acme.com`): the asked URL answered 401 or 403, the recorder was sent to an identity provider or a sign-in path, or the page is a sign-in form (one password field, or a one-time-code field) rendered in place. A redirect somewhere else with no sign-in in sight, which is what a site that shows strangers a public page looks like, is refused under `--strict` and in a rehearsal, and said as a warning otherwise. The check runs before a re-record clears anything, so a session that expired since the last take never costs the footage it failed to replace. The way past a wall is a session: `--storage-state <file>`, minted from the test auth the project already has wherever that exists. `--allow-wall` records the page anyway (a video OF a sign-in page is a legitimate take), and the take's `meta.wall` and its digest then say so.
130
151
 
@@ -143,6 +164,15 @@ Measured before this was written: a profile plain Chrome signed in reopens signe
143
164
 
144
165
  A session never enters a take directory and never reaches vos.so: `vos push` refuses a take holding a storage-state-shaped JSON, naming the file.
145
166
 
167
+ **`vos look`: see a page before scripting it.** An agent writing `actions.json` needs the page and a selector for each control it will touch, and behind a login the only other way to see the page is to record it. `vos look <url>` opens the page on the same browser and the same door `record` would use (`--session <name>` or `--storage-state <file>`, `--header`, `--browser-arg=`; headless unless `--headed`, `--viewport WxH` 1280x720 by default), waits until the DOM has held still for 0.8 s (never for network idle, which many apps never reach), runs any `--click <selector>` and `--type <selector>=<text>` in the order given, each followed by the same settle, and looks:
168
+
169
+ ```bash
170
+ vos look https://app.acme.com/projects --session acme
171
+ vos look https://app.acme.com/projects --session acme --click '[data-testid="new-project"]' --out look
172
+ ```
173
+
174
+ It writes `<out>/look.png` (the viewport; `--out look` by default) and lists every control in view (buttons, links, tabs, menu items, checkboxes, switches, options, fields, editable regions, and anything with a test id) with its tag, role, accessible name, test id, link path and query, rect in viewport px, whether it is disabled, inside an open dialog or covered by something on top of it, and ONE proposed selector: its test id; else its `aria-label`, inside the open dialog it sits in; else a link's own `href`, query kept (two tiles to `?vos=a` and `?vos=b` are two links); else role and name (`role=button[name="Continue"]`, what `page.locator` reads), narrowed by its dialog or landmark when that is what makes it one; else a field's own `name` or `placeholder`, a written id, or its words inside its dialog or landmark. Never a position in the tree. Each proposal is counted with the recorder's own selector engine and marked `UNIQUE` (it reaches this element and no other) or `AMBIGUOUS` (it reaches others too, and the recorder would take the first). A control whose words hold an address, a key or a card number is listed with those words withheld, and the frame's exposures are said the way a rehearsal says them. Looking never spends or destroys: a `--click` whose target's words begin with a stem that does (delet, remov, revok, eras, discard, publish, deploy, purchas, subscri, unsubscri, pay, buy, generat, submit, send, transfer, upgrad, confirm, leav, destroy, so `Allow deleting` and `Revoke` are caught with `Delete`), or say move to trash, empty trash or cancel subscription, is refused in words before it is pressed (exit 2), and `--type` fills a field without pressing Enter. A look cannot know every spend: `Make a video` or `Create` on a primary button may start paid work and passes, because refusing every Make would refuse half of every app. The list is a floor under looking; in a recorded flow, a step's `guard: "spend"` in `actions.json` is the fence. A sign-in in front of the page is the wall check's verdict, exit 4; the sign-in page is still looked at, since it is what a `setup` step scripts past. `--wait <ms>` adds a pause before the look for a page that animates in; `--json` prints the whole look as one done event (`url`, `screenshot`, `controls`, `exposures`, `wall`).
175
+
146
176
  **`setup`: the steps that run before the camera rolls.** A sign-in form, a cookie banner, the "choose your editor" modal, an onboarding tour: things a take must get past and must not show. `actions.json` takes `setup: [...]` beside `steps`, with the verbs `goto`, `click`, `type`, `press`, `wait`. They run after the first navigation and before a frame is captured, as plain actions with no cursor, no frames, no pace and nothing in `meta.steps`; then the recorder opens `url` again and the take begins where the setup left it. A `type` step's `text` may be `{ "env": "DEMO_PASSWORD" }`, read from the shell at run time and never logged or stored (the log names the field, never the value; a literal typed into a password field is refused by `validate`, because `actions.json` is committed and pushed with the take). A selector that never appears fails the take before anything is recorded (exit 2), because a take that begins at a half-finished sign-in is the wall by another name; rehearse the setup with `--dry-run` like everything else. This is rung 2 of the session ladder made scriptable: a local or self-hosted instance with a seeded user, no state file needed.
147
177
 
148
178
  ```json
@@ -171,7 +201,7 @@ A session never enters a take directory and never reaches vos.so: `vos push` ref
171
201
 
172
202
  `as: "blur"` (the default) blurs the element; `as: "text"` swaps its words, which reads as a product where a blur reads as a redaction. Use `text` for IDENTIFIERS (an email, a name, an account id), never for product copy or numbers: the video stays true to the product. A form control is always blurred, since writing into an input would change what the app submits. A mask whose selector reached no element hid nothing, so it fails `--strict` and a rehearsal (exit 2) and is named; `meta.masks[]` records each mask's `hits`.
173
203
 
174
- **Rehearse before you record.** `vos record … --dry-run` runs every step against the real page, in order, because a later selector usually exists only after an earlier click. Nothing is captured and nothing is written: the pointer lands instead of travelling, every pause is cut to a beat, and the take directory beside it keeps its footage, its cut and its script exactly as they were (a real re-record moves `doc.json` aside; a rehearsal does not). Selector lookups keep their whole timeout, so a miss here is a miss in the take. It prints each step with the rect it resolved, in capture px, which are the rects a pin or `vos callout --step` reads, and exits 2 on any miss or a first load that never settled. Add `--dry-run` to the exact command you were about to run; `--storage-state` and `--browser-arg=` apply to it too, and the `Next:` line it prints carries them, so the command it hands you records what it rehearsed.
204
+ **Rehearse before you record.** `vos record … --dry-run` runs every step against the real page, in order, because a later selector usually exists only after an earlier click. Nothing is captured and nothing is written: the pointer lands instead of travelling, every pause is cut to a beat, and the take directory beside it keeps its footage, its cut and its script exactly as they were (a real re-record moves `doc.json` aside; a rehearsal does not). Selector lookups keep their whole timeout, so a miss here is a miss in the take. It prints each step with the rect it resolved, in capture px, which are the rects a pin or `vos callout --step` reads, and exits 2 on any miss (a first load that never went network-idle is only a note). A step that spends is hovered and never pressed, so an `expect` or `await` after it is not waited on (`not waited: it follows #N (guarded, not pressed)`) and counts as resolved. Add `--dry-run` to the exact command you were about to run; `--storage-state` and `--browser-arg=` apply to it too, and the `Next:` line it prints carries them, so the command it hands you records what it rehearsed. The page's own console errors are said one line each, cut short, with a repeat counted (`[page error ×4]`) rather than printed again; `--verbose` prints every one whole, and the `--json` done event carries them all as `pageErrors` (`{ text, count }`).
175
205
 
176
206
  **The planners read the flow's own structure.** On a scripted take the step timeline is a planner input: a `wait` step between the first and the last doing step, or a hover's dwell, in which the digest's activity measured nothing moving is proposed at 2× (`speedParams.stillRate`; the opening and closing waits are the clip's establishing and resting beats and stay at 1×), the presses inside one step (a click and the dialog it opened, a drag's presses) are one zoom while their targets share a window, and a park before the first step (the cursor arriving on the logo) is never a dwell. Manual and rejected spans stay as they are.
177
207
 
@@ -215,6 +245,7 @@ take/
215
245
  actions.json the script that produced it, with the resolved url; pushed inside the document and written back by a fetch or pull with --media
216
246
  doc.json ProjectDoc, the editable cut
217
247
  doc.prev.json the previous cut, kept by a re-record for `vos plan --reuse`
248
+ outputs/ the files `download` steps kept, listed in meta.json's outputs
218
249
  vos.json hosted tracking (vos id + base version), written by push, fetch and pull
219
250
  stills/ `vos frames` output + stills.json
220
251
  digest/ `vos digest` output: digest.json, <id>.full.png, <id>.crop.png, sheet.png
@@ -229,7 +260,7 @@ claimed. Every verb resolves the name, and a push declares the container it
229
260
  finds in the bytes, so a file never reaches vos.so under the wrong type.
230
261
  `vos validate <take>` warns when an existing take holds one that does.
231
262
 
232
- A re-record replaces the footage, the cursor track, the frames and everything derived from them, moves `doc.json` to `doc.prev.json`, and keeps `actions.json` and `vos.json`.
263
+ A re-record replaces the footage, the cursor track, the frames, `outputs/` and everything derived from them, moves `doc.json` to `doc.prev.json`, and keeps `actions.json` and `vos.json`.
233
264
 
234
265
  ## actions.json
235
266
 
@@ -255,8 +286,40 @@ A re-record replaces the footage, the cursor track, the frames and everything de
255
286
 
256
287
  Eight verbs: `wait`, `hover` (`ms` 700, the dwell), `click` (`ms` the settle after the press, 150), `type` (`delayMs` 40 per key, paced by the clock; `focus: false` skips the focusing click; `replace: true` selects the field's value first so the typing replaces it; `at: "end" | "start"` puts the caret there before the first character, for a code editor line; `ms` the settle after, 150), `press` (one named key, `Escape`, `Enter`, `Tab`, `Backspace`, `Delete`, the arrows, `Home`, `End`, `PageUp`, `PageDown`, `Space`, recorded as a typing ping where the pointer is or on `selector` with no click, so an Escape closes what a click opened and an Enter submits what was typed without a second click ringing on the field; `ms` the settle, 150), `scroll` (`ms` the settle, 200), `move`, `drag` (press, move, release: a range input, a canvas element, a timeline clip; `ms` the travel, 700). The recorder's pace is the script's: a gesture is driven by the clock, so a page that answers each pointer sample slowly costs samples, never seconds, and the only pauses are the ones the script names (`wait`, a hover's dwell, a settle). `vos record` ends with a pace line, what the script asked, what the gestures added, what the page cost, and the steps that ran slow, and the `--json` done event carries it as `pace`. Every step takes an optional unique `id`; give steps ids so a span anchored to a step survives script edits and `vos plan --reuse` can follow it, and so a layer can name the element a step touched (`overlays[].pin.step`; the recorder keeps each hover, click, type and drag step's element rect on `meta.steps[].rect`). **The take carries its payload**: every step keeps the page it left (`meta.steps[].url`, origin and path), and a step with `read: "<selector>"` keeps that element's words once the step has settled (`meta.steps[].text`, at most 2 KB; on a click, what the click opened: the URL a share button minted, the code a dialog printed), so a layer quotes the take instead of a replay of the flow. Because the CLI issues every input itself, the cursor track is synthesized with exact coordinates, exact timing and fresh element rects, which is what powers element-aware auto-zoom and click effects downstream. The schema is [`schema/actions.schema.json`](./schema/actions.schema.json); `vos validate actions.json` checks a script without running anything.
257
288
 
289
+ Three steps that are not gestures, and a guard:
290
+
291
+ ```json
292
+ {
293
+ "steps": [
294
+ { "do": "expect", "selector": "text=Generate video", "enabled": true },
295
+ {
296
+ "id": "generate",
297
+ "do": "click",
298
+ "selector": "text=Generate video",
299
+ "guard": "spend"
300
+ },
301
+ {
302
+ "id": "ready",
303
+ "do": "await",
304
+ "until": { "text": "Your video is ready" },
305
+ "timeout": 900
306
+ },
307
+ { "do": "download", "selector": "text=Download", "as": "demo.mp4" }
308
+ ]
309
+ }
310
+ ```
311
+
312
+ - **`expect`** checks the page on camera, with no cursor move: exactly one of `selector` (visible; with `enabled: true | false`, enabled or disabled), `text` (some visible text contains it) or `gone` (no longer visible), waiting up to `timeout` seconds (5). A condition that never holds is a skip with its reason (`#10 expect: 'text=Generate video' stayed disabled for 5 s`), and `--strict` turns it into exit 2. Separately, every `click` asks whether its target is disabled: the press still happens, and the done event lists it under `disabled: [{ step, id, selector }]`, the rehearsal marks it, and `--strict` fails on it.
313
+ - **`await`** waits OFF CAMERA for something slow: a render, a generated result, a deploy. The capture stops, the cursor clock stops with it, and `until` (`{ selector }`, `{ text }` or `{ gone }`) is polled every `poll` seconds (2) for up to `timeout` (600), reloading the page between looks with `reload: true` (after a reload the recorder lets the page settle, no DOM change for 0.8 s, and wants the condition on two reads in a row, so a list drawn after the network went quiet cannot end the wait early). When it holds, the capture resumes, so the footage runs straight from the moment before the wait to the moment after: no gap, no frozen stretch. The cut is recorded in `meta.skips[]` (`{ step, id, at, ms }`: `at` the footage second of the cut, `ms` the wall time that did not reach the footage) and on the step's `meta.steps[]` entry (`offCameraMs`), and the planned `doc.json` starts a segment at every `at`. On timeout, `onTimeout: "fail"` (the default) stops the take there with exit 1 and a sentence naming the step and the time it waited, keeping the footage up to it; `"continue"` resumes and marks the skip `timedOut`. A rehearsal waits for real and prints how long it took, which is how you learn the timing. A wait that holds within 250 ms cut nothing and records no skip or segment boundary (the step still reports it). A predicate that may already be true before the action (an older item with the same text, a spinner not yet shown) holds at once: wait for something only the new result has. Under `--json` an await reports `{ event: "await", step, id, phase: "start" }`, a `phase: "wait"` heartbeat with `elapsedMs` every 30 s, and `phase: "end"` with `ms`.
314
+ - **`download`** keeps a file the page makes, in the take's `outputs/`: `selector` clicks a control and waits up to 30 s for the browser's download (the name the page sends in `Content-Disposition`); `src` names a `<video>`, `<audio>` or `<img>` (any selector the other steps take: `video >> nth=0`, `text=…`) and fetches its `currentSrc` with the page's own cookies. `as` names the file (a plain file name: no path separator, no `..`); absent, the page's name is used, made plain and unique. Each file is listed in `meta.outputs[]` (`{ step, id, name, bytes, contentType }`). A download that never arrives is a skip with its reason. A rehearsal fetches the file and keeps nothing.
315
+ - **`"guard": "spend"`** marks a `click`, `press` or `type` that costs something real: credits, a purchase, a message sent. It needs an `id`. `vos record --dry-run` moves the pointer to its target and never presses it (`#9 generate: guarded (spend), hovered, not pressed`); `vos record` presses it only when the command names it with `--confirm <id>` (repeatable), and otherwise refuses before capturing anything, naming the step and the flag (under `--json` also as `{ event: "refused", reason, step, id }`). The done event's `confirmed` and the step's `meta.steps[].confirmed` say which guarded steps were pressed.
316
+
317
+ `vos actions script` says these too: `Check that … is enabled`, `Wait until … (off camera: pause the recording meanwhile)`, `Download …`, and a guarded step is marked as one that spends.
318
+
258
319
  Verified the flow in agent-browser already? `vos actions from-agent-browser steps.jsonl [--out actions.json] [--url] [--viewport WxH]` writes the script from that walk (each command kept beside its `--json` result, the batch record shape; refs resolve through the last `snapshot -i`), and names every step the recorder cannot follow rather than dropping it.
259
320
 
321
+ Not sure of a selector? `vos look <url>` (with the take's `--session` or `--storage-state`) prints every control on screen with one proposed selector, counted UNIQUE or AMBIGUOUS by the recorder's own engine, and `--click` walks it to the next screen; take a UNIQUE one into a step as it is, then rehearse with `--dry-run`.
322
+
260
323
  ## doc.json
261
324
 
262
325
  `doc.json` is a `ProjectDoc` from [`@vosjs/studio-core`](../studio-core), all plain JSON. Zoom is `zoom: [{ id, in, out, level, cx, cy, source }]`, trims are `segments`, pacing is `speed`. Edit and re-render; nothing re-runs the browser. The full shape ships as a JSON Schema at [`schema/doc.schema.json`](./schema/doc.schema.json) (a `oneOf`: the recording document and the program document, sharing the layer definitions), and `vos validate <dir>` lints the semantics of either (span overlap, footage bounds, coordinate ranges, export honesty) before you spend a render.
@@ -273,6 +336,8 @@ Verified the flow in agent-browser already? `vos actions from-agent-browser step
273
336
 
274
337
  **Pinned layers.** A layer ABOUT something on the page names it, and the lowering keeps the two together: `"pin": { "step": "copy", "side": "auto", "mark": "ring", "leader": true }` on any overlay kind places the layer a gap off one side of that step's element and carries it with the element as the camera moves (the layer keeps its screen size; only its place follows). One of `step` (a recorder step's `id`, else its index), `press` (SOURCE seconds; the nearest press names the element, for a human recording) or `rect` (video fractions) names the referent. `side` is `auto` (the first of right, left, below, above whose box fits inside the frame through the layer's life) or a side by name; `gap` is design px (24); `mark` is `ring` or `underline`, a standing highlight on the referent for the layer's life; `leader` draws a hairline from the layer to the referent; `color` is their ink. `transform.x/y` stay the fallback for a pin that cannot resolve, and `vos validate` says why (an unknown step, a step that touched nothing, a scroll inside the layer's window). A layer that cannot name its referent is a caption: place it in the margin, never over the app.
275
338
 
339
+ **Captions anchored to a step.** A layer's TIME can name a step the way a pin names its place: `"anchor": { "step": "share", "media": "m1", "at": "end", "offset": 0.2 }` starts the layer where that step plays in the cut. The lowering maps the step's moment (its `tStart`, or `tEnd` with `at: "end"`, in `meta.steps` of the media `media` names; absent = the primary) through the segments, speed spans and freezes exactly as a source-anchored span is mapped, then adds `offset` in OUTPUT seconds, so a trim, a speed change or a re-record moves the caption with its step and nobody recomputes a `start`. `start` is the fallback when the step is missing, skipped or cut out, and `duration` is the layer's own. A text layer's `{elapsed}` fills with the anchored step's off-camera wait when the recorder kept one (the entry of `meta.skips` that names it, else `offCameraMs` on the step; never a step's own `ms`, which is its reading time), and a step that waited for nothing itself (the beat that lands after an await) reads the nearest earlier wait on its media, which validate notes, as a rounded duration: `"{elapsed} later"` renders `12 minutes later`. `vos validate` says when an anchor cannot resolve (and which fallback plays), when `{elapsed}` has no wait at or before its step to fill from, and when two captions are on screen at the same time, naming both. A document without an anchor lowers exactly as before. Not the re-record `anchor` on spans, which only `vos plan --reuse` reads.
340
+
276
341
  **Image and video overlays.** Media kinds on the same lane: `{ "id": "m0", "kind": "image" | "video", "start": 2, "duration": 4, "key": "/logo.png", "width": 0.35, "radius": 12, "opacity": 1, "loop": false, "transform": { … } }`. `key` is a file inside the take directory (`"/logo.png"`) or a URL; `width` is a fraction of the frame width, height follows the media's aspect; corners in design px. Video time is clip-local and muted by design; soundtracks belong to `doc.audio`.
277
342
 
278
343
  **HTML layers.** A UI component authored as DOM on the same lane, sharp at any export size because it is rasterized from source, not magnified from the footage: `{ "id": "h0", "kind": "html", "start": 2, "duration": 4, "html": "<div class=\"card\">Open Code</div>", "css": ".card{font-family:'Inter';background:#0b0b0d;border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.45)}", "box": { "width": 452, "height": 132 }, "transform": { … } }`. The markup is well-formed XML (`<br />`, `&amp;`, quoted attributes; `vos validate` refuses the rest in words, because a malformed layer decodes to nothing and says nothing). `box` is the design box in 1080p px; `width` defaults to the design size. The CSS is the chrome (shadow, radius, border; `bleed` is derived from the shadows, or stated), a `font-family` it names resolves against the hosted catalog at every `font-weight` it names (a face outside the catalog rides `fonts: [{ family, url }]`) (a face's `url` is an https URL on `https://assets.vos.so/`, a hosted file, or a `.woff2` beside the document, which `vos push` uploads like any layer file and a local render serves; a face on any other host is refused, like an image), and an image inside is inlined for you when its URL is on `https://assets.vos.so/` (`vos asset push` puts it there; any other host paints blank, so inline it as a `data:` URI). A still layer's `animation` runs on the wall clock rather than the timeline, so animate the layer with `anim` and `motion`; a LIVE layer (`"live": true`) is re-rasterized per frame at clip-local time: its `@keyframes` are scrubbed to `t` (paused, delayed by `-t`; fold any `animation-delay` into the keyframes) and `{{t}}` / `{{data.<name>}}` placeholders in the markup and CSS fill from the clock and the clip's `data`, so a counter, a progress bar or a typed line come out on the timeline's clock in the preview and in every export chunk alike. It is never re-hosted and never an upload.
@@ -283,6 +348,8 @@ Verified the flow in agent-browser already? `vos actions from-agent-browser step
283
348
 
284
349
  **Background.** `doc.frame.backgroundMedia` puts a looping video or a still behind the card: `{ "kind": "video", "key": "<url or /file>", "duration": 10, "dim": 0.2 }`. Time is output-anchored modulo `duration`, so trims and speed never retime the ambience; `dim` is a black scrim for legibility; `frame.parallax` (0..1, 0.6 reads well) counter-pans it with the zoom and `backgroundMedia.blur` softens it in design px. The hand-picked set is `GET https://vos.so/api/backdrops` (no auth): copy `key` from a row's `urls["1080p"]`, `duration` from the row, and `frame.background` from its `ground`. Drawn under the card, outside the zoom, fail-open to the CSS `frame.background`.
285
350
 
351
+ **Checking what rendered.** A still is only proof of what it shows. `vos frames --at-zooms` reads every zoom span on every media of the take through the cut to the output time it plays (a span the cut keeps none of is skipped and named), stills its apex, and stills the same time again with the zooms off (`--set zoom=[]`): a zoom whose frame matches its zoom-off twin did not render, and it is said, `zoom z3 at 4.50s did not render (its frame matches the zoom-off frame)`, with the twin kept beside it. Each zoom entry in `stills.json` and the `--json` done event carries `zoomId`, `media` when it is not the primary, `rendered` and `difference` (the mean absolute difference from the twin over a small grayscale, 0..1). `vos check-video <file>` checks the rendered file itself, decoding it in the browser the CLI already drives: duration, bytes, codecs, resolution and frame rate; full-frame scene changes (a large change that stands out from the frames around it, or a smaller one over at least a quarter of the frame that stands alone, as when a camera jumps between two views of one dark picture; a zoom, a pan or a cross-fade is neither), with the count per 5 s and a warning when any 5 s holds more than one; loudness per ITU-R BS.1770 (short-term every 3 s, integrated, the sample peak), with a warning below -23 LUFS (too quiet for a feed) or above -9, and a silent track named; and the first and last frames as PNGs, with a warning when either is near-uniform, an empty or faded open or close.
352
+
286
353
  ## Overrides on render and frames
287
354
 
288
355
  Check any presentation from the command line without hand-editing `doc.json`. The file on disk is untouched; the patched document is lint-gated, so a bad override fails exactly like a bad `doc.json`.
@@ -365,7 +432,7 @@ vos push /tmp/take --label "$TAG launch" --yes --json # VOS_API_KEY as a r
365
432
  - **Output.** Logs on stderr, results on stdout. `--json` turns every verb into NDJSON events ending with `{"event":"done",…}`.
366
433
  - **`vos <verb> --help`** prints that verb's usage lines and exits 0 (it used to be a usage error).
367
434
  - **Exit codes.** 0 ok, 1 error, 2 usage (including `--strict` failures), 3 no browser found, 4 the recorder met a wall (a sign-in, or under `--strict` a redirect away from the asked page) and nothing was recorded or cleared. `--json` carries the verdict as a `wall` event (`kind`, `level`, `asked`, `landed`, `refused`).
368
- - **`--strict`** on `record` and `create` (agents: always): a skipped selector, a page that never reaches network idle, or a take that hit `--max-duration` exits 2 and lists `skipped[]` (and `capped`) in the done event. The default is lenient (exit 0, `skipped[]` still reported) for exploratory runs.
435
+ - **`--strict`** on `record` and `create` (agents: always): a skipped step (a selector that never appeared, an `expect` that never held, a download that never came), a click on a disabled control, an `await` that timed out, or a take that hit `--max-duration` exits 2 and lists `skipped[]`, `disabled[]` (and `capped`) in the done event. A first load that never reaches network idle is a warning, `navTimeout: true` in the done event, never a strict failure: many real apps hold a socket open and never go network-idle. The default is lenient (exit 0, `skipped[]` still reported) for exploratory runs.
369
436
  - **`--max-duration <s>`** on `record` and `create` defaults to the hosted recording cap, read live from `GET /api/limits` (2 s, fail-open to 30 min when the origin is unreachable): the capture stops there and the done event says so. Cut the flow rather than raising the cap; the platform refuses a longer take.
370
437
  - **`create`** is the one-shot verb: record, auto-plan and render in one command and one browser session. The take directory still lands on disk, so the full loop (frames, edit `doc.json`, re-render) stays open afterwards.
371
438
  - **`vos validate <thing>`** takes an `actions.json`, a take directory, a program directory (`config.json`, plus its program document when present), or a `kit.json`; exit 1 on any problem.
@@ -4,7 +4,7 @@ import {
4
4
  localManifestRefs,
5
5
  pullManifest,
6
6
  uploadManifest
7
- } from "./chunk-UY2N42P2.js";
7
+ } from "./chunk-ZSJEISKL.js";
8
8
  import "./chunk-B4U4URE3.js";
9
9
  export {
10
10
  hostedLiteralWarnings,
@@ -12,4 +12,4 @@ export {
12
12
  pullManifest,
13
13
  uploadManifest
14
14
  };
15
- //# sourceMappingURL=assetManifest-MQ7HO7RZ.js.map
15
+ //# sourceMappingURL=assetManifest-S6H5F2VE.js.map