@vosjs/cli 0.78.0 → 0.80.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 +152 -31
- package/dist/{assetManifest-S6H5F2VE.js → assetManifest-ETT3C7ZA.js} +3 -3
- package/dist/{chunk-JYCW4EOA.js → chunk-B373NVJE.js} +7 -3
- package/dist/chunk-B373NVJE.js.map +1 -0
- package/dist/{chunk-B4U4URE3.js → chunk-J3KHIZCH.js} +466 -412
- package/dist/chunk-J3KHIZCH.js.map +1 -0
- package/dist/{chunk-RUDDBHQ7.js → chunk-OWYOJB7K.js} +5 -1
- package/dist/chunk-OWYOJB7K.js.map +1 -0
- package/dist/{chunk-ZSJEISKL.js → chunk-PJ6MEJZV.js} +213 -28
- package/dist/chunk-PJ6MEJZV.js.map +1 -0
- package/dist/{chunk-INRUYQ6J.js → chunk-SP5OHTVB.js} +4285 -1560
- package/dist/chunk-SP5OHTVB.js.map +1 -0
- package/dist/cli.js +17 -10
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +15 -6
- package/dist/index.js +5 -5
- package/dist/manifest-VTOIEUHJ.js +8 -0
- package/dist/{run-TIFM3ZHI.js → run-2ELHMPTB.js} +5 -5
- package/dist/{shotList-SCA234OS.js → shotList-MSNWLVDN.js} +63 -9
- package/dist/shotList-MSNWLVDN.js.map +1 -0
- package/package.json +3 -3
- package/schema/actions.schema.json +498 -17
- package/schema/doc.schema.json +46 -5
- package/skills/VERSION +1 -1
- package/skills/launch-kit/SKILL.md +12 -3
- package/skills/product-video/SKILL.md +34 -0
- package/skills/product-video/references/taste.md +7 -3
- package/skills/vos-authoring/SKILL.md +8 -1
- package/skills/vos-create/SKILL.md +5 -0
- package/skills/vos-cut/SKILL.md +25 -2
- package/skills/vos-footage/SKILL.md +4 -0
- package/skills/vos-migrate/SKILL.md +4 -0
- package/dist/chunk-B4U4URE3.js.map +0 -1
- package/dist/chunk-INRUYQ6J.js.map +0 -1
- package/dist/chunk-JYCW4EOA.js.map +0 -1
- package/dist/chunk-RUDDBHQ7.js.map +0 -1
- package/dist/chunk-ZSJEISKL.js.map +0 -1
- package/dist/manifest-6DJSPKVH.js +0 -8
- package/dist/shotList-SCA234OS.js.map +0 -1
- /package/dist/{assetManifest-S6H5F2VE.js.map → assetManifest-ETT3C7ZA.js.map} +0 -0
- /package/dist/{manifest-6DJSPKVH.js.map → manifest-VTOIEUHJ.js.map} +0 -0
- /package/dist/{run-TIFM3ZHI.js.map → run-2ELHMPTB.js.map} +0 -0
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ vos logout # remove ~/.config/vos/credentials
|
|
|
50
50
|
```bash
|
|
51
51
|
vos render <config.json|url|take> [out] # config → video; the name picks mp4/webm; a program document's sound is mixed in
|
|
52
52
|
vos still <config.json|url> [out.webp] # config → frames: --times 0,1.5,50% in one browser; .png/.jpg re-encoded
|
|
53
|
-
vos info <config.json|url>
|
|
53
|
+
vos info <config.json|url|take> # inspect a config; a take: its steps and its cut in output seconds
|
|
54
54
|
vos check <config.json|url> # migrate → schema → syntax → compile → determinism, dialect and declared-file lints, all local
|
|
55
55
|
vos preview <config.json|url> [--port N] # serve a local playback page
|
|
56
56
|
vos versions # installed @vosjs/* versions
|
|
@@ -115,7 +115,19 @@ What a trace cannot give is named, never guessed: agent-browser's own log carrie
|
|
|
115
115
|
vos join intro-take settings-take outro.mp4 --out film --captions
|
|
116
116
|
```
|
|
117
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).
|
|
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, starting as the step BEGINS (`anchor.at: "start"`, no offset, so the words name the beat while it plays; set `at: "end"` and an `offset` on a caption to land it after its gesture instead), 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
|
+
**Join again without losing the cut.** Re-running a join used to rebuild the document from its inputs, so adding a cold open or a scene recorded later lost every trim, speed span, kept or rejected zoom, caption, frame, sound and still made since. `--keep-edits` joins over the edited take instead:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
vos join intro-take settings-take outro.mp4 cold-open-take --out film --keep-edits --captions --zooms per-beat
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Every join writes `join.json` beside the document: each input's path (relative to the take) and the sha256 of its footage, and the media id it became. A re-join matches inputs to media by that SOURCE FILE, never by position: the same footage at the same path is the same media (the same footage at a new path too), and a repeated input pairs in order. A kept input keeps its media id and everything on it as edited (its segments, in their edited order; every span on it, `rejected` included; the layers anchored to its steps; its sound clip; its media entry), and the film-wide fields (frame, cursor, cam, export, styles, still) are the edited document's. A new input is joined as a fresh join would join it, under an id no media of the take ever held, and `--captions` and `--zooms` act on the new inputs only (the done line counts what they did on those inputs, or says there was no new input). The done line names an input by its path from where the command runs. A RE-RECORDED input keeps its edits too, re-timed onto the new footage by its steps the way `vos plan --reuse` re-times a cut (the same mapping): a take whose footage changed at the same path, or a take at a new path that recorded the same script (more than half of the old take's named steps are in the new one, and the url agrees when both say one). It comes in under a new media id; its segments take the old ones' place in the cut, re-timed; its manual spans and rejections follow the steps they sat on (an unnamed step is found by its place beside the nearest named one); the planner's fresh proposals fill in where no kept span or rejection stands; a layer anchored to one of its steps stays on that step; a layer placed by output second over its footage keeps its place in the scene. What cannot be placed (a span or a layer on a step the new recording lacks) is dropped and named under `re-recorded …` in the done line. A plain video file has no steps to re-time by, so a changed file is a removal plus an addition. An input that is gone takes its edits with it, and the done line names them (`dropped …: 2 segments, 3 spans, layers c6, c6b went with it`). While the kept inputs keep their relative order, the edited segment list stands (a cold open moved to the front stays there) and a new input's segments go just before the first segment of the next kept input in argument order, else at the end; when the kept inputs are reordered, the segments are regrouped in argument order, each input's own segments in their edited order, and the done line says so. A layer, prop, sound clip or still placed at an output second (no anchor) follows the footage it played over; one past the footage stays past the new end; a film-wide clip such as a score keeps its seconds, and the done line names the changed length (`vos score` places a score again). An ending anchored to a step that is no longer last is named too. The first input must stay the take's primary, its footage unchanged: the primary carries the frame and the camera, and another one is another film (`--force` replaces the take). The take is updated in place, so its `vos.json` and everything else in it stays; a removed media's directory goes. A take joined before `join.json` existed is matched by the footage it holds (a take's footage is copied byte for byte); a video file joined then was re-encoded on the way in and reads as new.
|
|
127
|
+
|
|
128
|
+
**One zoom per beat.** The zoom planner proposes a span per gesture, which is right for a form and far too busy for a canvas, where every drag, label and copy is one. `vos plan <take> --zooms per-beat` (and `vos join … --zooms per-beat`) keeps at most one zoom per BEAT, and the script says where the beats are and where they pay off: a step with a `caption` starts a beat, which runs until the next captioned step of the same media starts (the last one to the media's end), and what plays before the first captioned step is one more beat, the opening. A step marked `"zoom": true` is its beat's PAYOFF: the beat keeps, for each marked step, the proposal that overlaps that step's own span (`tStart`..`tEnd` in `meta.steps`) most (ties: the one the cut plays longest, then the longer, the higher level, the earlier), never two that overlap, so a beat may keep two zooms on two marked steps. A beat with no marked step keeps NO zoom, which is how a beat stays wide. When no step of the take carries `"zoom": true`, each beat's LAST gesture (its last `click`, `drag`, `type` or `paste`; never a key press, which closes a beat more often than it pays it off) stands in for the mark, the opening keeps none, and the done line says `no step is marked "zoom": falling back to each beat's last gesture`. A step marked `"zoom": false` is never zoomed on, in either case, and a proposal on it is rejected with the note `zoom: false in the script`, whichever beat it sits in. A mark never invents a zoom: the planner still decides the level and the focus, and a marked step it proposed nothing for is named (`marked #4 click, but no proposal on it`). A span belongs to the beat its midpoint falls in. The done line names each beat, the step it zoomed on and the span kept (`beat "Draw the flow": marked #3 click, kept z1`), and lists a captioned beat the planner proposed nothing in (`beat "…": no proposal in this beat`). The kept span is promoted to `manual`, and every other proposal in the beat is written into `doc.rejected` the way a person deletes one (note `one zoom per beat`), so no re-plan brings it back. A beat that already holds a manual zoom keeps its manual ones and loses its proposals. A media with no captioned step keeps its zooms as planned, and the done line says so. A mark is read from the take's `actions.json` first, so a mark added after recording takes effect on the next `vos plan` with no re-record (and `vos plan`, `vos join` and `vos info` warn, never refuse, when the file no longer matches what was recorded: `actions.json has 122 steps and the take recorded 119 (meta.steps)`, with any recorded step whose verb or selector changed); then from the step the recorder kept (`meta.steps[].zoom`), then from the script a pushed or joined take carries. A recorded step is matched to its script step by `id`; one the recording has no id for (an id added to the script afterwards does not count, the recording never saw it) is found beside the nearest recorded named steps before and after it, else at its own index, and only where the verb (and the selector, when both say one) agrees, so a check or a step added to the script after recording hands no other step its caption or its mark. Steps with ids recorded are found wherever they move. After a join, each media's captions and marks travel in its `script`, so `vos plan` on the joined take finds them.
|
|
129
|
+
|
|
130
|
+
**Where everything lands.** `vos info <take>` prints the take's two clocks, read through studio-core's own functions (`ratedSegments`, `segmentOutputExtents`, `spanOutputExtents`, `longestOutputExtent`, `cameraSpanExtents`, `resolveOverlayAnchors`, `docOutputDuration`): per media, every step with its id, what it did, its SOURCE seconds (`meta.json`'s `tStart`..`tEnd`), its caption and where it plays in the cut; then the OUTPUT timeline after trims, speed and freezes: each segment, speed span, freeze, zoom and tilt span with its output extent (`(cut)` when the trims leave it out; on a cut that replays its seconds, a cold open, every other place it plays as `also`: a step's own place is the pass an anchor on it lands in, its longest, and a zoom's or a tilt's are the passes where it moves the camera, with `at rest in` for a clip that rests it), every layer with its anchor resolved, each sound clip, the still and the film's end. `--json` gives the same as one done event. It writes nothing.
|
|
119
131
|
|
|
120
132
|
**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
133
|
|
|
@@ -124,7 +136,7 @@ vos score film --track fresh-focus --at m1:share --dry-run # a catalog slug, a
|
|
|
124
136
|
vos score film --track ./theme.wav --at 12 --gain 0.4 # a local file (copied into film/music/), output seconds
|
|
125
137
|
```
|
|
126
138
|
|
|
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).
|
|
139
|
+
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). The clip carries what the track measures (`loudness`, below) and a loudness target, `normalize: -16` (`--normalize <lufs>` sets another, `--normalize off` none), with `--gain` a trim on the target: a catalog track, mastered at about -16 LUFS, plays at the default 0.5 as it did before it had a target, and a file of your own lands at the same level.
|
|
128
140
|
|
|
129
141
|
**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.
|
|
130
142
|
|
|
@@ -132,33 +144,36 @@ The track is decoded (mediabunny in Node for PCM; a compressed track in the head
|
|
|
132
144
|
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
|
|
133
145
|
```
|
|
134
146
|
|
|
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
|
|
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
|
-
| `
|
|
147
|
+
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, a `download`, a `paste`, a `js` check or `peers` is refused on `--hosted` in words: nobody can confirm a step that spends on the fleet, a hosted take has no `outputs/` to keep a file in and no clipboard to paste from, a fleet browser runs no code it was handed, and it runs one browser.
|
|
148
|
+
|
|
149
|
+
| Verb | Flags |
|
|
150
|
+
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
151
|
+
| `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>` `--ready dom\|networkidle` `--background <slug\|url\|none>` `--hosted` (record on vos.so; with `--title` `--folder <id>`) |
|
|
152
|
+
| `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 |
|
|
153
|
+
| `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` `--zooms per-beat` (at most one zoom per captioned step) |
|
|
154
|
+
| `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) |
|
|
155
|
+
| `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` |
|
|
156
|
+
| `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>` |
|
|
157
|
+
| `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` |
|
|
158
|
+
| `join` | `<take> <take\|file.mp4>...` (positional, in order) `--out <dir>` `--keep-edits` (join again over an edited joined take, keeping its edits) or `--force` (replace a take directory) `--captions` (land the step captions, anchored) `--zooms per-beat` `--mute <file>`... (leave a file's own sound out) |
|
|
159
|
+
| `score` | `<take>` `--track <slug\|file>` `--at <step id\|step:<index>\|media:step\|seconds>` `--gain 0..1` (0.5) `--normalize <lufs\|off>` (-16) `--fade-in <s>` (0.8 when entered mid-song) `--fade-out <s>` (1.5) `--dry-run` |
|
|
160
|
+
| `audio` | `measure <take>` (write every audio clip's `loudness`, the fact a `normalize` target plays it to) |
|
|
161
|
+
| `open` | `--studio <url>` (the studio; `https://vos.so` by default, `VOS_ORIGIN` moves it) `--print` (print the URL, do not launch a browser) |
|
|
162
|
+
|
|
163
|
+
**When the take starts.** Step 1 runs once the first page is ready, and by default that means it has stopped changing: after `load`, the page must make no DOM change for 800 ms, at most 8 s (`ready: "dom"`). Network idle is the opt-in (`"ready": "networkidle"` in `actions.json`, or `--ready networkidle`), at most 45 s, because a page that holds a socket open or keeps an analytics beacon going never goes network-idle and every take of it would start after the whole timeout. The same mode opens a `setup` step's `goto`, the page after the setup, and an `await`'s reload. The done event says how the first load settled, `ready: { mode, ms, capped }`; `capped` is a page still changing at the cap (an animation, a ticker), recorded anyway, and `navTimeout: true` now means only that network idle was asked for and never came.
|
|
149
164
|
|
|
150
165
|
**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.
|
|
151
166
|
|
|
152
167
|
**`vos session`: takeover mode, local.** A production app behind an emailed code, SSO, a passkey or a CAPTCHA has no form to script and no test auth to mint from, so a person signs in once and the recorder reuses that session:
|
|
153
168
|
|
|
154
169
|
```bash
|
|
155
|
-
vos session open https://app.acme.com --name acme # a plain Chrome window opens; sign in,
|
|
170
|
+
vos session open https://app.acme.com --name acme # a plain Chrome window opens; sign in, set the account up, close the window (or press Enter)
|
|
156
171
|
vos session check acme --url https://app.acme.com/dashboard # does it still open the page? exit 0, or 4
|
|
157
172
|
vos record --actions actions.json --out take --session acme --strict
|
|
158
173
|
vos session list · vos session rm acme
|
|
159
174
|
```
|
|
160
175
|
|
|
161
|
-
`open` runs the SYSTEM Chrome as an ordinary child process on a profile under `~/.config/vos/sessions/<name>`, with no automation switch and no debugging pipe, so a sign-in that refuses automated browsers (Google's) goes through, and it waits for the
|
|
176
|
+
`open` runs the SYSTEM Chrome as an ordinary child process on a profile under `~/.config/vos/sessions/<name>`, with no automation switch and no debugging pipe, so a sign-in that refuses automated browsers (Google's) goes through, and it waits for the person to finish, however they finish: the last window closed, Chrome quit (⌘Q), or Enter in the terminal. It never finishes on a sign-in it sees, because the person often is not done (a second factor or a single sign-on hop lands later, and the workspace picked and the demo data made in that window are what every take on the session finds). Chrome writes its cookie store when it quits, so a closed window or Enter quits that Chrome, by its process id (never by name: the everyday Chrome is the same app), and only then is the session read. The window is watched from outside the browser: a debugging endpoint, even one that only lists tabs, makes Chrome tell every page `navigator.webdriver === true`, which is what a sign-in that refuses automated browsers reads. So it counts the window's page renderers in the process table (they all exit when the last window closes, while a minimized window or one on another desktop keeps its own), and reads the profile's cookie store for counts only, saying `app.acme.com set 12 cookies (analytics not counted)` when the host's own cookies change, leaving out measurement cookies a stranger's visit sets too (`_ga*`, `_gid`, `ph_*`, `g_state`, `ajs_*`, `_fbp`, `_hj*`, `__stripe_*` and the like); Chrome commits that store about every thirty seconds, so the line lags a little, and it needs Node 22.5 or later, staying quiet before. A cookie count is never called a sign-in. Once the session is saved, `open` checks it the way `check --url` does: the URL opens headless on the saved profile AND for a stranger (a fresh, empty profile), through the wall check. Walled on the profile is `NOT signed in: https://app.acme.com/projects sends a stranger to /login …` with the command to try again (exit 4); open on the profile and walled for the stranger is `signed in: https://app.acme.com/projects opens as you`; open for both is `can't tell: … opens the same for a stranger`, a warning (exit 4 under `--strict`), because a public page proves nothing; and a sign-in page (`/login`, `/signin`, an identity provider) is not opened at all, since a stranger reaches it too. Name a page only a signed-in person sees. Only a session that passed is offered for recording. It also prints what the session holds as counts and dates (`holds cookies for app.acme.com (7), accounts.google.com (12); newest expires in 29 days`), never a value, and `--json` does not either. `check` reads the session through the same Chrome and, with `--url`, asks the wall check whether the page still opens signed in: a session that expired says so in words with the command to sign in again, and exits 4. `record --session` records in that profile; it is one door and `--storage-state` is the other, never both.
|
|
162
177
|
|
|
163
178
|
Measured before this was written: a profile plain Chrome signed in reopens signed in ONLY under the system Chrome with the real keychain. Playwright's bundled Chromium keeps its own keychain entry and can never decrypt Chrome's cookies, so every reader of a session here is the system Chrome (`VOS_BROWSER_PATH` overrides it) with Playwright's `--use-mock-keychain` removed. Two things `check` says because they cost real runs: an app whose session cookie has no expiry leaves nothing to reuse once the window closes, and a Chrome that was killed rather than quit never wrote its store.
|
|
164
179
|
|
|
@@ -188,7 +203,9 @@ It writes `<out>/look.png` (the viewport; `--out look` by default) and lists eve
|
|
|
188
203
|
|
|
189
204
|
**`--header name=value`**, repeatable: a request header on every request the recording browser makes, the way past a preview deployment protected by a bypass header alone (`--header x-vercel-protection-bypass=$TOKEN`). The rehearsal's `Next:` line carries it.
|
|
190
205
|
|
|
191
|
-
**
|
|
206
|
+
**The sound a page plays.** The recorder captures frames, so sound the product makes (a voice preview, a video playing in the page) would be lost. It watches every `<video>` and `<audio>` the page plays, in the page and its same-origin frames, `new Audio()` included: each one heard (not muted, volume above 0) lands in `meta.media[]` as `{ step, src, tag, duration, volume, intervals: [{ at, from, to }] }`, footage seconds `at` while it played media seconds `from`..`to`, split wherever an `await` took the camera away; one that played muted is listed with `muted: true` and no intervals. At the end of the take each heard source is fetched with the page's own cookies (a `blob:` or `data:` inside the page) into `page-sound/`, and the plan places one audio clip per interval at the output time the footage reaches it, `source: "page"`, at the element's volume. An auto speed span over a heard stretch is dropped, or the footage would race ahead of the sound. A re-plan, and `plan --reuse` after a re-record, replace only the `page` clips. A source that cannot be fetched is said, and its entry carries `fetchError` instead of `file`. A hosted take records `meta.media` and places nothing, since it has nowhere to keep the file. Web Audio a page synthesizes is not heard.
|
|
207
|
+
|
|
208
|
+
**What the frame shows.** Getting past a login puts the account's own data in the picture, and asking for a demo account does not hold that line: a person asked to sign in signs in as themselves. So the recorder looks. After the page opens and after every step it reads the text visible in the viewport and reports the KIND of thing it saw and where, never the string: an email address (one on `example.com` or a `.test`, `.example`, `.invalid` or `.localhost` domain is demo data and is not reported), something shaped like an API key or a JWT, a card number that passes the Luhn check, a masked card's visible tail. They land in `meta.exposures[]` (`step`, `kind`, `selector`, `rect`, `seen`), in the `record` and `create` done events, at the end of a rehearsal (before anything is recorded), as warnings in `vos validate <take>`, and in the digest's `take.exposures`. They warn; they do not fail a take, because a product may legitimately show addresses. The `selector` reaches that element and no other, so it can be pasted into a mask. Beside them the scan keeps two NOTICES, information that is never a warning, never in `exposures` and never a `--strict` failure: the signed-in account's own name, read conservatively from where apps say whose account it is (a "Signed in as …" or "Welcome back, …" line, an account menu's label, or the name-like words on the account menu's own button, the one holding the avatar; a name-like label on an avatar is reported as what MAY be the name, and a control's generic label, "Account menu" or "Profile settings", never is), with every place in the frame it showed; and a list of the account's recent items (a "Recents", "Recent" or "History" heading over three or more links) with the one-line `mask` that hides it. They land in `meta.notices[]` (`step`, `kind`: `account-name` with `places` and `sure`, or `personal-list` with `heading`, `selector`, `rect`, `items`; `seen`), the `record` and `create` done events, the end of a rehearsal, and a `noticed` section of `vos validate <take>`. The name itself is never printed, not even inside a selector: a line reads `the account's name (3 places, as the page opened): …`.
|
|
192
209
|
|
|
193
210
|
**`mask`** in `actions.json` hides a selector BEFORE the first frame is captured and keeps it hidden across navigations and re-renders, so the real value is never in a frame, never in the recording, never pushed:
|
|
194
211
|
|
|
@@ -201,7 +218,7 @@ It writes `<out>/look.png` (the viewport; `--out look` by default) and lists eve
|
|
|
201
218
|
|
|
202
219
|
`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`.
|
|
203
220
|
|
|
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 }`).
|
|
221
|
+
**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 rehearsal captures no frames, so it says nothing of dead time). 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 }`).
|
|
205
222
|
|
|
206
223
|
**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.
|
|
207
224
|
|
|
@@ -245,7 +262,9 @@ take/
|
|
|
245
262
|
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
|
|
246
263
|
doc.json ProjectDoc, the editable cut
|
|
247
264
|
doc.prev.json the previous cut, kept by a re-record for `vos plan --reuse`
|
|
265
|
+
join.json a joined take's inputs: each one's path, footage sha256 and media id, read by `vos join --keep-edits`
|
|
248
266
|
outputs/ the files `download` steps kept, listed in meta.json's outputs
|
|
267
|
+
page-sound/ the sound the page played during the take, listed in meta.json's media
|
|
249
268
|
vos.json hosted tracking (vos id + base version), written by push, fetch and pull
|
|
250
269
|
stills/ `vos frames` output + stills.json
|
|
251
270
|
digest/ `vos digest` output: digest.json, <id>.full.png, <id>.crop.png, sheet.png
|
|
@@ -284,7 +303,54 @@ A re-record replaces the footage, the cursor track, the frames, `outputs/` and e
|
|
|
284
303
|
}
|
|
285
304
|
```
|
|
286
305
|
|
|
287
|
-
|
|
306
|
+
Nine verbs: `wait`, `hover` (`ms` 700, the dwell), `click` (`ms` the settle after the press, 150; `count: 2 | 3` for a double or triple click, `button: "right" | "middle"`, `modifiers` held for the press), `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; or `keys`, a chord, a plain key or a sequence, below; `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; `modifiers` and `path`, below), `paste` (below). 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`. A gesture step (`click`, `drag`, `type`, `paste`, `press`, `hover`) takes `"zoom": true` to mark it as its beat's payoff, the step `--zooms per-beat` keeps its zoom on, or `"zoom": false` for never zoom here: a plain `vos plan` rejects the planner's proposal on a `false` step for good, and names a `true` step it proposed nothing for (no zoom is invented); the recorder keeps the mark on `meta.steps`, `vos push` carries it in the script and `vos fetch` writes it back. 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.
|
|
307
|
+
|
|
308
|
+
**Canvas gestures**: what a whiteboard, a design tool or a timeline is recorded with.
|
|
309
|
+
|
|
310
|
+
```json
|
|
311
|
+
{
|
|
312
|
+
"settle": true,
|
|
313
|
+
"steps": [
|
|
314
|
+
{ "do": "press", "keys": "r" },
|
|
315
|
+
{ "do": "drag", "x": 200, "y": 160, "tx": 420, "ty": 300 },
|
|
316
|
+
{ "do": "press", "keys": ["v", "Escape"] },
|
|
317
|
+
{
|
|
318
|
+
"do": "drag",
|
|
319
|
+
"selector": ".shape",
|
|
320
|
+
"tx": 600,
|
|
321
|
+
"ty": 300,
|
|
322
|
+
"modifiers": ["Alt"]
|
|
323
|
+
},
|
|
324
|
+
{ "do": "drag", "path": "signature.json" },
|
|
325
|
+
{
|
|
326
|
+
"do": "drag",
|
|
327
|
+
"path": [
|
|
328
|
+
[300, 400],
|
|
329
|
+
[340, 420],
|
|
330
|
+
[380, 400]
|
|
331
|
+
],
|
|
332
|
+
"ms": 500
|
|
333
|
+
},
|
|
334
|
+
{ "do": "click", "selector": "text=Title", "count": 2 },
|
|
335
|
+
{ "do": "click", "selector": ".layer", "button": "right" },
|
|
336
|
+
{ "do": "click", "x": 640, "y": 420, "modifiers": ["Shift"] },
|
|
337
|
+
{ "do": "click", "x": 300, "y": 260, "count": 2 },
|
|
338
|
+
{ "do": "paste", "file": "logo.svg" },
|
|
339
|
+
{ "do": "press", "keys": "Mod+Z" }
|
|
340
|
+
]
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- **`press` `keys`** takes a chord (`"Shift+D"`, `"Meta+Shift+z"`, `"Mod+Z"`: `Mod` is Meta on macOS and Control elsewhere, wherever the browser runs), a plain key (`"r"`, a tool's shortcut) or a sequence (`["v", "Escape"]`, 150 ms apart). A key is a single character, a named key (`Escape`, `ArrowLeft`, `Space`), `F1` to `F24`, or a code (`KeyA`, `Digit1`); the modifiers are `Shift`, `Control`, `Alt`, `Meta`, `Mod`. Each key is a typing ping on the cursor track, when and where, never which key. The older `key` form still works.
|
|
345
|
+
- **`drag` `modifiers`** (`Alt`, `Shift`, `Meta`, `Control`, `Mod`) are held from the press to the release: Alt-drag to duplicate, Shift to constrain.
|
|
346
|
+
- **`drag` `path`** draws: viewport points inline (`[x, y]`, `[x, y, tMs]`, `[x, y, tMs, pressure]` or `{ "x", "y", "t", "pressure" }`, every point timed or none), or a file beside the actions file by a plain name (`"path": "loop.json"`, an array of points or `{ "points": [...] }`). The pointer travels to the first point, presses there, moves through every later point in order and releases at the last, replayed by the points' own times, or evenly over `ms` when they carry none. Every point is sent: a page that answers slowly makes the stroke late (an overrun, below), never a different stroke. A point that carries `pressure` draws the whole path with a pen, so the page's pointer events carry that pressure; without, the mouse draws it. A path takes no `selector`, `x`, `y`, `tx` or `ty`. The cursor track holds the press, every point and the release. The take keeps its script self-contained: a path a file held is written inline (with its times and pressure) in the take's `actions.json`, so the push embeds it, a fetch writes it back and a hosted take runs it, and a re-record draws the same strokes with the file gone; `--hosted` sends a path file's points inline too.
|
|
347
|
+
- **`click` `count`** 2 or 3 is a real double or triple click (that many presses inside the platform's double-click interval, each with its click count); `button` presses the right or middle one; `modifiers` (`Alt`, `Shift`, `Meta`, `Control`, `Mod`) are held for the press and released after it (a Shift-click adds to a selection). Every press is a down and an up on the cursor track.
|
|
348
|
+
- **`click` and `hover` at a point**: `x` and `y` (viewport CSS px) instead of a `selector`, for a spot no element names: a place on a canvas, a handle, empty space to click on to focus or deselect. The cursor track carries the press at that point with no element rect, so the planner treats it as a point. A drawn shape's DOM box is not always where it is drawn (an arrow's container sits at its anchor), so a point is often the truer target on a canvas.
|
|
349
|
+
- **`paste`** puts `text`, or a `file` beside the actions file (an image, an SVG, a text file; at most 10 MB), on the clipboard and pastes it the way a person's ⌘V does, into `selector` (clicked first) or where the focus is. The browser's own clipboard holds the item (a headless browser keeps its own, so this machine's clipboard is untouched), and the recorder presses the platform's paste chord, so the page gets a trusted keydown and a trusted `paste` event, and a page that reads `navigator.clipboard.read()` itself finds the item there. An SVG rides as its markup too. An IMAGE file is dispatched in the page with its own bytes instead (the focused element gets a `paste` event holding the file, and `navigator.clipboard.read` answers it for that paste only: shortly after it is first read, or after 3 s unread, the browser's own reads come back, so a later paste goes through the real clipboard), because the browser's clipboard re-encodes a picture and drops what it carries, a screenshot's density among it, so a 2x capture would land at twice its size; the log says which path ran, and that those events are untrusted. Words keep the trusted path, and fall back to the same dispatch in the page when the browser refuses them. A paste is a typing ping, never its contents. The take keeps a pasted file beside its script as `paste-<name>`, and its `actions.json` names the copy, so the take re-records on this machine with the original gone. **A pasted file travels with the take**: `vos push` sends each one with it and the document lists them (`source.scriptFiles`, the name the script pastes it by and where its bytes are), a picture through the same upload door as every other file the document names and a text file inside the document itself (at most 256 kB; the platform stores no text files), and `vos fetch --media` and `vos pull --media` write each one back beside `actions.json` under the name the script uses, never over a file already there, so a fetched take records again from anywhere. A paste file the take does not hold is said and left out of the push; one that resolves outside the take directory (a link out of it) is refused before anything is sent. A claimable push carries a pasted text file and a pasted picture the take fetched unchanged, and says so of any other. A drag's `path` needs none of this: the recorder writes a path file's points inline into the take's `actions.json`, so the pushed script carries the stroke itself.
|
|
350
|
+
- **`settle: true`** on a gesture step (or at the top of the script, for every one; a step's `settle: false` opts out) waits after the gesture until the page has made no DOM change and painted no new frame for 300 ms, counted from the page's last change (a page already still for longer goes on at once), at most 4 s: a heavy canvas that falls behind under load finishes the last stroke before the next one starts. `meta.steps[].settleMs` says how long it waited, and a page still changing at the cap is said in the log. A rehearsal captures no frames, so there it waits on the DOM alone.
|
|
351
|
+
- **Overruns**: a gesture the script paces (a straight drag's travel, a path's timing, typing, a key sequence, whose plan holds each key's round trip as well as its gaps) that took more than a fifth longer than planned, and at least 100 ms longer, is reported: `meta.steps[].overrunMs`, `overruns: [{ step, id, do, plannedMs, tookMs, overrunMs }]` in the done event, and one line (`3 steps ran 2.1× slower than scripted (…): the page could not keep up; add settle or raise ms`).
|
|
352
|
+
|
|
353
|
+
A hosted take (`--hosted`) refuses a `paste` (a fleet browser has no clipboard), a `path` read from a file (the script travels alone; write the points inline) and a `js` check (code a fleet browser would run on a stranger's say).
|
|
288
354
|
|
|
289
355
|
Three steps that are not gestures, and a guard:
|
|
290
356
|
|
|
@@ -309,12 +375,48 @@ Three steps that are not gestures, and a guard:
|
|
|
309
375
|
}
|
|
310
376
|
```
|
|
311
377
|
|
|
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)
|
|
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 `{
|
|
378
|
+
- **`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), `gone` (no longer visible) or `js` (below), 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.
|
|
379
|
+
- **`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 }`, `{ gone }` or `{ js }`, with `equals` beside a `js`) 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`.
|
|
380
|
+
- **`js`** checks what an app keeps in SCRIPT rather than in the DOM: a canvas app's shapes, a selection, a document model, so a take can say "the board has 14 shapes" the way it says "the button is enabled". `{ "do": "expect", "js": "window.app.count() === 14" }` holds when the expression, evaluated in the page (a promise is awaited), is truthy; `{ "do": "expect", "js": "window.app.count()", "equals": 14 }` holds when its value equals `equals` (compared as JSON, any key order), and is the form to prefer, because a miss then says what the app had: ``#3 board expect: js `window.app.count()` was 15, not 14, for 5 s``, where a bare comparison can only say it `was false`. An expression that throws (the app not loaded yet) is read again until the timeout, and a miss says what it threw. `await` takes the same in `until` (`{ "js": "window.app.status()", "equals": "done" }`). The value the expression last answered is kept on the step, `meta.steps[].value` (its JSON, at most 2 KB, else that JSON cut short), so the take carries the app's state at that beat as `text` carries an element's words, and a rehearsal prints it beside the step. Timeouts and `--strict` are an `expect`'s and an `await`'s; an expression that does not parse is refused before a browser opens (`vos validate actions.json` says so too). It is the script's own code run on its own page: write only what reads state, never what changes it, and a hosted take refuses it.
|
|
381
|
+
|
|
382
|
+
**Every step is bounded, and says when it runs.** A page that stops answering (a busy loop, a renderer stuck after a reload the page made itself) would leave a step waiting on it for good, so every call the recorder makes into the page is held to the step's own bound: an `expect` its `timeout` plus 5 s, an `await` its `timeout` plus 30 s, a `download` its 30 s plus 30 s, any other step its planned time plus 30 s. A step that runs into its bound ends as a skip whose reason carries the real time it took (`the page stopped answering: this expect ran 6 s, past its bound of 6 s`); under `--strict`, and always in a rehearsal, the take stops there, keeping the footage up to it. Under `--json` every step reports `{ event: "step", phase: "start", i, id, do }` as it begins and `phase: "end"` with `ms` (and `skipped`, `hung`) as it ends, so a slow take is never minutes of silence.
|
|
383
|
+
|
|
314
384
|
- **`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
385
|
- **`"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
386
|
|
|
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.
|
|
387
|
+
`vos actions script` says these too: `Check that … is enabled`, `Wait until … (off camera: pause the recording meanwhile)`, `Download …`, `Press ⌘/Ctrl+Z`, `Press v then Escape`, `Hold Alt/Option and drag …`, `Draw the stroke in loop.json, in one press`, `Double-click …`, `Copy the file logo.svg, then paste it (⌘/Ctrl+V)`, and a guarded step is marked as one that spends.
|
|
388
|
+
|
|
389
|
+
**A second person on the page: `peers`.** A collaboration product demos best with someone else on the canvas: their cursor gliding in, the shape they drew, the comment they left. `peers` adds other people to a take, each a browser context of its own (its own cookies, so its own user) on the same page, driven by its own steps and recorded OFF CAMERA:
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
{
|
|
393
|
+
"url": "https://app.example.com/board/42",
|
|
394
|
+
"steps": [
|
|
395
|
+
{ "id": "open", "do": "wait", "ms": 600 },
|
|
396
|
+
{ "id": "see-bob", "do": "expect", "text": "Bob is here" },
|
|
397
|
+
{ "id": "draw", "do": "drag", "x": 300, "y": 220, "tx": 420, "ty": 300 },
|
|
398
|
+
{ "id": "bob-replies", "do": "await", "until": { "text": "Bob commented" } }
|
|
399
|
+
],
|
|
400
|
+
"peers": [
|
|
401
|
+
{
|
|
402
|
+
"id": "bob",
|
|
403
|
+
"storageState": "bob.json",
|
|
404
|
+
"steps": [
|
|
405
|
+
{ "after": "draw", "do": "click", "selector": "text=Comment" },
|
|
406
|
+
{ "do": "type", "selector": "textarea", "text": "Ship it" },
|
|
407
|
+
{ "do": "press", "key": "Enter" }
|
|
408
|
+
]
|
|
409
|
+
}
|
|
410
|
+
]
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
- **Only the take's page is filmed**, and only the take's own steps make the cursor track: a peer's page is never screencast, scanned, masked or listened to, and a peer's gestures never become a cursor event, a click ring, a zoom or a dwell. What a peer does reaches the footage the way a viewer would see it, through the app's own sync (its presence, its remote cursors, the work it made). A peer's pointer glides and its typing is paced on the take's clock, so an app that draws remote cursors shows a person, not a jump.
|
|
415
|
+
- **Ordering is the take's.** A peer step names a take step by id: `with` starts it as that step starts (the two run together), `after` once that step has ended (its settle and its reads included). A step naming neither follows the peer's previous step at once, and a peer's first step with neither starts as the camera rolls. The take never waits on a peer: it waits on what the peer's work SHOWS, with its own `expect` or `await`, so the footage holds exactly the moment the sync landed (above, the take's `await` cuts out however long Bob's comment takes to arrive). A trigger that has already fired when the peer reaches it runs at once, marked `late`. When the take's steps are over, a peer step still waiting never runs (said, `notRun`), and one already running finishes, bounded, before the trailing hold.
|
|
416
|
+
- **A peer takes the take's gestures and its check**: `wait`, `move`, `hover`, `click`, `type`, `press`, `scroll`, `drag` (a `path` inline only) and `expect`, in exactly the take's shapes and words, each bounded like a take step. A miss is a skip listed with the take's (`skipped[]` entries carry `peer`), which `--strict` fails on. A `paste`, a `download`, an `await`, a guard, a `read`, a `caption`, `settle` and a `zoom` mark belong to the filmed page and are refused on a peer.
|
|
417
|
+
- **Each peer is its own user**: `storageState` names a Playwright storage state of the second person's sign-in (`context.storageState({ path })`), by a path from the actions file's directory or an absolute one; absent, the peer is signed out. It is opened by the browser and nothing else: never copied into the take, never uploaded, and a pushed script keeps its file name only. A peer that lands on a sign-in refuses the take before anything is captured, and so does a storage state that is not there. `url` (the take's by default) and `viewport` (the take's) may differ per peer; `--header` applies to each peer's own origin as to the take's. At most 4 peers, 200 steps each.
|
|
418
|
+
- **What it records**: `meta.peers[]` (`{ id, steps: [{ step, id, do, with | after, selector, tStart, tEnd, skipped, reason, late, value }], notRun }`, in the take's footage seconds; an `expect` whose check reads the app's state keeps what it last answered in `value`, as the take's own steps do) and `peers` in the done event; a rehearsal (`--dry-run`) runs the peers too and lists their steps under the take's. `vos actions script` says a peer's steps as a second person's beats against the take's (`Once beat 3 is done: Click "Comment"`).
|
|
419
|
+
- **Not supported**: a peer on a `--session` profile (a peer signs in from a storage state), a peer recorded by the hosted recorder (`--hosted` refuses peers: a fleet take runs one browser), a take step that waits on a peer step directly (wait on what it shows), and determinism a network cannot give: a peer's effect lands when the app's sync delivers it, which is why the take checks for it rather than sleeping.
|
|
318
420
|
|
|
319
421
|
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.
|
|
320
422
|
|
|
@@ -324,7 +426,7 @@ Not sure of a selector? `vos look <url>` (with the take's `--session` or `--stor
|
|
|
324
426
|
|
|
325
427
|
`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.
|
|
326
428
|
|
|
327
|
-
**Contracts that bite.** Time is source seconds in `zoom`, `segments`, `speed` and `tilt`, and output seconds in `overlays` and `audio`. Zoom `cx`/`cy`, overlay `transform.x`/`y` and a pin's `rect` are normalized fractions of the frame in `[0, 1]` (`0.5, 0.5` is the centre), never pixels. `level` is 1..5. The planners write `source: "auto"`; spans you add or edit carry `source: "manual"`, which a re-plan never touches, and a deleted automatic span is recorded in `doc.rejected` so a re-plan does not bring it back.
|
|
429
|
+
**Contracts that bite.** Time is source seconds in `zoom`, `segments`, `speed` and `tilt`, and output seconds in `overlays` and `audio`. The output plays `segments` in list order, and a segment may repeat seconds an earlier one played (a cold open of the final state, then the recording from its start, needs no second copy of the file): a zoom, tilt, cam move, speed span, freeze or click over those seconds plays in every occurrence, and a step-anchored layer starts in the pass where its step plays the longest. A segment marked `"camera": "rest"` keeps the camera at rest through its pass (no zoom, tilt, cam move or follow recenter there; the cursor, clicks, speed spans and freezes still play, and a span running into or out of it ramps at its edge), so a cold open can show the settled whole while the main play zooms; `vos validate` names, for each replay, the passes the camera rests in. Zoom `cx`/`cy`, overlay `transform.x`/`y` and a pin's `rect` are normalized fractions of the frame in `[0, 1]` (`0.5, 0.5` is the centre), never pixels. `level` is 1..5. The planners write `source: "auto"`; spans you add or edit carry `source: "manual"`, which a re-plan never touches, and a deleted automatic span is recorded in `doc.rejected` so a re-plan does not bring it back.
|
|
328
430
|
|
|
329
431
|
**Camera styles.** `doc.zoomStyle` is one of `glide` (default: one steady zoom that travels between clicks), `focus` (a zoom per click, framed and released), `cinema` (slow moves and long holds), `snappy` (quick cycles), `cut` (instant zoom-ins) or `none`. Every style times the tilt track to its own zoom tempo, so a lean lands with its zoom; how far the card leans is `tiltStyle` (`off` | `subtle` | `medium` | `strong`), which the studio stamps when a style is picked and `doc.json` sets itself, with `tilt` spans. `keynote` and `drift` are retired names that still read: `glide` + `tiltStyle: medium` and `cinema` + `tiltStyle: subtle`.
|
|
330
432
|
|
|
@@ -336,7 +438,7 @@ Not sure of a selector? `vos look <url>` (with the take's `--session` or `--stor
|
|
|
336
438
|
|
|
337
439
|
**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.
|
|
338
440
|
|
|
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,
|
|
441
|
+
**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 an anchored layer may leave it out: one whose step then resolves nowhere plays nowhere, which `vos validate` refuses, naming the step. `duration` is the layer's own; on a cut that replays the step's seconds the layer starts in the pass where the step plays the longest (the first on a tie), so a caption about a step lands in the main play rather than a short cold open. 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, when two captions are on screen at the same time, naming both, and when a layer anchored to one media's step starts over another media's footage (a caption landed after its gesture, then the footage after that step trimmed away). A document without an anchor lowers exactly as before. Not the re-record `anchor` on spans, which only `vos plan --reuse` reads.
|
|
340
442
|
|
|
341
443
|
**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`.
|
|
342
444
|
|
|
@@ -346,9 +448,28 @@ Not sure of a selector? `vos look <url>` (with the take's `--session` or `--stor
|
|
|
346
448
|
|
|
347
449
|
**Audio.** `doc.audio` clips (music, effects, `key` a take-directory file or a URL) are mixed with gain, fade and loop envelopes and muxed into every full render: Opus for webm, AAC (Opus fallback) for mp4. Audio forces single-flight (`--parallel` is ignored with a note); a `--range` render keeps its audio.
|
|
348
450
|
|
|
451
|
+
**Loudness as a target.** `gain` is 0..1, so it cannot lift a quiet file; a clip may say how loud it should play instead: `normalize`, an integrated loudness in LUFS from -30 to -6 (`-16` sits a score under speech without burying it). The lowering plays the clip at `10^((normalize - lufs) / 20) × gain`, which may exceed 1, from what the file measures, a FACT stored beside the clip and written by tooling, never by hand:
|
|
452
|
+
|
|
453
|
+
```json
|
|
454
|
+
{
|
|
455
|
+
"id": "score",
|
|
456
|
+
"key": "music/theme.wav",
|
|
457
|
+
"gain": 1,
|
|
458
|
+
"normalize": -16,
|
|
459
|
+
"loudness": {
|
|
460
|
+
"lufs": -24.3,
|
|
461
|
+
"peakDb": -9.1,
|
|
462
|
+
"at": "2026-10-22T10:04:00.000Z",
|
|
463
|
+
"src": "music/theme.wav"
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`vos audio measure <take>` decodes every clip's file and writes its `loudness`: BS.1770 integrated loudness (K-weighted, gated) over the whole file and its sample peak, by the meter `vos check-video` reads a render with. `vos score` writes it for the score and `vos join` for the sound it adds. The gain is CAPPED, never limited: no target puts the file's sample peak above -1 dBFS, none lifts more than +24 dB, and `vos validate` says when a cap holds a clip under its target. A target applies only while `loudness.src` is the clip's own `key`, so a clip pointed at another file plays at its gain alone until it is measured again (`vos validate` warns: measure it: `vos audio measure <take>`); a document with no target lowers byte for byte as before. `vos push` and `vos pull --media` move `loudness.src` with the key when a file is uploaded or brought home.
|
|
469
|
+
|
|
349
470
|
**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`.
|
|
350
471
|
|
|
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
|
|
472
|
+
**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 that stands alone, over at least a quarter of the frame or scattered across it, as when a camera jumps between two views of one dark picture or a light board of thin lines cuts to the empty canvas; a zoom, a pan, a cross-fade or an app's own change in one block, a panel or an embed appearing, is none of these), 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
473
|
|
|
353
474
|
## Overrides on render and frames
|
|
354
475
|
|
|
@@ -374,7 +495,7 @@ vos brand https://your.app --out BRAND.md # the brand kit,
|
|
|
374
495
|
|
|
375
496
|
## The vos.so loop
|
|
376
497
|
|
|
377
|
-
Everything that runs on your machine is open source; [vos.so](https://vos.so) is the hosted platform: a studio for the human half of an edit, version history with a typed changelog, a shelf of projects and recipes. All vos.so traffic lives behind one client: one origin (`VOS_ORIGIN`, default `https://vos.so`; `--origin` per call), one credential ladder (`--key`, then `VOS_API_KEY`, then `~/.config/vos/credentials`, written by `vos login
|
|
498
|
+
Everything that runs on your machine is open source; [vos.so](https://vos.so) is the hosted platform: a studio for the human half of an edit, version history with a typed changelog, a shelf of projects and recipes. All vos.so traffic lives behind one client: one origin (`VOS_ORIGIN`, default `https://vos.so`; `--origin` per call), one credential ladder (`--key`, then `VOS_API_KEY`, then `~/.config/vos/credentials`, written by `vos login` with the origin it was minted for; a `vos_rg_` remix grant is just a key), one tracking file (`vos.json` beside the artifact). Credentials are never printed, and the stored one is sent only to the origin it was minted for (a file written before the origin line is `https://vos.so`'s): a command aimed at another origin sends no key and says so, so pass `--key` or `VOS_API_KEY` for it, or `vos login --origin <it>`. Keys can never publish: pushes are private, and humans publish on vos.so.
|
|
378
499
|
|
|
379
500
|
```bash
|
|
380
501
|
vos login [--key <k>] [--label <name>] [--no-browser] # browser sign-in: a code and a vos.so/cli/auth URL, a human approves, the key stores itself
|
|
@@ -402,7 +523,7 @@ vos assets ls [--kind <kind>] [--q <text>] [--sort size] | usage | why <id> #
|
|
|
402
523
|
vos recipe push <FILE.md> --folder <slug> | --asset <id> # the one recipe write: create, or replace in place
|
|
403
524
|
```
|
|
404
525
|
|
|
405
|
-
`push` is polymorphic by a deterministic sniff, never a flag: a take directory (a `doc.json` carrying `source`) pushes recording and document through the take pipeline; a `config.json` (or a directory holding one) pushes the program, and a `doc.json` beside it that carries `program` (a program document: overlays, objects, audio, speed, tween edits, its own length; `program.config` omitted on disk) rides along, lint-gated. `fetch` and `pull` write a program document back the same way. `pull` takes `--since <versionId>` (walk the changelog from that base instead of the tracked one) and `--check` (print what changed and stop: nothing on disk moves) on both paths. A program push LANDS where `vos.json` says: a directory that tracks a vos iterates it, so the fetch, edit, check, push, pull loop needs no flags, and making something separate from the same config is the explicit `--remix-of` door. Program pushes take `--vos`, `--title`, `--slug`, `--desc`, `--tags`, `--folder`, `--remix-of`, `--base`, `--label`, `--note`, `--override <id>` (repeatable) and `--claimable`; take pushes take `--title`, `--label`, `--note`, `--folder`, `--override`, `--yes` and `--claimable`. Both take `--wait`. A new vos pushed without `--folder` lands unfiled, at the root of the shelf, and the done line says so. `--folder` takes a project on your own shelf (its slug or id) or anyone's project by its address, `<handle>/<slug>` as its page on vos.so names it: the server answers where the push lands, CONTINUING that project when the key is its owner's and otherwise STARTING the person's own from it (a private project made once, named after it, with the recipes they may read copied in, never its videos). The push says which.
|
|
526
|
+
`push` is polymorphic by a deterministic sniff, never a flag: a take directory (a `doc.json` carrying `source`) pushes recording and document through the take pipeline; a `config.json` (or a directory holding one) pushes the program, and a `doc.json` beside it that carries `program` (a program document: overlays, objects, audio, speed, tween edits, its own length; `program.config` omitted on disk) rides along, lint-gated. `fetch` and `pull` write a program document back the same way. `pull` takes `--since <versionId>` (walk the changelog from that base instead of the tracked one) and `--check` (print what changed and stop: nothing on disk moves) on both paths. A program push LANDS where `vos.json` says: a directory that tracks a vos iterates it, so the fetch, edit, check, push, pull loop needs no flags, and making something separate from the same config is the explicit `--remix-of` door. Program pushes take `--vos`, `--title`, `--slug`, `--desc`, `--tags`, `--folder`, `--remix-of`, `--base`, `--label`, `--note`, `--override <id>` (repeatable) and `--claimable`; take pushes take `--title`, `--label`, `--note`, `--folder`, `--override`, `--yes`, `--bake` and `--claimable`. Both take `--wait`. A take push first asks the platform which `@vosjs/studio-core` it renders with (`GET /api/engine`, public, `{ studioCore, core }`) and refuses (exit 2), before anything uploads, a take that uses a field newer than it: the program a push compiles plays right, but the platform's studio lowers the stored document again with its own release, so it would show an anchored caption at its fallback start or play a clip with a loudness target at its gain, and every version saved from the studio would keep it that way. `--bake` sends a copy any renderer plays: every such field the take uses, said in what an older release reads (an anchored caption at the start it plays at in this cut with its `{elapsed}` filled, a loudness target as its gain, up to 1), whatever the platform answered, behind, current or nothing at all, and it never touches `doc.json`. The table of fields and their releases is `DOC_FEATURES` in `@vosjs/studio-core`. A platform that cannot say (no such endpoint, a refusal, no answer in 2 s) is warned of and pushed to. A new vos pushed without `--folder` lands unfiled, at the root of the shelf, and the done line says so. `--folder` takes a project on your own shelf (its slug or id) or anyone's project by its address, `<handle>/<slug>` as its page on vos.so names it: the server answers where the push lands, CONTINUING that project when the key is its owner's and otherwise STARTING the person's own from it (a private project made once, named after it, with the recipes they may read copied in, never its videos). The push says which.
|
|
406
527
|
|
|
407
528
|
**A program's files are declared, never typed into a function.** `config.json` names each file it uses in `assets` (`"assets": { "logo": { "ref": "./logo.png", "kind": "image" }, "shots": { "ref": ["./shots/1.png", "./shots/2.png"] } }`) and reads `ctx.assets.logo`, or `"$assets.logo"` in an element, object or font. On disk a `ref` is a path in the config's own directory or below, so `vos render`, `vos still` and `vos preview` serve the file to the page from disk. A path that climbs out of that directory (`../`, an absolute path, a link that points away) is not served and not uploaded, and a config loaded from a URL reads no local file: a program's code runs in a page, and what the page is served the program can send anywhere. `vos push` uploads each local file (content-addressed, so a re-push sends nothing: pictures, SVG, video, sound, `.glb` models, fonts and HDR environments) and what it stores names the hosted file as `asset:<id>`, the one spelling the platform resolves for every render; the config on disk keeps its paths. A file an element names as `src` or a font names as `url` is lifted into the manifest on the way (the element then names `"$assets.<name>"`), and a path in `data` that is a file beside the config is uploaded and rewritten to the hosted file's path, so every place a program can name a file crosses. A file typed inside a function cannot be rewritten: `vos push` says so, with the manifest line to write. `vos fetch --media` brings a program's hosted files home under `assets/` and re-points the manifest at them (`assets/.hosted.json` remembers which hosted file each one is, so a later fetch replaces one that changed); without `--media` the manifest keeps `asset:<id>` and a local render reads each file with your key. `vos check` says when a `"$assets.<name>"` names nothing, when a declared path is not a file, and when a function names a hosted file in code instead of the manifest: a file named only in code does not load on a render of a private program and does not follow a remix. A `--claimable` push carries them too, through the claim's own upload session (below).
|
|
408
529
|
|
|
@@ -432,7 +553,7 @@ vos push /tmp/take --label "$TAG launch" --yes --json # VOS_API_KEY as a r
|
|
|
432
553
|
- **Output.** Logs on stderr, results on stdout. `--json` turns every verb into NDJSON events ending with `{"event":"done",…}`.
|
|
433
554
|
- **`vos <verb> --help`** prints that verb's usage lines and exits 0 (it used to be a usage error).
|
|
434
555
|
- **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`).
|
|
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
|
|
556
|
+
- **`--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 settles is a note, never a strict failure: `ready.capped` in the done event, and `navTimeout: true` when `ready: "networkidle"` was asked for and never came (many real apps hold a socket open and never go network-idle, which is why the default waits for the DOM instead). The default is lenient (exit 0, `skipped[]` still reported) for exploratory runs.
|
|
436
557
|
- **`--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.
|
|
437
558
|
- **`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.
|
|
438
559
|
- **`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,12 +4,12 @@ import {
|
|
|
4
4
|
localManifestRefs,
|
|
5
5
|
pullManifest,
|
|
6
6
|
uploadManifest
|
|
7
|
-
} from "./chunk-
|
|
8
|
-
import "./chunk-
|
|
7
|
+
} from "./chunk-PJ6MEJZV.js";
|
|
8
|
+
import "./chunk-J3KHIZCH.js";
|
|
9
9
|
export {
|
|
10
10
|
hostedLiteralWarnings,
|
|
11
11
|
localManifestRefs,
|
|
12
12
|
pullManifest,
|
|
13
13
|
uploadManifest
|
|
14
14
|
};
|
|
15
|
-
//# sourceMappingURL=assetManifest-
|
|
15
|
+
//# sourceMappingURL=assetManifest-ETT3C7ZA.js.map
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
ASSET_ROUTE,
|
|
4
4
|
UsageError,
|
|
5
5
|
serveFile
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-J3KHIZCH.js";
|
|
7
7
|
|
|
8
8
|
// src/loadConfig.ts
|
|
9
9
|
import { readFile } from "fs/promises";
|
|
@@ -154,9 +154,12 @@ Fixes, in order of preference:
|
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
156
|
};
|
|
157
|
+
var BROWSER_SWITCHES = [
|
|
158
|
+
"--disable-updater-scheduler"
|
|
159
|
+
];
|
|
157
160
|
async function launchBrowser(args = [], launch = {}) {
|
|
158
161
|
const opts = {
|
|
159
|
-
|
|
162
|
+
args: [...BROWSER_SWITCHES, ...args],
|
|
160
163
|
...launch.headless === false ? { headless: false } : {}
|
|
161
164
|
};
|
|
162
165
|
const explicit = process.env.VOS_BROWSER_PATH;
|
|
@@ -555,6 +558,7 @@ export {
|
|
|
555
558
|
loadVosConfig,
|
|
556
559
|
configDuration,
|
|
557
560
|
BrowserUnavailableError,
|
|
561
|
+
BROWSER_SWITCHES,
|
|
558
562
|
launchBrowser,
|
|
559
563
|
stillWarnings,
|
|
560
564
|
renderVideo,
|
|
@@ -569,4 +573,4 @@ export {
|
|
|
569
573
|
setFlags,
|
|
570
574
|
applyDataSets
|
|
571
575
|
};
|
|
572
|
-
//# sourceMappingURL=chunk-
|
|
576
|
+
//# sourceMappingURL=chunk-B373NVJE.js.map
|