@vosjs/cli 0.20.0 → 0.22.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
@@ -89,9 +89,9 @@ vos plan take --reuse # re-time that cut onto
89
89
 
90
90
  **Digest first.** `vos digest <take>` is how an agent sees a recording without reading the video. It writes `digest/digest.json`: one moment per thing the cursor track says mattered (click clusters, typing sessions, scroll runs, dwells, idle gaps, head, tail, and frame-diff scene changes), each with source and output extents, a normalized `focus` and `rect` you can copy into a zoom span, per-second `activity`, and the planners' `proposed` span ids; plus one footage frame and a crop around the target per moment, and `sheet.png`, the contact sheet. Read the JSON, then the sheet, then a crop only where you must decide. `vos validate` then warns when a zoom does not contain what was clicked under it, and `vos frames --at-moments` renders the composed output at every moment so a still and its footage crop share an id.
91
91
 
92
- **A series shares its look by data.** `vos plan <take> --style <seed doc.json | vosId>` copies the seed's `zoomStyle`, `zoomParams`, `speedParams`, `tiltStyle`, `frame`, `cursor`, `cam` and `export` onto a new take (never its spans, overlays or audio) and re-plans the automatic spans under them. A seed that is a POSTER carries its layout too: its card placement, its `stage-*` clips by id (with the release's words from LAUNCH.md's `headline` and `kicker` roles, BRAND.md's `wordmark`, or `--headline` and `--kicker` patched in; the brand's mark from BRAND.md `logoUrl` fetched into `<take>/brand/` for `stage-mark`), its rest lean, its trailing hold. What could not follow is said in words.
92
+ **A series shares its look by data.** `vos plan <take> --style <seed doc.json | vosId>` copies the seed's `zoomStyle`, `zoomParams`, `speedParams`, `tiltStyle`, `frame`, `cursor`, `cam` and `export` onto a new take (never its spans, overlays or audio) and re-plans the automatic spans under them. A seed that is a POSTER carries its layout too: its card placement, its `stage-*` clips by id (with the release's words from LAUNCH.md's `headline` and `kicker` roles, BRAND.md's `wordmark`, or `--headline` and `--kicker` patched in; the brand's mark from BRAND.md `logoUrl` fetched into `<take>/brand/` for `stage-mark`), its rest lean, its trailing freeze. What could not follow is said in words.
93
93
 
