@vosjs/cli 0.19.0 → 0.20.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
@@ -77,21 +77,21 @@ vos record --actions actions.json --out take --strict # footage replaced; the
77
77
  vos plan take --reuse # re-time that cut onto the new recording; what could not follow is named
78
78
  ```
79
79
 
80
- | Verb | Flags |
81
- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
- | `record` | `--actions <file>` (or positional) `--url` `--out take` `--strict` `--max-duration <s>` `--background <slug\|url\|none>` |
83
- | `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 |
84
- | `plan` | `--fresh` (discard the current plan) `--reuse` `--from <doc.json>` (defaults to `<take>/doc.prev.json`) `--style <doc.json\|take\|vosId>` `--background` `--motion` (re-propose the motion) `--headline` `--kicker` `--launch` `--brand` `--music` `--entrance` `--end-card` `--captions` `--clicks` `--release` |
85
- | `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) |
86
- | `frames` | `--times 0,25%,50%,75%,100%` (the default selector, output seconds or percent) `--frame <t>` `--at-zooms` `--at-moments` `--size WxH` `--out <take>/stills` `--background` `--set …`; writes `stills.json` |
87
- | `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>` |
88
- | `open` | `--studio http://localhost:6060` `--print` (print the URL, do not launch a browser) |
80
+ | Verb | Flags |
81
+ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `record` | `--actions <file>` (or positional) `--url` `--out take` `--strict` `--max-duration <s>` `--background <slug\|url\|none>` |
83
+ | `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 |
84
+ | `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` `--end-card on\|none\|<ref>` `--captions` `--clicks` `--release` |
85
+ | `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) |
86
+ | `frames` | `--times 0,25%,50%,75%,100%` (the default selector, output seconds or percent) `--frame <t>` `--at-zooms` `--at-moments` `--size WxH` `--out <take>/stills` `--background` `--set …`; writes `stills.json` |
87
+ | `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>` |
88
+ | `open` | `--studio http://localhost:6060` `--print` (print the URL, do not launch a browser) |
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
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.
93
93
 
