@vosjs/cli 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -143,7 +143,7 @@ A re-record replaces the footage, the cursor track, the frames and everything de
143
143
  }
144
144
  ```
145
145
 
146
- Seven verbs: `wait`, `hover` (`ms` 700), `click`, `type` (`delayMs` 40 per key; `focus: false` skips the focusing click, for a submitting Enter), `scroll`, `move`, `drag` (press, move, release: a range input, a canvas element, a timeline clip). 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. 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.
146
+ Seven verbs: `wait`, `hover` (`ms` 700), `click`, `type` (`delayMs` 40 per key; `focus: false` skips the focusing click, for a submitting Enter), `scroll`, `move`, `drag` (press, move, release: a range input, a canvas element, a timeline clip). 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`). 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.
147
147
 
148
148
  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.
149
149
 
@@ -151,13 +151,15 @@ Verified the flow in agent-browser already? `vos actions from-agent-browser step
151
151
 
152
152
  `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.
153
153
 
154
- **Contracts that bite.** Time is source seconds in `zoom`, `segments`, `speed` and `tilt`, and output seconds in `overlays` and `audio`. Zoom `cx`/`cy` and overlay `transform.x`/`y` 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.
154
+ **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.
155
155
 
156
156
  **Camera styles.** `doc.zoomStyle` is one of `glide` (default), `focus`, `cinema`, `snappy`, `cut`, `keynote` (gliding zooms plus a medium lean toward each focus, the launch-film register), `drift` (slow ambient zooms with a subtle lean) or `none`. In the studio, picking a style also stamps `tiltStyle` and plans the tilt spans; in `doc.json` set `tiltStyle` and `tilt` yourself.
157
157
 
158
158
  **Tilt.** `doc.tilt`: source-anchored, non-overlapping spans where the card leans to a pose and returns to rest: `[{ "id": "u0", "in": 4, "out": 8, "rx": 6, "ry": -9, "source": "manual" }]`. `rx`/`ry` are degrees (±45 hard limit; ±5..18 reads well); `+rx` brings the top edge toward the camera, `+ry` the left edge, so a lean toward a right-side focus is a negative `ry`. Rest is flat. `doc.tiltStyle` (`off`, `subtle`, `medium`, `strong`) is the intensity the planner derives automatic spans from.
159
159
 
160
- **Text overlays.** `doc.overlays`: screen-space clips above the card, outside the zoom, output-anchored. `{ "id": "t0", "kind": "text", "start": 1, "duration": 3, "text": "Ship it", "preset": "title", "transform": { "x": 0.5, "y": 0.82, "scale": 1, "rotation": 0 }, "enter": "rise", "exit": "fade" }`. Presets `title`, `caption`, `label`, overridable with `size` (12..200 design px) and `color`; `\n` breaks lines; a lower third sits at `y` ≈ 0.82. Enter and exit: `rise`, `fade`, `none`. Fonts load at render start, fail-open to system stacks.
160
+ **Text overlays.** `doc.overlays`: screen-space clips above the card, outside the zoom, output-anchored. `{ "id": "t0", "kind": "text", "start": 1, "duration": 3, "text": "Ship it", "preset": "title", "transform": { "x": 0.5, "y": 0.82, "scale": 1, "rotation": 0 }, "enter": "rise", "exit": "fade" }`. Presets `title`, `caption`, `label`, overridable with `size` (12..200 design px) and `color`; `\n` breaks lines; a caption (a layer with no referent) is a lower third at `y` ≈ 0.82. Enter and exit: `rise`, `fade`, `none`. Fonts load at render start, fail-open to system stacks.
161
+
162
+ **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.
161
163
 