94
- **The cut's motion is the document's, in one vocabulary.** Every visual thing carries `anim` (`enter`, `exit`, `idle`; a kind, or a step with its seconds and, for words, `unit`, `direction`, `stagger`): the card (`frame.anim`, entering by `tilt-in`, `pull-out`, `rise` or `fade` and leaving by `recede` or `fade`), a text, image or video clip, a prop. The output lasts until the last clip ends; past its footage the card holds its last frame at the pose its exit settled into, and a segment's `hold` is the freeze primitive. There is no end-card field and no entrance field. A COMPONENT is a TEMPLATE: a plain take on a shelf whose clips carry stable ids, laid onto a take at an anchor and stamped `from` with where its clips came from. A fresh `vos plan` proposes the card's enter, the templates the recipe names (`LAUNCH.md` `with: <ref>[@end|@start|@step:<id>|@<seconds>], …` or `--with`, repeatable; `endCard: <ref>` names one at the end), the house end card when `endCard` is on or absent (the headline, the release line, the wordmark and BRAND.md's mark as clips after the footage over a receding card, `from: endcard`), a caption per `actions.json` step, a music bed and click sounds, from LAUNCH.md's `entrance`, `endCard`, `with`, `captions`, `music` and `clicks` roles (or the flags), as data in doc.json with stable ids (`bed`, `click-<n>`, `caption-<step>`), so the studio shows what the kit will render; a refresh never re-proposes (a deleted clip stays deleted) and `--motion` re-proposes on purpose, replacing only its own work. A document written in the older spellings (`frame.entrance`, `endCard`, a clip's `enter`/`exit`/`fx`, a prop's `animation`) is read into the vocabulary and `vos validate` says so.
94
+ **The cut's motion is the document's, in one vocabulary.** Every visual thing carries `anim` (`enter`, `exit`, `idle`; a kind, or a step with its seconds and, for words, `unit`, `direction`, `stagger`): the card (`frame.anim`, entering by `tilt-in`, `pull-out`, `rise` or `fade` and leaving by `recede` or `fade`), a text, image or video clip, a prop. The output lasts until the last clip ends; the card is on screen exactly while its clip runs (the footage, freezes included) and leaves at its end, so its `exit` plays over the clip's last seconds like every clip's; past the footage the clips play over the ground alone. A FREEZE (`freeze: [{id, at, seconds}]`, a source moment held for output seconds, beside the speed spans on the retime lane) freezes the footage anywhere, the middle of the take included; a segment's older `hold` is read as a freeze at its end. There is no end-card field and no entrance field. A COMPONENT is a TEMPLATE: a plain take on a shelf whose clips carry stable ids, laid onto a take at an anchor and stamped `from` with where its clips came from. A fresh `vos plan` proposes the card's enter, the templates the recipe names (`LAUNCH.md` `with: <ref>[@end|@start|@step:<id>|@<seconds>], …` or `--with`, repeatable; `endCard: <ref>` names one at the end), the house end card when `endCard` is on or absent (a freeze of the last frame for the card's seconds, the card receding over it, and the headline, the release line, the wordmark and BRAND.md's mark as clips over the freeze, every one `from: endcard`), a caption per `actions.json` step, a music bed and click sounds, from LAUNCH.md's `entrance`, `endCard`, `with`, `captions`, `music` and `clicks` roles (or the flags), as data in doc.json with stable ids (`bed`, `click-<n>`, `caption-<step>`), so the studio shows what the kit will render; a refresh never re-proposes (a deleted clip stays deleted) and `--motion` re-proposes on purpose, replacing only its own work. A document written in the older spellings (`frame.entrance`, `endCard`, a clip's `enter`/`exit`/`fx`, a prop's `animation`) is read into the vocabulary and `vos validate` says so.
95
95
 
96
96
  **A fresh take opens on a backdrop.** `create`, `record` and `plan` put the first ready loop from `GET /api/backdrops` behind the card (its ground colour as `frame.background`); `--background <slug|url|none>` overrides it, and offline the frame stays bare with a note.
97
97
 
@@ -186,7 +186,7 @@ vos brand https://your.app --out BRAND.md # the brand kit,
186
186
 
187
187
  [`schema/channel-specs.json`](./schema/channel-specs.json) holds per-channel launch-asset specs: dimensions, byte and duration ceilings, and a genre per image destination (`screenshot` is the real page from the take, full bleed; `card` is a composed cover). Channels: `cws`, `producthunt` (`ph`), `x`, `linkedin` (`li`), `og`, `github` (`gh`), `youtube` (`yt`), `shorts-linkedin` (`shorts`), or `all`. `vos deliver <take> --to <channels>` loops them in one pass and writes `kit.json`, the manifest the `launch-kit` skill builds the rest of the release around; an asset that misses its spec lands in `skipped[]` with the reason, and the verb exits 1 when nothing was produced. Flags: `--release <tag>`, `--out <take>/kit`, `--times`, `--range`, `--parallel`, `--launch LAUNCH.md`, `--look`, `--brand`, `--composed` (keep the cut's camera and chrome on screenshot stills instead of the full-bleed page), `--set`, `--background`. `vos validate` reads a kit back from its bytes (a `.png` that is WebP, a lying size or duration, a set under its count, a byte ceiling).
188
188
 
189
- **A poster is a document, and deliver renders it.** A card-genre destination (OG, LinkedIn, X, the YouTube thumbnail, the CWS tile and marquee, the GitHub social preview) renders from the poster document of its aspect class (`landscape`, `square`, `portrait`, `tile`), found beside the take as `poster/<class>/doc.json` (`poster/doc.json` serves every class) or named in LAUNCH.md (`poster: <path>`, `poster-<class>: <path>`, a path to a doc.json or to a pulled poster take), at the document's REST (the trailing hold's start, the composed frame before nothing moves). The poster is a plain take document: the card placed by `frame.inset`, leaned by the tilt track, the words and the mark as `stage-*` clips, a trailing `hold`; an agent writes it with `vos plan --style <poster>` from a poster on a shelf, or by hand, and pushes it like any take. `kit.json` records `source: "poster"`, the class, the file, the vos it tracks, the shot rect and the text boxes read from the document. A class with no document is the take's own frame, said once. Deliver composes nothing; it applies each video destination's mechanics (the README loop drops the card's motion, every clip a template placed, the captions and the sound; a channel that autoplays muted drops the bed; the 9:16 cut reframes and follows the camera) and verifies.
189
+ **A poster is a document, and deliver renders it.** A card-genre destination (OG, LinkedIn, X, the YouTube thumbnail, the CWS tile and marquee, the GitHub social preview) renders from the poster document of its aspect class (`landscape`, `square`, `portrait`, `tile`), found beside the take as `poster/<class>/doc.json` (`poster/doc.json` serves every class) or named in LAUNCH.md (`poster: <path>`, `poster-<class>: <path>`, a path to a doc.json or to a pulled poster take), at the document's REST (where its last freeze begins, the composed frame before nothing moves). The poster is a plain take document: the card placed by `frame.inset`, leaned by the tilt track, the words and the mark as `stage-*` clips, a trailing freeze; an agent writes it with `vos plan --style <poster>` from a poster on a shelf, or by hand, and pushes it like any take. `kit.json` records `source: "poster"`, the class, the file, the vos it tracks, the shot rect and the text boxes read from the document. A class with no document is the take's own frame, said once. Deliver composes nothing; it applies each video destination's mechanics (the README loop drops the card's motion, every clip a template placed, the captions and the sound; a channel that autoplays muted drops the bed; the 9:16 cut reframes and follows the camera) and verifies.
190
190
 
191
191
  `vos brand <url>` reads the site's `/design.md` first (the convention beside `/llms.txt`), then `/llms.txt`, then witnesses one page, and writes `BRAND.md`: the palette, faces, marks and the avoid list, with the provenance of every value, so a brand is resolved before any asset is authored.
192
192
 
@@ -188,6 +188,8 @@ import {
188
188
  TEXT_ENTER_KINDS,
189
189
  TEXT_EXIT_KINDS,
190
190
  EXPORT_RESOLUTION_OPTIONS,
191
+ FREEZE_SECONDS_MAX,
192
+ FREEZE_SECONDS_MIN,
191
193
  SPEED_RATE_MAX,
192
194
  SPEED_RATE_MIN,
193
195
  TILT_DEG_MAX,
@@ -398,9 +400,37 @@ function lintDoc(docIn) {
398
400
  }
399
401
  for (const [i, seg] of (Array.isArray(doc.segments) ? doc.segments : []).entries()) {
400
402
  const hold = seg.hold;
401
- if (hold !== void 0 && (!isNum(hold) || hold < 0 || hold > 10)) {
403
+ if (hold === void 0) continue;
404
+ if (!isNum(hold) || hold < 0 || hold > FREEZE_SECONDS_MAX) {
402
405
  problems.push(
403
- `segments[${i}].hold must be 0..10 output seconds (got ${String(hold)})`
406
+ `segments[${i}].hold must be 0..${FREEZE_SECONDS_MAX} output seconds (got ${String(hold)})`
407
+ );
408
+ } else if (hold > 0) {
409
+ warnings.push(
410
+ `segments[${i}].hold is a legacy spelling, read as a freeze at ${String(seg.out)}s (doc.freeze); vos plan writes that shape`
411
+ );
412
+ }
413
+ }
414
+ if (doc.freeze !== void 0 && !Array.isArray(doc.freeze)) {
415
+ problems.push("freeze must be an array of {id, at, seconds}");
416
+ }
417
+ const freezes = Array.isArray(doc.freeze) ? doc.freeze : [];
418
+ const freezeIds = /* @__PURE__ */ new Set();
419
+ for (const [i, raw] of freezes.entries()) {
420
+ const f = raw;
421
+ const name = `freeze[${i}]${typeof f.id === "string" ? ` (${f.id})` : ""}`;
422
+ if (typeof f.id !== "string" || !f.id)
423
+ problems.push(`${name}: id must be a string`);
424
+ else if (freezeIds.has(f.id)) problems.push(`${name}: duplicate id`);
425
+ else freezeIds.add(f.id);
426
+ if (!isNum(f.at) || f.at < 0 || f.at > duration + EPS) {
427
+ problems.push(
428
+ `${name}: at must be a SOURCE moment inside the recording (0..${duration.toFixed(2)}s, got ${String(f.at)})`
429
+ );
430
+ }
431
+ if (!isNum(f.seconds) || f.seconds < FREEZE_SECONDS_MIN - EPS || f.seconds > FREEZE_SECONDS_MAX + EPS) {
432
+ problems.push(
433
+ `${name}: seconds must be ${FREEZE_SECONDS_MIN}..${FREEZE_SECONDS_MAX} output seconds (got ${String(f.seconds)})`
404
434
  );
405
435
  }
406
436
  }
@@ -3399,7 +3429,6 @@ function pickMoments(measured, opts = {}) {
3399
3429
  // src/plugin/motionPlan.ts
3400
3430
  import {
3401
3431
  END_CARD_FROM,
3402
- END_CARD_RECEDE,
3403
3432
  END_CARD_SECONDS,
3404
3433
  applyTemplate,
3405
3434
  docOutputDuration,
@@ -3526,13 +3555,32 @@ function proposeMotion(input, opts) {
3526
3555
  if (sub && sub !== headline) card.sub = sub;
3527
3556
  if (brand) card.wordmark = brand;
3528
3557
  if (opts.mark) card.mark = opts.mark;
3558
+ const endStart = outputLength(doc);
3559
+ const lastSeg = doc.segments.at(-1);
3560
+ if (lastSeg) {
3561
+ const own = (doc.freeze ?? []).filter(
3562
+ (f) => Math.abs(f.at - lastSeg.out) > 1e-9
3563
+ );
3564
+ const taken = new Set(own.map((f) => f.id));
3565
+ let n = 0;
3566
+ while (taken.has(`f${n}`)) n++;
3567
+ doc.freeze = [
3568
+ ...own,
3569
+ {
3570
+ id: `f${n}`,
3571
+ at: lastSeg.out,
3572
+ seconds: END_CARD_SECONDS,
3573
+ from: END_CARD_FROM
3574
+ }
3575
+ ].sort((a, b) => a.at - b.at);
3576
+ }
3529
3577
  doc.overlays = [
3530
3578
  ...doc.overlays ?? [],
3531
- ...endCardClips(card, doc, outputLength(doc))
3579
+ ...endCardClips(card, doc, endStart)
3532
3580
  ];
3533
3581
  doc.frame.anim = {
3534
3582
  ...doc.frame.anim ?? {},
3535
- exit: { kind: "recede", seconds: END_CARD_RECEDE }
3583
+ exit: { kind: "recede", seconds: END_CARD_SECONDS }
3536
3584
  };
3537
3585
  notes.push("end card");
3538
3586
  } else {
@@ -3593,7 +3641,7 @@ function proposeMotion(input, opts) {
3593
3641
  fadeIn: 0.6,
3594
3642
  fadeOut,
3595
3643
  loop: track.duration < length,
3596
- loopLen: track.duration < length ? length : void 0,
3644
+ loopLen: track.duration < length ? Math.round(length * 1e3) / 1e3 : void 0,
3597
3645
  duck: hasMic
3598
3646
  });
3599
3647
  notes.push(`bed ${track.slug}`);
@@ -3649,6 +3697,11 @@ function destinationMechanics(d, doc) {
3649
3697
  notes.push(loop ? "no template clips, no captions" : "no captions");
3650
3698
  }
3651
3699
  }
3700
+ if (loop) {
3701
+ const keptFreezes = (doc.freeze ?? []).filter((f) => !f.from);
3702
+ if (keptFreezes.length !== (doc.freeze ?? []).length)
3703
+ set.push(`freeze=${JSON.stringify(keptFreezes)}`);
3704
+ }
3652
3705
  if (portrait) {
3653
3706
  set.push("frame.fit=cover");
3654
3707
  set.push('frame.inset={"left":0.06,"right":0.06,"top":0.17,"bottom":0.17}');
@@ -3679,11 +3732,13 @@ function isLightHexGround(hex2) {
3679
3732
  import { existsSync as existsSync5 } from "fs";
3680
3733
  import { readFile as readFile5 } from "fs/promises";
3681
3734
  import { isAbsolute, join as join6, resolve as resolve3 } from "path";
3735
+ import { totalDuration } from "@vosjs/timeline";
3682
3736
  import {
3683
3737
  computeCardLayout,
3684
3738
  docRestTime,
3685
3739
  migrateHostedDoc,
3686
3740
  overlayRect,
3741
+ ratedSegments as ratedSegments5,
3687
3742
  resolveOverlayStyle
3688
3743
  } from "@vosjs/studio-core";
3689
3744
 
@@ -3925,7 +3980,9 @@ async function findPosterDocs(takeDir, launchRoles) {
3925
3980
  function posterStillTime(doc, duration) {
3926
3981
  const rest = docRestTime(doc);
3927
3982
  if (rest != null) return rest;
3928
- return Math.max(0, duration - 1 / 30);
3983
+ const footage = totalDuration(ratedSegments5(doc));
3984
+ const end = footage > 0 ? Math.min(duration, footage) : duration;
3985
+ return Math.max(0, end - 1 / 30);
3929
3986
  }
3930
3987
  var DESIGN_H = 1080;
3931
3988
  function designFrame(px) {
@@ -5789,6 +5846,7 @@ import {
5789
5846
  } from "@vosjs/studio-core";
5790
5847
 
5791
5848
  // src/plugin/reuse.ts
5849
+ import { docFreezes } from "@vosjs/studio-core";
5792
5850
  var MIN_SPAN = 0.15;
5793
5851
  function matchStep(old, newSteps) {
5794
5852
  const usable = (s) => !!s && s.skipped !== true;
@@ -5950,6 +6008,30 @@ function retimeCut(prev, newSteps, newDurationMs) {
5950
6008
  newDuration,
5951
6009
  report
5952
6010
  );
6011
+ const freeze = [];
6012
+ for (const f of docFreezes(prev)) {
6013
+ let at2 = null;
6014
+ if (f.anchor) {
6015
+ at2 = resolveAnchor2(f.anchor, newSteps);
6016
+ if (at2 === null) {
6017
+ report.flagged.push(
6018
+ `freeze ${f.id}: its anchored step (${String(f.anchor.step)}) is missing or skipped in the new recording \u2014 fell back to the step map`
6019
+ );
6020
+ } else report.anchored++;
6021
+ }
6022
+ if (at2 === null) {
6023
+ at2 = stepMap.map(f.at);
6024
+ report.mapped++;
6025
+ }
6026
+ if (at2 < 0 || at2 > newDuration) {
6027
+ report.flagged.push(
6028
+ `freeze ${f.id}: lands outside the new recording (${at2.toFixed(2)}s) \u2014 dropped`
6029
+ );
6030
+ continue;
6031
+ }
6032
+ freeze.push({ ...f, at: +at2.toFixed(3) });
6033
+ }
6034
+ freeze.sort((a, b) => a.at - b.at);
5953
6035
  const rejected = [];
5954
6036
  for (const lane of ["zoom", "tilt", "speed"]) {
5955
6037
  rejected.push(
@@ -5963,7 +6045,7 @@ function retimeCut(prev, newSteps, newDurationMs) {
5963
6045
  )
5964
6046
  );
5965
6047
  }
5966
- return { segments, zoom, speed, tilt, rejected, report };
6048
+ return { segments, zoom, speed, freeze, tilt, rejected, report };
5967
6049
  }
5968
6050
 
5969
6051
  // src/plugin/plan.ts
@@ -5992,7 +6074,7 @@ function patchStageWords(doc, words) {
5992
6074
  }
5993
6075
  function applyStyle(seed, doc, opts) {
5994
6076
  const parts = layoutOf(seed);
5995
- const carries = parts.clips.length > 0 || parts.lean || parts.hold;
6077
+ const carries = parts.clips.length > 0 || parts.lean || parts.freeze;
5996
6078
  if (!carries) return { doc: copyStyle(seed, doc), layout: void 0 };
5997
6079
  const keys = opts.mark ? { "stage-mark": opts.mark.key } : void 0;
5998
6080
  const { doc: next, notes } = copyLayout(seed, copyStyle(seed, doc), { keys });
@@ -6018,10 +6100,7 @@ async function planTake(dir, opts = {}) {
6018
6100
  doc = copyStyle(prev, doc);
6019
6101
  const rt = retimeCut(prev, meta.steps ?? [], meta.durationMs);
6020
6102
  doc.segments = rt.segments;
6021
- const prevHold = prev.segments.at(-1)?.hold;
6022
- const last = doc.segments.at(-1);
6023
- if (typeof prevHold === "number" && prevHold > 0 && last)
6024
- last.hold = prevHold;
6103
+ if (rt.freeze.length) doc.freeze = rt.freeze;
6025
6104
  if (rt.rejected.length) doc.rejected = rt.rejected;
6026
6105
  const manualZoom = rt.zoom;
6027
6106
  const autoZoom = planAutoZoom(doc.source.cursor, {
@@ -8855,7 +8934,7 @@ Screenshot-genre stills never take a look.
8855
8934
  A POSTER is a document, never a template: a plain take whose card sits
8856
8935
  where the poster wants it (frame.inset), leans (the tilt track), carries
8857
8936
  the words and the mark as clips (ids stage-title, stage-kicker,
8858
- stage-brand, stage-mark) and ends on a trailing hold whose start is the
8937
+ stage-brand, stage-mark) and ends on a trailing freeze whose start is the
8859
8938
  still. Card-genre destinations (OG, LinkedIn, X, YouTube thumbnail, the
8860
8939
  CWS tile + marquee, GitHub social preview) render from the poster
8861
8940
  document of their aspect CLASS (landscape, square, portrait, tile), found
@@ -8868,7 +8947,7 @@ deliver renders and verifies; it composes nothing. The composition is
8868
8947
  made with plan --style <poster>, which copies a poster's layout onto a
8869
8948
  take (its card placement, its stage clips with the release's words
8870
8949
  patched in from LAUNCH.md's headline and kicker roles or --headline and
8871
- --kicker, its rest lean, its hold), or by hand in doc.json.
8950
+ --kicker, its rest lean, its freeze), or by hand in doc.json.
8872
8951
  The cut's MOTION is the document's too, in ONE vocabulary: every visual
8873
8952
  thing (the card, a text, image or video clip, a prop) carries anim.enter,
8874
8953
  anim.exit and anim.idle; the output lasts until the last clip ends, and
@@ -9256,7 +9335,7 @@ async function cmdPlan(argv) {
9256
9335
  layout from ${s.styleFrom}: ${[
9257
9336
  s.layout.clips.length ? `${s.layout.clips.length} stage clip(s)` : "",
9258
9337
  s.layout.lean ? "the rest lean" : "",
9259
- s.layout.hold ? "the hold" : ""
9338
+ s.layout.freeze ? "the freeze" : ""
9260
9339
  ].filter(Boolean).join(", ")}` + (s.layout.notes.length ? `
9261
9340
  ${s.layout.notes.join("\n ")}` : "") : "";
9262
9341
  const motionLines = s.motion ? (s.motion.notes.length ? `
@@ -10085,4 +10164,4 @@ export {
10085
10164
  convertAgentBrowser,
10086
10165
  run
10087
10166
  };
10088
- //# sourceMappingURL=chunk-ZONOZZZY.js.map
10167
+ //# sourceMappingURL=chunk-CRZTGWF3.js.map