94
- **The cut's motion is the document's.** A fresh `vos plan` proposes the card's entrance, the end card, a caption per `actions.json` step, a music bed and click sounds from LAUNCH.md's `entrance`, `endCard`, `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 end card stays deleted) and `--motion` re-proposes on purpose, replacing only its own proposals.
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.
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 plays no entrance, end card, captions or 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 (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.
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
 
@@ -9,8 +9,7 @@ import {
9
9
  import { mkdir as mkdir9, readFile as readFile14, rm as rm4 } from "fs/promises";
10
10
  import { existsSync as existsSync14 } from "fs";
11
11
  import { join as join17, resolve as resolve9 } from "path";
12
- import { totalDuration as totalDuration2 } from "@vosjs/timeline";
13
- import { migrateHostedDoc as migrateHostedDoc5, ratedSegments as ratedSegments6 } from "@vosjs/studio-core";
12
+ import { docOutputDuration as docOutputDuration3, migrateHostedDoc as migrateHostedDoc5 } from "@vosjs/studio-core";
14
13
 
15
14
  // src/plugin/args.ts
16
15
  var UsageError = class extends Error {
@@ -407,6 +406,9 @@ function lintDoc(docIn) {
407
406
  }
408
407
  const endCard = doc.endCard;
409
408
  if (endCard !== void 0) {
409
+ warnings.push(
410
+ "endCard is a legacy spelling, read as clips after the footage plus a card exit (frame.anim.exit); vos plan writes that shape"
411
+ );
410
412
  if (typeof endCard !== "object" || endCard === null) {
411
413
  problems.push(
412
414
  "endCard must be an object: {seconds?, headline?, sub?, wordmark?}"
@@ -653,6 +655,9 @@ function lintDoc(docIn) {
653
655
  }
654
656
  const ent = frame.entrance;
655
657
  if (ent !== void 0) {
658
+ warnings.push(
659
+ "frame.entrance is a legacy spelling; write frame.anim.enter"
660
+ );
656
661
  const kinds = ["tilt-in", "pull-out", "rise", "none"];
657
662
  if (typeof ent !== "object" || ent === null || !kinds.includes(String(ent.kind))) {
658
663
  problems.push(
@@ -925,6 +930,10 @@ function lintDoc(docIn) {
925
930
  problems.push(`${name}.${key} must be one of ${TRANSITIONS.join("|")}`);
926
931
  }
927
932
  }
933
+ if (o.enter !== void 0 || o.exit !== void 0 || o.fx !== void 0)
934
+ warnings.push(
935
+ `${name}: enter, exit and fx are legacy spellings; write anim.enter / anim.exit`
936
+ );
928
937
  lintAnim(
929
938
  name,
930
939
  o.anim,
@@ -1072,6 +1081,8 @@ function lintDoc(docIn) {
1072
1081
  if (o.animation !== void 0 && o.animation !== null && o.animation !== "spin" && o.animation !== "float") {
1073
1082
  problems.push(`${name}.animation must be "spin" | "float" | null`);
1074
1083
  }
1084
+ if (o.animation !== void 0)
1085
+ warnings.push(`${name}.animation is a legacy spelling; write anim.idle`);
1075
1086
  lintAnim(
1076
1087
  name,
1077
1088
  o.anim,
@@ -2759,7 +2770,6 @@ async function writeIndexJson(result) {
2759
2770
  import { mkdir as mkdir4, mkdtemp, rename as rename4, rm as rm3, stat, writeFile as writeFile6 } from "fs/promises";
2760
2771
  import { tmpdir } from "os";
2761
2772
  import { join as join7, relative, resolve as resolve4 } from "path";
2762
- import { totalDuration } from "@vosjs/timeline";
2763
2773
  import { existsSync as existsSync6 } from "fs";
2764
2774
  import { readFile as readFile6 } from "fs/promises";
2765
2775
  import { parseFrontmatter } from "@vosjs/shared/frontmatter";
@@ -2771,7 +2781,7 @@ import {
2771
2781
  houseLook,
2772
2782
  isLookKind,
2773
2783
  lookFromBrand,
2774
- ratedSegments as ratedSegments5
2784
+ docOutputDuration as docOutputDuration2
2775
2785
  } from "@vosjs/studio-core";
2776
2786
 
2777
2787
  // src/plugin/renderTake.ts
@@ -3387,7 +3397,18 @@ function pickMoments(measured, opts = {}) {
3387
3397
  }
3388
3398
 
3389
3399
  // src/plugin/motionPlan.ts
3390
- import { ratedSegments as ratedSegments4, spanOutputExtent as spanOutputExtent4 } from "@vosjs/studio-core";
3400
+ import {
3401
+ END_CARD_FROM,
3402
+ END_CARD_RECEDE,
3403
+ END_CARD_SECONDS,
3404
+ applyTemplate,
3405
+ docOutputDuration,
3406
+ dropTemplate,
3407
+ endCardClips,
3408
+ migrateMotion,
3409
+ ratedSegments as ratedSegments4,
3410
+ spanOutputExtent as spanOutputExtent4
3411
+ } from "@vosjs/studio-core";
3391
3412
  var SOUND_DESTINATIONS = /* @__PURE__ */ new Set([
3392
3413
  "x-feed-cut",
3393
3414
  "youtube-main-demo",
@@ -3422,41 +3443,103 @@ var outputLength = (doc) => ratedSegments4(doc).reduce((acc, s) => {
3422
3443
  const rate = s.rate && s.rate > 0 ? s.rate : 1;
3423
3444
  return acc + (s.out - s.in) / rate;
3424
3445
  }, 0);
3446
+ function templateWords(words) {
3447
+ const headline = (words.headline ?? "").trim();
3448
+ const kicker = (words.kicker ?? "").trim();
3449
+ const brand = (words.brand ?? "").trim();
3450
+ const sub = [brand, (words.release ?? "").trim()].filter(Boolean).join(" ");
3451
+ const out = {};
3452
+ if (headline) {
3453
+ out["stage-title"] = headline;
3454
+ out["endcard-title"] = headline;
3455
+ }
3456
+ if (kicker) out["stage-kicker"] = kicker;
3457
+ if (brand) {
3458
+ out["stage-brand"] = brand;
3459
+ out["endcard-mark"] = brand;
3460
+ }
3461
+ if (sub && sub !== headline) out["endcard-sub"] = sub;
3462
+ return out;
3463
+ }
3464
+ function resolveAnchor(doc, at2) {
3465
+ if (typeof at2 !== "object") return at2;
3466
+ const steps = doc.source.meta.steps ?? [];
3467
+ const step = steps.find((s) => s.id === at2.step || String(s.step) === at2.step);
3468
+ if (!step || step.skipped) return null;
3469
+ const t = stepOutputTime(ratedSegments4(doc), step, 0.2);
3470
+ return t === null ? null : +t.toFixed(3);
3471
+ }
3472
+ var anchorWord = (at2) => typeof at2 === "object" ? `step ${at2.step}` : typeof at2 === "number" ? `${at2}s` : `the ${at2}`;
3473
+ var onWord = (v) => v === void 0 || /^(on|yes|true)$/i.test(v.trim());
3425
3474
  function proposeMotion(input, opts) {
3426
- const doc = structuredClone(input);
3475
+ let doc = migrateMotion(structuredClone(input));
3427
3476
  const { words, launch, catalog } = opts;
3428
3477
  const notes = [];
3429
3478
  const skipped = [];
3430
- const length = outputLength(doc);
3431
- const range = [0, length];
3479
+ const footage = outputLength(doc);
3480
+ const range = [0, footage];
3432
3481
  const entrance = launch.entrance;
3482
+ delete doc.frame.entrance;
3433
3483
  if (!off(entrance)) {
3434
- const kind = entrance && /^(tilt-in|pull-out|rise)$/.test(entrance.trim()) ? entrance.trim() : "tilt-in";
3435
- doc.frame.entrance = { kind };
3436
- notes.push(`entrance ${kind}`);
3437
- } else {
3438
- delete doc.frame.entrance;
3439
- }
3440
- if (!off(launch.endCard)) {
3484
+ const kind = entrance && /^(tilt-in|pull-out|rise|fade)$/.test(entrance.trim()) ? entrance.trim() : "tilt-in";
3485
+ doc.frame.anim = { ...doc.frame.anim ?? {}, enter: kind };
3486
+ notes.push(`enter ${kind}`);
3487
+ } else if (doc.frame.anim?.enter !== void 0) {
3488
+ const { enter: _enter, ...rest } = doc.frame.anim;
3489
+ if (Object.keys(rest).length) doc.frame.anim = rest;
3490
+ else delete doc.frame.anim;
3491
+ }
3492
+ const tWords = templateWords(words);
3493
+ const keys = opts.mark ? { "stage-mark": opts.mark.key, "endcard-markimg": opts.mark.key } : void 0;
3494
+ for (const t of opts.templates ?? []) {
3495
+ const at2 = resolveAnchor(doc, t.at);
3496
+ if (at2 === null) {
3497
+ skipped.push(`${t.from}: ${anchorWord(t.at)} was not recorded`);
3498
+ continue;
3499
+ }
3500
+ const applied = applyTemplate(t.doc, doc, {
3501
+ at: at2,
3502
+ from: t.from,
3503
+ words: tWords,
3504
+ keys
3505
+ });
3506
+ doc = applied.doc;
3507
+ notes.push(`${t.from} at ${anchorWord(t.at)}`);
3508
+ for (const n of applied.notes) skipped.push(`${t.from}: ${n}`);
3509
+ }
3510
+ const endCardRole = launch.endCard;
3511
+ doc = dropTemplate(doc, END_CARD_FROM);
3512
+ if (off(endCardRole)) {
3513
+ if (doc.frame.anim?.exit !== void 0) {
3514
+ const { exit: _exit, ...rest } = doc.frame.anim;
3515
+ if (Object.keys(rest).length) doc.frame.anim = rest;
3516
+ else delete doc.frame.anim;
3517
+ }
3518
+ } else if (onWord(endCardRole)) {
3441
3519
  const headline = (words.headline ?? "").trim();
3442
3520
  const brand = (words.brand ?? "").trim();
3443
3521
  const sub = [brand, (words.release ?? "").trim()].filter(Boolean).join(" ");
3444
3522
  if (headline || brand) {
3445
- const card = { seconds: 2.5 };
3523
+ const card = { seconds: END_CARD_SECONDS };
3446
3524
  if (opts.ink) card.ink = opts.ink;
3447
3525
  if (headline) card.headline = headline;
3448
3526
  if (sub && sub !== headline) card.sub = sub;
3449
3527
  if (brand) card.wordmark = brand;
3450
3528
  if (opts.mark) card.mark = opts.mark;
3451
- doc.endCard = card;
3529
+ doc.overlays = [
3530
+ ...doc.overlays ?? [],
3531
+ ...endCardClips(card, doc, outputLength(doc))
3532
+ ];
3533
+ doc.frame.anim = {
3534
+ ...doc.frame.anim ?? {},
3535
+ exit: { kind: "recede", seconds: END_CARD_RECEDE }
3536
+ };
3452
3537
  notes.push("end card");
3453
3538
  } else {
3454
3539
  skipped.push(
3455
3540
  "no end card (no headline or wordmark in LAUNCH.md, BRAND.md or the flags)"
3456
3541
  );
3457
3542
  }
3458
- } else {
3459
- delete doc.endCard;
3460
3543
  }
3461
3544
  const kept = (doc.overlays ?? []).filter(
3462
3545
  (o) => !o.id.startsWith(CAPTION_ID_PREFIX)
@@ -3471,17 +3554,16 @@ function proposeMotion(input, opts) {
3471
3554
  );
3472
3555
  if (!step || step.skipped) continue;
3473
3556
  const t = stepOutputTime(rated, step, 0.2);
3474
- if (t === null || t < 0 || t > length - 1) continue;
3557
+ if (t === null || t < 0 || t > footage - 1) continue;
3475
3558
  captionClips.push({
3476
3559
  id: `${CAPTION_ID_PREFIX}${c.step}`,
3477
3560
  kind: "text",
3478
3561
  text: c.caption,
3479
3562
  preset: "caption",
3480
3563
  start: +t.toFixed(3),
3481
- duration: Math.min(3.5, Math.max(2.5, length - t - 0.2)),
3564
+ duration: Math.min(3.5, Math.max(2.5, footage - t - 0.2)),
3482
3565
  transform: { x: 0.5, y: 0.86, scale: 1, rotation: 0 },
3483
- enter: "rise",
3484
- exit: "fade",
3566
+ anim: { enter: "rise", exit: "fade" },
3485
3567
  align: "center",
3486
3568
  box: { color: "rgba(17,17,17,0.72)" }
3487
3569
  });
@@ -3491,6 +3573,7 @@ function proposeMotion(input, opts) {
3491
3573
  const overlays = [...kept, ...captionClips];
3492
3574
  if (overlays.length) doc.overlays = overlays;
3493
3575
  else delete doc.overlays;
3576
+ const length = docOutputDuration(doc);
3494
3577
  const clips = (doc.audio ?? []).filter(
3495
3578
  (a) => a.id !== BED_ID && !a.id.startsWith(CLICK_ID_PREFIX)
3496
3579
  );
@@ -3550,8 +3633,8 @@ function destinationMechanics(d, doc) {
3550
3633
  const sound = SOUND_DESTINATIONS.has(d.id);
3551
3634
  const portrait = d.px.w / d.px.h < 0.9;
3552
3635
  if (loop) {
3553
- unset.push("frame.entrance", "endCard");
3554
- notes.push("loop: no entrance, no end card");
3636
+ unset.push("frame.anim", "frame.entrance", "endCard");
3637
+ notes.push("loop: no card motion");
3555
3638
  }
3556
3639
  if (loop || !sound) {
3557
3640
  set.push("audio=[]");
@@ -3559,11 +3642,11 @@ function destinationMechanics(d, doc) {
3559
3642
  }
3560
3643
  if (loop || d.text === "none") {
3561
3644
  const kept = (doc.overlays ?? []).filter(
3562
- (o) => !o.id.startsWith(CAPTION_ID_PREFIX)
3645
+ (o) => !o.id.startsWith(CAPTION_ID_PREFIX) && !(loop && o.from)
3563
3646
  );
3564
3647
  if (kept.length !== (doc.overlays ?? []).length) {
3565
3648
  set.push(`overlays=${JSON.stringify(kept)}`);
3566
- notes.push("no captions");
3649
+ notes.push(loop ? "no template clips, no captions" : "no captions");
3567
3650
  }
3568
3651
  }
3569
3652
  if (portrait) {
@@ -4087,7 +4170,7 @@ async function deliverTake(browser, dir, opts) {
4087
4170
  const take = await loadTake(dir);
4088
4171
  if (!take.doc) throw new Error(`${dir} has no doc.json \u2014 run plan first`);
4089
4172
  const doc = take.doc;
4090
- const duration = totalDuration(ratedSegments5(doc));
4173
+ const duration = docOutputDuration2(doc);
4091
4174
  const videoSeconds = opts.range ? Math.min(opts.range[1], duration) - Math.min(opts.range[0], duration) : duration;
4092
4175
  const outDir = resolve4(opts.outDir ?? join7(dir, "kit"));
4093
4176
  await mkdir4(outDir, { recursive: true });
@@ -4145,7 +4228,7 @@ async function deliverTake(browser, dir, opts) {
4145
4228
  }
4146
4229
  posterCardIds.add(d.id);
4147
4230
  const label = `${d.channel} ${d.asset}`;
4148
- const posterDuration = totalDuration(ratedSegments5(ref.doc));
4231
+ const posterDuration = docOutputDuration2(ref.doc);
4149
4232
  const time = posterStillTime(ref.doc, posterDuration);
4150
4233
  opts.onPhase?.(
4151
4234
  `${label} (${specWords(d)}) from ${ref.from}${ref.vosId ? ` ${ref.vosId}` : ""}, the rest at ${time.toFixed(2)}s`
@@ -5701,7 +5784,8 @@ import {
5701
5784
  planAutoSpeed,
5702
5785
  planAutoZoom,
5703
5786
  projectFromArtifact,
5704
- withBackdrop
5787
+ withBackdrop,
5788
+ migrateMotion as migrateMotion2
5705
5789
  } from "@vosjs/studio-core";
5706
5790
 
5707
5791
  // src/plugin/reuse.ts
@@ -5763,7 +5847,7 @@ function buildStepMap(oldSteps, newSteps, oldDuration, newDuration) {
5763
5847
  };
5764
5848
  return { map, unmatched, matched };
5765
5849
  }
5766
- function resolveAnchor(anchor, newSteps) {
5850
+ function resolveAnchor2(anchor, newSteps) {
5767
5851
  const step = typeof anchor.step === "string" ? newSteps.find((s) => s.id === anchor.step) : newSteps.find((s) => s.step === anchor.step);
5768
5852
  if (!step || step.skipped) return null;
5769
5853
  const base = anchor.at === "end" ? step.tEnd : step.tStart;
@@ -5775,7 +5859,7 @@ function retimeSpans(kind, spans, stepMap, newSteps, newDuration, report) {
5775
5859
  const length = span.out - span.in;
5776
5860
  let nextIn = null;
5777
5861
  if (span.anchor) {
5778
- nextIn = resolveAnchor(span.anchor, newSteps);
5862
+ nextIn = resolveAnchor2(span.anchor, newSteps);
5779
5863
  if (nextIn === null) {
5780
5864
  report.flagged.push(
5781
5865
  `${kind} ${span.id}: its anchored step (${String(span.anchor.step)}) is missing or skipped in the new recording \u2014 fell back to the step map`
@@ -5924,7 +6008,7 @@ async function planTake(dir, opts = {}) {
5924
6008
  let fresh;
5925
6009
  let layout;
5926
6010
  if (opts.reuse) {
5927
- const prev = opts.reuse.doc;
6011
+ const prev = migrateMotion2(opts.reuse.doc);
5928
6012
  const artifact = {
5929
6013
  videoKey: RECORDING_NAME,
5930
6014
  cursor,
@@ -5959,7 +6043,6 @@ async function planTake(dir, opts = {}) {
5959
6043
  if (prev.objects?.length) doc.objects = prev.objects;
5960
6044
  if (prev.audio.length) doc.audio = prev.audio;
5961
6045
  if (prev.camMotion?.length) doc.camMotion = prev.camMotion;
5962
- if (prev.endCard) doc.endCard = structuredClone(prev.endCard);
5963
6046
  await writeJson(take.paths.doc, doc, true);
5964
6047
  return {
5965
6048
  doc,
@@ -8657,7 +8740,7 @@ var HELP = `vos \u2014 record a browser flow, plan effects, render a product vid
8657
8740
  Take pipeline