162
164
  **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`.
163
165
 
@@ -210,9 +210,15 @@ import {
210
210
  ZOOM_LEVEL_MIN,
211
211
  ZOOM_SPAN_MIN,
212
212
  docCardLayout,
213
+ docZoomTrack,
214
+ findStep,
213
215
  outputEnd,
216
+ pinBox,
217
+ pinRectOnScreen,
218
+ pinReferent,
214
219
  ratedSegments,
215
220
  recommendedExportResolution,
221
+ resolvePins,
216
222
  spanOutputExtent,
217
223
  zoomCoversRect,
218
224
  cameraModel
@@ -944,6 +950,7 @@ function lintDoc(docIn) {
944
950
  if (!isNum(o.duration) || o.duration <= 0) {
945
951
  problems.push(`${name}.duration must be > 0 (seconds)`);
946
952
  }
953
+ checkPin(o, name, docIn, recording, problems, warnings);
947
954
  if (o.kind === "text") {
948
955
  if (typeof o.text !== "string")
949
956
  problems.push(`${name}.text must be a string`);
@@ -1455,6 +1462,106 @@ function clicksWithRects(source) {
1455
1462
  }
1456
1463
  return out;
1457
1464
  }
1465
+ var PIN_SIDES = ["auto", "right", "left", "below", "above"];
1466
+ var PIN_MARKS = ["none", "ring", "underline"];
1467
+ function checkPin(o, name, docIn, recording, problems, warnings) {
1468
+ if (o.pin === void 0) return;
1469
+ if (!isObj(o.pin)) {
1470
+ problems.push(
1471
+ `${name}.pin must be { step | press | rect, side?, gap?, mark?, leader?, color? }`
1472
+ );
1473
+ return;
1474
+ }
1475
+ const p = o.pin;
1476
+ const named = ["step", "press", "rect"].filter((k) => p[k] !== void 0);
1477
+ if (named.length !== 1) {
1478
+ problems.push(
1479
+ `${name}.pin names its referent by exactly one of step (a recorder step id or index), press (a source second) or rect (video fractions)${named.length ? ` \u2014 got ${named.join(" and ")}` : ""}`
1480
+ );
1481
+ return;
1482
+ }
1483
+ if (p.step !== void 0 && typeof p.step !== "string" && !(isNum(p.step) && Number.isInteger(p.step) && p.step >= 0)) {
1484
+ problems.push(
1485
+ `${name}.pin.step must be a step id (string) or index (integer \u2265 0)`
1486
+ );
1487
+ }
1488
+ if (p.press !== void 0 && !isNum(p.press))
1489
+ problems.push(`${name}.pin.press must be a source second (number)`);
1490
+ if (p.rect !== void 0) {
1491
+ const r = isObj(p.rect) ? p.rect : null;
1492
+ if (!r || !isNum(r.x) || !isNum(r.y) || !isNum(r.w) || !isNum(r.h)) {
1493
+ problems.push(`${name}.pin.rect must be { x, y, w, h }`);
1494
+ } else if (Math.abs(r.x) > 2 || Math.abs(r.y) > 2 || r.w > 2 || r.h > 2) {
1495
+ problems.push(
1496
+ `${name}.pin.rect looks like PIXELS (${String(r.x)}, ${String(r.y)}, ${String(r.w)}\xD7${String(r.h)}) \u2014 it is FRACTIONS of the video frame [0..1] (the zoom cx/cy convention)`
1497
+ );
1498
+ } else if (r.w <= 0 || r.h <= 0) {
1499
+ problems.push(`${name}.pin.rect must have w > 0 and h > 0`);
1500
+ }
1501
+ }
1502
+ if (p.side !== void 0 && !PIN_SIDES.includes(p.side))
1503
+ problems.push(`${name}.pin.side must be one of ${PIN_SIDES.join(" | ")}`);
1504
+ if (p.gap !== void 0 && (!isNum(p.gap) || p.gap < 0))
1505
+ problems.push(`${name}.pin.gap must be design px \u2265 0`);
1506
+ if (p.mark !== void 0 && !PIN_MARKS.includes(p.mark))
1507
+ problems.push(`${name}.pin.mark must be one of ${PIN_MARKS.join(" | ")}`);
1508
+ if (p.leader !== void 0 && typeof p.leader !== "boolean")
1509
+ problems.push(`${name}.pin.leader must be true or false`);
1510
+ if (p.color !== void 0 && typeof p.color !== "string")
1511
+ problems.push(`${name}.pin.color must be a CSS colour string`);
1512
+ if (problems.some((m) => m.startsWith(`${name}.pin`))) return;
1513
+ if (!recording) {
1514
+ problems.push(
1515
+ `${name}.pin names something on a recording's page; a program has no referents \u2014 place the layer with transform`
1516
+ );
1517
+ return;
1518
+ }
1519
+ const doc = docIn;
1520
+ const steps = doc.source.meta.steps ?? [];
1521
+ if (p.step !== void 0) {
1522
+ const step = findStep(steps, p.step);
1523
+ if (!step) {
1524
+ const known = steps.filter((s) => s.selector).map((s) => s.id !== void 0 ? s.id : String(s.step));
1525
+ problems.push(
1526
+ `${name}.pin.step "${String(p.step)}" is not a step of this take${known.length ? ` \u2014 steps with an element: ${known.join(", ")}` : " \u2014 the take has no step timeline (a human recording); pin by press or rect"}`
1527
+ );
1528
+ return;
1529
+ }
1530
+ if (step.skipped) {
1531
+ problems.push(
1532
+ `${name}.pin.step "${String(p.step)}" was skipped at record time (its selector never became visible), so it has no element to point at`
1533
+ );
1534
+ return;
1535
+ }
1536
+ }
1537
+ let ref;
1538
+ try {
1539
+ ref = pinReferent(doc, p);
1540
+ } catch {
1541
+ ref = null;
1542
+ }
1543
+ if (!ref) {
1544
+ problems.push(
1545
+ p.step !== void 0 ? `${name}.pin.step "${String(p.step)}" has no element rect and no press inside its window (a wait, scroll or move step touches nothing) \u2014 pin a hover, click, type or drag step, or a press` : p.press !== void 0 ? `${name}.pin.press ${String(p.press)}s has no press with an element rect within half a second \u2014 the presses are the cursor track's down events` : `${name}.pin.rect could not be read`
1546
+ );
1547
+ return;
1548
+ }
1549
+ if (ref.at !== null && isNum(o.start) && isNum(o.duration)) {
1550
+ const rated = ratedSegments(doc);
1551
+ for (const s of steps) {
1552
+ if (s.tStart <= ref.at) continue;
1553
+ if (s.do !== "scroll" && !s.navigated) continue;
1554
+ const out = spanOutputExtent(rated, s.tStart, s.tEnd);
1555
+ if (!out) continue;
1556
+ if (out.start < o.start + o.duration && out.end > o.start) {
1557
+ warnings.push(
1558
+ `${name} is pinned to something measured at ${ref.at.toFixed(1)}s, but ${s.do === "scroll" ? "a scroll" : "a navigation"} at ${s.tStart.toFixed(1)}s (output ${out.start.toFixed(1)}s) falls inside the layer's window \u2014 the referent may have moved; end the layer before it`
1559
+ );
1560
+ break;
1561
+ }
1562
+ }
1563
+ }
1564
+ }
1458
1565
  function framingWarnings(docIn, doc, zoom, overlays, warnings) {
1459
1566
  try {
1460
1567
  const source = isObj(doc.source) ? doc.source : {};
@@ -1496,24 +1603,54 @@ function framingWarnings(docIn, doc, zoom, overlays, warnings) {
1496
1603
  }
1497
1604
  }
1498
1605
  const rated = ratedSegments(docIn);
1499
- const toFrame = (nx, ny) => ({
1500
- x: (layout.dx + nx * layout.dw) / layout.W,
1501
- y: (layout.dy + ny * layout.dh) / layout.H
1502
- });
1606
+ const camera = cameraModel(docIn.frame);
1607
+ const zoomTrack = docZoomTrack(docIn);
1608
+ const pins = resolvePins(docIn, layout, camera, zoomTrack);
1503
1609
  overlays.forEach((o, i) => {
1504
- if (!isObj(o) || o.kind !== void 0 && o.kind !== "text") return;
1610
+ if (!isObj(o) || !isNum(o.start) || !isNum(o.duration)) return;
1505
1611
  const tf = isObj(o.transform) ? o.transform : null;
1506
- if (!tf || !isNum(tf.x) || !isNum(tf.y) || !isNum(o.start) || !isNum(o.duration))
1612
+ if (!tf || !isNum(tf.x) || !isNum(tf.y)) return;
1613
+ const pinned = pins.get(String(o.id));
1614
+ if (pinned) {
1615
+ if (pinned.clamped > 4) {
1616
+ warnings.push(
1617
+ `overlays[${i}] is pinned ${pinned.side} of its referent but the frame has no room there \u2014 it sits ${Math.round(pinned.clamped)} design px off; a smaller box, another side, or a lower zoom level keeps it beside what it names`
1618
+ );
1619
+ }
1620
+ return;
1621
+ }
1622
+ let box;
1623
+ try {
1624
+ box = pinBox(o, layout);
1625
+ } catch {
1507
1626
  return;
1627
+ }
1508
1628
  for (const c of clicks) {
1509
1629
  const out = spanOutputExtent(rated, c.t, c.t + 1e-3);
1510
1630
  if (!out || out.start < o.start || out.start > o.start + o.duration)
1511
1631
  continue;
1512
- const a = toFrame(c.rect.x / w, c.rect.y / h);
1513
- const b = toFrame((c.rect.x + c.rect.w) / w, (c.rect.y + c.rect.h) / h);
1514
- if (tf.x >= a.x && tf.x <= b.x && tf.y >= a.y && tf.y <= b.y) {
1632
+ const target = pinRectOnScreen(
1633
+ {
1634
+ x: c.rect.x / w,
1635
+ y: c.rect.y / h,
1636
+ w: c.rect.w / w,
1637
+ h: c.rect.h / h
1638
+ },
1639
+ out.start,
1640
+ layout,
1641
+ camera,
1642
+ zoomTrack
1643
+ );
1644
+ const L = {
1645
+ x: tf.x * layout.W - box.w / 2,
1646
+ y: tf.y * layout.H - box.h / 2,
1647
+ w: box.w,
1648
+ h: box.h
1649
+ };
1650
+ const overlaps2 = L.x < target.x + target.w && L.x + L.w > target.x && L.y < target.y + target.h && L.y + L.h > target.y;
1651
+ if (overlaps2) {
1515
1652
  warnings.push(
1516
- `overlays[${i}] sits over the thing being clicked at ${c.t.toFixed(1)}s (output ${out.start.toFixed(1)}s) \u2014 move it off the target`
1653
+ `overlays[${i}] sits over the thing being clicked at ${c.t.toFixed(1)}s (output ${out.start.toFixed(1)}s) \u2014 pin it to that step (pin: { step }) so it sits beside the target and follows it, or move it off`
1517
1654
  );
1518
1655
  break;
1519
1656
  }
@@ -5894,6 +6031,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
5894
6031
  const stepStart = now();
5895
6032
  const skippedBefore = skipped.length;
5896
6033
  const urlBefore = page.url();
6034
+ let stepRect = null;
5897
6035
  switch (step.do) {
5898
6036
  case "wait":
5899
6037
  await sleep(clampWait(step.ms, now(), maxSeconds));
@@ -5903,6 +6041,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
5903
6041
  break;
5904
6042
  case "hover": {
5905
6043
  const rect = await boxOf(step.selector);
6044
+ stepRect = rect;
5906
6045
  if (rect) {
5907
6046
  await moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2);
5908
6047
  log(`hover ${step.selector}`);
@@ -5913,6 +6052,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
5913
6052
  }
5914
6053
  case "click": {
5915
6054
  const rect = await boxOf(step.selector);
6055
+ stepRect = rect;
5916
6056
  if (rect) {
5917
6057
  await clickAt(rect);
5918
6058
  log(`click ${step.selector}`);
@@ -5923,6 +6063,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
5923
6063
  }
5924
6064
  case "type": {
5925
6065
  const rect = await boxOf(step.selector);
6066
+ stepRect = rect;
5926
6067
  if (rect) {
5927
6068
  if (step.focus !== false) await clickAt(rect);
5928
6069
  const delay = step.delayMs ?? 40;
@@ -5964,6 +6105,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
5964
6105
  let rect;
5965
6106
  if (step.selector) {
5966
6107
  const box = await boxOf(step.selector);
6108
+ stepRect = box;
5967
6109
  if (box) {
5968
6110
  rect = box;
5969
6111
  start = { x: box.x + box.w / 2, y: box.y + box.h / 2 };
@@ -6026,7 +6168,15 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6026
6168
  tStart: +(stepStart / 1e3).toFixed(3),
6027
6169
  tEnd: +(now() / 1e3).toFixed(3),
6028
6170
  ...skipped.length > skippedBefore ? { skipped: true } : {},
6029
- ...page.url() !== urlBefore ? { navigated: true } : {}
6171
+ ...page.url() !== urlBefore ? { navigated: true } : {},
6172
+ ...stepRect ? {
6173
+ rect: {
6174
+ x: +stepRect.x.toFixed(1),
6175
+ y: +stepRect.y.toFixed(1),
6176
+ w: +stepRect.w.toFixed(1),
6177
+ h: +stepRect.h.toFixed(1)
6178
+ }
6179
+ } : {}
6030
6180
  });
6031
6181
  }
6032
6182
  if (!capped) await sleep(600);
@@ -10628,4 +10778,4 @@ export {
10628
10778
  convertAgentBrowser,
10629
10779
  run
10630
10780
  };
10631
- //# sourceMappingURL=chunk-APVJ4MDJ.js.map
10781
+ //# sourceMappingURL=chunk-QJORXTJM.js.map