8658
8741
  vos create --actions actions.json [--url <url>] [--out take] [out.webm] [--strict] [--max-duration <s>] [--background <slug|url|none>] [render flags] [--json]
8659
8742
  vos record --actions actions.json [--url <url>] [--out take] [--strict] [--max-duration <s>] [--background <slug|url|none>] [--json]
8660
- vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--background <slug|url|none>] [--motion] [--headline "\u2026"] [--kicker "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md] [--music <slug|mood|none>] [--entrance tilt-in|pull-out|rise|none] [--end-card none] [--captions none] [--clicks none] [--release v2.1] [--json]
8743
+ vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--with <doc.json|vosId>[@end|@start|@step:<id>|@<s>]]... [--background <slug|url|none>] [--motion] [--headline "\u2026"] [--kicker "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md] [--music <slug|mood|none>] [--entrance tilt-in|pull-out|rise|fade|none] [--end-card on|none|<doc.json|vosId>] [--captions none] [--clicks none] [--release v2.1] [--json]
8661
8744
  vos render <take> [out.webm] [--width] [--height] [--fps] [--format webm|mp4] [--parallel N] [--range a..b] [--draft] [--frame <kind>] [--background <url|slug>] [--set <path=value>]... [--json]
8662
8745
  vos frames <take> [--times 0,25%,50%,75%,100%] [--frame <t>] [--at-zooms] [--at-moments] [--size WxH] [--out dir] [--background <url|slug>] [--set <path=value>]... [--json]
8663
8746
  vos deliver <take> --to cws,producthunt,x,linkedin,og,github,youtube (or all) [--launch LAUNCH.md] [--look plate|gradient|dark|none] [--brand BRAND.md] [--composed] [--set path=value] [--release v2.1] [--out dir] [--times a,b] [--range a..b] [--parallel N] [--json]
@@ -8786,19 +8869,27 @@ made with plan --style <poster>, which copies a poster's layout onto a
8786
8869
  take (its card placement, its stage clips with the release's words
8787
8870
  patched in from LAUNCH.md's headline and kicker roles or --headline and
8788
8871
  --kicker, its rest lean, its hold), or by hand in doc.json.
8789
- The cut's MOTION is the document's too. plan proposes it on a fresh plan
8790
- (--motion re-proposes onto an existing doc.json, replacing only its own
8791
- proposals): the card's ENTRANCE (tilt-in by default: the card swings in
8792
- from a perspective pose and settles), the END CARD (the last frame holds
8793
- 2.5 s while the card recedes and the headline, the release line and the
8794
- wordmark rise; the brand's mark from BRAND.md logoUrl above them), a
8795
- CAPTION per actions.json step at the step's moment, a music BED from
8796
- LAUNCH.md's music role (a catalog slug or a mood) and a click sound on
8797
- every press when the take has no mic. LAUNCH.md's entrance, endCard,
8798
- captions, music and clicks roles, or the flags, change or switch each
8799
- off; a deleted proposal stays deleted on a refresh. deliver applies each
8800
- destination's MECHANICS and nothing more: the README loop plays no
8801
- entrance, end card or sound; a channel that autoplays muted drops the
8872
+ The cut's MOTION is the document's too, in ONE vocabulary: every visual
8873
+ thing (the card, a text, image or video clip, a prop) carries anim.enter,
8874
+ anim.exit and anim.idle; the output lasts until the last clip ends, and
8875
+ past its footage the card holds its last frame at the pose its exit
8876
+ settled into. There is no end-card field and no entrance field: a
8877
+ COMPONENT is a TEMPLATE, a plain take on a shelf whose clips carry stable
8878
+ ids, laid onto the take at an anchor (LAUNCH.md with: <ref>[@end|@start|
8879
+ @step:<id>|@<s>], comma list; --with the same, repeatable; endCard: <ref>
8880
+ names one at the end), its clips stamped with where they came from
8881
+ (from). plan proposes on a fresh plan (--motion re-proposes, replacing
8882
+ only its own work): the card's ENTER (tilt-in by default), the templates
8883
+ named, the house END CARD when endCard is on or absent (clips after the
8884
+ footage: the headline, the release line, the wordmark, the mark from
8885
+ BRAND.md logoUrl, over a card that recedes; from: endcard), a CAPTION per
8886
+ actions.json step at the step's moment, a music BED from LAUNCH.md's music
8887
+ role (a catalog slug or a mood) and a click sound on every press when the
8888
+ take has no mic. LAUNCH.md's entrance, endCard, captions, music and clicks
8889
+ roles, or the flags, change or switch each off; a deleted proposal stays
8890
+ deleted on a refresh. deliver applies each destination's MECHANICS and
8891
+ nothing more: the README loop drops the card's motion, every clip a
8892
+ template placed and every sound; a channel that autoplays muted drops the
8802
8893
  bed; the 9:16 cut is a reframe, not a letterbox, and the crop follows
8803
8894
  the camera. Screenshot-genre
8804
8895
  destinations (CWS screenshots, the PH gallery) are the real page at that
@@ -9109,11 +9200,15 @@ async function cmdCreate2(argv) {
9109
9200
  }
9110
9201
  }
9111
9202
  async function cmdPlan(argv) {
9112
- const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
9203
+ const { positionals, flags, multi } = parseArgs(
9204
+ argv,
9205
+ BOOLEAN_FLAGS5,
9206
+ /* @__PURE__ */ new Set(["with"])
9207
+ );
9113
9208
  const dir = positionals[0];
9114
9209
  if (!dir)
9115
9210
  throw new UsageError(
9116
- 'vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--motion] [--headline "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md]'
9211
+ 'vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--with <doc.json|vosId>[@end|@start|@step:<id>|@<seconds>]]... [--motion] [--headline "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md]'
9117
9212
  );
9118
9213
  const r = createReporter(flags.json === true);
9119
9214
  if (flags.fresh === true) {
@@ -9136,7 +9231,7 @@ async function cmdPlan(argv) {
9136
9231
  const style = await resolveStyleRef(flags);
9137
9232
  const hasDoc = existsSync14(join17(dir, "doc.json"));
9138
9233
  const backdrop = hasDoc ? null : await takeBackdrop(flags, r);
9139
- const release = await releaseInputs(dir, flags, r);
9234
+ const release = await releaseInputs(dir, flags, r, multi.with ?? []);
9140
9235
  const motionWanted = !hasDoc || flags.motion === true || flags.fresh === true;
9141
9236
  const s = await planTake(dir, {
9142
9237
  ...style ? { style } : {},
@@ -9152,6 +9247,7 @@ async function cmdPlan(argv) {
9152
9247
  mark: release.mark,
9153
9248
  captions: release.captions,
9154
9249
  catalog: release.catalog,
9250
+ templates: release.templates,
9155
9251
  again: flags.motion === true
9156
9252
  }
9157
9253
  } : {}
@@ -9188,7 +9284,7 @@ async function cmdPlan(argv) {
9188
9284
  );
9189
9285
  return EXIT_OK;
9190
9286
  }
9191
- async function releaseInputs(dir, flags, r) {
9287
+ async function releaseInputs(dir, flags, r, withRefs = []) {
9192
9288
  let lookPick;
9193
9289
  try {
9194
9290
  lookPick = await resolveLook(dir, {
@@ -9243,13 +9339,36 @@ async function releaseInputs(dir, flags, r) {
9243
9339
  const step = st;
9244
9340
  return typeof step.caption === "string" && step.caption.trim() ? [{ step: i, id: step.id, caption: step.caption.trim() }] : [];
9245
9341
  });
9342
+ const refs = [];
9343
+ const parseRef = (raw, fallback) => {
9344
+ const m = /^(.*?)(?:@(end|start|step:[^@\s]+|\d+(?:\.\d+)?))?$/.exec(
9345
+ raw.trim()
9346
+ );
9347
+ const ref = m?.[1]?.trim() ?? raw.trim();
9348
+ const anchor = m?.[2];
9349
+ const at2 = !anchor ? fallback : anchor === "end" || anchor === "start" ? anchor : anchor.startsWith("step:") ? { step: anchor.slice(5) } : Number(anchor);
9350
+ if (ref) refs.push({ ref, at: at2 });
9351
+ };
9352
+ for (const raw of withRefs) parseRef(raw, "end");
9353
+ for (const raw of (launchRoles.with ?? "").split(","))
9354
+ if (raw.trim()) parseRef(raw, "end");
9355
+ const endCardRole = launchRoles.endCard;
9356
+ if (endCardRole && !/^(none|off|no|false|on|yes|true)$/i.test(endCardRole.trim()))
9357
+ parseRef(endCardRole, "end");
9358
+ const templates = [];
9359
+ for (const { ref, at: at2 } of refs) {
9360
+ const found = await resolveDocRef(ref, flags, "--with");
9361
+ templates.push({ from: found.from, doc: found.doc, at: at2 });
9362
+ r.log(`template: ${found.from}`);
9363
+ }
9246
9364
  return {
9247
9365
  words,
9248
9366
  launchRoles,
9249
9367
  ink: endCardInk(lookPick.look, lookPick.roles),
9250
9368
  mark,
9251
9369
  captions,
9252
- catalog
9370
+ catalog,
9371
+ templates
9253
9372
  };
9254
9373
  }
9255
9374
  async function cmdRender(argv) {
@@ -9344,7 +9463,7 @@ async function cmdFrames(argv) {
9344
9463
  if (!m) throw new UsageError("--size expects WxH (e.g. --size 1280x800)");
9345
9464
  size = { width: Number(m[1]), height: Number(m[2]) };
9346
9465
  }
9347
- const duration = totalDuration2(ratedSegments6(take.doc));
9466
+ const duration = docOutputDuration3(take.doc);
9348
9467
  const frameRaw = strFlag(flags, "frame");
9349
9468
  const timesRaw = strFlag(flags, "times");
9350
9469
  const atZooms = flags["at-zooms"] === true;
@@ -9431,7 +9550,7 @@ async function cmdDeliver(argv) {
9431
9550
  }
9432
9551
  const take = await loadTake(dir);
9433
9552
  if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
9434
- const duration = totalDuration2(ratedSegments6(take.doc));
9553
+ const duration = docOutputDuration3(take.doc);
9435
9554
  const timesRaw = strFlag(flags, "times");
9436
9555
  let times;
9437
9556
  if (timesRaw !== void 0) {
@@ -9509,6 +9628,10 @@ Store uploads stay manual: hand the human this directory and the manifest.`
9509
9628
  async function resolveStyleRef(flags) {
9510
9629
  const styleRef = strFlag(flags, "style");
9511
9630
  if (!styleRef) return null;
9631
+ return resolveDocRef(styleRef, flags, "--style");
9632
+ }
9633
+ async function resolveDocRef(ref, flags, what) {
9634
+ const styleRef = ref;
9512
9635
  const file = existsSync14(styleRef) ? resolve9(
9513
9636
  styleRef,
9514
9637
  existsSync14(join17(styleRef, "doc.json")) ? "doc.json" : ""
@@ -9516,7 +9639,9 @@ async function resolveStyleRef(flags) {
9516
9639
  if (file && existsSync14(file)) {
9517
9640
  return {
9518
9641
  from: file,
9519
- doc: JSON.parse(await readFile14(file, "utf8"))
9642
+ doc: migrateHostedDoc5(
9643
+ JSON.parse(await readFile14(file, "utf8"))
9644
+ )
9520
9645
  };
9521
9646
  }
9522
9647
  const origin = platformOrigin({
@@ -9528,7 +9653,7 @@ async function resolveStyleRef(flags) {
9528
9653
  const head = meta.body.vos?.currentVersionId;
9529
9654
  if (meta.status !== 200 || !head)
9530
9655
  throw new UsageError(
9531
- `--style: ${styleRef} is neither a doc.json nor a vos I can read`
9656
+ `${what}: ${styleRef} is neither a doc.json nor a vos I can read`
9532
9657
  );
9533
9658
  const doc = await apiJson(
9534
9659
  origin,
@@ -9536,7 +9661,7 @@ async function resolveStyleRef(flags) {
9536
9661
  { key }
9537
9662
  );
9538
9663
  if (doc.status !== 200)
9539
- throw new UsageError(`--style: ${styleRef} carries no doc (is it a take?)`);
9664
+ throw new UsageError(`${what}: ${styleRef} carries no doc (is it a take?)`);
9540
9665
  return {
9541
9666
  from: `${origin}/vos/${styleRef}`,
9542
9667
  doc: migrateHostedDoc5(doc.body)
@@ -9960,4 +10085,4 @@ export {
9960
10085
  convertAgentBrowser,
9961
10086
  run
9962
10087
  };
9963
- //# sourceMappingURL=chunk-4LLBPFCW.js.map
10088
+ //# sourceMappingURL=chunk-ZONOZZZY.js.map