@vosjs/cli 0.17.4 → 0.19.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.
@@ -6,11 +6,11 @@ import {
6
6
  } from "./chunk-NHKHTIDR.js";
7
7
 
8
8
  // src/plugin/run.ts
9
- import { mkdir as mkdir9, readFile as readFile13, rm as rm4 } from "fs/promises";
10
- import { existsSync as existsSync13 } from "fs";
11
- import { join as join16, resolve as resolve8 } from "path";
9
+ import { mkdir as mkdir9, readFile as readFile14, rm as rm4 } from "fs/promises";
10
+ import { existsSync as existsSync14 } from "fs";
11
+ import { join as join17, resolve as resolve9 } from "path";
12
12
  import { totalDuration as totalDuration2 } from "@vosjs/timeline";
13
- import { migrateHostedDoc as migrateHostedDoc4, ratedSegments as ratedSegments6 } from "@vosjs/studio-core";
13
+ import { migrateHostedDoc as migrateHostedDoc5, ratedSegments as ratedSegments6 } from "@vosjs/studio-core";
14
14
 
15
15
  // src/plugin/args.ts
16
16
  var UsageError = class extends Error {
@@ -181,6 +181,13 @@ import {
181
181
  CAM_SIZE_MAX,
182
182
  CAM_SIZE_MIN,
183
183
  CAM_SPAN_MIN,
184
+ CARD_ENTER_KINDS,
185
+ CARD_EXIT_KINDS,
186
+ IDLE_KINDS,
187
+ MEDIA_ENTER_KINDS,
188
+ MEDIA_EXIT_KINDS,
189
+ TEXT_ENTER_KINDS,
190
+ TEXT_EXIT_KINDS,
184
191
  EXPORT_RESOLUTION_OPTIONS,
185
192
  SPEED_RATE_MAX,
186
193
  SPEED_RATE_MIN,
@@ -196,6 +203,79 @@ import {
196
203
  zoomCoversRect
197
204
  } from "@vosjs/studio-core";
198
205
  import { TYPEFACE_CATALOG, findFontFamily, findTypeface } from "@vosjs/shared";
206
+ var STEP_KINDS_ALL = [
207
+ "none",
208
+ "fade",
209
+ "rise",
210
+ "pop",
211
+ "blur",
212
+ "typewriter",
213
+ "tilt-in",
214
+ "pull-out",
215
+ "recede"
216
+ ];
217
+ var STEP_UNITS = ["block", "line", "word", "char"];
218
+ var STEP_DIRS = ["forward", "reverse", "center"];
219
+ function lintAnim(name, anim, kinds, problems) {
220
+ if (anim === void 0) return;
221
+ if (typeof anim !== "object" || anim === null || Array.isArray(anim)) {
222
+ problems.push(`${name}.anim must be { enter?, exit?, idle? }`);
223
+ return;
224
+ }
225
+ const a = anim;
226
+ for (const side of ["enter", "exit"]) {
227
+ const step = a[side];
228
+ if (step === void 0) continue;
229
+ const allowed = kinds[side];
230
+ const spelled = typeof step === "string" ? step : null;
231
+ const obj = typeof step === "object" && step !== null && !Array.isArray(step) ? step : null;
232
+ const kind = spelled ?? (obj ? obj.kind : void 0);
233
+ if (typeof kind !== "string" || !STEP_KINDS_ALL.includes(kind)) {
234
+ problems.push(
235
+ `${name}.anim.${side} must be a kind or { kind, seconds?${kinds.words ? ", unit?, direction?, stagger?" : ""} } (got ${JSON.stringify(step)})`
236
+ );
237
+ continue;
238
+ }
239
+ if (!allowed.includes(kind)) {
240
+ problems.push(
241
+ `${name}.anim.${side} cannot be "${kind}" here; one of ${allowed.join(" | ")}`
242
+ );
243
+ }
244
+ if (!obj) continue;
245
+ const secs = obj.seconds;
246
+ if (secs !== void 0 && (typeof secs !== "number" || !Number.isFinite(secs) || secs < 0.05 || secs > 3)) {
247
+ problems.push(`${name}.anim.${side}.seconds must be 0.05..3`);
248
+ }
249
+ for (const key of ["unit", "direction", "stagger"]) {
250
+ if (obj[key] === void 0) continue;
251
+ if (!kinds.words) {
252
+ problems.push(`${name}.anim.${side}.${key} is for words only`);
253
+ continue;
254
+ }
255
+ if (key === "unit" && (typeof obj.unit !== "string" || !STEP_UNITS.includes(obj.unit)))
256
+ problems.push(
257
+ `${name}.anim.${side}.unit must be ${STEP_UNITS.join("|")}`
258
+ );
259
+ if (key === "direction" && (typeof obj.direction !== "string" || !STEP_DIRS.includes(obj.direction)))
260
+ problems.push(
261
+ `${name}.anim.${side}.direction must be ${STEP_DIRS.join("|")}`
262
+ );
263
+ if (key === "stagger" && (typeof obj.stagger !== "number" || obj.stagger < 0 || obj.stagger > 2))
264
+ problems.push(`${name}.anim.${side}.stagger must be seconds in 0..2`);
265
+ }
266
+ }
267
+ if (a.idle !== void 0 && a.idle !== null) {
268
+ if (!kinds.idle) problems.push(`${name}.anim.idle is for props only`);
269
+ else if (typeof a.idle !== "string" || !kinds.idle.includes(a.idle))
270
+ problems.push(
271
+ `${name}.anim.idle must be ${kinds.idle.join(" | ")} | null`
272
+ );
273
+ }
274
+ }
275
+ function lintFrom(name, from, problems) {
276
+ if (from !== void 0 && (typeof from !== "string" || !from.length))
277
+ problems.push(`${name}.from must be the id of the template that placed it`);
278
+ }
199
279
  var EPS = 1e-3;
200
280
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
201
281
  var isObj = (v) => typeof v === "object" && v !== null;
@@ -328,19 +408,27 @@ function lintDoc(docIn) {
328
408
  const endCard = doc.endCard;
329
409
  if (endCard !== void 0) {
330
410
  if (typeof endCard !== "object" || endCard === null) {
331
- problems.push("endCard must be an object: {seconds?, headline?, sub?, wordmark?}");
411
+ problems.push(
412
+ "endCard must be an object: {seconds?, headline?, sub?, wordmark?}"
413
+ );
332
414
  } else {
333
415
  const ec = endCard;
334
416
  if (ec.seconds !== void 0 && (!isNum(ec.seconds) || ec.seconds < 1 || ec.seconds > 8)) {
335
- problems.push(`endCard.seconds must be 1..8 (got ${String(ec.seconds)}); absent = 2.5`);
417
+ problems.push(
418
+ `endCard.seconds must be 1..8 (got ${String(ec.seconds)}); absent = 2.5`
419
+ );
336
420
  }
337
421
  for (const k of ["headline", "sub", "wordmark"]) {
338
422
  if (ec[k] !== void 0 && typeof ec[k] !== "string") {
339
423
  problems.push(`endCard.${k} must be a string`);
340
424
  }
341
425
  }
342
- if (!["headline", "sub", "wordmark"].some((k) => typeof ec[k] === "string" && ec[k].trim())) {
343
- warnings.push("endCard carries no words: it holds the last frame and recedes the card over nothing");
426
+ if (!["headline", "sub", "wordmark"].some(
427
+ (k) => typeof ec[k] === "string" && ec[k].trim()
428
+ )) {
429
+ warnings.push(
430
+ "endCard carries no words: it holds the last frame and recedes the card over nothing"
431
+ );
344
432
  }
345
433
  }
346
434
  }
@@ -579,6 +667,17 @@ function lintDoc(docIn) {
579
667
  }
580
668
  }
581
669
  }
670
+ lintAnim(
671
+ "frame",
672
+ frame.anim,
673
+ {
674
+ enter: CARD_ENTER_KINDS,
675
+ exit: CARD_EXIT_KINDS,
676
+ idle: null,
677
+ words: false
678
+ },
679
+ problems
680
+ );
582
681
  if (frame.focusFollow !== void 0 && frame.focusFollow !== "camera") {
583
682
  problems.push(
584
683
  `frame.focusFollow must be "camera" (got ${String(frame.focusFollow)}); it reads under fit: cover only`
@@ -826,6 +925,23 @@ function lintDoc(docIn) {
826
925
  problems.push(`${name}.${key} must be one of ${TRANSITIONS.join("|")}`);
827
926
  }
828
927
  }
928
+ lintAnim(
929
+ name,
930
+ o.anim,
931
+ o.kind === "text" ? {
932
+ enter: TEXT_ENTER_KINDS,
933
+ exit: TEXT_EXIT_KINDS,
934
+ idle: null,
935
+ words: true
936
+ } : {
937
+ enter: MEDIA_ENTER_KINDS,
938
+ exit: MEDIA_EXIT_KINDS,
939
+ idle: null,
940
+ words: false
941
+ },
942
+ problems
943
+ );
944
+ lintFrom(name, o.from, problems);
829
945
  if (o.motion !== void 0) {
830
946
  if (!Array.isArray(o.motion)) {
831
947
  problems.push(`${name}.motion must be an array of poses`);
@@ -956,6 +1072,18 @@ function lintDoc(docIn) {
956
1072
  if (o.animation !== void 0 && o.animation !== null && o.animation !== "spin" && o.animation !== "float") {
957
1073
  problems.push(`${name}.animation must be "spin" | "float" | null`);
958
1074
  }
1075
+ lintAnim(
1076
+ name,
1077
+ o.anim,
1078
+ {
1079
+ enter: MEDIA_ENTER_KINDS,
1080
+ exit: MEDIA_EXIT_KINDS,
1081
+ idle: IDLE_KINDS,
1082
+ words: false
1083
+ },
1084
+ problems
1085
+ );
1086
+ lintFrom(name, o.from, problems);
959
1087
  if (o.motion !== void 0) {
960
1088
  if (!Array.isArray(o.motion)) {
961
1089
  problems.push(`${name}.motion must be an array of poses`);
@@ -1352,10 +1480,10 @@ function startTakeServer(rootDir, pages) {
1352
1480
  res.writeHead(404).end();
1353
1481
  });
1354
1482
  return new Promise(
1355
- (resolve9) => server.listen(0, () => {
1483
+ (resolve10) => server.listen(0, () => {
1356
1484
  const addr = server.address();
1357
1485
  const port = typeof addr === "object" && addr ? addr.port : 0;
1358
- resolve9({ base: `http://localhost:${port}`, close: () => server.close() });
1486
+ resolve10({ base: `http://localhost:${port}`, close: () => server.close() });
1359
1487
  })
1360
1488
  );
1361
1489
  }
@@ -1806,7 +1934,7 @@ async function resolveBackdropSlug(o) {
1806
1934
  o.backgroundDuration = hit.duration;
1807
1935
  }
1808
1936
  function hasOverrides(o) {
1809
- return !!(o.set?.length || o.frame !== void 0 || o.background !== void 0);
1937
+ return !!(o.set?.length || o.unset?.length || o.frame !== void 0 || o.background !== void 0);
1810
1938
  }
1811
1939
  var FRAME_KINDS = {
1812
1940
  macos: "mac-light",
@@ -1913,6 +2041,16 @@ function applyDocOverrides(doc, o) {
1913
2041
  setPath(d, path, value);
1914
2042
  applied.push(`${path} = ${JSON.stringify(value)}`);
1915
2043
  }
2044
+ for (const path of o.unset ?? []) {
2045
+ const segs = parsePath(path);
2046
+ let node = d;
2047
+ for (let i = 0; i < segs.length - 1 && node && typeof node === "object"; i++)
2048
+ node = node[segs[i].key];
2049
+ if (node && typeof node === "object") {
2050
+ delete node[segs[segs.length - 1].key];
2051
+ applied.push(`${path} removed`);
2052
+ }
2053
+ }
1916
2054
  return applied;
1917
2055
  }
1918
2056
  function applyAndValidate(doc, o) {
@@ -2476,8 +2614,8 @@ async function stillsInPage(opts) {
2476
2614
  }
2477
2615
  async function framesTake(browser, dir, opts) {
2478
2616
  const take = await loadTake(dir);
2479
- if (!take.doc) throw new Error(`${dir} has no doc.json \u2014 run plan first`);
2480
- const doc = take.doc;
2617
+ const doc = opts.doc ? structuredClone(opts.doc) : take.doc;
2618
+ if (!doc) throw new Error(`${dir} has no doc.json \u2014 run plan first`);
2481
2619
  if (opts.overrides && hasOverrides(opts.overrides)) {
2482
2620
  await resolveBackdropSlug(opts.overrides);
2483
2621
  applyAndValidate(doc, opts.overrides);
@@ -2505,7 +2643,7 @@ async function framesTake(browser, dir, opts) {
2505
2643
  }
2506
2644
  }
2507
2645
  if (opts.atMoments) {
2508
- for (const m of await momentsFor(dir, take.doc)) {
2646
+ for (const m of await momentsFor(dir, doc)) {
2509
2647
  if (m.outputAt === null) continue;
2510
2648
  shots.push({ time: clamp(m.outputAt), kind: "moment", momentId: m.id });
2511
2649
  }
@@ -2618,9 +2756,9 @@ async function writeIndexJson(result) {
2618
2756
  }
2619
2757
 
2620
2758
  // src/plugin/deliver.ts
2621
- import { mkdir as mkdir5, mkdtemp, rename as rename4, rm as rm3, stat, writeFile as writeFile7 } from "fs/promises";
2759
+ import { mkdir as mkdir4, mkdtemp, rename as rename4, rm as rm3, stat, writeFile as writeFile6 } from "fs/promises";
2622
2760
  import { tmpdir } from "os";
2623
- import { join as join6, relative, resolve as resolve4 } from "path";
2761
+ import { join as join7, relative, resolve as resolve4 } from "path";
2624
2762
  import { totalDuration } from "@vosjs/timeline";
2625
2763
  import { existsSync as existsSync6 } from "fs";
2626
2764
  import { readFile as readFile6 } from "fs/promises";
@@ -2741,114 +2879,6 @@ async function renderTake(browser, dir, outFile, opts) {
2741
2879
  };
2742
2880
  }
2743
2881
 
2744
- // src/plugin/posterStill.ts
2745
- import { compileVosConfig as compileVosConfig3 } from "@vosjs/core";
2746
- async function posterStillInPage(opts) {
2747
- const w = window;
2748
- const RW = opts.W * opts.ss;
2749
- const RH = opts.H * opts.ss;
2750
- try {
2751
- const mod = await import(
2752
- /* @vite-ignore */
2753
- URL.createObjectURL(
2754
- new Blob([opts.animationCode], { type: "text/javascript" })
2755
- )
2756
- );
2757
- w.__vos__ = w.__vos__ || {};
2758
- w.__vos__.isPaused = true;
2759
- const deps = {
2760
- THREE: w.__THREE__,
2761
- gsap: w.__gsap__,
2762
- resolution: {
2763
- width: RW,
2764
- height: RH,
2765
- pixelRatio: 1,
2766
- drawingBufferWidth: RW,
2767
- drawingBufferHeight: RH
2768
- },
2769
- preserveDrawingBuffer: true
2770
- };
2771
- const result = await mod.initVos(document.body, deps);
2772
- if (result.assetsReady) await result.assetsReady;
2773
- const { timeline } = result;
2774
- timeline.pause();
2775
- timeline.seek(opts.time, false);
2776
- const canvas = document.querySelector("canvas");
2777
- canvas.width = RW;
2778
- canvas.height = RH;
2779
- canvas.style.width = RW + "px";
2780
- canvas.style.height = RH + "px";
2781
- const raf = () => new Promise((r) => requestAnimationFrame(r));
2782
- await raf();
2783
- await raf();
2784
- await raf();
2785
- let src = canvas;
2786
- if (opts.ss > 1) {
2787
- const out = document.createElement("canvas");
2788
- out.width = opts.W;
2789
- out.height = opts.H;
2790
- const g = out.getContext("2d");
2791
- g.imageSmoothingEnabled = true;
2792
- g.imageSmoothingQuality = "high";
2793
- g.drawImage(canvas, 0, 0, opts.W, opts.H);
2794
- src = out;
2795
- }
2796
- const png = await new Promise(
2797
- (res) => src.toBlob((b) => res(b), "image/png")
2798
- );
2799
- if (!png) throw new Error("toBlob returned null");
2800
- await fetch("/save?name=" + opts.outName, { method: "POST", body: png });
2801
- w.__done = { ok: true };
2802
- } catch (e) {
2803
- w.__error = String(e instanceof Error && e.stack || e);
2804
- }
2805
- }
2806
- async function renderPosterStills(browser, config, serveDir, shots, time) {
2807
- const animationCode = compileVosConfig3(config, {
2808
- tweenEngine: "vos"
2809
- });
2810
- const server = await startTakeServer(serveDir, {
2811
- "/render.html": renderPageHtml()
2812
- });
2813
- try {
2814
- for (const shot of shots) {
2815
- const ss = stillSupersample(shot.width, shot.height);
2816
- const context = await browser.newContext({
2817
- viewport: { width: shot.width * ss, height: shot.height * ss }
2818
- });
2819
- try {
2820
- const page = await context.newPage();
2821
- page.on("console", (m) => {
2822
- if (m.type() === "error")
2823
- process.stderr.write(` [poster page] ${m.text()}
2824
- `);
2825
- });
2826
- await page.addInitScript(() => {
2827
- ;
2828
- globalThis.__name = (f) => f;
2829
- });
2830
- await page.goto(`${server.base}/render.html`);
2831
- await page.waitForFunction("window.__pageReady__ === true");
2832
- void page.evaluate(posterStillInPage, {
2833
- animationCode,
2834
- W: shot.width,
2835
- H: shot.height,
2836
- ss,
2837
- time,
2838
- outName: shot.name
2839
- }).catch(() => {
2840
- });
2841
- await waitForPageDone(page, `poster ${shot.name}`, () => {
2842
- }, 12e4);
2843
- } finally {
2844
- await context.close();
2845
- }
2846
- }
2847
- } finally {
2848
- server.close();
2849
- }
2850
- }
2851
-
2852
2882
  // src/plugin/moments.ts
2853
2883
  import { ratedSegments as ratedSegments3, spanOutputExtent as spanOutputExtent3 } from "@vosjs/studio-core";
2854
2884
 
@@ -3356,1242 +3386,516 @@ function pickMoments(measured, opts = {}) {
3356
3386
  return { times: kept.map((k) => k.time), dropped };
3357
3387
  }
3358
3388
 
3359
- // src/plugin/shotBake.ts
3360
- import { deflateSync } from "zlib";
3361
- function blurPlane(src, w, h, r, passes = 3) {
3362
- if (r < 1) return;
3363
- const tmp = new Float32Array(src.length);
3364
- for (let p = 0; p < passes; p++) {
3365
- for (let y = 0; y < h; y++) {
3366
- let acc = 0;
3367
- const row = y * w;
3368
- for (let x = -r; x <= r; x++)
3369
- acc += src[row + Math.min(w - 1, Math.max(0, x))];
3370
- for (let x = 0; x < w; x++) {
3371
- tmp[row + x] = acc / (2 * r + 1);
3372
- const add = src[row + Math.min(w - 1, x + r + 1)];
3373
- const sub = src[row + Math.max(0, x - r)];
3374
- acc += add - sub;
3375
- }
3376
- }
3377
- for (let x = 0; x < w; x++) {
3378
- let acc = 0;
3379
- for (let y = -r; y <= r; y++)
3380
- acc += tmp[Math.min(h - 1, Math.max(0, y)) * w + x];
3381
- for (let y = 0; y < h; y++) {
3382
- src[y * w + x] = acc / (2 * r + 1);
3383
- const add = tmp[Math.min(h - 1, y + r + 1) * w + x];
3384
- const sub = tmp[Math.max(0, y - r) * w + x];
3385
- acc += add - sub;
3386
- }
3387
- }
3389
+ // src/plugin/motionPlan.ts
3390
+ import { ratedSegments as ratedSegments4, spanOutputExtent as spanOutputExtent4 } from "@vosjs/studio-core";
3391
+ var SOUND_DESTINATIONS = /* @__PURE__ */ new Set([
3392
+ "x-feed-cut",
3393
+ "youtube-main-demo",
3394
+ "shorts-linkedin-vertical-cut"
3395
+ ]);
3396
+ var LOOP_DESTINATIONS = /* @__PURE__ */ new Set(["github-readme-loop"]);
3397
+ var BED_ID = "bed";
3398
+ var CLICK_ID_PREFIX = "click-";
3399
+ var CAPTION_ID_PREFIX = "caption-";
3400
+ var off = (v) => v !== void 0 && /^(none|off|false|no)$/i.test(v.trim());
3401
+ function pickTrack(catalog, ask) {
3402
+ if (!catalog || !ask || off(ask)) return null;
3403
+ const want = ask.trim().toLowerCase();
3404
+ return catalog.tracks.find((t) => t.slug.toLowerCase() === want) ?? catalog.tracks.find((t) => (t.mood ?? "").toLowerCase() === want) ?? null;
3405
+ }
3406
+ function clickTimes(doc, range) {
3407
+ const rated = ratedSegments4(doc);
3408
+ const out = [];
3409
+ for (const e of doc.source.cursor) {
3410
+ if (e.type !== "down") continue;
3411
+ const src = e.t / 1e3;
3412
+ const ext = spanOutputExtent4(rated, src, src + 1e-3);
3413
+ if (!ext) continue;
3414
+ const t = ext.start;
3415
+ if (t < range[0] || t > range[1]) continue;
3416
+ if (out.length && t - out[out.length - 1] < 0.12) continue;
3417
+ out.push(+(t - range[0]).toFixed(3));
3388
3418
  }
3419
+ return out;
3389
3420
  }
3390
- function roundedCoverage(x, y, rect, r) {
3391
- let inside = 0;
3392
- for (const dy of [0.25, 0.75]) {
3393
- for (const dx of [0.25, 0.75]) {
3394
- const px = x + dx;
3395
- const py = y + dy;
3396
- if (px < rect.x || py < rect.y || px > rect.x + rect.w || py > rect.y + rect.h)
3397
- continue;
3398
- const cx = Math.min(Math.max(px, rect.x + r), rect.x + rect.w - r);
3399
- const cy = Math.min(Math.max(py, rect.y + r), rect.y + rect.h - r);
3400
- if ((px - cx) ** 2 + (py - cy) ** 2 <= r * r) inside++;
3401
- }
3402
- }
3403
- return inside / 4;
3404
- }
3405
- function bakeShot(shot, opts = {}) {
3406
- const margin = Math.round(shot.w * (opts.margin ?? 0.06));
3407
- const radius = shot.w * (opts.radius ?? 0.014);
3408
- const shadowA = opts.shadow ?? 0.3;
3409
- const blur = Math.round(shot.w * (opts.blur ?? 0.05));
3410
- const offsetY = Math.round(shot.w * (opts.offsetY ?? 0.02));
3411
- const hair = opts.hairline ?? 0;
3412
- const w = shot.w + margin * 2;
3413
- const h = shot.h + margin * 2;
3414
- const out = new Uint8Array(w * h * 4);
3415
- const rect = { x: margin, y: margin, w: shot.w, h: shot.h };
3416
- if (shadowA > 0) {
3417
- const layers = [
3418
- [Math.max(1, Math.round(blur / 4)), Math.round(offsetY / 5), 0.4],
3419
- [blur, offsetY, 0.6]
3420
- ];
3421
- const acc = new Float32Array(w * h);
3422
- for (const [lb, lo, share] of layers) {
3423
- const mask = new Float32Array(w * h);
3424
- for (let y = 0; y < h; y++)
3425
- for (let x = 0; x < w; x++) {
3426
- const c = roundedCoverage(x, y - lo, rect, radius);
3427
- if (c > 0) mask[y * w + x] = c;
3428
- }
3429
- blurPlane(mask, w, h, Math.max(1, Math.round(lb / 2)));
3430
- const a = shadowA * share;
3431
- for (let i = 0; i < w * h; i++)
3432
- acc[i] = 1 - (1 - acc[i]) * (1 - mask[i] * a);
3421
+ var outputLength = (doc) => ratedSegments4(doc).reduce((acc, s) => {
3422
+ const rate = s.rate && s.rate > 0 ? s.rate : 1;
3423
+ return acc + (s.out - s.in) / rate;
3424
+ }, 0);
3425
+ function proposeMotion(input, opts) {
3426
+ const doc = structuredClone(input);
3427
+ const { words, launch, catalog } = opts;
3428
+ const notes = [];
3429
+ const skipped = [];
3430
+ const length = outputLength(doc);
3431
+ const range = [0, length];
3432
+ const entrance = launch.entrance;
3433
+ 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)) {
3441
+ const headline = (words.headline ?? "").trim();
3442
+ const brand = (words.brand ?? "").trim();
3443
+ const sub = [brand, (words.release ?? "").trim()].filter(Boolean).join(" ");
3444
+ if (headline || brand) {
3445
+ const card = { seconds: 2.5 };
3446
+ if (opts.ink) card.ink = opts.ink;
3447
+ if (headline) card.headline = headline;
3448
+ if (sub && sub !== headline) card.sub = sub;
3449
+ if (brand) card.wordmark = brand;
3450
+ if (opts.mark) card.mark = opts.mark;
3451
+ doc.endCard = card;
3452
+ notes.push("end card");
3453
+ } else {
3454
+ skipped.push(
3455
+ "no end card (no headline or wordmark in LAUNCH.md, BRAND.md or the flags)"
3456
+ );
3433
3457
  }
3434
- for (let i = 0; i < w * h; i++) {
3435
- const a = acc[i];
3436
- if (a <= 2e-3) continue;
3437
- out[i * 4] = 0;
3438
- out[i * 4 + 1] = 0;
3439
- out[i * 4 + 2] = 0;
3440
- out[i * 4 + 3] = Math.round(a * 255);
3458
+ } else {
3459
+ delete doc.endCard;
3460
+ }
3461
+ const kept = (doc.overlays ?? []).filter(
3462
+ (o) => !o.id.startsWith(CAPTION_ID_PREFIX)
3463
+ );
3464
+ const captionClips = [];
3465
+ if (opts.captions.length && !off(launch.captions)) {
3466
+ const rated = ratedSegments4(doc);
3467
+ const steps = doc.source.meta.steps ?? [];
3468
+ for (const c of opts.captions) {
3469
+ const step = steps.find(
3470
+ (s) => c.id !== void 0 && s.id === c.id || s.step === c.step
3471
+ );
3472
+ if (!step || step.skipped) continue;
3473
+ const t = stepOutputTime(rated, step, 0.2);
3474
+ if (t === null || t < 0 || t > length - 1) continue;
3475
+ captionClips.push({
3476
+ id: `${CAPTION_ID_PREFIX}${c.step}`,
3477
+ kind: "text",
3478
+ text: c.caption,
3479
+ preset: "caption",
3480
+ start: +t.toFixed(3),
3481
+ duration: Math.min(3.5, Math.max(2.5, length - t - 0.2)),
3482
+ transform: { x: 0.5, y: 0.86, scale: 1, rotation: 0 },
3483
+ enter: "rise",
3484
+ exit: "fade",
3485
+ align: "center",
3486
+ box: { color: "rgba(17,17,17,0.72)" }
3487
+ });
3441
3488
  }
3489
+ if (captionClips.length) notes.push(`${captionClips.length} caption(s)`);
3442
3490
  }
3443
- for (let y = 0; y < h; y++) {
3444
- for (let x = 0; x < w; x++) {
3445
- const cov = roundedCoverage(x, y, rect, radius);
3446
- if (cov <= 0) continue;
3447
- const sx = Math.min(shot.w - 1, Math.max(0, x - margin));
3448
- const sy = Math.min(shot.h - 1, Math.max(0, y - margin));
3449
- const si = (sy * shot.w + sx) * 4;
3450
- const o = (y * w + x) * 4;
3451
- let r = shot.data[si];
3452
- let g = shot.data[si + 1];
3453
- let b = shot.data[si + 2];
3454
- if (hair > 0) {
3455
- const edge = Math.min(
3456
- x - rect.x,
3457
- rect.x + rect.w - x,
3458
- y - rect.y,
3459
- rect.y + rect.h - y
3460
- );
3461
- if (edge < 1.5) {
3462
- r = Math.round(r * (1 - hair));
3463
- g = Math.round(g * (1 - hair));
3464
- b = Math.round(b * (1 - hair));
3465
- }
3466
- }
3467
- const a = cov;
3468
- const ba = out[o + 3] / 255;
3469
- const outA = a + ba * (1 - a);
3470
- const mix = (fg, bg) => outA > 0 ? Math.round((fg * a + bg * ba * (1 - a)) / outA) : 0;
3471
- out[o] = mix(r, out[o]);
3472
- out[o + 1] = mix(g, out[o + 1]);
3473
- out[o + 2] = mix(b, out[o + 2]);
3474
- out[o + 3] = Math.round(outA * 255);
3491
+ const overlays = [...kept, ...captionClips];
3492
+ if (overlays.length) doc.overlays = overlays;
3493
+ else delete doc.overlays;
3494
+ const clips = (doc.audio ?? []).filter(
3495
+ (a) => a.id !== BED_ID && !a.id.startsWith(CLICK_ID_PREFIX)
3496
+ );
3497
+ const track = pickTrack(catalog, launch.music);
3498
+ if (track) {
3499
+ const hasMic = !!doc.source.micKey;
3500
+ const fadeOut = Math.min(2.5, length * 0.15);
3501
+ clips.push({
3502
+ id: BED_ID,
3503
+ key: track.url,
3504
+ name: track.title,
3505
+ start: 0,
3506
+ in: 0,
3507
+ out: Math.min(track.duration, length),
3508
+ duration: track.duration,
3509
+ gain: hasMic ? 0.35 : 0.5,
3510
+ fadeIn: 0.6,
3511
+ fadeOut,
3512
+ loop: track.duration < length,
3513
+ loopLen: track.duration < length ? length : void 0,
3514
+ duck: hasMic
3515
+ });
3516
+ notes.push(`bed ${track.slug}`);
3517
+ } else if (launch.music && !off(launch.music)) {
3518
+ skipped.push(
3519
+ `music "${launch.music}" is not a catalog track or mood${catalog ? "" : " (the catalog could not be read)"}`
3520
+ );
3521
+ }
3522
+ const click = catalog?.sfx.find((s) => s.slug === "sfx-click");
3523
+ if (click && !doc.source.micKey && !off(launch.clicks)) {
3524
+ const times = clickTimes(doc, range);
3525
+ for (const [i, t] of times.entries()) {
3526
+ clips.push({
3527
+ id: `${CLICK_ID_PREFIX}${i}`,
3528
+ key: click.url,
3529
+ name: click.title,
3530
+ start: t,
3531
+ in: 0,
3532
+ out: click.duration,
3533
+ duration: click.duration,
3534
+ gain: 0.4,
3535
+ fadeIn: 0,
3536
+ fadeOut: 0
3537
+ });
3475
3538
  }
3539
+ if (times.length) notes.push(`${times.length} click sound(s)`);
3476
3540
  }
3477
- return { w, h, data: out };
3541
+ doc.audio = clips;
3542
+ return { doc, notes, skipped };
3478
3543
  }
3479
- var crcTable = (() => {
3480
- const t = new Uint32Array(256);
3481
- for (let n = 0; n < 256; n++) {
3482
- let c = n;
3483
- for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
3484
- t[n] = c >>> 0;
3544
+ function destinationMechanics(d, doc) {
3545
+ const set = [];
3546
+ const unset = [];
3547
+ const notes = [];
3548
+ if (d.kind !== "video") return { set, unset, notes };
3549
+ const loop = LOOP_DESTINATIONS.has(d.id);
3550
+ const sound = SOUND_DESTINATIONS.has(d.id);
3551
+ const portrait = d.px.w / d.px.h < 0.9;
3552
+ if (loop) {
3553
+ unset.push("frame.entrance", "endCard");
3554
+ notes.push("loop: no entrance, no end card");
3485
3555
  }
3486
- return t;
3487
- })();
3488
- function crc32(buf) {
3489
- let c = 4294967295;
3490
- for (const b of buf) c = crcTable[(c ^ b) & 255] ^ c >>> 8;
3491
- return (c ^ 4294967295) >>> 0;
3492
- }
3493
- function chunk(type, body) {
3494
- const out = new Uint8Array(12 + body.length);
3495
- const dv = new DataView(out.buffer);
3496
- dv.setUint32(0, body.length);
3497
- for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
3498
- out.set(body, 8);
3499
- dv.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)));
3500
- return out;
3501
- }
3502
- function encodePng(img) {
3503
- const stride = img.w * 4;
3504
- const raw = new Uint8Array((stride + 1) * img.h);
3505
- for (let y = 0; y < img.h; y++) {
3506
- raw[y * (stride + 1)] = 2;
3507
- for (let i = 0; i < stride; i++) {
3508
- const cur = img.data[y * stride + i];
3509
- const up = y ? img.data[(y - 1) * stride + i] : 0;
3510
- raw[y * (stride + 1) + 1 + i] = cur - up & 255;
3556
+ if (loop || !sound) {
3557
+ set.push("audio=[]");
3558
+ if (!loop) notes.push("silent channel");
3559
+ }
3560
+ if (loop || d.text === "none") {
3561
+ const kept = (doc.overlays ?? []).filter(
3562
+ (o) => !o.id.startsWith(CAPTION_ID_PREFIX)
3563
+ );
3564
+ if (kept.length !== (doc.overlays ?? []).length) {
3565
+ set.push(`overlays=${JSON.stringify(kept)}`);
3566
+ notes.push("no captions");
3511
3567
  }
3512
3568
  }
3513
- const ihdr = new Uint8Array(13);
3514
- const dv = new DataView(ihdr.buffer);
3515
- dv.setUint32(0, img.w);
3516
- dv.setUint32(4, img.h);
3517
- ihdr[8] = 8;
3518
- ihdr[9] = 6;
3519
- const parts = [
3520
- new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
3521
- chunk("IHDR", ihdr),
3522
- chunk("IDAT", new Uint8Array(deflateSync(raw))),
3523
- chunk("IEND", new Uint8Array(0))
3524
- ];
3525
- const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
3526
- let o = 0;
3527
- for (const p of parts) {
3528
- out.set(p, o);
3529
- o += p.length;
3569
+ if (portrait) {
3570
+ set.push("frame.fit=cover");
3571
+ set.push('frame.inset={"left":0.06,"right":0.06,"top":0.17,"bottom":0.17}');
3572
+ set.push("frame.focusFollow=camera");
3573
+ notes.push("vertical reframe follows the camera");
3530
3574
  }
3531
- return out;
3575
+ return { set, unset, notes };
3532
3576
  }
3533
-
3534
- // src/plugin/template.ts
3535
- function templateOf(config) {
3536
- const t = config.template;
3537
- if (!t || typeof t !== "object") return null;
3538
- return t;
3539
- }
3540
- function aspectOf(size) {
3541
- const r = size.w / Math.max(1, size.h);
3542
- if (r > 1.15) return "landscape";
3543
- if (r < 0.87) return "portrait";
3544
- return "square";
3577
+ function endCardInk(look, brand) {
3578
+ if (!look) return null;
3579
+ const ground = look.ground;
3580
+ const m = /#([0-9a-f]{6})/i.exec(ground);
3581
+ const hex2 = m ? m[0] : null;
3582
+ const light = look.kind === "plate" || (hex2 ? isLightHexGround(hex2) : look.kind === "gradient");
3583
+ if (!light) return "#ffffff";
3584
+ const ink = brand?.ink;
3585
+ return ink && /^#[0-9a-f]{6}$/i.test(ink) ? ink : "#111111";
3545
3586
  }
3546
- function templateProblems(config) {
3547
- const t = templateOf(config);
3548
- if (!t) return ["no template block: a poster template declares config.template"];
3549
- const out = [];
3550
- const elements = Array.isArray(config.elements) ? config.elements : [];
3551
- const byId = new Map(elements.filter((e) => e.id).map((e) => [e.id, e]));
3552
- const params = Array.isArray(config.params) ? config.params : [];
3553
- const keys = new Set(params.map((p) => p.key).filter(Boolean));
3554
- if (!t.family) out.push("template.family is missing");
3555
- for (const s of t.slots ?? []) {
3556
- const el = byId.get(s.id);
3557
- if (!el) out.push(`template.slots: no element with id "${s.id}"`);
3558
- else if (el.type !== s.kind)
3559
- out.push(`template.slots: "${s.id}" is a ${String(el.type)} element, the slot wants ${s.kind}`);
3560
- }
3561
- for (const x of t.text ?? []) {
3562
- const el = byId.get(x.element);
3563
- if (!el) out.push(`template.text: no element with id "${x.element}"`);
3564
- else if (el.type !== "text") out.push(`template.text: "${x.element}" is not a text element`);
3565
- if (!keys.has(x.param)) out.push(`template.text: param "${x.param}" is not declared in config.params`);
3566
- const bound = el?.content;
3567
- if (el && (!bound || typeof bound !== "object" || bound.$data !== x.param))
3568
- out.push(`template.text: "${x.element}" must bind its content to {$data: "${x.param}"}`);
3569
- }
3570
- for (const k of t.params?.required ?? []) {
3571
- if (!keys.has(k)) out.push(`template.params.required: "${k}" is not declared in config.params`);
3572
- }
3573
- if (!t.layouts?.landscape) out.push("template.layouts.landscape is required");
3574
- for (const [name, layout] of Object.entries(t.layouts ?? {})) {
3575
- for (const s of t.slots ?? []) {
3576
- if (!layout?.slots?.[s.id]) out.push(`template.layouts.${name}: slot "${s.id}" is not placed`);
3577
- }
3578
- }
3579
- return out;
3587
+ function isLightHexGround(hex2) {
3588
+ const n = parseInt(hex2.slice(1), 16);
3589
+ const r = n >> 16 & 255;
3590
+ const g = n >> 8 & 255;
3591
+ const b = n & 255;
3592
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 >= 0.6;
3580
3593
  }
3581
- var pctOf = (v) => {
3582
- if (typeof v === "number") return null;
3583
- if (typeof v !== "string") return null;
3584
- const m = /^(-?\d+(?:\.\d+)?)%$/.exec(v.trim());
3585
- return m ? Number(m[1]) / 100 : null;
3586
- };
3587
- function fillTemplate(config, input) {
3588
- const t = templateOf(config);
3589
- if (!t) throw new Error("fillTemplate: the config carries no template block");
3590
- const aspect = aspectOf(input.size);
3591
- const layout = t.layouts[aspect] ?? t.layouts.landscape;
3592
- const out = structuredClone(config);
3593
- const designH = 1080;
3594
- const designW = designH * input.size.w / input.size.h;
3595
- const elements = Array.isArray(out.elements) ? out.elements : [];
3596
- const byId = new Map(elements.filter((e) => e.id).map((e) => [e.id, e]));
3597
- const slotRects = {};
3598
- for (const s of t.slots) {
3599
- const el = byId.get(s.id);
3600
- const place = layout.slots[s.id];
3601
- const src = input.slots[s.id];
3602
- if (!el || !place) continue;
3603
- if (src) el.src = src.src;
3604
- const pad = src?.pad ?? 0;
3605
- slotRects[s.id] = {
3606
- x: place.x,
3607
- y: place.y,
3608
- w: place.w,
3609
- h: place.w * (input.size.w / input.size.h) / (src?.aspect ?? 16 / 9)
3610
- };
3611
- const w = place.w * designW * (1 + 2 * pad);
3612
- const x = place.x - place.w * pad;
3613
- const y = place.y - place.w * pad * (input.size.w / input.size.h) / (src?.aspect ?? 16 / 9);
3614
- el.position = { x: `${(x * 100).toFixed(2)}%`, y: `${(y * 100).toFixed(2)}%` };
3615
- el.anchor = "top-left";
3616
- el.size = { ...el.size ?? {}, width: Math.round(w), height: "auto" };
3617
- if (!src && s.required) el.opacity = 0;
3618
- }
3619
- const data = out.data && typeof out.data === "object" ? { ...out.data } : {};
3620
- const params = Array.isArray(out.params) ? out.params : [];
3621
- const missing = [];
3622
- for (const k of [...t.params.required ?? [], ...t.params.brand ?? []]) {
3623
- if (input.values[k] === void 0 || input.values[k] === null || input.values[k] === "") {
3624
- if ((t.params.required ?? []).includes(k)) missing.push(k);
3625
- continue;
3626
- }
3627
- data[k] = input.values[k];
3628
- const p = params.find((q) => q.key === k);
3629
- if (p) p.default = input.values[k];
3630
- }
3631
- for (const [k, v] of Object.entries(input.values)) {
3632
- if (v !== void 0 && v !== null && v !== "") {
3633
- data[k] = v;
3634
- const p = params.find((q) => q.key === k);
3635
- if (p) p.default = v;
3636
- }
3637
- }
3638
- out.data = data;
3639
- const boxes = [];
3640
- for (const x of t.text) {
3641
- const el = byId.get(x.element);
3642
- if (!el) continue;
3643
- const pos = layout.text?.[x.element];
3644
- if (pos) el.position = { x: pos.x, y: pos.y };
3645
- const size = layout.size?.[x.element];
3646
- if (size) el.font = { ...el.font ?? {}, size };
3647
- if (layout.hide?.includes(x.element)) {
3648
- el.opacity = 0;
3649
- continue;
3650
- }
3651
- const value = data[x.param];
3652
- if (typeof value !== "string" || !value.trim()) continue;
3653
- const font = el.font ?? {};
3654
- const px = typeof font.size === "number" ? font.size : 40;
3655
- const lines = value.split("\n");
3656
- const longest = Math.max(...lines.map((l) => l.length));
3657
- const lh = typeof font.lineHeight === "number" ? font.lineHeight : 1.15;
3658
- const boxW = longest * px * 0.56;
3659
- const boxH = lines.length * px * lh;
3660
- const ex = pctOf(el.position?.x) ?? 0;
3661
- const ey = pctOf(el.position?.y) ?? 0;
3662
- const anchor = String(el.anchor ?? "center");
3663
- let left = ex * designW;
3664
- let top = ey * designH;
3665
- if (anchor.includes("right")) left -= boxW;
3666
- else if (!anchor.includes("left")) left -= boxW / 2;
3667
- if (anchor.includes("bottom")) top -= boxH;
3668
- else if (!anchor.includes("top")) top -= boxH / 2;
3669
- const rawColor = font.color;
3670
- const color = typeof rawColor === "string" ? rawColor : rawColor && typeof rawColor === "object" && typeof rawColor.$data === "string" ? data[rawColor.$data] : void 0;
3671
- boxes.push({
3672
- x: left / designW,
3673
- y: top / designH,
3674
- w: boxW / designW,
3675
- h: boxH / designH,
3676
- color: color && /^#[0-9a-f]{6}$/i.test(color) ? color : void 0,
3677
- role: x.role,
3678
- label: value.length > 24 ? `${value.slice(0, 24)}\u2026` : value
3679
- });
3680
- }
3681
- for (const id of layout.hide ?? []) {
3682
- const el = byId.get(id);
3683
- if (el && !t.text.some((x) => x.element === id)) el.opacity = 0;
3594
+
3595
+ // src/plugin/posterDoc.ts
3596
+ import { existsSync as existsSync5 } from "fs";
3597
+ import { readFile as readFile5 } from "fs/promises";
3598
+ import { isAbsolute, join as join6, resolve as resolve3 } from "path";
3599
+ import {
3600
+ computeCardLayout,
3601
+ docRestTime,
3602
+ migrateHostedDoc,
3603
+ overlayRect,
3604
+ resolveOverlayStyle
3605
+ } from "@vosjs/studio-core";
3606
+
3607
+ // src/plugin/platform.ts
3608
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
3609
+ import { homedir } from "os";
3610
+ import { join as join5 } from "path";
3611
+ function platformOrigin(flags = {}) {
3612
+ const legacyEnv = process.env.VOS_API_BASE?.trim();
3613
+ const raw = flags.origin ?? flags.api ?? process.env.VOS_ORIGIN?.trim() ?? legacyEnv ?? "https://vos.so";
3614
+ if (!flags.origin && !flags.api && !process.env.VOS_ORIGIN && legacyEnv) {
3615
+ process.stderr.write(
3616
+ "note: VOS_API_BASE is deprecated \u2014 set VOS_ORIGIN (an origin, no /api suffix)\n"
3617
+ );
3684
3618
  }
3685
- return { config: out, text: boxes, slots: slotRects, aspect, missing };
3619
+ return raw.replace(/\/+$/, "").replace(/\/api$/, "");
3686
3620
  }
3687
- function textLimitProblems(t, values) {
3688
- const out = [];
3689
- for (const x of t.text) {
3690
- const v = values[x.param];
3691
- if (typeof v !== "string") continue;
3692
- const words = v.trim().split(/\s+/).filter(Boolean).length;
3693
- const lines = v.split("\n").length;
3694
- if (x.maxWords && words > x.maxWords)
3695
- out.push(`${x.param}: ${words} words, the template holds ${x.maxWords}`);
3696
- if (x.lines && lines > x.lines)
3697
- out.push(`${x.param}: ${lines} lines, the template holds ${x.lines}`);
3621
+ function parseVosId(input) {
3622
+ if (!/^https?:\/\//.test(input)) {
3623
+ if (/^[A-Za-z0-9_-]+$/.test(input)) return input;
3624
+ throw new UsageError(`"${input}" is not a vos id or URL`);
3698
3625
  }
3699
- return out;
3700
- }
3701
-
3702
- // src/plugin/templates/cardOnGradient.ts
3703
- var GROUND = `(ctx) => {
3704
- const THREE = ctx.THREE
3705
- const d = ctx.data
3706
- const canvas = document.createElement('canvas')
3707
- canvas.width = 1280
3708
- canvas.height = 720
3709
- const c = canvas.getContext('2d')
3710
- c.fillStyle = d.bgA
3711
- c.fillRect(0, 0, 1280, 720)
3712
- const blob = (x, y, r, color) => {
3713
- const g = c.createRadialGradient(x, y, 0, x, y, r)
3714
- g.addColorStop(0, color)
3715
- g.addColorStop(1, 'rgba(0,0,0,0)')
3716
- c.fillStyle = g
3717
- c.fillRect(0, 0, 1280, 720)
3718
- }
3719
- blob(180, 120, 760, d.blobA)
3720
- blob(1120, 620, 820, d.blobB)
3721
- blob(760, 60, 620, d.blobC)
3722
- let seed = 7
3723
- const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 }
3724
- const img = c.getImageData(0, 0, 1280, 720)
3725
- const px = img.data
3726
- const grain = d.grain
3727
- for (let i = 0; i < px.length; i += 4) {
3728
- const n = (rand() - 0.5) * grain
3729
- px[i] += n; px[i + 1] += n; px[i + 2] += n
3730
- }
3731
- c.putImageData(img, 0, 0)
3732
- const tex = new THREE.CanvasTexture(canvas)
3733
- tex.colorSpace = THREE.SRGBColorSpace
3734
- const plane = new THREE.Mesh(
3735
- new THREE.PlaneGeometry(2, 2),
3736
- new THREE.MeshBasicMaterial({ map: tex, depthTest: false }),
3737
- )
3738
- plane.renderOrder = -10
3739
- ctx.scene.add(plane)
3740
- return { refs: { plane }, dispose: () => { tex.dispose(); plane.geometry.dispose(); plane.material.dispose() } }
3741
- }`;
3742
- var TIMELINE = `(ctx, content, duration) => {
3743
- const { gsap, elements } = ctx
3744
- const tl = gsap.timeline({ paused: true })
3745
- const shot = elements.get('shot')
3746
- if (shot) tl.fromTo(shot.props, { opacity: 0, translateY: 30, scale: 0.97 }, { opacity: 1, translateY: 0, scale: 1, duration: 1.0, ease: 'power3.out' }, 0.2)
3747
- return tl
3748
- }`;
3749
- function cardOnGradient() {
3750
- return {
3751
- version: 2,
3752
- duration: 4,
3753
- camera: { preset: "fullscreen" },
3754
- template: {
3755
- family: "card-on-gradient",
3756
- slots: [{ id: "shot", kind: "image", required: true }],
3757
- params: { required: [], brand: ["bgA", "blobA", "blobB", "blobC", "grain"] },
3758
- text: [],
3759
- layouts: {
3760
- landscape: { slots: { shot: { x: 0.09, y: 0.12, w: 0.82 } } },
3761
- square: { slots: { shot: { x: 0.08, y: 0.27, w: 0.84 } } },
3762
- portrait: { slots: { shot: { x: 0.06, y: 0.3, w: 0.88 } } }
3763
- }
3764
- },
3765
- params: [
3766
- { key: "bgA", type: "color", label: "Ground", default: "#f3efe8" },
3767
- { key: "blobA", type: "color", label: "Blob A", default: "rgba(255,183,146,0.55)" },
3768
- { key: "blobB", type: "color", label: "Blob B", default: "rgba(170,196,255,0.5)" },
3769
- { key: "blobC", type: "color", label: "Blob C", default: "rgba(255,236,170,0.45)" },
3770
- { key: "grain", type: "number", label: "Grain", default: 14, min: 0, max: 40, step: 1 }
3771
- ],
3772
- data: {
3773
- bgA: "#f3efe8",
3774
- blobA: "rgba(255,183,146,0.55)",
3775
- blobB: "rgba(170,196,255,0.5)",
3776
- blobC: "rgba(255,236,170,0.45)",
3777
- grain: 14
3778
- },
3779
- elements: [
3780
- {
3781
- type: "image",
3782
- id: "shot",
3783
- src: "",
3784
- position: { x: "9%", y: "12%" },
3785
- anchor: "top-left",
3786
- size: { width: 1574, height: "auto", fit: "contain" },
3787
- zIndex: 60,
3788
- opacity: 0
3789
- }
3790
- ],
3791
- createContent: GROUND,
3792
- createTimeline: TIMELINE
3793
- };
3794
- }
3795
-
3796
- // src/plugin/templates/splitCover.ts
3797
- var GROUND2 = `(ctx) => {
3798
- const THREE = ctx.THREE
3799
- const d = ctx.data
3800
- const canvas = document.createElement('canvas')
3801
- canvas.width = 1280
3802
- canvas.height = 720
3803
- const c = canvas.getContext('2d')
3804
- const g = c.createLinearGradient(0, 0, 1280, 720)
3805
- g.addColorStop(0, d.bgA)
3806
- g.addColorStop(0.55, d.bgB)
3807
- g.addColorStop(1, d.bgC)
3808
- c.fillStyle = g
3809
- c.fillRect(0, 0, 1280, 720)
3810
- const rg = c.createRadialGradient(980, 120, 40, 980, 120, 820)
3811
- rg.addColorStop(0, d.streak)
3812
- rg.addColorStop(1, 'rgba(0,0,0,0)')
3813
- c.fillStyle = rg
3814
- c.fillRect(0, 0, 1280, 720)
3815
- let seed = 41
3816
- const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 }
3817
- const img = c.getImageData(0, 0, 1280, 720)
3818
- const px = img.data
3819
- const grain = d.grain
3820
- for (let i = 0; i < px.length; i += 4) {
3821
- const n = (rand() - 0.5) * grain
3822
- px[i] += n; px[i + 1] += n; px[i + 2] += n
3823
- }
3824
- c.putImageData(img, 0, 0)
3825
- const tex = new THREE.CanvasTexture(canvas)
3826
- tex.colorSpace = THREE.SRGBColorSpace
3827
- const plane = new THREE.Mesh(
3828
- new THREE.PlaneGeometry(2, 2),
3829
- new THREE.MeshBasicMaterial({ map: tex, depthTest: false }),
3830
- )
3831
- plane.renderOrder = -10
3832
- ctx.scene.add(plane)
3833
- return { refs: { plane }, dispose: () => { tex.dispose(); plane.geometry.dispose(); plane.material.dispose() } }
3834
- }`;
3835
- var TIMELINE2 = `(ctx, content, duration) => {
3836
- const { gsap, elements } = ctx
3837
- const tl = gsap.timeline({ paused: true })
3838
- const kicker = elements.get('kicker')
3839
- const title = elements.get('title')
3840
- const brand = elements.get('brand')
3841
- const shot = elements.get('shot')
3842
- if (kicker) tl.fromTo(kicker.props, { opacity: 0, translateY: 14 }, { opacity: 1, translateY: 0, duration: 0.7, ease: 'power2.out' }, 0.25)
3843
- if (title) tl.fromTo(title.props, { opacity: 0, translateY: 26 }, { opacity: 1, translateY: 0, duration: 0.9, ease: 'power3.out' }, 0.45)
3844
- if (brand) tl.fromTo(brand.props, { opacity: 0 }, { opacity: 1, duration: 0.8, ease: 'power2.out' }, 0.9)
3845
- if (shot) tl.fromTo(shot.props, { opacity: 0, translateX: 70 }, { opacity: 1, translateX: 0, duration: 1.1, ease: 'power3.out' }, 0.55)
3846
- return tl
3847
- }`;
3848
- function splitCover() {
3849
- return {
3850
- version: 2,
3851
- duration: 6,
3852
- camera: { preset: "fullscreen" },
3853
- fonts: [
3854
- { family: "Fraunces", url: "https://assets.vos.so/fonts/fraunces/600.woff2", weight: 600 },
3855
- { family: "Lexend", url: "https://assets.vos.so/fonts/lexend/700.woff2", weight: 700 },
3856
- { family: "JetBrains Mono", url: "https://assets.vos.so/fonts/jetbrains-mono/400.woff2", weight: 400 }
3857
- ],
3858
- template: {
3859
- family: "split-cover",
3860
- slots: [{ id: "shot", kind: "image", required: true }],
3861
- params: {
3862
- required: ["headline", "brand"],
3863
- brand: ["bgA", "bgB", "bgC", "ink", "inkSoft", "accent", "streak", "fontDisplay", "fontBody", "grain"]
3864
- },
3865
- text: [
3866
- { element: "title", param: "headline", role: "headline", maxWords: 8, lines: 3 },
3867
- { element: "kicker", param: "kicker", role: "body" },
3868
- { element: "brand", param: "brand", role: "body" }
3869
- ],
3870
- layouts: {
3871
- landscape: {
3872
- slots: { shot: { x: 0.46, y: 0.28, w: 1.02 } },
3873
- text: { kicker: { x: "7%", y: "24%" }, title: { x: "7%", y: "44%" }, brand: { x: "7%", y: "86%" } },
3874
- size: { title: 84, kicker: 19, brand: 25 }
3875
- },
3876
- square: {
3877
- slots: { shot: { x: 0.1, y: 0.5, w: 1 } },
3878
- text: { kicker: { x: "8%", y: "12%" }, title: { x: "8%", y: "27%" }, brand: { x: "8%", y: "93%" } },
3879
- size: { title: 72, kicker: 18, brand: 23 }
3880
- },
3881
- portrait: {
3882
- slots: { shot: { x: 0.08, y: 0.44, w: 1.06 } },
3883
- text: { kicker: { x: "8%", y: "12%" }, title: { x: "8%", y: "26%" }, brand: { x: "8%", y: "94%" } },
3884
- size: { title: 64, kicker: 17, brand: 22 }
3885
- }
3886
- }
3887
- },
3888
- params: [
3889
- { key: "kicker", type: "text", label: "Kicker", default: "RELEASE" },
3890
- { key: "headline", type: "text", label: "Headline", default: "What shipped,\nin three lines" },
3891
- { key: "brand", type: "text", label: "Wordmark", default: "brand" },
3892
- { key: "bgA", type: "color", label: "Ground A", default: "#8a3d2a" },
3893
- { key: "bgB", type: "color", label: "Ground B", default: "#c0632f" },
3894
- { key: "bgC", type: "color", label: "Ground C", default: "#5c5a2e" },
3895
- { key: "ink", type: "color", label: "Ink", default: "#fff6ec" },
3896
- { key: "inkSoft", type: "color", label: "Ink, softened", default: "#e9d9c8" },
3897
- { key: "streak", type: "color", label: "Light streak", default: "rgba(255,220,170,0.18)" },
3898
- { key: "fontDisplay", type: "text", label: "Headline face", default: "Fraunces, serif" },
3899
- { key: "fontBody", type: "text", label: "Body face", default: "Lexend, sans-serif" },
3900
- { key: "grain", type: "number", label: "Grain", default: 22, min: 0, max: 40, step: 1 }
3901
- ],
3902
- data: {
3903
- kicker: "RELEASE",
3904
- headline: "What shipped,\nin three lines",
3905
- brand: "brand",
3906
- bgA: "#8a3d2a",
3907
- bgB: "#c0632f",
3908
- bgC: "#5c5a2e",
3909
- ink: "#fff6ec",
3910
- inkSoft: "#e9d9c8",
3911
- streak: "rgba(255,220,170,0.18)",
3912
- fontDisplay: "Fraunces, serif",
3913
- fontBody: "Lexend, sans-serif",
3914
- grain: 22
3915
- },
3916
- elements: [
3917
- {
3918
- type: "text",
3919
- id: "kicker",
3920
- content: { $data: "kicker" },
3921
- position: { x: "7%", y: "24%" },
3922
- anchor: "center-left",
3923
- font: { family: "JetBrains Mono, monospace", size: 19, weight: 400, color: { $data: "inkSoft" }, letterSpacing: 6, align: "left" },
3924
- opacity: 0
3925
- },
3926
- {
3927
- type: "text",
3928
- id: "title",
3929
- content: { $data: "headline" },
3930
- position: { x: "7%", y: "44%" },
3931
- anchor: "center-left",
3932
- font: { family: { $data: "fontDisplay" }, size: 84, weight: 600, color: { $data: "ink" }, lineHeight: 1.08, align: "left", letterSpacing: 0 },
3933
- opacity: 0
3934
- },
3935
- {
3936
- type: "text",
3937
- id: "brand",
3938
- content: { $data: "brand" },
3939
- position: { x: "7%", y: "86%" },
3940
- anchor: "center-left",
3941
- font: { family: { $data: "fontBody" }, size: 25, weight: 700, color: { $data: "ink" }, letterSpacing: 1, align: "left" },
3942
- opacity: 0
3943
- },
3944
- {
3945
- type: "image",
3946
- id: "shot",
3947
- src: "",
3948
- position: { x: "46%", y: "28%" },
3949
- anchor: "top-left",
3950
- size: { width: 1400, height: "auto", fit: "contain" },
3951
- zIndex: 60,
3952
- opacity: 0
3953
- }
3954
- ],
3955
- createContent: GROUND2,
3956
- createTimeline: TIMELINE2
3957
- };
3626
+ let url;
3627
+ try {
3628
+ url = new URL(input);
3629
+ } catch {
3630
+ throw new UsageError(`"${input}" is not a valid URL`);
3631
+ }
3632
+ const query = url.searchParams.get("vos");
3633
+ if (query) return query;
3634
+ const path = url.pathname.match(/\/vos\/([A-Za-z0-9_-]+)/);
3635
+ if (path) return path[1];
3636
+ throw new UsageError(
3637
+ `could not find a vos id in ${input} \u2014 expected /vos/{id} or ?vos={id}`
3638
+ );
3958
3639
  }
3959
-
3960
- // src/plugin/templates/index.ts
3961
- var TEMPLATES = {
3962
- "split-cover": splitCover,
3963
- "card-on-gradient": cardOnGradient
3964
- };
3965
- var TEMPLATE_NAMES = Object.keys(TEMPLATES);
3966
- function templateByName(name) {
3967
- const make = TEMPLATES[name];
3968
- return make ? make() : null;
3640
+ function deriveSlug(title) {
3641
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50).replace(/-+$/, "") || "remix";
3969
3642
  }
3970
-
3971
- // src/plugin/posterValues.ts
3972
- import {
3973
- findFontFamily as findFontFamily2,
3974
- fontFaceUrl,
3975
- fontStack,
3976
- nearestFontWeight
3977
- } from "@vosjs/shared";
3978
- var HEX = /^#([0-9a-f]{6})$/i;
3979
- var rgb = (hex2) => {
3980
- const m = HEX.exec(hex2.trim());
3981
- if (!m) return null;
3982
- const n = parseInt(m[1], 16);
3983
- return [n >> 16 & 255, n >> 8 & 255, n & 255];
3984
- };
3985
- var toHex = (c) => "#" + c.map(
3986
- (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")
3987
- ).join("");
3988
- function mixHex(a, b, t) {
3989
- const pa = rgb(a);
3990
- const pb = rgb(b);
3991
- if (!pa || !pb) return a;
3992
- return toHex([
3993
- pa[0] + (pb[0] - pa[0]) * t,
3994
- pa[1] + (pb[1] - pa[1]) * t,
3995
- pa[2] + (pb[2] - pa[2]) * t
3996
- ]);
3643
+ var CREDENTIALS_PATH = join5(homedir(), ".config", "vos", "credentials");
3644
+ function resolveCredential(explicit) {
3645
+ const flag = explicit?.trim();
3646
+ if (flag) return flag;
3647
+ const env = process.env.VOS_API_KEY?.trim();
3648
+ if (env) return env;
3649
+ try {
3650
+ const first = readFileSync2(CREDENTIALS_PATH, "utf8").split("\n")[0].trim();
3651
+ return first || null;
3652
+ } catch {
3653
+ return null;
3654
+ }
3997
3655
  }
3998
- function rgba(hex2, alpha) {
3999
- const c = rgb(hex2) ?? [255, 255, 255];
4000
- return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`;
4001
- }
4002
- function isLightHex(hex2) {
4003
- const c = rgb(hex2);
4004
- if (!c) return true;
4005
- return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255 >= 0.6;
4006
- }
4007
- function resolveFace(family, weights) {
4008
- if (!family) return null;
4009
- const first = family.split(",")[0].replace(/['"]/g, "").replace(/\s+variable$/i, "").trim();
4010
- const entry = findFontFamily2(first);
4011
- if (!entry) return null;
4012
- const fonts = [
4013
- ...new Set(weights.map((w) => nearestFontWeight(entry, w)))
4014
- ].map((w) => ({
4015
- family: entry.family,
4016
- url: fontFaceUrl(entry.slug, w),
4017
- weight: w
4018
- }));
4019
- return { stack: fontStack(entry), category: entry.category, fonts };
4020
- }
4021
- function posterValues(brand, words) {
4022
- const b = brand ?? {};
4023
- const bgA = b.bgA && HEX.test(b.bgA) ? b.bgA : null;
4024
- const bgB = b.bgB && HEX.test(b.bgB) ? b.bgB : bgA;
4025
- const bgC = b.bgC && HEX.test(b.bgC) ? b.bgC : bgB;
4026
- const ink = b.ink && HEX.test(b.ink) ? b.ink : null;
4027
- const accent = b.accent && HEX.test(b.accent) ? b.accent : null;
4028
- const wordmark = (words.brand ?? b.wordmark ?? b.name ?? "").trim();
4029
- const release = (words.release ?? "").trim();
4030
- const lightGround = bgA ? isLightHex(bgA) : false;
4031
- const values = {};
4032
- if (bgA) values.bgA = bgA;
4033
- if (bgB) values.bgB = bgB;
4034
- if (bgC) values.bgC = bgC;
4035
- if (ink) values.ink = ink;
4036
- if (accent) values.accent = accent;
4037
- if (ink && bgA) values.inkSoft = mixHex(ink, bgA, 0.28);
4038
- if (accent) values.streak = rgba(accent, lightGround ? 0.12 : 0.18);
4039
- if (bgA) values.grain = lightGround ? 10 : 22;
4040
- if (accent) values.blobA = rgba(accent, lightGround ? 0.28 : 0.4);
4041
- if (bgC) values.blobB = rgba(bgC, 0.75);
4042
- if (accent)
4043
- values.blobC = rgba(
4044
- mixHex(accent, "#ffffff", 0.55),
4045
- lightGround ? 0.35 : 0.25
3656
+ function requireCredential(explicit) {
3657
+ const key = resolveCredential(explicit);
3658
+ if (!key) {
3659
+ throw new Error(
3660
+ "no credential found \u2014 pass --key, set VOS_API_KEY, or run `vos login` (mint a content key at https://vos.so/app/api; a vos_rg_ remix grant works too)"
4046
3661
  );
4047
- if (wordmark) values.brand = wordmark;
4048
- const headline = (words.headline ?? "").trim();
4049
- if (headline) values.headline = headline;
4050
- const kicker = (words.kicker ?? "").trim();
4051
- values.kicker = kicker ? kicker : [wordmark, release].filter(Boolean).join(" ").toUpperCase();
4052
- const fonts = [];
4053
- const display = resolveFace(b.fontDisplay, [600, 700]);
4054
- if (display) {
4055
- values.fontDisplay = display.stack;
4056
- fonts.push(...display.fonts);
4057
- }
4058
- const body = resolveFace(b.fontBody, [700]);
4059
- if (body) {
4060
- values.fontBody = body.stack;
4061
- fonts.push(...body.fonts);
4062
3662
  }
4063
- return { values, fonts, lightGround };
3663
+ return key;
4064
3664
  }
4065
-
4066
- // src/plugin/stages.ts
4067
- var str = (v, fallback) => typeof v === "string" && v.trim() ? v.trim() : fallback;
4068
- var firstFamily = (stack) => stack.split(",")[0].replace(/['"]/g, "").trim();
4069
- function stageSplitCover(input) {
4070
- const R = input.size.w / Math.max(1, input.size.h);
4071
- const portrait = R < 0.87;
4072
- const square = !portrait && R <= 1.15;
4073
- const v = input.values;
4074
- const ground = `linear-gradient(135deg, ${str(v.bgA, "#8a3d2a")}, ${str(v.bgC, str(v.bgB, "#5c5a2e"))})`;
4075
- const ink = str(v.ink, "#fff6ec");
4076
- const inkSoft = str(v.inkSoft, ink);
4077
- const display = firstFamily(str(v.fontDisplay, "Lexend"));
4078
- const body = firstFamily(str(v.fontBody, "Lexend"));
4079
- const headline = str(v.headline, "");
4080
- const kicker = str(v.kicker, "");
4081
- const brand = str(v.brand, "");
4082
- const inset = portrait ? { left: 0.08, right: -0.08, top: 0.44, bottom: -0.1 } : square ? { left: 0.1, right: -0.1, top: 0.5, bottom: -0.12 } : { left: 0.44, right: -0.06, top: 0.3, bottom: -0.12 };
4083
- const shot = {
4084
- x: inset.left,
4085
- y: inset.top,
4086
- w: 1 - inset.left - inset.right,
4087
- h: 1 - inset.top - inset.bottom
4088
- };
4089
- const set = [
4090
- `frame.background=${JSON.stringify(ground)}`,
4091
- "frame.backgroundMedia=null",
4092
- "frame.fit=cover",
4093
- `frame.inset=${JSON.stringify(inset)}`,
4094
- 'frame.focus={"cx":0,"cy":0}',
4095
- "frame.radius=16",
4096
- "frame.shadow=0.5",
4097
- "frame.shadowContact=0",
4098
- "frame.border=0",
4099
- "zoom=[]",
4100
- `tilt=[{"id":"stage","in":0,"out":${input.sourceSeconds.toFixed(3)},"rx":3,"ry":${portrait || square ? 0 : 10}}]`,
4101
- "cursor.visible=false",
4102
- "cursor.clickFx.style=none"
4103
- ];
4104
- const designW = 1080 * input.size.w / input.size.h;
4105
- const boxes = [];
4106
- const clips = [];
4107
- const column = portrait || square ? 0.84 : 0.36;
4108
- const left = portrait || square ? 0.08 : 0.07;
4109
- const size = portrait ? 60 : square ? 68 : 84;
4110
- const word = (id, text, preset, family, px, y, color, extra, role, x0 = left) => {
4111
- if (!text) return;
4112
- const lines = text.split("\n");
4113
- const longest = Math.max(...lines.map((l) => l.length));
4114
- const widest = Math.min(
4115
- column,
4116
- longest * px * (preset === "title" ? 0.5 : 0.62) / designW
4117
- );
4118
- const lh = preset === "title" ? 1.05 : 1.2;
4119
- const h = lines.length * px * lh / 1080;
4120
- clips.push({
4121
- id,
4122
- kind: "text",
4123
- text,
4124
- preset,
4125
- family,
4126
- size: px,
4127
- color,
4128
- align: "left",
4129
- maxWidth: column,
4130
- lineHeight: lh,
4131
- transform: { x: x0 + widest / 2, y, scale: 1, rotation: 0 },
4132
- // Words on the ground carry no footage shadow: a drop shadow under a
4133
- // headline on a plate is the old-web tell.
4134
- shadow: 0,
4135
- start: 0,
4136
- duration: +input.outputSeconds.toFixed(3),
4137
- enter: "none",
4138
- exit: "none",
4139
- ...extra
4140
- });
4141
- boxes.push({
4142
- x: x0,
4143
- y: y - h / 2,
4144
- w: widest,
4145
- h,
4146
- color,
4147
- role,
4148
- label: text.length > 24 ? `${text.slice(0, 24)}\u2026` : text
4149
- });
4150
- };
4151
- const markKey = str(v.logoKey, "");
4152
- const markAspect = typeof v.logoAspect === "number" && v.logoAspect > 0 ? v.logoAspect : 1;
4153
- const wideMark = !!markKey && markAspect > 2.2;
4154
- const lockup = (px, y) => {
4155
- if (!markKey) return left;
4156
- const hPx = wideMark ? px * 1.6 : px * 1.3;
4157
- const w = Math.min(column, hPx * markAspect / designW);
4158
- clips.push({
4159
- id: "stage-mark",
4160
- kind: "image",
4161
- key: markKey,
4162
- width: +w.toFixed(4),
4163
- radius: 0,
4164
- shadow: "none",
4165
- transform: { x: left + w / 2, y, scale: 1, rotation: 0 },
4166
- start: 0,
4167
- duration: +input.outputSeconds.toFixed(3),
4168
- enter: "none",
4169
- exit: "none"
4170
- });
4171
- return left + w + px * 0.5 / designW;
4172
- };
4173
- const kickerY = portrait ? 0.1 : square ? 0.12 : 0.24;
4174
- const titleY = portrait ? 0.24 : square ? 0.28 : 0.46;
4175
- const brandY = portrait ? 0.94 : square ? 0.94 : 0.86;
4176
- word(
4177
- "stage-kicker",
4178
- kicker,
4179
- "label",
4180
- "JetBrains Mono",
4181
- 19,
4182
- kickerY,
4183
- inkSoft,
4184
- { letterSpacing: 6, weight: 400 },
4185
- "body"
4186
- );
4187
- word(
4188
- "stage-title",
4189
- headline,
4190
- "title",
4191
- display,
4192
- size,
4193
- titleY,
4194
- ink,
4195
- { weight: 600, letterSpacing: -Math.round(size * 0.02) },
4196
- "headline"
4197
- );
4198
- const brandX = lockup(25, brandY);
4199
- if (!wideMark)
4200
- word(
4201
- "stage-brand",
4202
- brand,
4203
- "label",
4204
- body,
4205
- 25,
4206
- brandY,
4207
- ink,
4208
- { weight: 700, letterSpacing: 1 },
4209
- "body",
4210
- brandX
4211
- );
4212
- set.push(`overlays=${JSON.stringify(clips)}`);
4213
- return { set, text: boxes, shot };
3665
+ function writeCredential(key) {
3666
+ mkdirSync(join5(homedir(), ".config", "vos"), { recursive: true, mode: 448 });
3667
+ writeFileSync(CREDENTIALS_PATH, `${key.trim()}
3668
+ `, { mode: 384 });
3669
+ return CREDENTIALS_PATH;
4214
3670
  }
4215
- var TILE_MAX_PX = 700;
4216
- function isTileSize(size) {
4217
- return Math.max(size.w, size.h) < TILE_MAX_PX;
4218
- }
4219
- function stageTile(input) {
4220
- const R = input.size.w / Math.max(1, input.size.h);
4221
- const square = R <= 1.15;
4222
- const v = input.values;
4223
- const ground = `linear-gradient(135deg, ${str(v.bgA, "#8a3d2a")}, ${str(v.bgC, str(v.bgB, "#5c5a2e"))})`;
4224
- const ink = str(v.ink, "#fff6ec");
4225
- const display = firstFamily(str(v.fontDisplay, "Lexend"));
4226
- const headline = str(v.headline, "") || str(v.brand, "Release");
4227
- const wordless = input.text === "none";
4228
- const aspect = input.footageAspect && input.footageAspect > 0 ? input.footageAspect : 16 / 9;
4229
- const left = square ? 0.08 : 0.07;
4230
- const top = wordless ? square ? 0.1 : 0.12 : square ? 0.42 : 0.47;
4231
- const visible = wordless ? square ? 0.66 : 0.8 : square ? 0.56 : 0.62;
4232
- const cardW = (1 - left) / visible;
4233
- const cardH = cardW * R / aspect;
4234
- const inset = {
4235
- left,
4236
- right: +(1 - left - cardW).toFixed(4),
4237
- top,
4238
- bottom: +(1 - top - cardH).toFixed(4)
4239
- };
4240
- const shot = {
4241
- x: inset.left,
4242
- y: inset.top,
4243
- w: 1 - inset.left - inset.right,
4244
- h: 1 - inset.top - inset.bottom
4245
- };
4246
- const set = [
4247
- `frame.background=${JSON.stringify(ground)}`,
4248
- "frame.backgroundMedia=null",
4249
- "frame.fit=cover",
4250
- `frame.inset=${JSON.stringify(inset)}`,
4251
- 'frame.focus={"cx":0,"cy":0}',
4252
- "frame.radius=24",
4253
- "frame.shadow=0.5",
4254
- "frame.shadowContact=0",
4255
- "frame.border=0",
4256
- "zoom=[]",
4257
- "tilt=[]",
4258
- "cursor.visible=false",
4259
- "cursor.clickFx.style=none"
4260
- ];
4261
- const designW = 1080 * input.size.w / input.size.h;
4262
- const column = square ? 0.84 : 0.86;
4263
- const px = square ? 96 : 108;
4264
- const lines = headline.split("\n");
4265
- const longest = Math.max(...lines.map((l) => l.length));
4266
- const widest = Math.min(column, longest * px * 0.5 / designW);
4267
- const lh = 1.05;
4268
- const h = lines.length * px * lh / 1080;
4269
- const y = square ? 0.2 : 0.24;
4270
- const clip = {
4271
- id: "stage-title",
4272
- kind: "text",
4273
- text: headline,
4274
- preset: "title",
4275
- family: display,
4276
- size: px,
4277
- color: ink,
4278
- align: "left",
4279
- maxWidth: column,
4280
- lineHeight: lh,
4281
- weight: 600,
4282
- letterSpacing: -Math.round(px * 0.02),
4283
- transform: { x: left + widest / 2, y, scale: 1, rotation: 0 },
4284
- shadow: 0,
4285
- start: 0,
4286
- duration: +input.outputSeconds.toFixed(3),
4287
- enter: "none",
4288
- exit: "none"
3671
+ function clientId() {
3672
+ const env = process.env.VOS_CLIENT?.trim();
3673
+ if (env && env.length <= 60 && !/[\r\n]/.test(env)) return env;
3674
+ return "vos-cli";
3675
+ }
3676
+ async function apiJson(origin, path, init = {}) {
3677
+ const headers = {
3678
+ accept: "application/json",
3679
+ ...init.headers
4289
3680
  };
4290
- if (wordless) {
4291
- const markKey = str(v.logoKey, "");
4292
- const markAspect = typeof v.logoAspect === "number" && v.logoAspect > 0 ? v.logoAspect : 1;
4293
- if (markKey) {
4294
- const hFrac = top * 0.5;
4295
- const w = Math.min(
4296
- 0.8,
4297
- hFrac * input.size.h * markAspect / input.size.w
4298
- );
4299
- set.push(
4300
- `overlays=${JSON.stringify([
4301
- {
4302
- id: "stage-mark",
4303
- kind: "image",
4304
- key: markKey,
4305
- width: +w.toFixed(4),
4306
- radius: 0,
4307
- shadow: "none",
4308
- transform: { x: left + w / 2, y: top / 2, scale: 1, rotation: 0 },
4309
- start: 0,
4310
- duration: +input.outputSeconds.toFixed(3),
4311
- enter: "none",
4312
- exit: "none"
4313
- }
4314
- ])}`
4315
- );
4316
- } else set.push("overlays=[]");
4317
- return { set, text: [], shot };
3681
+ if (init.key) headers.authorization = `Bearer ${init.key}`;
3682
+ if (init.body !== void 0) headers["content-type"] = "application/json";
3683
+ const reqBody = init.raw !== void 0 ? init.raw : init.body === void 0 ? void 0 : JSON.stringify(init.body);
3684
+ const res = await fetch(`${origin}${path}`, {
3685
+ method: init.method ?? "GET",
3686
+ headers,
3687
+ body: reqBody
3688
+ });
3689
+ let body = {};
3690
+ try {
3691
+ body = await res.json();
3692
+ } catch {
4318
3693
  }
4319
- set.push(`overlays=${JSON.stringify([clip])}`);
4320
- const text = [
4321
- {
4322
- x: left,
4323
- y: y - h / 2,
4324
- w: widest,
4325
- h,
4326
- color: ink,
4327
- role: "headline",
4328
- label: headline.length > 24 ? `${headline.slice(0, 24)}\u2026` : headline
4329
- }
4330
- ];
4331
- return { set, text, shot };
3694
+ return { status: res.status, body };
4332
3695
  }
4333
-
4334
- // src/plugin/markAsset.ts
4335
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile6 } from "fs/promises";
4336
- import { existsSync as existsSync5 } from "fs";
4337
- import { isAbsolute, join as join5, resolve as resolve3 } from "path";
4338
- function markExtension(url) {
4339
- const m = /\.(svg|png|webp|jpe?g)(?:[?#]|$)/i.exec(url);
4340
- if (!m) return null;
4341
- const e = m[1].toLowerCase();
4342
- return e === "jpeg" ? "jpg" : e;
3696
+ function apiError(what, r) {
3697
+ const detail = typeof r.body.error === "string" ? r.body.error : r.body.details !== void 0 ? JSON.stringify(r.body.details) : "";
3698
+ const hint = r.status === 401 ? " (run `vos login`, or set VOS_API_KEY \u2014 mint a key at https://vos.so/app/api)" : "";
3699
+ return `${what} \u2192 ${r.status}${detail ? `: ${detail}` : ""}${hint}`;
4343
3700
  }
4344
- function imageAspect(bytes, ext) {
4345
- if (ext === "svg") {
4346
- const head = new TextDecoder().decode(bytes.subarray(0, 4096));
4347
- const vb = /viewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(
4348
- head
4349
- );
4350
- if (vb) return ratio(+vb[1], +vb[2]);
4351
- const w = /<svg[^>]*\swidth\s*=\s*["']([\d.]+)/i.exec(head);
4352
- const h = /<svg[^>]*\sheight\s*=\s*["']([\d.]+)/i.exec(head);
4353
- if (w && h) return ratio(+w[1], +h[1]);
3701
+ var SYNC_STATE_NAME = "vos.json";
3702
+ function readJsonFile(file) {
3703
+ try {
3704
+ return JSON.parse(readFileSync2(file, "utf8"));
3705
+ } catch {
4354
3706
  return null;
4355
3707
  }
4356
- const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
4357
- if (ext === "png") {
4358
- if (bytes.length < 24 || bytes[0] !== 137 || bytes[1] !== 80) return null;
4359
- return ratio(dv.getUint32(16), dv.getUint32(20));
3708
+ }
3709
+ function readSyncState(dir) {
3710
+ const own = readJsonFile(join5(dir, SYNC_STATE_NAME));
3711
+ if (own && typeof own.vosId === "string") {
3712
+ return {
3713
+ vosId: own.vosId,
3714
+ versionId: typeof own.versionId === "string" ? own.versionId : null,
3715
+ ...typeof own.pushedAt === "string" ? { pushedAt: own.pushedAt } : {},
3716
+ ...typeof own.title === "string" ? { title: own.title } : {},
3717
+ ...typeof own.slug === "string" ? { slug: own.slug } : {},
3718
+ ...typeof own.remixOfId === "string" ? { remixOfId: own.remixOfId } : {}
3719
+ };
4360
3720
  }
4361
- if (ext === "webp") {
4362
- if (bytes.length < 30) return null;
4363
- const tag = String.fromCharCode(...bytes.subarray(12, 16));
4364
- if (tag === "VP8X")
4365
- return ratio(
4366
- 1 + (dv.getUint32(24, true) & 16777215),
4367
- 1 + (dv.getUint32(27, true) & 16777215)
4368
- );
4369
- if (tag === "VP8L") {
4370
- const b = dv.getUint32(21, true);
4371
- return ratio(1 + (b & 16383), 1 + (b >> 14 & 16383));
4372
- }
4373
- if (tag === "VP8 ")
4374
- return ratio(
4375
- dv.getUint16(26, true) & 16383,
4376
- dv.getUint16(28, true) & 16383
4377
- );
4378
- return null;
3721
+ const push = readJsonFile(join5(dir, "push.json"));
3722
+ if (push && typeof push.vosId === "string") {
3723
+ return {
3724
+ vosId: push.vosId,
3725
+ versionId: typeof push.versionId === "string" ? push.versionId : null,
3726
+ ...typeof push.pushedAt === "string" ? { pushedAt: push.pushedAt } : {}
3727
+ };
4379
3728
  }
4380
- if (ext === "jpg") {
4381
- let i = 2;
4382
- while (i + 9 < bytes.length) {
4383
- if (bytes[i] !== 255) return null;
4384
- const marker = bytes[i + 1];
4385
- const len = dv.getUint16(i + 2);
4386
- if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
4387
- return ratio(dv.getUint16(i + 7), dv.getUint16(i + 5));
4388
- }
4389
- i += 2 + len;
4390
- }
4391
- return null;
3729
+ const meta = readJsonFile(join5(dir, "meta.json"));
3730
+ if (meta && typeof meta.id === "string") {
3731
+ return {
3732
+ vosId: meta.id,
3733
+ versionId: typeof meta.currentVersionId === "string" ? meta.currentVersionId : null,
3734
+ ...typeof meta.title === "string" ? { title: meta.title } : {},
3735
+ ...typeof meta.slug === "string" ? { slug: meta.slug } : {},
3736
+ ...typeof meta.remixOfId === "string" ? { remixOfId: meta.remixOfId } : {}
3737
+ };
4392
3738
  }
4393
3739
  return null;
4394
3740
  }
4395
- function ratio(w, h) {
4396
- return w > 0 && h > 0 ? w / h : null;
4397
- }
4398
- async function fetchBrandMarks(takeDir, roles) {
4399
- const out = { light: null, dark: null, notes: [] };
4400
- if (!roles) return out;
4401
- const one = async (role, name) => {
4402
- const src = (roles[role] ?? "").trim();
4403
- if (!src) return null;
4404
- const ext = markExtension(src);
4405
- if (!ext) {
4406
- out.notes.push(
4407
- `${role}: ${src} is not an svg, png, webp or jpg; the wordmark stands in`
4408
- );
4409
- return null;
4410
- }
4411
- let bytes;
4412
- try {
4413
- if (/^https?:/i.test(src)) {
4414
- const res = await fetch(src);
4415
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
4416
- bytes = new Uint8Array(await res.arrayBuffer());
4417
- } else {
4418
- const file = isAbsolute(src) ? src : resolve3(takeDir, src);
4419
- if (!existsSync5(file)) throw new Error("no such file");
4420
- bytes = new Uint8Array(await readFile5(file));
4421
- }
4422
- } catch (e) {
4423
- out.notes.push(
4424
- `${role}: ${src} could not be read (${e instanceof Error ? e.message : String(e)}); the wordmark stands in`
4425
- );
4426
- return null;
4427
- }
4428
- const aspect = imageAspect(bytes, ext);
4429
- if (!aspect) {
4430
- out.notes.push(
4431
- `${role}: ${src} has no readable size; the wordmark stands in`
4432
- );
4433
- return null;
4434
- }
4435
- await mkdir4(join5(takeDir, "brand"), { recursive: true });
4436
- await writeFile6(join5(takeDir, "brand", `${name}.${ext}`), bytes);
4437
- return { key: `/brand/${name}.${ext}`, aspect };
3741
+ function writeSyncState(dir, patch) {
3742
+ const merged = {
3743
+ ...readSyncState(dir) ?? { versionId: null },
3744
+ ...patch
4438
3745
  };
4439
- out.light = await one("logoUrl", "mark");
4440
- out.dark = await one("logoOnDarkUrl", "mark-on-dark");
4441
- return out;
4442
- }
4443
-
4444
- // src/plugin/motionPlan.ts
4445
- import { ratedSegments as ratedSegments4, spanOutputExtent as spanOutputExtent4 } from "@vosjs/studio-core";
4446
- var SOUND_DESTINATIONS = /* @__PURE__ */ new Set([
4447
- "x-feed-cut",
4448
- "youtube-main-demo",
4449
- "shorts-linkedin-vertical-cut"
4450
- ]);
4451
- var LOOP_DESTINATIONS = /* @__PURE__ */ new Set(["github-readme-loop"]);
4452
- var off = (v) => v !== void 0 && /^(none|off|false|no)$/i.test(v.trim());
4453
- function pickTrack(catalog, ask) {
4454
- if (!catalog || !ask || off(ask)) return null;
4455
- const want = ask.trim().toLowerCase();
4456
- return catalog.tracks.find((t) => t.slug.toLowerCase() === want) ?? catalog.tracks.find((t) => (t.mood ?? "").toLowerCase() === want) ?? null;
4457
- }
4458
- function clickTimes(doc, range) {
4459
- const rated = ratedSegments4(doc);
4460
- const out = [];
4461
- for (const e of doc.source.cursor) {
4462
- if (e.type !== "down") continue;
4463
- const src = e.t / 1e3;
4464
- const ext = spanOutputExtent4(rated, src, src + 1e-3);
4465
- if (!ext) continue;
4466
- const t = ext.start;
4467
- if (t < range[0] || t > range[1]) continue;
4468
- if (out.length && t - out[out.length - 1] < 0.12) continue;
4469
- out.push(+(t - range[0]).toFixed(3));
4470
- }
4471
- return out;
3746
+ writeFileSync(
3747
+ join5(dir, SYNC_STATE_NAME),
3748
+ `${JSON.stringify(merged, null, 2)}
3749
+ `
3750
+ );
3751
+ return merged;
4472
3752
  }
4473
- function planMotion(input) {
4474
- const { destination: d, doc, range, words, launch, catalog } = input;
4475
- const set = [];
4476
- const notes = [];
4477
- const skipped = [];
4478
- if (d.kind !== "video") return { set, notes, skipped };
4479
- const loop = LOOP_DESTINATIONS.has(d.id);
4480
- const sound = SOUND_DESTINATIONS.has(d.id);
4481
- const length = range[1] - range[0];
4482
- const portrait = d.px.w / d.px.h < 0.9;
4483
- const entrance = launch.entrance;
4484
- if (!loop && !off(entrance)) {
4485
- const kind = entrance && /^(tilt-in|pull-out|rise)$/.test(entrance.trim()) ? entrance.trim() : "tilt-in";
4486
- set.push(`frame.entrance={"kind":"${kind}"}`);
4487
- notes.push(`entrance ${kind}`);
4488
- }
4489
- if (!loop && !off(launch.endCard)) {
4490
- const headline = (words.headline ?? "").trim();
4491
- const brand = (words.brand ?? "").trim();
4492
- const sub = [brand, (words.release ?? "").trim()].filter(Boolean).join(" ");
4493
- if (headline || brand) {
4494
- const card = { seconds: 2.5 };
4495
- if (input.ink) card.ink = input.ink;
4496
- if (headline) card.headline = headline;
4497
- if (sub && sub !== headline) card.sub = sub;
4498
- if (brand) card.wordmark = brand;
4499
- if (input.mark) card.mark = input.mark;
4500
- set.push(`endCard=${JSON.stringify(card)}`);
4501
- notes.push("end card");
4502
- } else {
4503
- skipped.push(
4504
- `${d.id}: no end card (no headline or wordmark in LAUNCH.md, BRAND.md or the flags)`
4505
- );
4506
- }
3753
+ function formatChanges(changes) {
3754
+ const lines = [];
3755
+ for (const ch of changes) {
3756
+ const label = ch.label ? ` \xB7 ${ch.label}` : "";
3757
+ const who = ch.origin === "studio" ? "human" : ch.origin ?? "unknown";
3758
+ lines.push(
3759
+ `v${String(ch.versionNumber ?? "?")} (${who}${label}): ${ch.summary ?? ""}`
3760
+ );
3761
+ if (ch.note) lines.push(` note: ${ch.note}`);
4507
3762
  }
4508
- if (!loop && d.text !== "none" && input.captions.length && !off(launch.captions)) {
4509
- const rated = ratedSegments4(doc);
4510
- const steps = doc.source.meta.steps ?? [];
4511
- const clips = [];
4512
- for (const c of input.captions) {
4513
- const step = steps.find(
4514
- (s) => c.id !== void 0 && s.id === c.id || s.step === c.step
4515
- );
4516
- if (!step || step.skipped) continue;
4517
- const t = stepOutputTime(rated, step, 0.2);
4518
- if (t === null || t < range[0] || t > range[1] - 1) continue;
4519
- const start = +(t - range[0]).toFixed(3);
4520
- clips.push({
4521
- id: `caption-${c.step}`,
4522
- kind: "text",
4523
- text: c.caption,
4524
- preset: "caption",
4525
- start,
4526
- duration: Math.min(3.5, Math.max(2.5, range[1] - t - 0.2)),
4527
- transform: { x: 0.5, y: portrait ? 0.8 : 0.86, scale: 1, rotation: 0 },
4528
- enter: "rise",
4529
- exit: "fade",
4530
- align: "center",
4531
- box: { color: "rgba(17,17,17,0.72)" }
4532
- });
4533
- }
4534
- if (clips.length) {
4535
- const existing = Array.isArray(doc.overlays) ? doc.overlays : [];
4536
- set.push(`overlays=${JSON.stringify([...existing, ...clips])}`);
4537
- notes.push(`${clips.length} caption(s)`);
4538
- }
3763
+ return lines;
3764
+ }
3765
+
3766
+ // src/plugin/posterDoc.ts
3767
+ var POSTER_CLASSES = [
3768
+ "landscape",
3769
+ "square",
3770
+ "portrait",
3771
+ "tile"
3772
+ ];
3773
+ var TILE_MAX_PX = 700;
3774
+ function posterClassFor(px) {
3775
+ if (Math.max(px.w, px.h) < TILE_MAX_PX) return "tile";
3776
+ const r = px.w / Math.max(1, px.h);
3777
+ if (r < 0.87) return "portrait";
3778
+ if (r <= 1.15) return "square";
3779
+ return "landscape";
3780
+ }
3781
+ var ROLE_ALL = "poster";
3782
+ var roleFor = (cls) => `poster-${cls}`;
3783
+ async function readPosterAt(cls, path, takeDir, from) {
3784
+ let file = path;
3785
+ let docDir = path;
3786
+ if (existsSync5(join6(path, "doc.json"))) {
3787
+ file = join6(path, "doc.json");
3788
+ } else if (!/\.json$/i.test(path) || !existsSync5(path)) {
3789
+ return null;
3790
+ } else {
3791
+ docDir = resolve3(path, "..");
4539
3792
  }
4540
- if (sound && !loop) {
4541
- const track = pickTrack(catalog, launch.music);
4542
- const clips = Array.isArray(doc.audio) ? [...doc.audio] : [];
4543
- if (track) {
4544
- const hasMic = !!doc.source.micKey;
4545
- const fadeOut = Math.min(2.5, length * 0.15);
4546
- clips.push({
4547
- id: "bed",
4548
- key: track.url,
4549
- name: track.title,
4550
- start: 0,
4551
- in: 0,
4552
- out: Math.min(track.duration, length),
4553
- duration: track.duration,
4554
- gain: hasMic ? 0.35 : 0.5,
4555
- fadeIn: 0.6,
4556
- fadeOut,
4557
- loop: track.duration < length,
4558
- loopLen: track.duration < length ? length : void 0,
4559
- duck: hasMic
4560
- });
4561
- notes.push(`bed ${track.slug}`);
4562
- } else if (launch.music && !off(launch.music)) {
4563
- skipped.push(
4564
- `${d.id}: music "${launch.music}" is not a catalog track or mood${catalog ? "" : " (the catalog could not be read)"}`
4565
- );
4566
- }
4567
- const click = catalog?.sfx.find((s) => s.slug === "sfx-click");
4568
- if (click && !doc.source.micKey && !off(launch.clicks)) {
4569
- const times = clickTimes(doc, range);
4570
- for (const [i, t] of times.entries()) {
4571
- clips.push({
4572
- id: `click-${i}`,
4573
- key: click.url,
4574
- name: click.title,
4575
- start: t,
4576
- in: 0,
4577
- out: click.duration,
4578
- duration: click.duration,
4579
- gain: 0.4,
4580
- fadeIn: 0,
4581
- fadeOut: 0
4582
- });
3793
+ let raw;
3794
+ try {
3795
+ raw = JSON.parse(await readFile5(file, "utf8"));
3796
+ } catch {
3797
+ return null;
3798
+ }
3799
+ if (!raw || typeof raw !== "object") return null;
3800
+ const source = raw.source;
3801
+ if (!source || typeof source.videoKey !== "string") return null;
3802
+ const doc = migrateHostedDoc(
3803
+ raw
3804
+ );
3805
+ const ownFootage = existsSync5(join6(docDir, "meta.json")) && existsSync5(join6(docDir, source.videoKey));
3806
+ const sync = readSyncState(docDir);
3807
+ return {
3808
+ cls,
3809
+ file,
3810
+ takeDir: ownFootage ? docDir : takeDir,
3811
+ ownFootage,
3812
+ doc,
3813
+ ...sync?.vosId ? { vosId: sync.vosId } : {},
3814
+ from
3815
+ };
3816
+ }
3817
+ async function findPosterDocs(takeDir, launchRoles) {
3818
+ const out = {};
3819
+ const at2 = (p) => isAbsolute(p) ? p : resolve3(takeDir, p);
3820
+ for (const cls of POSTER_CLASSES) {
3821
+ const named = launchRoles?.[roleFor(cls)] ?? launchRoles?.[ROLE_ALL];
3822
+ const candidates = [];
3823
+ if (named && !/^(none|off|no|false)$/i.test(named.trim())) {
3824
+ const role = launchRoles?.[roleFor(cls)] ? roleFor(cls) : ROLE_ALL;
3825
+ candidates.push({ path: at2(named.trim()), from: `LAUNCH.md ${role}` });
3826
+ }
3827
+ candidates.push({
3828
+ path: join6(takeDir, "poster", cls),
3829
+ from: `poster/${cls}`
3830
+ });
3831
+ candidates.push({ path: join6(takeDir, "poster"), from: "poster" });
3832
+ for (const c of candidates) {
3833
+ const ref = await readPosterAt(cls, c.path, takeDir, c.from);
3834
+ if (ref) {
3835
+ out[cls] = ref;
3836
+ break;
4583
3837
  }
4584
- if (times.length) notes.push(`${times.length} click sound(s)`);
4585
3838
  }
4586
- if (clips.length) set.push(`audio=${JSON.stringify(clips)}`);
4587
3839
  }
4588
- if (portrait) {
4589
- set.push("frame.fit=cover");
4590
- set.push('frame.inset={"left":0.06,"right":0.06,"top":0.17,"bottom":0.17}');
4591
- set.push("frame.focusFollow=camera");
4592
- notes.push("vertical reframe follows the camera");
3840
+ return out;
3841
+ }
3842
+ function posterStillTime(doc, duration) {
3843
+ const rest = docRestTime(doc);
3844
+ if (rest != null) return rest;
3845
+ return Math.max(0, duration - 1 / 30);
3846
+ }
3847
+ var DESIGN_H = 1080;
3848
+ function designFrame(px) {
3849
+ return {
3850
+ W: Math.max(2, Math.round(DESIGN_H * px.w / Math.max(1, px.h))),
3851
+ H: DESIGN_H
3852
+ };
3853
+ }
3854
+ function posterShotRect(doc, px) {
3855
+ const { W, H } = designFrame(px);
3856
+ const meta = doc.source.meta;
3857
+ const layout = computeCardLayout(
3858
+ doc.frame,
3859
+ {
3860
+ width: meta.captureWidth ?? meta.width,
3861
+ height: meta.captureHeight ?? meta.height
3862
+ },
3863
+ W,
3864
+ H
3865
+ );
3866
+ const r3 = (v) => Math.round(v * 1e3) / 1e3;
3867
+ return {
3868
+ x: r3(layout.cardX / W),
3869
+ y: r3(layout.cardY / H),
3870
+ w: r3(layout.cardW / W),
3871
+ h: r3(layout.cardH / H)
3872
+ };
3873
+ }
3874
+ function estimateWidth(text, font, letterSpacingPx = 0) {
3875
+ const m = /(\d+(?:\.\d+)?)px/.exec(font);
3876
+ const px = m ? Number(m[1]) : 32;
3877
+ return text.length * (px * 0.55 + letterSpacingPx);
3878
+ }
3879
+ function posterTextBoxes(doc, px, time) {
3880
+ const { W, H } = designFrame(px);
3881
+ const out = [];
3882
+ for (const clip of doc.overlays ?? []) {
3883
+ if (clip.kind !== "text") continue;
3884
+ if (time < clip.start || time > clip.start + clip.duration) continue;
3885
+ const rect = overlayRect(clip, estimateWidth, W, H);
3886
+ const style = resolveOverlayStyle(clip);
3887
+ const r3 = (v) => Math.round(v * 1e3) / 1e3;
3888
+ out.push({
3889
+ x: r3((rect.cx - rect.w / 2) / W),
3890
+ y: r3((rect.cy - rect.h / 2) / H),
3891
+ w: r3(rect.w / W),
3892
+ h: r3(rect.h / H),
3893
+ role: clip.preset === "title" ? "headline" : "body",
3894
+ label: clip.text.split("\n")[0].slice(0, 40),
3895
+ .../^#[0-9a-f]{6}$/i.test(style.color) ? { color: style.color } : {}
3896
+ });
4593
3897
  }
4594
- return { set, notes, skipped };
3898
+ return out;
4595
3899
  }
4596
3900
 
4597
3901
  // src/plugin/deliver.ts
@@ -4622,7 +3926,7 @@ function resolveChannels(raw) {
4622
3926
  return out;
4623
3927
  }
4624
3928
  async function readBrandBesideTake(dir, explicit) {
4625
- const candidates = explicit ? [explicit] : [join6(dir, "BRAND.md"), join6(dir, "..", "BRAND.md")];
3929
+ const candidates = explicit ? [explicit] : [join7(dir, "BRAND.md"), join7(dir, "..", "BRAND.md")];
4626
3930
  for (const file of candidates) {
4627
3931
  if (!existsSync6(file)) continue;
4628
3932
  const roles = parseFrontmatter(await readFile6(file, "utf8"));
@@ -4631,7 +3935,7 @@ async function readBrandBesideTake(dir, explicit) {
4631
3935
  return null;
4632
3936
  }
4633
3937
  async function readLaunchBesideTake(dir, explicit) {
4634
- const candidates = explicit ? [explicit] : [join6(dir, "LAUNCH.md"), join6(dir, "..", "LAUNCH.md")];
3938
+ const candidates = explicit ? [explicit] : [join7(dir, "LAUNCH.md"), join7(dir, "..", "LAUNCH.md")];
4635
3939
  for (const file of candidates) {
4636
3940
  if (!existsSync6(file)) continue;
4637
3941
  return { file, roles: parseFrontmatter(await readFile6(file, "utf8")) };
@@ -4659,28 +3963,6 @@ async function resolveLook(dir, opts) {
4659
3963
  roles
4660
3964
  };
4661
3965
  }
4662
- function templateForCard(d, opts) {
4663
- if (opts.poster === null) return null;
4664
- if (opts.poster) return { config: opts.poster.config, from: opts.poster.from };
4665
- if (!d.template) return null;
4666
- if (isTileSize(d.px)) {
4667
- const config2 = templateByName(d.template) ?? templateByName("card-on-gradient");
4668
- if (config2) return { config: config2, from: "stage tile", stage: "tile" };
4669
- }
4670
- let name = d.template;
4671
- let note;
4672
- const wants = templateOf(templateByName(name) ?? {});
4673
- const needsHeadline = wants?.text.some((t) => t.role === "headline");
4674
- if (needsHeadline && !opts.words?.headline?.trim()) {
4675
- name = "card-on-gradient";
4676
- note = `${d.id}: no headline (LAUNCH.md headline: or --headline), so the ${d.template} template stands down for card-on-gradient`;
4677
- }
4678
- const config = templateByName(name);
4679
- if (!config) return null;
4680
- if (name === "split-cover")
4681
- return { config, from: "stage split-cover", note, stage: "split-cover" };
4682
- return { config, from: `template ${name}`, note };
4683
- }
4684
3966
  function lookOverrides(look, placement, size, video, opts) {
4685
3967
  const inset = cardInset(look, size, video, placement);
4686
3968
  const set = [
@@ -4708,23 +3990,6 @@ function lookOverrides(look, placement, size, video, opts) {
4708
3990
  }
4709
3991
  return set;
4710
3992
  }
4711
- function endCardInk(look, brand) {
4712
- if (!look) return null;
4713
- const ground = look.ground;
4714
- const m = /#([0-9a-f]{6})/i.exec(ground);
4715
- const hex2 = m ? m[0] : null;
4716
- const light = look.kind === "plate" || (hex2 ? isLightHexGround(hex2) : look.kind === "gradient");
4717
- if (!light) return "#ffffff";
4718
- const ink = brand?.ink;
4719
- return ink && /^#[0-9a-f]{6}$/i.test(ink) ? ink : "#111111";
4720
- }
4721
- function isLightHexGround(hex2) {
4722
- const n = parseInt(hex2.slice(1), 16);
4723
- const r = n >> 16 & 255;
4724
- const g = n >> 8 & 255;
4725
- const b = n & 255;
4726
- return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 >= 0.6;
4727
- }
4728
3993
  var PROBE_WIDTH = 640;
4729
3994
  async function pickStillTimes(browser, dir, doc, duration, opts) {
4730
3995
  const { candidates, dropped } = momentCandidates(doc, duration);
@@ -4737,7 +4002,7 @@ async function pickStillTimes(browser, dir, doc, duration, opts) {
4737
4002
  const meta = doc.source.meta;
4738
4003
  const vw = meta.captureWidth ?? meta.width;
4739
4004
  const vh = meta.captureHeight ?? meta.height;
4740
- const probeDir = await mkdtemp(join6(tmpdir(), "vos-moments-"));
4005
+ const probeDir = await mkdtemp(join7(tmpdir(), "vos-moments-"));
4741
4006
  try {
4742
4007
  const probe = await framesTake(browser, dir, {
4743
4008
  times: candidates.map((c) => c.time),
@@ -4822,16 +4087,10 @@ async function deliverTake(browser, dir, opts) {
4822
4087
  const take = await loadTake(dir);
4823
4088
  if (!take.doc) throw new Error(`${dir} has no doc.json \u2014 run plan first`);
4824
4089
  const doc = take.doc;
4825
- const marks = await fetchBrandMarks(dir, opts.brandRoles);
4826
- for (const n of marks.notes) opts.onPhase?.(`note: ${n}`);
4827
- const darkGround = opts.look?.kind === "dark";
4828
- const mark = darkGround ? marks.dark : marks.light;
4829
- if (mark)
4830
- opts.onPhase?.(`brand mark: ${mark.key} (${mark.aspect.toFixed(2)}:1)`);
4831
4090
  const duration = totalDuration(ratedSegments5(doc));
4832
4091
  const videoSeconds = opts.range ? Math.min(opts.range[1], duration) - Math.min(opts.range[0], duration) : duration;
4833
- const outDir = resolve4(opts.outDir ?? join6(dir, "kit"));
4834
- await mkdir5(outDir, { recursive: true });
4092
+ const outDir = resolve4(opts.outDir ?? join7(dir, "kit"));
4093
+ await mkdir4(outDir, { recursive: true });
4835
4094
  const destinations = opts.channels.flatMap((c) => destinationsForChannel(c));
4836
4095
  const assets = [];
4837
4096
  const skipped = [];
@@ -4855,7 +4114,7 @@ async function deliverTake(browser, dir, opts) {
4855
4114
  w: meta0.captureWidth ?? meta0.width,
4856
4115
  h: meta0.captureHeight ?? meta0.height
4857
4116
  };
4858
- const videoOverrides = (d, range) => {
4117
+ const videoOverrides = (d) => {
4859
4118
  const set = [];
4860
4119
  if (opts.look) {
4861
4120
  set.push(
@@ -4865,203 +4124,82 @@ async function deliverTake(browser, dir, opts) {
4865
4124
  })
4866
4125
  );
4867
4126
  }
4868
- const plan = planMotion({
4869
- destination: d,
4870
- doc,
4871
- range: range ?? [0, duration],
4872
- words: opts.words ?? {},
4873
- launch: opts.launchRoles ?? {},
4874
- captions: opts.captions ?? [],
4875
- catalog: opts.catalog ?? null,
4876
- ink: endCardInk(opts.look, opts.brandRoles),
4877
- mark
4878
- });
4879
- set.push(...plan.set);
4880
- if (plan.notes.length) opts.onPhase?.(`${d.id}: ${plan.notes.join(", ")}`);
4881
- for (const s of plan.skipped) skipped.push(`note: ${s}`);
4127
+ const mech = destinationMechanics(d, doc);
4128
+ set.push(...mech.set);
4129
+ if (mech.notes.length) opts.onPhase?.(`${d.id}: ${mech.notes.join(", ")}`);
4882
4130
  set.push(...opts.overrides?.set ?? []);
4883
- if (!set.length) return opts.overrides;
4884
- return { ...opts.overrides, set };
4131
+ if (!set.length && !mech.unset.length) return opts.overrides;
4132
+ return { ...opts.overrides, set, unset: mech.unset };
4885
4133
  };
4886
- const cardPlans = destinations.filter(
4887
- (d) => d.kind !== "video" && d.genre === "card" && !NOT_FROM_FOOTAGE[d.id]
4888
- ).map((d) => ({ d, plan: templateForCard(d, opts) })).filter(
4889
- (p) => p.plan !== null
4890
- );
4891
- const posterCardIds = new Set(cardPlans.map((p) => p.d.id));
4892
- for (const p of cardPlans)
4893
- if (p.plan.note) skipped.push(`note: ${p.plan.note}`);
4894
- if (cardPlans.length) {
4895
- const meta = doc.source.meta;
4896
- const heroTime = opts.shotTime ?? (stillTimes.length ? stillTimes[0] : duration / 2);
4897
- const fill = posterValues(opts.brandRoles, opts.words ?? {});
4898
- if (mark) {
4899
- fill.values.logoKey = mark.key;
4900
- fill.values.logoAspect = mark.aspect;
4134
+ const posters = await findPosterDocs(dir, opts.launchRoles);
4135
+ const posterCardIds = /* @__PURE__ */ new Set();
4136
+ const missing = /* @__PURE__ */ new Set();
4137
+ for (const d of destinations) {
4138
+ if (d.kind === "video" || d.genre !== "card" || NOT_FROM_FOOTAGE[d.id])
4139
+ continue;
4140
+ const cls = posterClassFor(d.px);
4141
+ const ref = posters[cls];
4142
+ if (!ref) {
4143
+ missing.add(cls);
4144
+ continue;
4901
4145
  }
4146
+ posterCardIds.add(d.id);
4147
+ const label = `${d.channel} ${d.asset}`;
4148
+ const posterDuration = totalDuration(ratedSegments5(ref.doc));
4149
+ const time = posterStillTime(ref.doc, posterDuration);
4902
4150
  opts.onPhase?.(
4903
- `poster shot (full bleed at ${heroTime.toFixed(2)}s), baked as an object`
4151
+ `${label} (${specWords(d)}) from ${ref.from}${ref.vosId ? ` ${ref.vosId}` : ""}, the rest at ${time.toFixed(2)}s`
4904
4152
  );
4905
- const serveDir = await mkdtemp(join6(tmpdir(), "vos-poster-"));
4153
+ const shotDir = await mkdtemp(join7(tmpdir(), "vos-poster-"));
4906
4154
  try {
4907
- const shot = await framesTake(browser, dir, {
4908
- times: [heroTime],
4909
- width: meta.captureWidth ?? meta.width,
4910
- height: meta.captureHeight ?? meta.height,
4911
- outDir: serveDir,
4912
- overrides: {
4913
- ...opts.overrides,
4914
- set: [
4915
- "frame.fit=contain",
4916
- "frame.padding=0",
4917
- "frame.radius=0",
4918
- "frame.shadow=0",
4919
- "frame.border=0",
4920
- "frame.browserBar.kind=none",
4921
- "cursor.visible=false",
4922
- "cursor.clickFx.style=none",
4923
- ...opts.overrides?.set ?? []
4924
- ]
4925
- }
4926
- });
4927
- const raw = decodePng(new Uint8Array(await readFile6(shot.frames[0].file)));
4928
- if (!raw) throw new Error("the poster shot could not be decoded");
4929
- const PAD = 0.06;
4930
- const baked = bakeShot(raw, {
4931
- margin: PAD,
4932
- hairline: fill.lightGround ? 0.14 : 0,
4933
- shadow: fill.lightGround ? 0.28 : 0.4
4155
+ const captured = await framesTake(browser, ref.takeDir, {
4156
+ times: [time],
4157
+ width: d.px.w,
4158
+ height: d.px.h,
4159
+ outDir: shotDir,
4160
+ doc: ref.doc,
4161
+ overrides: opts.overrides
4934
4162
  });
4935
- await writeFile7(join6(serveDir, "shot.png"), encodePng(baked));
4936
- const shotAspect = raw.w / raw.h;
4937
- for (const { d, plan } of cardPlans) {
4938
- if (plan.stage) {
4939
- const stageInput = {
4940
- size: d.px,
4941
- values: fill.values,
4942
- sourceSeconds: meta.durationMs / 1e3,
4943
- outputSeconds: duration,
4944
- text: d.text,
4945
- footageAspect: (meta.captureWidth ?? meta.width) / (meta.captureHeight ?? meta.height)
4946
- };
4947
- const staged = plan.stage === "tile" ? stageTile(stageInput) : stageSplitCover(stageInput);
4948
- opts.onPhase?.(
4949
- `${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}`
4950
- );
4951
- const shotDir = await mkdtemp(join6(tmpdir(), "vos-stage-"));
4952
- try {
4953
- const captured = await framesTake(browser, dir, {
4954
- times: [heroTime],
4955
- width: d.px.w,
4956
- height: d.px.h,
4957
- outDir: shotDir,
4958
- overrides: {
4959
- ...opts.overrides,
4960
- set: [...staged.set, ...opts.overrides?.set ?? []]
4961
- }
4962
- });
4963
- const to2 = join6(outDir, `${d.id}.png`);
4964
- await rename4(captured.frames[0].file, to2);
4965
- const bytes2 = (await stat(to2)).size;
4966
- if (d.maxBytes !== void 0 && bytes2 > d.maxBytes) {
4967
- skipped.push(
4968
- `${d.channel} ${d.asset}: ${overCeiling(bytes2, d.maxBytes)} (kept at ${to2})`
4969
- );
4970
- continue;
4971
- }
4972
- assets.push({
4973
- channel: d.channel,
4974
- asset: d.asset,
4975
- destination: d.id,
4976
- path: relative(outDir, to2),
4977
- w: d.px.w,
4978
- h: d.px.h,
4979
- bytes: bytes2,
4980
- seconds: null,
4981
- frameTime: heroTime,
4982
- source: "stage",
4983
- template: `${plan.stage}-stage`,
4984
- text: staged.text,
4985
- shot: staged.shot,
4986
- ...plan.stage === "tile" ? { crop: true } : {}
4987
- });
4988
- } finally {
4989
- await rm3(shotDir, { recursive: true, force: true });
4990
- }
4991
- continue;
4992
- }
4993
- const problems = templateProblems(plan.config);
4994
- if (problems.length) {
4995
- skipped.push(
4996
- `${d.channel} ${d.asset}: ${plan.from} is not a valid template (${problems[0]}) \u2014 kept from the take`
4997
- );
4998
- posterCardIds.delete(d.id);
4999
- continue;
5000
- }
5001
- const filled = fillTemplate(plan.config, {
5002
- size: d.px,
5003
- slots: { shot: { src: "/shot.png", aspect: shotAspect, pad: PAD } },
5004
- values: fill.values
5005
- });
5006
- const limits = textLimitProblems(templateOf(plan.config), fill.values);
5007
- for (const l of limits) skipped.push(`note: ${d.id}: ${l}`);
5008
- if (filled.missing.length) {
5009
- skipped.push(
5010
- `${d.channel} ${d.asset}: ${plan.from} needs ${filled.missing.join(", ")} \u2014 kept from the take`
5011
- );
5012
- posterCardIds.delete(d.id);
5013
- continue;
5014
- }
5015
- const config = filled.config;
5016
- if (fill.fonts.length) {
5017
- const declared = Array.isArray(config.fonts) ? config.fonts : [];
5018
- config.fonts = [...declared, ...fill.fonts];
5019
- }
5020
- const posterDuration = typeof config.duration === "number" ? config.duration : 6;
5021
- const time = Math.min(
5022
- opts.posterTime ?? posterDuration * 0.9,
5023
- Math.max(0, posterDuration - 0.05)
5024
- );
5025
- opts.onPhase?.(
5026
- `${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}, ${filled.aspect}`
5027
- );
5028
- await renderPosterStills(
5029
- browser,
5030
- config,
5031
- serveDir,
5032
- [{ name: `${d.id}.png`, width: d.px.w, height: d.px.h }],
5033
- time
4163
+ const to = join7(outDir, `${d.id}.png`);
4164
+ await rename4(captured.frames[0].file, to);
4165
+ const bytes = (await stat(to)).size;
4166
+ if (d.maxBytes !== void 0 && bytes > d.maxBytes) {
4167
+ skipped.push(
4168
+ `${label}: ${overCeiling(bytes, d.maxBytes)} (kept at ${to})`
5034
4169
  );
5035
- const from = join6(serveDir, `${d.id}.png`);
5036
- const to = join6(outDir, `${d.id}.png`);
5037
- await rename4(from, to);
5038
- const bytes = (await stat(to)).size;
5039
- if (d.maxBytes !== void 0 && bytes > d.maxBytes) {
5040
- skipped.push(
5041
- `${d.channel} ${d.asset}: ${overCeiling(bytes, d.maxBytes)} (kept at ${to})`
5042
- );
5043
- continue;
5044
- }
5045
- assets.push({
5046
- channel: d.channel,
5047
- asset: d.asset,
5048
- destination: d.id,
5049
- path: relative(outDir, to),
5050
- w: d.px.w,
5051
- h: d.px.h,
5052
- bytes,
5053
- seconds: null,
5054
- frameTime: heroTime,
5055
- source: "poster",
5056
- template: templateOf(plan.config)?.family ?? plan.from,
5057
- text: filled.text,
5058
- shot: filled.slots.shot
5059
- });
4170
+ continue;
5060
4171
  }
4172
+ const shot = posterShotRect(ref.doc, d.px);
4173
+ assets.push({
4174
+ channel: d.channel,
4175
+ asset: d.asset,
4176
+ destination: d.id,
4177
+ path: relative(outDir, to),
4178
+ w: d.px.w,
4179
+ h: d.px.h,
4180
+ bytes,
4181
+ seconds: null,
4182
+ frameTime: time,
4183
+ source: "poster",
4184
+ poster: {
4185
+ class: cls,
4186
+ file: relative(dir, ref.file),
4187
+ ...ref.vosId ? { vosId: ref.vosId } : {}
4188
+ },
4189
+ text: posterTextBoxes(ref.doc, d.px, time),
4190
+ shot,
4191
+ ...shot.x < 0 && shot.x + shot.w > 1 ? { crop: true } : {}
4192
+ });
5061
4193
  } finally {
5062
- await rm3(serveDir, { recursive: true, force: true });
4194
+ await rm3(shotDir, { recursive: true, force: true });
5063
4195
  }
5064
4196
  }
4197
+ if (missing.size) {
4198
+ const classes = [...missing].sort().join(", ");
4199
+ skipped.push(
4200
+ `note: no poster document for the ${classes} class${missing.size > 1 ? "es" : ""} beside the take (poster/<class>/doc.json, or LAUNCH.md poster:), so those cards are the take's own frame`
4201
+ );
4202
+ }
5065
4203
  for (const d of destinations) {
5066
4204
  const label = `${d.channel} ${d.asset}`;
5067
4205
  const excuse = NOT_FROM_FOOTAGE[d.id];
@@ -5094,7 +4232,7 @@ async function deliverTake(browser, dir, opts) {
5094
4232
  }
5095
4233
  }
5096
4234
  opts.onPhase?.(`${label} (${specWords(d)})`);
5097
- const outFile = join6(outDir, `${d.id}.${d.format}`);
4235
+ const outFile = join7(outDir, `${d.id}.${d.format}`);
5098
4236
  const bitrate = d.maxBytes !== void 0 ? Math.min(
5099
4237
  1e7,
5100
4238
  Math.floor(d.maxBytes * 8 / seconds * 0.85)
@@ -5106,7 +4244,7 @@ async function deliverTake(browser, dir, opts) {
5106
4244
  parallel: opts.parallel,
5107
4245
  range,
5108
4246
  bitrate,
5109
- overrides: videoOverrides(d, range),
4247
+ overrides: videoOverrides(d),
5110
4248
  onProgress: opts.onProgress
5111
4249
  });
5112
4250
  if (d.maxBytes !== void 0 && result.bytes > d.maxBytes) {
@@ -5141,7 +4279,7 @@ async function deliverTake(browser, dir, opts) {
5141
4279
  for (let i = 0; i < captured.frames.length; i++) {
5142
4280
  const frame = captured.frames[i];
5143
4281
  const name = captured.frames.length > 1 ? `${d.id}-${i + 1}.png` : `${d.id}.png`;
5144
- const to = join6(outDir, name);
4282
+ const to = join7(outDir, name);
5145
4283
  await rename4(frame.file, to);
5146
4284
  const bytes = (await stat(to)).size;
5147
4285
  if (d.maxBytes !== void 0 && bytes > d.maxBytes) {
@@ -5163,25 +4301,135 @@ async function deliverTake(browser, dir, opts) {
5163
4301
  ...d.genre === "screenshot" && opts.composed ? { composed: true } : {}
5164
4302
  });
5165
4303
  }
5166
- if (d.count && captured.frames.length < d.count.min) {
5167
- skipped.push(
5168
- `${label}: spec wants ${d.count.min}-${d.count.max}, only ${captured.frames.length} still time(s) available \u2014 pass --times`
4304
+ if (d.count && captured.frames.length < d.count.min) {
4305
+ skipped.push(
4306
+ `${label}: spec wants ${d.count.min}-${d.count.max}, only ${captured.frames.length} still time(s) available \u2014 pass --times`
4307
+ );
4308
+ }
4309
+ }
4310
+ const kit = {
4311
+ release: opts.release ?? null,
4312
+ look: opts.look?.kind ?? null,
4313
+ take: dir,
4314
+ produced: (/* @__PURE__ */ new Date()).toISOString(),
4315
+ specsVerified: CHANNEL_SPECS_VERIFIED,
4316
+ moments,
4317
+ skipped,
4318
+ assets
4319
+ };
4320
+ const kitFile = join7(outDir, "kit.json");
4321
+ await writeFile6(kitFile, JSON.stringify(kit, null, 2));
4322
+ return { kit, kitFile, outDir };
4323
+ }
4324
+
4325
+ // src/plugin/markAsset.ts
4326
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile7 } from "fs/promises";
4327
+ import { existsSync as existsSync7 } from "fs";
4328
+ import { isAbsolute as isAbsolute2, join as join8, resolve as resolve5 } from "path";
4329
+ function markExtension(url) {
4330
+ const m = /\.(svg|png|webp|jpe?g)(?:[?#]|$)/i.exec(url);
4331
+ if (!m) return null;
4332
+ const e = m[1].toLowerCase();
4333
+ return e === "jpeg" ? "jpg" : e;
4334
+ }
4335
+ function imageAspect(bytes, ext) {
4336
+ if (ext === "svg") {
4337
+ const head = new TextDecoder().decode(bytes.subarray(0, 4096));
4338
+ const vb = /viewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(
4339
+ head
4340
+ );
4341
+ if (vb) return ratio(+vb[1], +vb[2]);
4342
+ const w = /<svg[^>]*\swidth\s*=\s*["']([\d.]+)/i.exec(head);
4343
+ const h = /<svg[^>]*\sheight\s*=\s*["']([\d.]+)/i.exec(head);
4344
+ if (w && h) return ratio(+w[1], +h[1]);
4345
+ return null;
4346
+ }
4347
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
4348
+ if (ext === "png") {
4349
+ if (bytes.length < 24 || bytes[0] !== 137 || bytes[1] !== 80) return null;
4350
+ return ratio(dv.getUint32(16), dv.getUint32(20));
4351
+ }
4352
+ if (ext === "webp") {
4353
+ if (bytes.length < 30) return null;
4354
+ const tag = String.fromCharCode(...bytes.subarray(12, 16));
4355
+ if (tag === "VP8X")
4356
+ return ratio(
4357
+ 1 + (dv.getUint32(24, true) & 16777215),
4358
+ 1 + (dv.getUint32(27, true) & 16777215)
4359
+ );
4360
+ if (tag === "VP8L") {
4361
+ const b = dv.getUint32(21, true);
4362
+ return ratio(1 + (b & 16383), 1 + (b >> 14 & 16383));
4363
+ }
4364
+ if (tag === "VP8 ")
4365
+ return ratio(
4366
+ dv.getUint16(26, true) & 16383,
4367
+ dv.getUint16(28, true) & 16383
4368
+ );
4369
+ return null;
4370
+ }
4371
+ if (ext === "jpg") {
4372
+ let i = 2;
4373
+ while (i + 9 < bytes.length) {
4374
+ if (bytes[i] !== 255) return null;
4375
+ const marker = bytes[i + 1];
4376
+ const len = dv.getUint16(i + 2);
4377
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
4378
+ return ratio(dv.getUint16(i + 7), dv.getUint16(i + 5));
4379
+ }
4380
+ i += 2 + len;
4381
+ }
4382
+ return null;
4383
+ }
4384
+ return null;
4385
+ }
4386
+ function ratio(w, h) {
4387
+ return w > 0 && h > 0 ? w / h : null;
4388
+ }
4389
+ async function fetchBrandMarks(takeDir, roles) {
4390
+ const out = { light: null, dark: null, notes: [] };
4391
+ if (!roles) return out;
4392
+ const one = async (role, name) => {
4393
+ const src = (roles[role] ?? "").trim();
4394
+ if (!src) return null;
4395
+ const ext = markExtension(src);
4396
+ if (!ext) {
4397
+ out.notes.push(
4398
+ `${role}: ${src} is not an svg, png, webp or jpg; the wordmark stands in`
4399
+ );
4400
+ return null;
4401
+ }
4402
+ let bytes;
4403
+ try {
4404
+ if (/^https?:/i.test(src)) {
4405
+ const res = await fetch(src);
4406
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
4407
+ bytes = new Uint8Array(await res.arrayBuffer());
4408
+ } else {
4409
+ const file = isAbsolute2(src) ? src : resolve5(takeDir, src);
4410
+ if (!existsSync7(file)) throw new Error("no such file");
4411
+ bytes = new Uint8Array(await readFile7(file));
4412
+ }
4413
+ } catch (e) {
4414
+ out.notes.push(
4415
+ `${role}: ${src} could not be read (${e instanceof Error ? e.message : String(e)}); the wordmark stands in`
4416
+ );
4417
+ return null;
4418
+ }
4419
+ const aspect = imageAspect(bytes, ext);
4420
+ if (!aspect) {
4421
+ out.notes.push(
4422
+ `${role}: ${src} has no readable size; the wordmark stands in`
5169
4423
  );
4424
+ return null;
5170
4425
  }
5171
- }
5172
- const kit = {
5173
- release: opts.release ?? null,
5174
- look: opts.look?.kind ?? null,
5175
- take: dir,
5176
- produced: (/* @__PURE__ */ new Date()).toISOString(),
5177
- specsVerified: CHANNEL_SPECS_VERIFIED,
5178
- moments,
5179
- skipped,
5180
- assets
4426
+ await mkdir5(join8(takeDir, "brand"), { recursive: true });
4427
+ await writeFile7(join8(takeDir, "brand", `${name}.${ext}`), bytes);
4428
+ return { key: `/brand/${name}.${ext}`, aspect };
5181
4429
  };
5182
- const kitFile = join6(outDir, "kit.json");
5183
- await writeFile7(kitFile, JSON.stringify(kit, null, 2));
5184
- return { kit, kitFile, outDir };
4430
+ out.light = await one("logoUrl", "mark");
4431
+ out.dark = await one("logoOnDarkUrl", "mark-on-dark");
4432
+ return out;
5185
4433
  }
5186
4434
 
5187
4435
  // src/plugin/music.ts
@@ -5197,10 +4445,69 @@ async function fetchMusicCatalog(origin) {
5197
4445
  }
5198
4446
 
5199
4447
  // src/plugin/judge.ts
5200
- import { existsSync as existsSync7 } from "fs";
5201
- import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile8 } from "fs/promises";
5202
- import { dirname, isAbsolute as isAbsolute2, join as join7, resolve as resolve5 } from "path";
4448
+ import { existsSync as existsSync8 } from "fs";
4449
+ import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile8 } from "fs/promises";
4450
+ import { dirname, isAbsolute as isAbsolute3, join as join9, resolve as resolve6 } from "path";
5203
4451
  import { DESTINATIONS as DESTINATIONS2 } from "@vosjs/studio-core";
4452
+
4453
+ // src/plugin/shotBake.ts
4454
+ import { deflateSync } from "zlib";
4455
+ var crcTable = (() => {
4456
+ const t = new Uint32Array(256);
4457
+ for (let n = 0; n < 256; n++) {
4458
+ let c = n;
4459
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
4460
+ t[n] = c >>> 0;
4461
+ }
4462
+ return t;
4463
+ })();
4464
+ function crc32(buf) {
4465
+ let c = 4294967295;
4466
+ for (const b of buf) c = crcTable[(c ^ b) & 255] ^ c >>> 8;
4467
+ return (c ^ 4294967295) >>> 0;
4468
+ }
4469
+ function chunk(type, body) {
4470
+ const out = new Uint8Array(12 + body.length);
4471
+ const dv = new DataView(out.buffer);
4472
+ dv.setUint32(0, body.length);
4473
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
4474
+ out.set(body, 8);
4475
+ dv.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)));
4476
+ return out;
4477
+ }
4478
+ function encodePng(img) {
4479
+ const stride = img.w * 4;
4480
+ const raw = new Uint8Array((stride + 1) * img.h);
4481
+ for (let y = 0; y < img.h; y++) {
4482
+ raw[y * (stride + 1)] = 2;
4483
+ for (let i = 0; i < stride; i++) {
4484
+ const cur = img.data[y * stride + i];
4485
+ const up = y ? img.data[(y - 1) * stride + i] : 0;
4486
+ raw[y * (stride + 1) + 1 + i] = cur - up & 255;
4487
+ }
4488
+ }
4489
+ const ihdr = new Uint8Array(13);
4490
+ const dv = new DataView(ihdr.buffer);
4491
+ dv.setUint32(0, img.w);
4492
+ dv.setUint32(4, img.h);
4493
+ ihdr[8] = 8;
4494
+ ihdr[9] = 6;
4495
+ const parts = [
4496
+ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
4497
+ chunk("IHDR", ihdr),
4498
+ chunk("IDAT", new Uint8Array(deflateSync(raw))),
4499
+ chunk("IEND", new Uint8Array(0))
4500
+ ];
4501
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
4502
+ let o = 0;
4503
+ for (const p of parts) {
4504
+ out.set(p, o);
4505
+ o += p.length;
4506
+ }
4507
+ return out;
4508
+ }
4509
+
4510
+ // src/plugin/judge.ts
5204
4511
  var RUBRIC = [
5205
4512
  "A plate, and room on it: the subject sits at 74 to 89% of the width with headroom; nobody bleeds a window on all four sides.",
5206
4513
  "The window is an object: rounded corners and a shadow with weight (a soft wide one on a light plate, a tight glow on a dark one).",
@@ -5303,26 +4610,26 @@ function composeSheet(left, right, height = 540, gutter = 32) {
5303
4610
  return { w, h, data: out };
5304
4611
  }
5305
4612
  async function judgeKit(kitPath, manifestPath, outDir) {
5306
- const kit = JSON.parse(await readFile7(kitPath, "utf8"));
5307
- const manifest = JSON.parse(await readFile7(manifestPath, "utf8"));
5308
- const manifestDir = dirname(resolve5(manifestPath));
5309
- const kitDir = dirname(resolve5(kitPath));
5310
- const out = resolve5(outDir ?? join7(kitDir, "judge"));
4613
+ const kit = JSON.parse(await readFile8(kitPath, "utf8"));
4614
+ const manifest = JSON.parse(await readFile8(manifestPath, "utf8"));
4615
+ const manifestDir = dirname(resolve6(manifestPath));
4616
+ const kitDir = dirname(resolve6(kitPath));
4617
+ const out = resolve6(outDir ?? join9(kitDir, "judge"));
5311
4618
  await mkdir6(out, { recursive: true });
5312
4619
  const sheets = [];
5313
4620
  const skipped = [];
5314
4621
  const refCache = /* @__PURE__ */ new Map();
5315
4622
  const loadRef = async (file) => {
5316
4623
  if (refCache.has(file)) return refCache.get(file) ?? null;
5317
- const p = join7(manifestDir, file);
5318
- const img = existsSync7(p) ? decodePng(new Uint8Array(await readFile7(p))) : null;
4624
+ const p = join9(manifestDir, file);
4625
+ const img = existsSync8(p) ? decodePng(new Uint8Array(await readFile8(p))) : null;
5319
4626
  refCache.set(file, img);
5320
4627
  return img;
5321
4628
  };
5322
4629
  for (const a of kit.assets) {
5323
4630
  if (!/\.png$/i.test(a.path)) continue;
5324
- const file = isAbsolute2(a.path) && existsSync7(a.path) ? a.path : join7(kitDir, a.path.split("/").pop() ?? a.path);
5325
- const img = existsSync7(file) ? decodePng(new Uint8Array(await readFile7(file))) : null;
4631
+ const file = isAbsolute3(a.path) && existsSync8(a.path) ? a.path : join9(kitDir, a.path.split("/").pop() ?? a.path);
4632
+ const img = existsSync8(file) ? decodePng(new Uint8Array(await readFile8(file))) : null;
5326
4633
  if (!img) {
5327
4634
  skipped.push(`${a.destination ?? a.path}: unreadable`);
5328
4635
  continue;
@@ -5342,8 +4649,8 @@ async function judgeKit(kitPath, manifestPath, outDir) {
5342
4649
  const id = stem.replace(/[^A-Za-z0-9_-]+/g, "-") || a.destination || "asset";
5343
4650
  const nameA = `${id}--A.png`;
5344
4651
  const nameB = `${id}--B.png`;
5345
- await writeFile8(join7(out, nameA), encodePng(composeSheet(img, refImg)));
5346
- await writeFile8(join7(out, nameB), encodePng(composeSheet(refImg, img)));
4652
+ await writeFile8(join9(out, nameA), encodePng(composeSheet(img, refImg)));
4653
+ await writeFile8(join9(out, nameB), encodePng(composeSheet(refImg, img)));
5347
4654
  const rubric = [
5348
4655
  `# ${id} against ${ref.id} (${ref.file})`,
5349
4656
  "",
@@ -5363,11 +4670,11 @@ async function judgeKit(kitPath, manifestPath, outDir) {
5363
4670
  "In judge.json: `win` true when the kit asset is at least as good as the reference on the rules that apply to its genre, false when it is worse, null for a tie; `reasons` names the rules by number.",
5364
4671
  ""
5365
4672
  ].join("\n");
5366
- await writeFile8(join7(out, `${id}.md`), rubric);
4673
+ await writeFile8(join9(out, `${id}.md`), rubric);
5367
4674
  sheets.push({ asset: id, reference: ref.id, sheetA: nameA, sheetB: nameB, rubric: `${id}.md` });
5368
4675
  }
5369
- const verdictFile = join7(out, "judge.json");
5370
- if (!existsSync7(verdictFile)) {
4676
+ const verdictFile = join9(out, "judge.json");
4677
+ if (!existsSync8(verdictFile)) {
5371
4678
  await writeFile8(
5372
4679
  verdictFile,
5373
4680
  JSON.stringify(
@@ -5394,7 +4701,7 @@ function winRate(verdicts) {
5394
4701
  // src/plugin/kitPicture.ts
5395
4702
  import { execFile } from "child_process";
5396
4703
  import { promisify } from "util";
5397
- import { readFile as readFile8 } from "fs/promises";
4704
+ import { readFile as readFile9 } from "fs/promises";
5398
4705
  var execFileP = promisify(execFile);
5399
4706
  var SUBJECT_BAND = { min: 0.6, max: 0.92 };
5400
4707
  var BLANK_INK = 0.12;
@@ -5571,10 +4878,10 @@ function textFindings(a, img) {
5571
4878
  });
5572
4879
  }
5573
4880
  }
5574
- const rgb2 = t.color ? hexToRgb(t.color) : null;
5575
- if (rgb2) {
4881
+ const rgb = t.color ? hexToRgb(t.color) : null;
4882
+ if (rgb) {
5576
4883
  const ground = medianColour(img, box);
5577
- const lc = apcaContrast(rgb2, ground);
4884
+ const lc = apcaContrast(rgb, ground);
5578
4885
  const floor = t.role === "body" ? 75 : 60;
5579
4886
  if (lc < floor) {
5580
4887
  out.push({
@@ -5625,7 +4932,7 @@ function duplicateFindings(stills) {
5625
4932
  severity: composed ? "info" : g.length >= 3 ? "error" : "warning",
5626
4933
  asset: g.map((s) => s.destination).join(", "),
5627
4934
  message: composed ? `${g.length} channels share one cover: ${g.map((s) => s.destination).join(", ")}` : `${g.length} assets share one frame${g[0].time !== null ? ` (${g[0].time.toFixed(2)}s)` : ""}: ${g.map((s) => s.destination).join(", ")}`,
5628
- fixHint: "a kit is many moments: let deliver pick from the step timeline, or pass --times with one step per still; a poster template composes each card differently from one shot"
4935
+ fixHint: "a kit is many moments: let deliver pick from the step timeline, or pass --times with one step per still; a poster document per class (poster/<class>/doc.json) or per destination (LAUNCH.md poster roles) gives each channel its own cover"
5629
4936
  };
5630
4937
  });
5631
4938
  }
@@ -5670,7 +4977,7 @@ async function pictureChecks(assets) {
5670
4977
  let ffmpeg = null;
5671
4978
  for (const a of assets) {
5672
4979
  if (/\.png$/i.test(a.file)) {
5673
- const img = decodePng(new Uint8Array(await readFile8(a.file)));
4980
+ const img = decodePng(new Uint8Array(await readFile9(a.file)));
5674
4981
  if (!img) {
5675
4982
  findings.push({
5676
4983
  code: "unreadable",
@@ -5763,9 +5070,9 @@ function formatFinding(f) {
5763
5070
  }
5764
5071
 
5765
5072
  // src/plugin/validateKit.ts
5766
- import { existsSync as existsSync8 } from "fs";
5767
- import { readFile as readFile9, stat as stat2 } from "fs/promises";
5768
- import { dirname as dirname2, isAbsolute as isAbsolute3, join as join8 } from "path";
5073
+ import { existsSync as existsSync9 } from "fs";
5074
+ import { readFile as readFile10, stat as stat2 } from "fs/promises";
5075
+ import { dirname as dirname2, isAbsolute as isAbsolute4, join as join10 } from "path";
5769
5076
  import { DESTINATIONS as DESTINATIONS3 } from "@vosjs/studio-core";
5770
5077
  var PNG_SIG2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
5771
5078
  function pngDimensions(bytes) {
@@ -5784,7 +5091,7 @@ async function probeVideo(path) {
5784
5091
  const MB = await import("mediabunny");
5785
5092
  const input = new MB.Input({
5786
5093
  formats: MB.ALL_FORMATS,
5787
- source: new MB.BufferSource(new Uint8Array(await readFile9(path)))
5094
+ source: new MB.BufferSource(new Uint8Array(await readFile10(path)))
5788
5095
  });
5789
5096
  try {
5790
5097
  const track = await input.getPrimaryVideoTrack();
@@ -5805,7 +5112,7 @@ async function validateKit(kitPath, opts = {}) {
5805
5112
  const measured = [];
5806
5113
  let kit;
5807
5114
  try {
5808
- kit = JSON.parse(await readFile9(kitPath, "utf8"));
5115
+ kit = JSON.parse(await readFile10(kitPath, "utf8"));
5809
5116
  } catch (e) {
5810
5117
  return {
5811
5118
  valid: false,
@@ -5824,8 +5131,8 @@ async function validateKit(kitPath, opts = {}) {
5824
5131
  }
5825
5132
  const base = dirname2(kitPath);
5826
5133
  const resolvePath = (p) => {
5827
- if (isAbsolute3(p) && existsSync8(p)) return p;
5828
- const beside = join8(base, p.split("/").pop() ?? p);
5134
+ if (isAbsolute4(p) && existsSync9(p)) return p;
5135
+ const beside = join10(base, p.split("/").pop() ?? p);
5829
5136
  return beside;
5830
5137
  };
5831
5138
  const perDestination = /* @__PURE__ */ new Map();
@@ -5851,7 +5158,7 @@ async function validateKit(kitPath, opts = {}) {
5851
5158
  let h = null;
5852
5159
  let seconds = null;
5853
5160
  if (/\.(png|jpe?g|webp)$/i.test(file)) {
5854
- const head = Buffer.from(await readFile9(file));
5161
+ const head = Buffer.from(await readFile10(file));
5855
5162
  const kind = sniffImage(head);
5856
5163
  if (file.toLowerCase().endsWith(".png") && kind !== "png")
5857
5164
  problems.push(
@@ -5899,228 +5206,69 @@ async function validateKit(kitPath, opts = {}) {
5899
5206
  pictureAssets.push({
5900
5207
  destination: id,
5901
5208
  path: a.path,
5902
- file,
5903
- spec,
5904
- text: a.text,
5905
- seconds,
5906
- composed: a.composed,
5907
- shot: a.source === "poster" || a.source === "stage" ? a.shot : void 0,
5908
- crop: a.crop
5909
- });
5910
- if (!spec) {
5911
- if (a.channel !== "demo")
5912
- warnings.push(
5913
- `${label}: no channel spec for "${id}" \u2014 not verified against one`
5914
- );
5915
- continue;
5916
- }
5917
- if (w !== null && h !== null && (w !== spec.px.w || h !== spec.px.h))
5918
- problems.push(
5919
- `${label}: spec wants ${spec.px.w}x${spec.px.h}, the file is ${w}x${h}`
5920
- );
5921
- if (spec.maxBytes !== void 0 && bytes > spec.maxBytes)
5922
- problems.push(
5923
- `${label}: ${Math.ceil(bytes / 1024)} KB exceeds the ${Math.floor(spec.maxBytes / 1024)} KB ceiling`
5924
- );
5925
- if (seconds !== null) {
5926
- if (spec.minSeconds !== void 0 && seconds < spec.minSeconds - 0.25)
5927
- problems.push(
5928
- `${label}: spec wants at least ${spec.minSeconds} s, the file is ${seconds.toFixed(1)} s`
5929
- );
5930
- if (spec.maxSeconds !== void 0 && seconds > spec.maxSeconds + 0.25)
5931
- problems.push(
5932
- `${label}: spec caps at ${spec.maxSeconds} s, the file is ${seconds.toFixed(1)} s`
5933
- );
5934
- }
5935
- if (spec.format === "png" && !file.toLowerCase().endsWith(".png"))
5936
- warnings.push(
5937
- `${label}: spec renders png, the file is ${file.split(".").pop()}`
5938
- );
5939
- }
5940
- for (const [id, n] of perDestination) {
5941
- const spec = specById.get(id);
5942
- if (spec?.count && (n < spec.count.min || n > spec.count.max))
5943
- problems.push(
5944
- `${spec.channel} ${spec.asset}: spec wants ${spec.count.min}-${spec.count.max}, the kit has ${n}`
5945
- );
5946
- }
5947
- if (!opts.picture) {
5948
- return { valid: problems.length === 0, problems, warnings, measured };
5949
- }
5950
- const picture = await pictureChecks(pictureAssets);
5951
- const pictureErrors = picture.findings.filter((f) => f.severity === "error");
5952
- return {
5953
- valid: problems.length === 0 && pictureErrors.length === 0,
5954
- problems,
5955
- warnings,
5956
- measured,
5957
- picture: picture.findings,
5958
- pictureMeasured: picture.measured
5959
- };
5960
- }
5961
-
5962
- // src/plugin/platform.ts
5963
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
5964
- import { homedir } from "os";
5965
- import { join as join9 } from "path";
5966
- function platformOrigin(flags = {}) {
5967
- const legacyEnv = process.env.VOS_API_BASE?.trim();
5968
- const raw = flags.origin ?? flags.api ?? process.env.VOS_ORIGIN?.trim() ?? legacyEnv ?? "https://vos.so";
5969
- if (!flags.origin && !flags.api && !process.env.VOS_ORIGIN && legacyEnv) {
5970
- process.stderr.write(
5971
- "note: VOS_API_BASE is deprecated \u2014 set VOS_ORIGIN (an origin, no /api suffix)\n"
5972
- );
5973
- }
5974
- return raw.replace(/\/+$/, "").replace(/\/api$/, "");
5975
- }
5976
- function parseVosId(input) {
5977
- if (!/^https?:\/\//.test(input)) {
5978
- if (/^[A-Za-z0-9_-]+$/.test(input)) return input;
5979
- throw new UsageError(`"${input}" is not a vos id or URL`);
5980
- }
5981
- let url;
5982
- try {
5983
- url = new URL(input);
5984
- } catch {
5985
- throw new UsageError(`"${input}" is not a valid URL`);
5986
- }
5987
- const query = url.searchParams.get("vos");
5988
- if (query) return query;
5989
- const path = url.pathname.match(/\/vos\/([A-Za-z0-9_-]+)/);
5990
- if (path) return path[1];
5991
- throw new UsageError(
5992
- `could not find a vos id in ${input} \u2014 expected /vos/{id} or ?vos={id}`
5993
- );
5994
- }
5995
- function deriveSlug(title) {
5996
- return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50).replace(/-+$/, "") || "remix";
5997
- }
5998
- var CREDENTIALS_PATH = join9(homedir(), ".config", "vos", "credentials");
5999
- function resolveCredential(explicit) {
6000
- const flag = explicit?.trim();
6001
- if (flag) return flag;
6002
- const env = process.env.VOS_API_KEY?.trim();
6003
- if (env) return env;
6004
- try {
6005
- const first = readFileSync2(CREDENTIALS_PATH, "utf8").split("\n")[0].trim();
6006
- return first || null;
6007
- } catch {
6008
- return null;
6009
- }
6010
- }
6011
- function requireCredential(explicit) {
6012
- const key = resolveCredential(explicit);
6013
- if (!key) {
6014
- throw new Error(
6015
- "no credential found \u2014 pass --key, set VOS_API_KEY, or run `vos login` (mint a content key at https://vos.so/app/api; a vos_rg_ remix grant works too)"
6016
- );
6017
- }
6018
- return key;
6019
- }
6020
- function writeCredential(key) {
6021
- mkdirSync(join9(homedir(), ".config", "vos"), { recursive: true, mode: 448 });
6022
- writeFileSync(CREDENTIALS_PATH, `${key.trim()}
6023
- `, { mode: 384 });
6024
- return CREDENTIALS_PATH;
6025
- }
6026
- function clientId() {
6027
- const env = process.env.VOS_CLIENT?.trim();
6028
- if (env && env.length <= 60 && !/[\r\n]/.test(env)) return env;
6029
- return "vos-cli";
6030
- }
6031
- async function apiJson(origin, path, init = {}) {
6032
- const headers = {
6033
- accept: "application/json",
6034
- ...init.headers
6035
- };
6036
- if (init.key) headers.authorization = `Bearer ${init.key}`;
6037
- if (init.body !== void 0) headers["content-type"] = "application/json";
6038
- const reqBody = init.raw !== void 0 ? init.raw : init.body === void 0 ? void 0 : JSON.stringify(init.body);
6039
- const res = await fetch(`${origin}${path}`, {
6040
- method: init.method ?? "GET",
6041
- headers,
6042
- body: reqBody
6043
- });
6044
- let body = {};
6045
- try {
6046
- body = await res.json();
6047
- } catch {
6048
- }
6049
- return { status: res.status, body };
6050
- }
6051
- function apiError(what, r) {
6052
- const detail = typeof r.body.error === "string" ? r.body.error : r.body.details !== void 0 ? JSON.stringify(r.body.details) : "";
6053
- const hint = r.status === 401 ? " (run `vos login`, or set VOS_API_KEY \u2014 mint a key at https://vos.so/app/api)" : "";
6054
- return `${what} \u2192 ${r.status}${detail ? `: ${detail}` : ""}${hint}`;
6055
- }
6056
- var SYNC_STATE_NAME = "vos.json";
6057
- function readJsonFile(file) {
6058
- try {
6059
- return JSON.parse(readFileSync2(file, "utf8"));
6060
- } catch {
6061
- return null;
6062
- }
6063
- }
6064
- function readSyncState(dir) {
6065
- const own = readJsonFile(join9(dir, SYNC_STATE_NAME));
6066
- if (own && typeof own.vosId === "string") {
6067
- return {
6068
- vosId: own.vosId,
6069
- versionId: typeof own.versionId === "string" ? own.versionId : null,
6070
- ...typeof own.pushedAt === "string" ? { pushedAt: own.pushedAt } : {},
6071
- ...typeof own.title === "string" ? { title: own.title } : {},
6072
- ...typeof own.slug === "string" ? { slug: own.slug } : {},
6073
- ...typeof own.remixOfId === "string" ? { remixOfId: own.remixOfId } : {}
6074
- };
5209
+ file,
5210
+ spec,
5211
+ text: a.text,
5212
+ seconds,
5213
+ composed: a.composed,
5214
+ shot: a.source === "poster" || a.source === "stage" ? a.shot : void 0,
5215
+ crop: a.crop
5216
+ });
5217
+ if (!spec) {
5218
+ if (a.channel !== "demo")
5219
+ warnings.push(
5220
+ `${label}: no channel spec for "${id}" \u2014 not verified against one`
5221
+ );
5222
+ continue;
5223
+ }
5224
+ if (w !== null && h !== null && (w !== spec.px.w || h !== spec.px.h))
5225
+ problems.push(
5226
+ `${label}: spec wants ${spec.px.w}x${spec.px.h}, the file is ${w}x${h}`
5227
+ );
5228
+ if (spec.maxBytes !== void 0 && bytes > spec.maxBytes)
5229
+ problems.push(
5230
+ `${label}: ${Math.ceil(bytes / 1024)} KB exceeds the ${Math.floor(spec.maxBytes / 1024)} KB ceiling`
5231
+ );
5232
+ if (seconds !== null) {
5233
+ if (spec.minSeconds !== void 0 && seconds < spec.minSeconds - 0.25)
5234
+ problems.push(
5235
+ `${label}: spec wants at least ${spec.minSeconds} s, the file is ${seconds.toFixed(1)} s`
5236
+ );
5237
+ if (spec.maxSeconds !== void 0 && seconds > spec.maxSeconds + 0.25)
5238
+ problems.push(
5239
+ `${label}: spec caps at ${spec.maxSeconds} s, the file is ${seconds.toFixed(1)} s`
5240
+ );
5241
+ }
5242
+ if (spec.format === "png" && !file.toLowerCase().endsWith(".png"))
5243
+ warnings.push(
5244
+ `${label}: spec renders png, the file is ${file.split(".").pop()}`
5245
+ );
6075
5246
  }
6076
- const push = readJsonFile(join9(dir, "push.json"));
6077
- if (push && typeof push.vosId === "string") {
6078
- return {
6079
- vosId: push.vosId,
6080
- versionId: typeof push.versionId === "string" ? push.versionId : null,
6081
- ...typeof push.pushedAt === "string" ? { pushedAt: push.pushedAt } : {}
6082
- };
5247
+ for (const [id, n] of perDestination) {
5248
+ const spec = specById.get(id);
5249
+ if (spec?.count && (n < spec.count.min || n > spec.count.max))
5250
+ problems.push(
5251
+ `${spec.channel} ${spec.asset}: spec wants ${spec.count.min}-${spec.count.max}, the kit has ${n}`
5252
+ );
6083
5253
  }
6084
- const meta = readJsonFile(join9(dir, "meta.json"));
6085
- if (meta && typeof meta.id === "string") {
6086
- return {
6087
- vosId: meta.id,
6088
- versionId: typeof meta.currentVersionId === "string" ? meta.currentVersionId : null,
6089
- ...typeof meta.title === "string" ? { title: meta.title } : {},
6090
- ...typeof meta.slug === "string" ? { slug: meta.slug } : {},
6091
- ...typeof meta.remixOfId === "string" ? { remixOfId: meta.remixOfId } : {}
6092
- };
5254
+ if (!opts.picture) {
5255
+ return { valid: problems.length === 0, problems, warnings, measured };
6093
5256
  }
6094
- return null;
6095
- }
6096
- function writeSyncState(dir, patch) {
6097
- const merged = {
6098
- ...readSyncState(dir) ?? { versionId: null },
6099
- ...patch
5257
+ const picture = await pictureChecks(pictureAssets);
5258
+ const pictureErrors = picture.findings.filter((f) => f.severity === "error");
5259
+ return {
5260
+ valid: problems.length === 0 && pictureErrors.length === 0,
5261
+ problems,
5262
+ warnings,
5263
+ measured,
5264
+ picture: picture.findings,
5265
+ pictureMeasured: picture.measured
6100
5266
  };
6101
- writeFileSync(
6102
- join9(dir, SYNC_STATE_NAME),
6103
- `${JSON.stringify(merged, null, 2)}
6104
- `
6105
- );
6106
- return merged;
6107
- }
6108
- function formatChanges(changes) {
6109
- const lines = [];
6110
- for (const ch of changes) {
6111
- const label = ch.label ? ` \xB7 ${ch.label}` : "";
6112
- const who = ch.origin === "studio" ? "human" : ch.origin ?? "unknown";
6113
- lines.push(
6114
- `v${String(ch.versionNumber ?? "?")} (${who}${label}): ${ch.summary ?? ""}`
6115
- );
6116
- if (ch.note) lines.push(` note: ${ch.note}`);
6117
- }
6118
- return lines;
6119
5267
  }
6120
5268
 
6121
5269
  // src/plugin/recorder.ts
6122
5270
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
6123
- import { join as join10 } from "path";
5271
+ import { join as join11 } from "path";
6124
5272
 
6125
5273
  // src/plugin/recordingCap.ts
6126
5274
  var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
@@ -6208,7 +5356,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6208
5356
  cdp.on("Page.screencastFrame", (ev) => {
6209
5357
  const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
6210
5358
  const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
6211
- writeFileSync2(join10(paths.framesDir, file), Buffer.from(ev.data, "base64"));
5359
+ writeFileSync2(join11(paths.framesDir, file), Buffer.from(ev.data, "base64"));
6212
5360
  frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
6213
5361
  cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
6214
5362
  });
@@ -6431,7 +5579,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6431
5579
  await sleep(200);
6432
5580
  const pageTitle = await page.title().catch(() => "");
6433
5581
  await context.close();
6434
- const firstFrame = frames[0] ? jpegDims(readFileSync3(join10(paths.framesDir, frames[0].file))) : null;
5582
+ const firstFrame = frames[0] ? jpegDims(readFileSync3(join11(paths.framesDir, frames[0].file))) : null;
6435
5583
  const meta = {
6436
5584
  dpr: 1,
6437
5585
  zoom: 1,
@@ -6539,14 +5687,17 @@ async function encodeRecording(browser, takeDir, onProgress) {
6539
5687
  }
6540
5688
 
6541
5689
  // src/plugin/plan.ts
6542
- import { existsSync as existsSync9 } from "fs";
6543
- import { readFile as readFile10 } from "fs/promises";
6544
- import { join as join11 } from "path";
5690
+ import { existsSync as existsSync10 } from "fs";
5691
+ import { readFile as readFile11 } from "fs/promises";
5692
+ import { join as join12 } from "path";
6545
5693
  import {
6546
5694
  DEFAULT_FRAME_STYLE,
6547
5695
  STYLE_FIELDS,
5696
+ copyLayout,
6548
5697
  copyStyle,
6549
5698
  isRejected,
5699
+ isStageClip,
5700
+ layoutOf,
6550
5701
  planAutoSpeed,
6551
5702
  planAutoZoom,
6552
5703
  projectFromArtifact,
@@ -6734,15 +5885,36 @@ function retimeCut(prev, newSteps, newDurationMs) {
6734
5885
  // src/plugin/plan.ts
6735
5886
  var overlaps = (a, b) => a.in < b.out && b.in < a.out;
6736
5887
  async function readDigestActivity(dir) {
6737
- const file = join11(dir, "digest", "digest.json");
6738
- if (!existsSync9(file)) return null;
5888
+ const file = join12(dir, "digest", "digest.json");
5889
+ if (!existsSync10(file)) return null;
6739
5890
  try {
6740
- const d = JSON.parse(await readFile10(file, "utf8"));
5891
+ const d = JSON.parse(await readFile11(file, "utf8"));
6741
5892
  return Array.isArray(d.activity) && d.activity.every((v) => typeof v === "number") ? d.activity : null;
6742
5893
  } catch {
6743
5894
  return null;
6744
5895
  }
6745
5896
  }
5897
+ function patchStageWords(doc, words) {
5898
+ const map = {
5899
+ "stage-title": words.headline,
5900
+ "stage-kicker": words.kicker,
5901
+ "stage-brand": words.brand
5902
+ };
5903
+ for (const clip of doc.overlays ?? []) {
5904
+ if (clip.kind !== "text" || !isStageClip(clip)) continue;
5905
+ const v = map[clip.id];
5906
+ if (typeof v === "string" && v.trim()) clip.text = v.trim();
5907
+ }
5908
+ }
5909
+ function applyStyle(seed, doc, opts) {
5910
+ const parts = layoutOf(seed);
5911
+ const carries = parts.clips.length > 0 || parts.lean || parts.hold;
5912
+ if (!carries) return { doc: copyStyle(seed, doc), layout: void 0 };
5913
+ const keys = opts.mark ? { "stage-mark": opts.mark.key } : void 0;
5914
+ const { doc: next, notes } = copyLayout(seed, copyStyle(seed, doc), { keys });
5915
+ if (opts.words) patchStageWords(next, opts.words);
5916
+ return { doc: next, layout: { ...parts, notes } };
5917
+ }
6746
5918
  async function planTake(dir, opts = {}) {
6747
5919
  const take = await loadTake(dir);
6748
5920
  const { meta, cursor } = take;
@@ -6750,6 +5922,7 @@ async function planTake(dir, opts = {}) {
6750
5922
  const ingest = opts.backdrop ? { frame: withBackdrop(DEFAULT_FRAME_STYLE, opts.backdrop) } : {};
6751
5923
  let doc;
6752
5924
  let fresh;
5925
+ let layout;
6753
5926
  if (opts.reuse) {
6754
5927
  const prev = opts.reuse.doc;
6755
5928
  const artifact = {
@@ -6761,6 +5934,10 @@ async function planTake(dir, opts = {}) {
6761
5934
  doc = copyStyle(prev, doc);
6762
5935
  const rt = retimeCut(prev, meta.steps ?? [], meta.durationMs);
6763
5936
  doc.segments = rt.segments;
5937
+ const prevHold = prev.segments.at(-1)?.hold;
5938
+ const last = doc.segments.at(-1);
5939
+ if (typeof prevHold === "number" && prevHold > 0 && last)
5940
+ last.hold = prevHold;
6764
5941
  if (rt.rejected.length) doc.rejected = rt.rejected;
6765
5942
  const manualZoom = rt.zoom;
6766
5943
  const autoZoom = planAutoZoom(doc.source.cursor, {
@@ -6782,6 +5959,7 @@ async function planTake(dir, opts = {}) {
6782
5959
  if (prev.objects?.length) doc.objects = prev.objects;
6783
5960
  if (prev.audio.length) doc.audio = prev.audio;
6784
5961
  if (prev.camMotion?.length) doc.camMotion = prev.camMotion;
5962
+ if (prev.endCard) doc.endCard = structuredClone(prev.endCard);
6785
5963
  await writeJson(take.paths.doc, doc, true);
6786
5964
  return {
6787
5965
  doc,
@@ -6793,7 +5971,13 @@ async function planTake(dir, opts = {}) {
6793
5971
  };
6794
5972
  }
6795
5973
  if (take.doc) {
6796
- doc = opts.style ? copyStyle(opts.style.doc, take.doc) : take.doc;
5974
+ if (opts.style) {
5975
+ const applied = applyStyle(opts.style.doc, take.doc, opts);
5976
+ doc = applied.doc;
5977
+ layout = applied.layout;
5978
+ } else {
5979
+ doc = take.doc;
5980
+ }
6797
5981
  fresh = false;
6798
5982
  const manual = doc.zoom.filter((z) => z.source === "manual");
6799
5983
  const auto = planAutoZoom(doc.source.cursor, {
@@ -6817,7 +6001,11 @@ async function planTake(dir, opts = {}) {
6817
6001
  meta
6818
6002
  };
6819
6003
  doc = projectFromArtifact(artifact, RECORDING_NAME, ingest).doc;
6820
- if (opts.style) doc = copyStyle(opts.style.doc, doc);
6004
+ if (opts.style) {
6005
+ const applied = applyStyle(opts.style.doc, doc, opts);
6006
+ doc = applied.doc;
6007
+ layout = applied.layout;
6008
+ }
6821
6009
  doc.zoom = planAutoZoom(doc.source.cursor, {
6822
6010
  width: doc.source.meta.width,
6823
6011
  height: doc.source.meta.height,
@@ -6831,6 +6019,12 @@ async function planTake(dir, opts = {}) {
6831
6019
  });
6832
6020
  fresh = true;
6833
6021
  }
6022
+ let motion;
6023
+ if (opts.motion && (fresh || opts.motion.again)) {
6024
+ const proposed = proposeMotion(doc, opts.motion);
6025
+ doc = proposed.doc;
6026
+ motion = { notes: proposed.notes, skipped: proposed.skipped };
6027
+ }
6834
6028
  await writeJson(take.paths.doc, doc, true);
6835
6029
  return {
6836
6030
  doc,
@@ -6844,22 +6038,24 @@ async function planTake(dir, opts = {}) {
6844
6038
  styleFields: STYLE_FIELDS.filter(
6845
6039
  (k) => opts.style.doc[k] !== void 0
6846
6040
  )
6847
- } : {}
6041
+ } : {},
6042
+ ...layout ? { layout } : {},
6043
+ ...motion ? { motion } : {}
6848
6044
  };
6849
6045
  }
6850
6046
 
6851
6047
  // src/plugin/sync.ts
6852
6048
  import { createHash } from "crypto";
6853
- import { existsSync as existsSync11 } from "fs";
6854
- import { readFile as readFile11 } from "fs/promises";
6049
+ import { existsSync as existsSync12 } from "fs";
6050
+ import { readFile as readFile12 } from "fs/promises";
6855
6051
  import { createInterface } from "readline/promises";
6856
- import { basename, join as join14 } from "path";
6857
- import { lowerToComposition as lowerToComposition3, migrateHostedDoc as migrateHostedDoc2 } from "@vosjs/studio-core";
6052
+ import { basename, join as join15 } from "path";
6053
+ import { lowerToComposition as lowerToComposition3, migrateHostedDoc as migrateHostedDoc3 } from "@vosjs/studio-core";
6858
6054
 
6859
6055
  // src/plugin/media.ts
6860
- import { createWriteStream, existsSync as existsSync10 } from "fs";
6056
+ import { createWriteStream, existsSync as existsSync11 } from "fs";
6861
6057
  import { writeFile as writeFile9 } from "fs/promises";
6862
- import { join as join12 } from "path";
6058
+ import { join as join13 } from "path";
6863
6059
  import { Readable } from "stream";
6864
6060
  import { pipeline } from "stream/promises";
6865
6061
  var MIC_NAME = "mic.webm";
@@ -6869,6 +6065,81 @@ var KEYS = [
6869
6065
  { key: "micKey", file: MIC_NAME },
6870
6066
  { key: "camKey", file: CAM_NAME }
6871
6067
  ];
6068
+ function isTakeRelativeKey(key) {
6069
+ return !!key && !/^(https?:|blob:|data:|\/\/)/.test(key) && !key.startsWith("/api/");
6070
+ }
6071
+ var takeRelativeFile = (key) => key.replace(/^\/+/, "");
6072
+ function docMediaRefs(doc, keep = isTakeRelativeKey) {
6073
+ const out = [];
6074
+ for (const clip of doc.overlays ?? []) {
6075
+ if (clip.kind === "text" || !keep(clip.key)) continue;
6076
+ out.push({
6077
+ where: `overlay ${clip.id}`,
6078
+ key: clip.key,
6079
+ set: (next) => {
6080
+ clip.key = next;
6081
+ }
6082
+ });
6083
+ }
6084
+ const mark = doc.endCard?.mark;
6085
+ if (mark && keep(mark.key))
6086
+ out.push({
6087
+ where: "end card mark",
6088
+ key: mark.key,
6089
+ set: (next) => {
6090
+ mark.key = next;
6091
+ }
6092
+ });
6093
+ const bg = doc.frame.backgroundMedia;
6094
+ if (bg && keep(bg.key))
6095
+ out.push({
6096
+ where: "background",
6097
+ key: bg.key,
6098
+ set: (next) => {
6099
+ bg.key = next;
6100
+ }
6101
+ });
6102
+ return out;
6103
+ }
6104
+ function mediaContentType(file) {
6105
+ const ext = (/\.([a-z0-9]+)$/i.exec(file)?.[1] ?? "").toLowerCase();
6106
+ const types = {
6107
+ svg: "image/svg+xml",
6108
+ png: "image/png",
6109
+ jpg: "image/jpeg",
6110
+ jpeg: "image/jpeg",
6111
+ webp: "image/webp",
6112
+ gif: "image/gif",
6113
+ avif: "image/avif",
6114
+ webm: "video/webm",
6115
+ mp4: "video/mp4",
6116
+ mov: "video/quicktime",
6117
+ mp3: "audio/mpeg",
6118
+ wav: "audio/wav",
6119
+ ogg: "audio/ogg",
6120
+ m4a: "audio/mp4"
6121
+ };
6122
+ return types[ext] ?? "application/octet-stream";
6123
+ }
6124
+ function extensionFor(contentType) {
6125
+ const t = contentType.split(";")[0].trim().toLowerCase();
6126
+ const exts = {
6127
+ "image/svg+xml": ".svg",
6128
+ "image/png": ".png",
6129
+ "image/jpeg": ".jpg",
6130
+ "image/webp": ".webp",
6131
+ "image/gif": ".gif",
6132
+ "image/avif": ".avif",
6133
+ "video/webm": ".webm",
6134
+ "video/mp4": ".mp4",
6135
+ "video/quicktime": ".mov",
6136
+ "audio/mpeg": ".mp3",
6137
+ "audio/wav": ".wav",
6138
+ "audio/ogg": ".ogg",
6139
+ "audio/mp4": ".m4a"
6140
+ };
6141
+ return exts[t] ?? "";
6142
+ }
6872
6143
  function assetIdOf(url) {
6873
6144
  if (!url) return null;
6874
6145
  const m = /\/api\/assets\/([A-Za-z0-9_-]+)\/file/.exec(url);
@@ -6880,8 +6151,8 @@ async function pullMedia(ctx, dir, doc, log) {
6880
6151
  const url = doc.source[key];
6881
6152
  const assetId = assetIdOf(url);
6882
6153
  if (!assetId) continue;
6883
- const target = join12(dir, file);
6884
- if (existsSync10(target)) {
6154
+ const target = join13(dir, file);
6155
+ if (existsSync11(target)) {
6885
6156
  result.kept.push(file);
6886
6157
  } else {
6887
6158
  const abs = /^https?:/.test(url) ? url : `${ctx.origin}${url}`;
@@ -6903,19 +6174,56 @@ async function pullMedia(ctx, dir, doc, log) {
6903
6174
  }
6904
6175
  doc.source[key] = file;
6905
6176
  }
6906
- const metaPath = join12(dir, "meta.json");
6907
- if (!existsSync10(metaPath)) await writeJson(metaPath, doc.source.meta, true);
6908
- const cursorPath = join12(dir, "cursor.json");
6909
- if (!existsSync10(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
6910
- await writeFile9(join12(dir, "doc.json"), JSON.stringify(doc, null, 2));
6177
+ for (const ref of docMediaRefs(doc, (k) => assetIdOf(k) !== null)) {
6178
+ const assetId = assetIdOf(ref.key);
6179
+ const abs = /^https?:/.test(ref.key) ? ref.key : `${ctx.origin}${ref.key}`;
6180
+ const existing = existsSync11(join13(dir, "media")) ? (await import("fs/promises").then(
6181
+ (fs) => fs.readdir(join13(dir, "media"))
6182
+ )).find((f) => f.startsWith(`${assetId}.`)) : void 0;
6183
+ let file;
6184
+ if (existing) {
6185
+ file = `media/${existing}`;
6186
+ result.kept.push(file);
6187
+ } else {
6188
+ const res = await fetch(abs, {
6189
+ headers: ctx.key ? { authorization: `Bearer ${ctx.key}` } : {}
6190
+ });
6191
+ if (!res.ok || !res.body) {
6192
+ throw new Error(
6193
+ `download of ${ref.where} (${ref.key}) failed (${res.status})`
6194
+ );
6195
+ }
6196
+ const ext = extensionFor(res.headers.get("content-type") ?? "");
6197
+ file = `media/${assetId}${ext}`;
6198
+ const target = join13(dir, file);
6199
+ await import("fs/promises").then(
6200
+ (fs) => fs.mkdir(join13(dir, "media"), { recursive: true })
6201
+ );
6202
+ await pipeline(
6203
+ Readable.fromWeb(res.body),
6204
+ createWriteStream(target)
6205
+ );
6206
+ const bytes = (await import("fs/promises").then((fs) => fs.stat(target))).size;
6207
+ result.downloaded.push({ file, assetId, bytes });
6208
+ log(
6209
+ ` ${file} \u2190 ${ref.where}, asset ${assetId} (${Math.round(bytes / 1024)} kB)`
6210
+ );
6211
+ }
6212
+ ref.set(file);
6213
+ }
6214
+ const metaPath = join13(dir, "meta.json");
6215
+ if (!existsSync11(metaPath)) await writeJson(metaPath, doc.source.meta, true);
6216
+ const cursorPath = join13(dir, "cursor.json");
6217
+ if (!existsSync11(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
6218
+ await writeFile9(join13(dir, "doc.json"), JSON.stringify(doc, null, 2));
6911
6219
  return result;
6912
6220
  }
6913
6221
 
6914
6222
  // src/plugin/folder.ts
6915
6223
  import { mkdir as mkdir7, writeFile as writeFile10 } from "fs/promises";
6916
- import { join as join13 } from "path";
6224
+ import { join as join14 } from "path";
6917
6225
  import { recipeHints } from "@vosjs/shared/frontmatter";
6918
- import { migrateHostedDoc } from "@vosjs/studio-core";
6226
+ import { migrateHostedDoc as migrateHostedDoc2 } from "@vosjs/studio-core";
6919
6227
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "media"]);
6920
6228
  async function listFolders(origin, key) {
6921
6229
  const r = await apiJson(origin, "/api/folders", { key });
@@ -7089,18 +6397,18 @@ async function cmdPull(argv) {
7089
6397
  throw new Error(apiError(`pull folder ${folder.slug}`, res));
7090
6398
  const payload = res.body;
7091
6399
  const out = strFlag(flags, "out") ?? folder.slug;
7092
- await mkdir7(join13(out, "recipes"), { recursive: true });
6400
+ await mkdir7(join14(out, "recipes"), { recursive: true });
7093
6401
  const lines = [];
7094
6402
  const recipes = [];
7095
6403
  for (const rec of payload.recipes) {
7096
- const path = join13(out, "recipes", safeName(rec.filename));
6404
+ const path = join14(out, "recipes", safeName(rec.filename));
7097
6405
  await writeFile10(path, rec.body ?? "");
7098
6406
  recipes.push({ path, line: recipeLine(rec.filename, rec.body ?? "") });
7099
6407
  }
7100
6408
  for (const rec of payload.inheritedRecipes) {
7101
6409
  const from = safeName(rec.folderSlug ?? "inherited");
7102
- await mkdir7(join13(out, "recipes", "_inherited", from), { recursive: true });
7103
- const path = join13(
6410
+ await mkdir7(join14(out, "recipes", "_inherited", from), { recursive: true });
6411
+ const path = join14(
7104
6412
  out,
7105
6413
  "recipes",
7106
6414
  "_inherited",
@@ -7115,7 +6423,7 @@ async function cmdPull(argv) {
7115
6423
  }
7116
6424
  const members = [];
7117
6425
  for (const v of payload.voses) {
7118
- const dir = join13(out, "members", safeName(v.slug || v.title || v.id));
6426
+ const dir = join14(out, "members", safeName(v.slug || v.title || v.id));
7119
6427
  await mkdir7(dir, { recursive: true });
7120
6428
  const cfg = await apiJson(origin, `/api/vos/${v.id}/config`, { key });
7121
6429
  if (cfg.status !== 200) {
@@ -7123,7 +6431,7 @@ async function cmdPull(argv) {
7123
6431
  continue;
7124
6432
  }
7125
6433
  await writeFile10(
7126
- join13(dir, "config.json"),
6434
+ join14(dir, "config.json"),
7127
6435
  JSON.stringify(cfg.body.config, null, 2)
7128
6436
  );
7129
6437
  let take = false;
@@ -7135,12 +6443,12 @@ async function cmdPull(argv) {
7135
6443
  );
7136
6444
  if (doc.status === 200) {
7137
6445
  await writeFile10(
7138
- join13(dir, "doc.json"),
6446
+ join14(dir, "doc.json"),
7139
6447
  JSON.stringify(doc.body, null, 2)
7140
6448
  );
7141
6449
  take = true;
7142
6450
  if (flags.media === true) {
7143
- const hosted = migrateHostedDoc(doc.body);
6451
+ const hosted = migrateHostedDoc2(doc.body);
7144
6452
  await pullMedia({ origin, key }, dir, hosted, r.log);
7145
6453
  }
7146
6454
  }
@@ -7252,7 +6560,7 @@ async function pushTake(dir, flags, r) {
7252
6560
  throw new Error(`doc.json fails lint:
7253
6561
  ${lint.problems.join("\n ")}`);
7254
6562
  }
7255
- if (!existsSync11(take.paths.recording)) {
6563
+ if (!existsSync12(take.paths.recording)) {
7256
6564
  throw new Error("no recording.webm in this take");
7257
6565
  }
7258
6566
  const ctx = apiContext(flags);
@@ -7284,7 +6592,7 @@ async function pushTake(dir, flags, r) {
7284
6592
  throw new Error("push cancelled");
7285
6593
  }
7286
6594
  }
7287
- const bytes = await readFile11(take.paths.recording);
6595
+ const bytes = await readFile12(take.paths.recording);
7288
6596
  const hash = createHash("sha256").update(bytes).digest("hex");
7289
6597
  r.log(`uploading recording (${Math.round(bytes.length / 1024)} kB)\u2026`);
7290
6598
  const upload = await api(ctx, "/assets/recording", {
@@ -7322,6 +6630,35 @@ async function pushTake(dir, flags, r) {
7322
6630
  r.log(" mic track is local-only \u2014 dropped from the push");
7323
6631
  delete docForPush.source.micKey;
7324
6632
  }
6633
+ for (const ref of docMediaRefs(docForPush)) {
6634
+ const file = join15(dir, takeRelativeFile(ref.key));
6635
+ if (!existsSync12(file)) {
6636
+ r.log(` ${ref.where}: ${ref.key} is not in the take \u2014 left as is`);
6637
+ continue;
6638
+ }
6639
+ const media = await readFile12(file);
6640
+ const mediaHash = createHash("sha256").update(media).digest("hex");
6641
+ const type = mediaContentType(file);
6642
+ const put = await api(ctx, "/assets/recording", {
6643
+ method: "POST",
6644
+ headers: {
6645
+ "Content-Type": type,
6646
+ "Content-Length": String(media.length),
6647
+ "X-Filename": basename(file),
6648
+ "X-Content-Hash": mediaHash
6649
+ },
6650
+ raw: new Uint8Array(media)
6651
+ });
6652
+ if (put.status !== 201 && put.status !== 200) {
6653
+ throw new Error(
6654
+ `${ref.where} upload failed (${put.status}): ${String(put.json.error ?? "")}`
6655
+ );
6656
+ }
6657
+ ref.set(String(put.json.url));
6658
+ r.log(
6659
+ ` ${ref.where}: ${ref.key} \u2192 asset ${String(put.json.id)}${put.json.reused === true ? " (reused)" : ""}`
6660
+ );
6661
+ }
7325
6662
  const lowered = lowerToComposition3(docForPush);
7326
6663
  const config = { ...lowered.config, data: lowered.data };
7327
6664
  if (!state) {
@@ -7435,9 +6772,9 @@ async function pullTake(dir, flags, r) {
7435
6772
  };
7436
6773
  }
7437
6774
  let media2;
7438
- if (flags.media && existsSync11(join14(dir, "doc.json"))) {
6775
+ if (flags.media && existsSync12(join15(dir, "doc.json"))) {
7439
6776
  const local = JSON.parse(
7440
- await readFile11(join14(dir, "doc.json"), "utf8")
6777
+ await readFile12(join15(dir, "doc.json"), "utf8")
7441
6778
  );
7442
6779
  media2 = await pullMedia(ctx, dir, local, r.log);
7443
6780
  }
@@ -7495,12 +6832,12 @@ async function pullTake(dir, flags, r) {
7495
6832
  docRes.status === 404 ? "the head version carries no doc \u2014 is this a take vos?" : `doc fetch failed (${docRes.status})`
7496
6833
  );
7497
6834
  }
7498
- const hostedDoc = migrateHostedDoc2(docRes.json);
6835
+ const hostedDoc = migrateHostedDoc3(docRes.json);
7499
6836
  const doc = hostedDoc;
7500
- if (existsSync11(join14(dir, RECORDING_NAME))) {
6837
+ if (existsSync12(join15(dir, RECORDING_NAME))) {
7501
6838
  doc.source.videoKey = RECORDING_NAME;
7502
6839
  }
7503
- await writeJson(join14(dir, "doc.json"), doc, true);
6840
+ await writeJson(join15(dir, "doc.json"), doc, true);
7504
6841
  const media = flags.media ? await pullMedia(ctx, dir, doc, r.log) : void 0;
7505
6842
  const versions = await api(ctx, `/vos/${vosId}/versions`);
7506
6843
  const headRow = (versions.json.versions ?? []).find((v) => v.id === head);
@@ -7519,16 +6856,16 @@ async function pullTake(dir, flags, r) {
7519
6856
  }
7520
6857
 
7521
6858
  // src/plugin/program.ts
7522
- import { existsSync as existsSync12 } from "fs";
7523
- import { mkdir as mkdir8, readFile as readFile12, writeFile as writeFile11 } from "fs/promises";
6859
+ import { existsSync as existsSync13 } from "fs";
6860
+ import { mkdir as mkdir8, readFile as readFile13, writeFile as writeFile11 } from "fs/promises";
7524
6861
  import { createInterface as createInterface2 } from "readline/promises";
7525
- import { basename as basename2, dirname as dirname3, join as join15 } from "path";
6862
+ import { basename as basename2, dirname as dirname3, join as join16 } from "path";
7526
6863
  import {
7527
6864
  CURRENT_CONFIG_VERSION,
7528
6865
  migrateConfig,
7529
6866
  vosConfigJsonSchema
7530
6867
  } from "@vosjs/core";
7531
- import { migrateHostedDoc as migrateHostedDoc3 } from "@vosjs/studio-core";
6868
+ import { migrateHostedDoc as migrateHostedDoc4 } from "@vosjs/studio-core";
7532
6869
 
7533
6870
  // src/plugin/login.ts
7534
6871
  import { hostname } from "os";
@@ -7692,8 +7029,8 @@ function preflightConfig(parsed) {
7692
7029
  }
7693
7030
  function resolveConfigPath(target) {
7694
7031
  if (target.endsWith(".json")) return target;
7695
- const inDir = join15(target, "config.json");
7696
- if (existsSync12(inDir)) return inDir;
7032
+ const inDir = join16(target, "config.json");
7033
+ if (existsSync13(inDir)) return inDir;
7697
7034
  throw new UsageError(
7698
7035
  `${target} has no config.json (and is not a take \u2014 no doc.json)`
7699
7036
  );
@@ -7721,7 +7058,7 @@ async function cmdFetch(argv) {
7721
7058
  const out = strFlag(flags, "out") ?? slug;
7722
7059
  await mkdir8(out, { recursive: true });
7723
7060
  await writeFile11(
7724
- join15(out, "config.json"),
7061
+ join16(out, "config.json"),
7725
7062
  JSON.stringify(cfg.body.config, null, 2)
7726
7063
  );
7727
7064
  const title = typeof vosMeta.title === "string" ? vosMeta.title : "";
@@ -7743,15 +7080,15 @@ async function cmdFetch(argv) {
7743
7080
  }
7744
7081
  );
7745
7082
  if (doc.status === 200 && !("source" in doc.body)) {
7746
- const hosted = migrateHostedDoc3(doc.body);
7083
+ const hosted = migrateHostedDoc4(doc.body);
7747
7084
  const { config: own } = await writeProgramDoc(out, hosted);
7748
7085
  if (own) {
7749
- await writeFile11(join15(out, "config.json"), JSON.stringify(own, null, 2));
7086
+ await writeFile11(join16(out, "config.json"), JSON.stringify(own, null, 2));
7750
7087
  }
7751
7088
  } else if (doc.status === 200) {
7752
7089
  take = true;
7753
- const hosted = migrateHostedDoc3(doc.body);
7754
- await writeFile11(join15(out, "doc.json"), JSON.stringify(hosted, null, 2));
7090
+ const hosted = migrateHostedDoc4(doc.body);
7091
+ await writeFile11(join16(out, "doc.json"), JSON.stringify(hosted, null, 2));
7755
7092
  if (flags.media === true) {
7756
7093
  const media = await pullMedia({ origin, key }, out, hosted, r.log);
7757
7094
  mediaLine = media.downloaded.length ? ` + ${media.downloaded.map((m) => m.file).join(", ")}` : "";
@@ -7863,7 +7200,7 @@ async function cmdPushProgram(argv) {
7863
7200
  api: strFlag(flags, "api")
7864
7201
  });
7865
7202
  const dir = dirname3(source);
7866
- const parsed = JSON.parse(await readFile12(source, "utf8"));
7203
+ const parsed = JSON.parse(await readFile13(source, "utf8"));
7867
7204
  const pre = preflightConfig(parsed);
7868
7205
  if (!pre.ok || !pre.config) {
7869
7206
  for (const issue of pre.issues) r.log(`error ${issue}`);
@@ -8096,10 +7433,10 @@ async function cmdPullProgram(argv) {
8096
7433
  const cfg = await apiJson(origin, `/api/vos/${vosId}/config`, { key });
8097
7434
  if (cfg.status !== 200)
8098
7435
  throw new Error(apiError(`fetch head config for ${vosId}`, cfg));
8099
- const configPath = join15(dir, "config.json");
7436
+ const configPath = join16(dir, "config.json");
8100
7437
  let backedUp = false;
8101
- if (existsSync12(configPath)) {
8102
- await writeFile11(join15(dir, "config.backup.json"), await readFile12(configPath));
7438
+ if (existsSync13(configPath)) {
7439
+ await writeFile11(join16(dir, "config.backup.json"), await readFile13(configPath));
8103
7440
  backedUp = true;
8104
7441
  }
8105
7442
  let headConfig = cfg.body.config;
@@ -8113,7 +7450,7 @@ async function cmdPullProgram(argv) {
8113
7450
  if (docRes.status === 200 && !("source" in docRes.body)) {
8114
7451
  const { config: own } = await writeProgramDoc(
8115
7452
  dir,
8116
- migrateHostedDoc3(docRes.body)
7453
+ migrateHostedDoc4(docRes.body)
8117
7454
  );
8118
7455
  if (own) headConfig = own;
8119
7456
  }
@@ -8132,7 +7469,7 @@ async function cmdPullProgram(argv) {
8132
7469
  protected: protectedIds,
8133
7470
  changes,
8134
7471
  out: configPath,
8135
- backup: backedUp ? join15(dir, "config.backup.json") : null
7472
+ backup: backedUp ? join16(dir, "config.backup.json") : null
8136
7473
  },
8137
7474
  `Pulled ${changes.length} version${changes.length === 1 ? "" : "s"} \u2192 ${configPath}` + (backedUp ? ` (previous copy: config.backup.json)` : "") + `
8138
7475
  Re-apply your edit on the new head, then: vos push ${configPath} --vos ${vosId}`
@@ -8189,9 +7526,9 @@ function isTakeDir(target) {
8189
7526
  return directoryKind(target) === "take";
8190
7527
  }
8191
7528
  async function readProgramDoc(dir, config) {
8192
- const docPath = join15(dir, "doc.json");
8193
- if (!existsSync12(docPath)) return null;
8194
- const parsed = JSON.parse(await readFile12(docPath, "utf8"));
7529
+ const docPath = join16(dir, "doc.json");
7530
+ if (!existsSync13(docPath)) return null;
7531
+ const parsed = JSON.parse(await readFile13(docPath, "utf8"));
8195
7532
  if (typeof parsed !== "object" || parsed === null || "source" in parsed)
8196
7533
  return null;
8197
7534
  const raw = parsed;
@@ -8202,7 +7539,7 @@ async function writeProgramDoc(dir, hosted) {
8202
7539
  const program = hosted.program && typeof hosted.program === "object" ? hosted.program : {};
8203
7540
  const { config, ...rest } = program;
8204
7541
  const onDisk = { ...hosted, program: rest };
8205
- await writeFile11(join15(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
7542
+ await writeFile11(join16(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
8206
7543
  return {
8207
7544
  config: config && typeof config === "object" ? config : null
8208
7545
  };
@@ -8410,7 +7747,7 @@ async function cmdRecipe(argv) {
8410
7747
 
8411
7748
  // src/plugin/brand.ts
8412
7749
  import { writeFile as writeFile12 } from "fs/promises";
8413
- import { resolve as resolve6 } from "path";
7750
+ import { resolve as resolve7 } from "path";
8414
7751
  import { parseFrontmatter as parseFrontmatter2 } from "@vosjs/shared/frontmatter";
8415
7752
  import { lookKindForGround } from "@vosjs/studio-core";
8416
7753
  var HEX_RE = /#(?:[0-9a-f]{6}|[0-9a-f]{3})\b/gi;
@@ -8459,7 +7796,7 @@ function isSaturated(hex2) {
8459
7796
  return s >= 0.25 && l > 0.12 && l < 0.88;
8460
7797
  }
8461
7798
  var isLight = (hex2) => hsl(hex2).l >= 0.5;
8462
- function mixHex2(a, b, t) {
7799
+ function mixHex(a, b, t) {
8463
7800
  const ch = (i) => Math.round(
8464
7801
  parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t
8465
7802
  ).toString(16).padStart(2, "0");
@@ -8477,7 +7814,7 @@ function mostCommon(values) {
8477
7814
  }
8478
7815
  return best;
8479
7816
  }
8480
- function firstFamily2(fontFamily) {
7817
+ function firstFamily(fontFamily) {
8481
7818
  const first = fontFamily.split(",")[0]?.trim() ?? "";
8482
7819
  return first.replace(/^["']|["']$/g, "");
8483
7820
  }
@@ -8596,7 +7933,7 @@ function composeBrand(input) {
8596
7933
  p.bgA = `the body's background`;
8597
7934
  const surfaceHexes = w.surfaces.map(rgbToHex).filter((h) => h !== null && h !== bgA);
8598
7935
  const neutralSurface = mostCommon(surfaceHexes.filter((h) => !isSaturated(h)));
8599
- const bgB = neutralSurface ?? mixHex2(bgA, isLight(bgA) ? "#000000" : "#ffffff", 0.04);
7936
+ const bgB = neutralSurface ?? mixHex(bgA, isLight(bgA) ? "#000000" : "#ffffff", 0.04);
8600
7937
  p.bgB = neutralSurface ? `the most common section / card ground` : `no distinct surface found; bgA stepped 4% toward ${isLight(bgA) ? "black" : "white"}`;
8601
7938
  const accentHexes = w.accents.map(rgbToHex).filter((h) => h !== null && isSaturated(h));
8602
7939
  const themeHex = w.themeColor ? rgbToHex(w.themeColor) : null;
@@ -8606,13 +7943,13 @@ function composeBrand(input) {
8606
7943
  const inkHex = rgbToHex(w.h1?.color ?? w.body.color) ?? (isLight(bgA) ? "#111111" : "#f5f5f5");
8607
7944
  if (!accent) accent = inkHex;
8608
7945
  p.accent = accentHexes.length ? `the saturated colour buttons and links agree on` : themeHex && isSaturated(themeHex) ? `the theme-color meta` : design?.hexes.find(isSaturated) ? `a hex quoted in design.md` : `no saturated colour on the page; the ink stands in`;
8609
- const bgC = mixHex2(bgA, accent, 0.14);
7946
+ const bgC = mixHex(bgA, accent, 0.14);
8610
7947
  p.bgC = `bgA tinted 14% toward the accent (a highlight ground)`;
8611
7948
  p.ink = w.h1 ? `the h1's colour` : `the body's colour`;
8612
- const fontDisplay = design?.fonts[0] ?? (w.h1 ? firstFamily2(w.h1.fontFamily) : firstFamily2(w.body.fontFamily));
7949
+ const fontDisplay = design?.fonts[0] ?? (w.h1 ? firstFamily(w.h1.fontFamily) : firstFamily(w.body.fontFamily));
8613
7950
  p.fontDisplay = design?.fonts[0] ? `named in design.md` : w.h1 ? `the h1's computed face` : `the body's computed face (no h1)`;
8614
7951
  const designBody = design?.fonts.slice(1).find((f) => !/\bmono\b/i.test(f));
8615
- const fontBody = designBody ?? firstFamily2(w.body.fontFamily);
7952
+ const fontBody = designBody ?? firstFamily(w.body.fontFamily);
8616
7953
  p.fontBody = designBody ? `named in design.md` : `the body's computed face`;
8617
7954
  const isIcon = (u) => /favicon|apple-touch|icon/i.test(u);
8618
7955
  const onDark = (u) => /on-dark|white/i.test(u);
@@ -8727,7 +8064,7 @@ async function cmdBrand(argv) {
8727
8064
  throw new UsageError(`not a URL: ${raw}`);
8728
8065
  }
8729
8066
  const r = createReporter(flags.json === true);
8730
- const out = resolve6(strFlag(flags, "out") ?? "BRAND.md");
8067
+ const out = resolve7(strFlag(flags, "out") ?? "BRAND.md");
8731
8068
  r.log(`reading ${origin}/design.md and /llms.txt\u2026`);
8732
8069
  const [designText, llmsText] = await Promise.all([
8733
8070
  fetchText(`${origin}/design.md`),
@@ -8780,7 +8117,7 @@ async function cmdBrand(argv) {
8780
8117
 
8781
8118
  // src/plugin/agentBrowser.ts
8782
8119
  import { writeFile as writeFile13 } from "fs/promises";
8783
- import { resolve as resolve7 } from "path";
8120
+ import { resolve as resolve8 } from "path";
8784
8121
  function parseAgentBrowserLog(text) {
8785
8122
  const records = [];
8786
8123
  const problems = [];
@@ -9250,7 +8587,7 @@ async function cmdActions(argv) {
9250
8587
  );
9251
8588
  }
9252
8589
  const r = createReporter(flags.json === true);
9253
- const out = resolve7(strFlag(flags, "out") ?? "actions.json");
8590
+ const out = resolve8(strFlag(flags, "out") ?? "actions.json");
9254
8591
  const viewportFlag = strFlag(flags, "viewport");
9255
8592
  let viewport;
9256
8593
  if (viewportFlag) {
@@ -9258,8 +8595,8 @@ async function cmdActions(argv) {
9258
8595
  if (!m) throw new UsageError("--viewport expects WxH, e.g. 1280x720");
9259
8596
  viewport = { width: Number(m[1]), height: Number(m[2]) };
9260
8597
  }
9261
- const { readFile: readFile14 } = await import("fs/promises");
9262
- const text = await readFile14(resolve7(input), "utf8");
8598
+ const { readFile: readFile15 } = await import("fs/promises");
8599
+ const text = await readFile15(resolve8(input), "utf8");
9263
8600
  const { records, problems } = parseAgentBrowserLog(text);
9264
8601
  for (const p of problems) r.log(` ${p}`);
9265
8602
  const result = convertAgentBrowser(records, {
@@ -9311,7 +8648,8 @@ var BOOLEAN_FLAGS5 = /* @__PURE__ */ new Set([
9311
8648
  "print",
9312
8649
  "yes",
9313
8650
  "composed",
9314
- "check"
8651
+ "check",
8652
+ "motion"
9315
8653
  ]);
9316
8654
  var MULTI_FLAGS2 = /* @__PURE__ */ new Set(["set", "override"]);
9317
8655
  var HELP = `vos \u2014 record a browser flow, plan effects, render a product video; sync with vos.so
@@ -9319,10 +8657,10 @@ var HELP = `vos \u2014 record a browser flow, plan effects, render a product vid
9319
8657
  Take pipeline
9320
8658
  vos create --actions actions.json [--url <url>] [--out take] [out.webm] [--strict] [--max-duration <s>] [--background <slug|url|none>] [render flags] [--json]
9321
8659
  vos record --actions actions.json [--url <url>] [--out take] [--strict] [--max-duration <s>] [--background <slug|url|none>] [--json]
9322
- vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--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]
9323
8661
  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]
9324
8662
  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]
9325
- vos deliver <take> --to cws,producthunt,x,linkedin,og,github,youtube (or all) [--headline "\u2026"] [--kicker "\u2026"] [--launch LAUNCH.md] [--music <slug|mood|none>] [--entrance tilt-in|pull-out|rise|none] [--end-card none] [--captions none] [--clicks none] [--look plate|gradient|dark|none] [--brand BRAND.md] [--poster <split-cover|card-on-gradient|config.json|vosId|none>] [--shot-time <t>] [--poster-time <t>] [--composed] [--set path=value] [--release v2.1] [--out dir] [--times a,b] [--range a..b] [--parallel N] [--json]
8663
+ 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]
9326
8664
  vos digest <take> [--out dir] [--full 960] [--crop 640] [--no-frames] [--transcript <file.json>] [--style <doc.json|vosId>] [--json]
9327
8665
  vos brand <url> [--out BRAND.md] [--json]
9328
8666
  vos open <take> [--studio <url>] [--print]
@@ -9422,44 +8760,47 @@ read once as the real page, blank ones (a wallpaper, an empty canvas) are
9422
8760
  dropped and two of one frame collapse to one, with every drop said in
9423
8761
  skipped[]. --times overrides with seconds, percents or step:<id>[+offset]
9424
8762
  (the id from actions.json); --range cuts every video destination.
9425
- The LOOK presents the card: card-genre stills with no poster and every
9426
- video cut sit on a ground (a cream plate, the house gradient, a dark plate
9427
- with a light streak) at ~84% of the width with headroom, a soft ambient
9428
- shadow plus a tight contact shadow, and a hairline when card and ground
9429
- are both light; a wide frame runs the card off the bottom. --look picks a
9430
- house look (or none for the pre-look crops); with no flag the BRAND.md
9431
- beside the take (or --brand <file>) decides from its look role or its own
9432
- ground (a paper site is a plate, a dark site is dark), and with no brand
9433
- the house gradient. Screenshot-genre stills never take a look.
9434
- Card-genre destinations (OG, LinkedIn, X, YouTube thumbnail, the CWS
9435
- tile + marquee, GitHub social preview) COMPOSE by default: each renders
9436
- from its destination's poster TEMPLATE (split-cover is a STAGE, the
9437
- take's own card leaning in perspective with its chrome and shadow beside
9438
- a serif headline column, on the brand's ground; card-on-gradient is the
9439
- shot alone on a mesh, the store's tile rule), filled with BRAND.md's colours and faces and the
9440
- release's words, the shot baked as an object (padded, rounded, shadowed,
9441
- a hairline on a light ground), PNG at exact pixels; kit.json records the
9442
- template and the text boxes. The headline is LAUNCH.md's headline role
9443
- beside the take or --headline (with none, the headline templates stand
9444
- down for card-on-gradient, said); --kicker overrides the wordmark plus
9445
- --release line. --poster names a bundled template for every card, your
9446
- own template (a config.json or a hosted vos id carrying a template
9447
- block), or none to keep the take path.
9448
- Every video cut but the README loop opens on an ENTRANCE (tilt-in by
9449
- default: the card swings in from a perspective pose and settles) and
9450
- closes on an END CARD (the last frame holds 2.5 s while the card recedes
9451
- and the headline, the release line and the wordmark rise); LAUNCH.md's
9452
- entrance and endCard roles, or --entrance and --end-card, change or
9453
- switch them off. A step's caption in actions.json lands as a lower-third
9454
- at the step's moment on cuts that take words (--captions none to skip).
9455
- Destinations that play sound (the X cut, the YouTube demo, the vertical
9456
- cut) take a music bed from LAUNCH.md's music role (a catalog slug or a
9457
- mood; --music overrides) and a click sound on every press when the take
9458
- has no mic (--clicks none). The 9:16 cut is a reframe, not a letterbox:
9459
- the crop follows the camera. --shot-time <t> picks the take moment (OUTPUT seconds;
9460
- default the first still time \u2014 pick a zoom apex, the cut's camera makes
9461
- the shot the feature, not the whole page); --poster-time <t> is the instant
9462
- inside the poster's OWN timeline (default 90% through it). Screenshot-genre
8763
+ The LOOK presents the card: card-genre stills with no poster document
8764
+ and every video cut sit on a ground (a cream plate, the house gradient, a
8765
+ dark plate with a light streak) at ~84% of the width with headroom, a
8766
+ soft ambient shadow plus a tight contact shadow, and a hairline when card
8767
+ and ground are both light; a wide frame runs the card off the bottom.
8768
+ --look picks a house look (or none for the pre-look crops); with no flag
8769
+ the BRAND.md beside the take (or --brand <file>) decides from its look
8770
+ role or its own ground, and with no brand the house gradient.
8771
+ Screenshot-genre stills never take a look.
8772
+ A POSTER is a document, never a template: a plain take whose card sits
8773
+ where the poster wants it (frame.inset), leans (the tilt track), carries
8774
+ the words and the mark as clips (ids stage-title, stage-kicker,
8775
+ stage-brand, stage-mark) and ends on a trailing hold whose start is the
8776
+ still. Card-genre destinations (OG, LinkedIn, X, YouTube thumbnail, the
8777
+ CWS tile + marquee, GitHub social preview) render from the poster
8778
+ document of their aspect CLASS (landscape, square, portrait, tile), found
8779
+ beside the take as poster/<class>/doc.json (poster/doc.json serves every
8780
+ class) or named in LAUNCH.md (poster: <path>, poster-landscape: \u2026), at
8781
+ the document's rest; kit.json records source poster, the class, the file,
8782
+ the vos it tracks, the shot rect and the text boxes read FROM the
8783
+ document. A class with no document is the take's own frame, said once.
8784
+ deliver renders and verifies; it composes nothing. The composition is
8785
+ made with plan --style <poster>, which copies a poster's layout onto a
8786
+ take (its card placement, its stage clips with the release's words
8787
+ patched in from LAUNCH.md's headline and kicker roles or --headline and
8788
+ --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
8802
+ bed; the 9:16 cut is a reframe, not a letterbox, and the crop follows
8803
+ the camera. Screenshot-genre
9463
8804
  destinations (CWS screenshots, the PH gallery) are the real page at that
9464
8805
  moment, FULL BLEED: no zoom, no tilt, no browser bar, no padding (store
9465
8806
  policy); --composed keeps the cut's camera and chrome instead. --set
@@ -9547,7 +8888,7 @@ lint-gated, so a bad override fails like a bad doc.json):
9547
8888
  first ready loop (the house backdrop the studio opens on), or a flat ground offline
9548
8889
  `;
9549
8890
  async function loadActions(file) {
9550
- const raw = JSON.parse(await readFile13(file, "utf8"));
8891
+ const raw = JSON.parse(await readFile14(file, "utf8"));
9551
8892
  const errors = validateActions(raw);
9552
8893
  if (errors.length)
9553
8894
  throw new UsageError(`invalid actions file:
@@ -9603,10 +8944,10 @@ async function cmdRecord(argv) {
9603
8944
  const url = strFlag(flags, "url") ?? actions.url;
9604
8945
  if (!url)
9605
8946
  throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
9606
- const outDir = resolve8(strFlag(flags, "out") ?? "take");
8947
+ const outDir = resolve9(strFlag(flags, "out") ?? "take");
9607
8948
  const backdrop = await takeBackdrop(flags, r);
9608
8949
  const maxDurationSeconds = await maxDuration(flags, r);
9609
- if (existsSync13(join16(outDir, "meta.json"))) {
8950
+ if (existsSync14(join17(outDir, "meta.json"))) {
9610
8951
  const { prevDoc, kept } = await prepareReRecord(outDir);
9611
8952
  r.log(
9612
8953
  `note: re-recording ${outDir} \u2014 kept ${kept.length ? kept.join(", ") : "nothing"}` + (prevDoc ? "; apply the previous cut to the new footage with: vos plan " + outDir + " --reuse" : "")
@@ -9653,7 +8994,7 @@ async function cmdRecord(argv) {
9653
8994
  },
9654
8995
  strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
9655
8996
  ${(rec.meta.durationMs / 1e3).toFixed(1)}s \xB7 ${rec.frames.length} frames \xB7 ${rec.events.length} cursor events \xB7 ${clicks} clicks \xB7 ${plan.doc.zoom.length} zoom spans planned \xB7 ${rec.freezePct}% frozen${rec.freezePct >= 25 ? " \u26A0 keep motion in frame or trim" : ""}${skippedNote}
9656
- Next: edit ${join16(outDir, "doc.json")} (optional), then: vos render ${outDir}`
8997
+ Next: edit ${join17(outDir, "doc.json")} (optional), then: vos render ${outDir}`
9657
8998
  );
9658
8999
  return strictFail ? EXIT_USAGE : EXIT_OK;
9659
9000
  } finally {
@@ -9677,19 +9018,19 @@ async function cmdCreate2(argv) {
9677
9018
  const url = strFlag(flags, "url") ?? actions.url;
9678
9019
  if (!url)
9679
9020
  throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
9680
- const outDir = resolve8(strFlag(flags, "out") ?? "take");
9021
+ const outDir = resolve9(strFlag(flags, "out") ?? "take");
9681
9022
  const fmtRaw = strFlag(flags, "format") ?? "webm";
9682
9023
  if (fmtRaw !== "webm" && fmtRaw !== "mp4")
9683
9024
  throw new UsageError("--format must be webm or mp4");
9684
9025
  const format = fmtRaw;
9685
- const out = positionals[0] ?? join16(outDir, `out.${format}`);
9026
+ const out = positionals[0] ?? join17(outDir, `out.${format}`);
9686
9027
  const parallel = numFlag(flags, "parallel", 1);
9687
9028
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
9688
9029
  throw new UsageError("--parallel expects an integer between 1 and 16");
9689
9030
  }
9690
9031
  const backdrop = await takeBackdrop(flags, r);
9691
9032
  const maxDurationSeconds = await maxDuration(flags, r);
9692
- if (existsSync13(join16(outDir, "meta.json"))) {
9033
+ if (existsSync14(join17(outDir, "meta.json"))) {
9693
9034
  const { prevDoc, kept } = await prepareReRecord(outDir);
9694
9035
  r.log(
9695
9036
  `note: re-recording ${outDir} \u2014 kept ${kept.length ? kept.join(", ") : "nothing"}` + (prevDoc ? "; apply the previous cut to the new footage with: vos plan " + outDir + " --reuse" : "")
@@ -9772,40 +9113,66 @@ async function cmdPlan(argv) {
9772
9113
  const dir = positionals[0];
9773
9114
  if (!dir)
9774
9115
  throw new UsageError(
9775
- "vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>]"
9116
+ 'vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--motion] [--headline "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md]'
9776
9117
  );
9777
9118
  const r = createReporter(flags.json === true);
9778
9119
  if (flags.fresh === true) {
9779
- await rm4(join16(dir, "doc.json"), { force: true });
9120
+ await rm4(join17(dir, "doc.json"), { force: true });
9780
9121
  r.log("note: --fresh discarded the existing doc.json");
9781
9122
  }
9782
9123
  let reuse;
9783
9124
  if (flags.reuse === true) {
9784
- const from = resolve8(strFlag(flags, "from") ?? join16(dir, PREV_DOC_NAME));
9785
- if (!existsSync13(from)) {
9125
+ const from = resolve9(strFlag(flags, "from") ?? join17(dir, PREV_DOC_NAME));
9126
+ if (!existsSync14(from)) {
9786
9127
  throw new UsageError(
9787
9128
  `--reuse: ${from} does not exist \u2014 a re-record into this take writes doc.prev.json, or pass --from <doc.json>`
9788
9129
  );
9789
9130
  }
9790
9131
  reuse = {
9791
9132
  from,
9792
- doc: JSON.parse(await readFile13(from, "utf8"))
9133
+ doc: JSON.parse(await readFile14(from, "utf8"))
9793
9134
  };
9794
9135
  }
9795
9136
  const style = await resolveStyleRef(flags);
9796
- const backdrop = existsSync13(join16(dir, "doc.json")) ? null : await takeBackdrop(flags, r);
9137
+ const hasDoc = existsSync14(join17(dir, "doc.json"));
9138
+ const backdrop = hasDoc ? null : await takeBackdrop(flags, r);
9139
+ const release = await releaseInputs(dir, flags, r);
9140
+ const motionWanted = !hasDoc || flags.motion === true || flags.fresh === true;
9797
9141
  const s = await planTake(dir, {
9798
9142
  ...style ? { style } : {},
9799
9143
  ...reuse ? { reuse } : {},
9800
- backdrop
9144
+ backdrop,
9145
+ words: release.words,
9146
+ mark: release.mark,
9147
+ ...motionWanted ? {
9148
+ motion: {
9149
+ words: release.words,
9150
+ launch: release.launchRoles,
9151
+ ink: release.ink,
9152
+ mark: release.mark,
9153
+ captions: release.captions,
9154
+ catalog: release.catalog,
9155
+ again: flags.motion === true
9156
+ }
9157
+ } : {}
9801
9158
  });
9159
+ const layoutLines = s.layout ? `
9160
+ layout from ${s.styleFrom}: ${[
9161
+ s.layout.clips.length ? `${s.layout.clips.length} stage clip(s)` : "",
9162
+ s.layout.lean ? "the rest lean" : "",
9163
+ s.layout.hold ? "the hold" : ""
9164
+ ].filter(Boolean).join(", ")}` + (s.layout.notes.length ? `
9165
+ ${s.layout.notes.join("\n ")}` : "") : "";
9166
+ const motionLines = s.motion ? (s.motion.notes.length ? `
9167
+ proposed: ${s.motion.notes.join(", ")}` : "") + (s.motion.skipped.length ? `
9168
+ not proposed: ${s.motion.skipped.join("; ")}` : "") : "";
9802
9169
  const reuseLines = s.reuse ? `
9803
9170
  reused ${s.reuse.from}: ${s.reuse.anchored} anchored + ${s.reuse.mapped} mapped span(s)` + (s.reuse.flagged.length ? `
9804
9171
  flagged:
9805
9172
  ${s.reuse.flagged.join("\n ")}` : "") : "";
9806
9173
  r.done(
9807
9174
  {
9808
- take: resolve8(dir),
9175
+ take: resolve9(dir),
9809
9176
  fresh: s.fresh,
9810
9177
  cursorKept: s.cursorKept,
9811
9178
  zoomAuto: s.zoomAuto,
@@ -9813,12 +9180,78 @@ async function cmdPlan(argv) {
9813
9180
  duration: s.doc.source.meta.durationMs / 1e3,
9814
9181
  ...s.backdrop !== void 0 ? { backdrop: s.backdrop } : {},
9815
9182
  ...s.styleFrom ? { styleFrom: s.styleFrom, styleFields: s.styleFields } : {},
9183
+ ...s.layout ? { layout: s.layout } : {},
9184
+ ...s.motion ? { motion: s.motion } : {},
9816
9185
  ...s.reuse ? { reuse: s.reuse } : {}
9817
9186
  },
9818
- `${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join16(dir, "doc.json")}: ${s.zoomAuto} auto + ${s.zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${reuseLines}`
9187
+ `${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join17(dir, "doc.json")}: ${s.zoomAuto} auto + ${s.zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${layoutLines}${motionLines}${reuseLines}`
9819
9188
  );
9820
9189
  return EXIT_OK;
9821
9190
  }
9191
+ async function releaseInputs(dir, flags, r) {
9192
+ let lookPick;
9193
+ try {
9194
+ lookPick = await resolveLook(dir, {
9195
+ look: strFlag(flags, "look"),
9196
+ brand: strFlag(flags, "brand")
9197
+ });
9198
+ } catch (e) {
9199
+ throw new UsageError(e instanceof Error ? e.message : String(e));
9200
+ }
9201
+ const launch = await readLaunchBesideTake(dir, strFlag(flags, "launch"));
9202
+ const lines = (v) => v === null || v === void 0 ? null : v.replace(/\\n/g, "\n");
9203
+ const words = {
9204
+ headline: lines(strFlag(flags, "headline") ?? launch?.roles.headline),
9205
+ kicker: lines(strFlag(flags, "kicker") ?? launch?.roles.kicker),
9206
+ brand: strFlag(flags, "brand-name") ?? lookPick.roles?.wordmark ?? null,
9207
+ release: strFlag(flags, "release") ?? null
9208
+ };
9209
+ if (launch) r.log(`words: ${launch.file}`);
9210
+ const launchRoles = { ...launch?.roles ?? {} };
9211
+ for (const [flag, role] of [
9212
+ ["music", "music"],
9213
+ ["entrance", "entrance"],
9214
+ ["end-card", "endCard"],
9215
+ ["captions", "captions"],
9216
+ ["clicks", "clicks"]
9217
+ ]) {
9218
+ const v = strFlag(flags, flag);
9219
+ if (v !== void 0) launchRoles[role] = v;
9220
+ }
9221
+ const marks = await fetchBrandMarks(dir, lookPick.roles);
9222
+ for (const n of marks.notes) r.log(`note: ${n}`);
9223
+ const mark = lookPick.look?.kind === "dark" ? marks.dark : marks.light;
9224
+ if (mark) r.log(`brand mark: ${mark.key} (${mark.aspect.toFixed(2)}:1)`);
9225
+ const off2 = (v) => v !== void 0 && /^(none|off|no|false)$/i.test(v);
9226
+ let catalog = null;
9227
+ if (launchRoles.music && !off2(launchRoles.music)) {
9228
+ try {
9229
+ catalog = await fetchMusicCatalog(
9230
+ platformOrigin({
9231
+ origin: strFlag(flags, "origin"),
9232
+ api: strFlag(flags, "api")
9233
+ })
9234
+ );
9235
+ } catch (e) {
9236
+ r.log(
9237
+ `music catalog: ${e instanceof Error ? e.message : String(e)} \u2014 no bed`
9238
+ );
9239
+ }
9240
+ }
9241
+ const take = await loadTake(dir);
9242
+ const captions = (take.actions?.steps ?? []).flatMap((st, i) => {
9243
+ const step = st;
9244
+ return typeof step.caption === "string" && step.caption.trim() ? [{ step: i, id: step.id, caption: step.caption.trim() }] : [];
9245
+ });
9246
+ return {
9247
+ words,
9248
+ launchRoles,
9249
+ ink: endCardInk(lookPick.look, lookPick.roles),
9250
+ mark,
9251
+ captions,
9252
+ catalog
9253
+ };
9254
+ }
9822
9255
  async function cmdRender(argv) {
9823
9256
  const { positionals, flags, multi } = parseArgs(
9824
9257
  argv,
@@ -9835,7 +9268,7 @@ async function cmdRender(argv) {
9835
9268
  if (fmtRaw !== "webm" && fmtRaw !== "mp4")
9836
9269
  throw new UsageError("--format must be webm or mp4");
9837
9270
  const format = fmtRaw;
9838
- const out = positionals[1] ?? join16(dir, `out.${format}`);
9271
+ const out = positionals[1] ?? join17(dir, `out.${format}`);
9839
9272
  const parallel = numFlag(flags, "parallel", 1);
9840
9273
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
9841
9274
  throw new UsageError("--parallel expects an integer between 1 and 16");
@@ -10013,34 +9446,6 @@ async function cmdDeliver(argv) {
10013
9446
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
10014
9447
  throw new UsageError("--parallel expects an integer between 1 and 16");
10015
9448
  }
10016
- let poster;
10017
- const posterRef = strFlag(flags, "poster");
10018
- if (posterRef === "none") {
10019
- poster = null;
10020
- } else if (posterRef !== void 0 && templateByName(posterRef)) {
10021
- poster = { from: `template ${posterRef}`, config: templateByName(posterRef) };
10022
- } else if (posterRef !== void 0) {
10023
- if (existsSync13(posterRef)) {
10024
- poster = {
10025
- from: resolve8(posterRef),
10026
- config: JSON.parse(await readFile13(posterRef, "utf8"))
10027
- };
10028
- } else {
10029
- const origin = platformOrigin({
10030
- origin: strFlag(flags, "origin"),
10031
- api: strFlag(flags, "api")
10032
- });
10033
- const key = await resolveCredential(strFlag(flags, "key"));
10034
- const res = await apiJson(origin, `/api/vos/${posterRef}/config`, {
10035
- key
10036
- });
10037
- if (!res.config)
10038
- throw new UsageError(
10039
- `--poster ${posterRef}: not a file on disk and the platform returned no config`
10040
- );
10041
- poster = { from: posterRef, config: res.config };
10042
- }
10043
- }
10044
9449
  let lookPick;
10045
9450
  try {
10046
9451
  lookPick = await resolveLook(dir, {
@@ -10052,63 +9457,18 @@ async function cmdDeliver(argv) {
10052
9457
  }
10053
9458
  r.log(`look: ${lookPick.from}`);
10054
9459
  const launch = await readLaunchBesideTake(dir, strFlag(flags, "launch"));
10055
- const lines = (v) => v === null || v === void 0 ? null : v.replace(/\\n/g, "\n");
10056
- const words = {
10057
- headline: lines(strFlag(flags, "headline") ?? launch?.roles.headline),
10058
- kicker: lines(strFlag(flags, "kicker") ?? launch?.roles.kicker),
10059
- brand: strFlag(flags, "brand-name") ?? lookPick.roles?.wordmark ?? null,
10060
- release: strFlag(flags, "release") ?? null
10061
- };
10062
- if (launch) r.log(`words: ${launch.file}`);
10063
- const launchRoles = { ...launch?.roles ?? {} };
10064
- for (const [flag, role] of [
10065
- ["music", "music"],
10066
- ["entrance", "entrance"],
10067
- ["end-card", "endCard"],
10068
- ["captions", "captions"],
10069
- ["clicks", "clicks"]
10070
- ]) {
10071
- const v = strFlag(flags, flag);
10072
- if (v !== void 0) launchRoles[role] = v;
10073
- }
10074
- const soundWanted = channels.some(
10075
- (c) => ["x", "youtube", "shorts-linkedin"].includes(c)
10076
- );
10077
- let catalog = null;
10078
- if (soundWanted && !/^(none|off|no|false)$/i.test(launchRoles.music ?? "") && !/^(none|off|no|false)$/i.test(launchRoles.clicks ?? "")) {
10079
- try {
10080
- catalog = await fetchMusicCatalog(
10081
- platformOrigin({
10082
- origin: strFlag(flags, "origin"),
10083
- api: strFlag(flags, "api")
10084
- })
10085
- );
10086
- } catch (e) {
10087
- r.log(`music catalog: ${e instanceof Error ? e.message : String(e)} \u2014 the cuts stay silent`);
10088
- }
10089
- }
10090
- const captions = (take.actions?.steps ?? []).flatMap((s, i) => {
10091
- const step = s;
10092
- return typeof step.caption === "string" && step.caption.trim() ? [{ step: i, id: step.id, caption: step.caption.trim() }] : [];
10093
- });
9460
+ if (launch) r.log(`launch: ${launch.file}`);
10094
9461
  const browser = await launchBrowser();
10095
9462
  try {
10096
9463
  const result = await deliverTake(browser, dir, {
10097
9464
  look: lookPick.look,
10098
- brandRoles: lookPick.roles,
10099
- launchRoles,
10100
- catalog,
10101
- captions,
10102
- words,
9465
+ launchRoles: launch?.roles ?? null,
10103
9466
  channels,
10104
9467
  outDir: strFlag(flags, "out"),
10105
9468
  release: strFlag(flags, "release"),
10106
9469
  times,
10107
9470
  range,
10108
9471
  parallel,
10109
- poster,
10110
- posterTime: hasFlag(flags, "poster-time") ? numFlag(flags, "poster-time", 0) : void 0,
10111
- shotTime: hasFlag(flags, "shot-time") ? numFlag(flags, "shot-time", 0) : void 0,
10112
9472
  composed: hasFlag(flags, "composed"),
10113
9473
  overrides: {
10114
9474
  set: multi.set,
@@ -10126,7 +9486,7 @@ async function cmdDeliver(argv) {
10126
9486
  });
10127
9487
  const { kit } = result;
10128
9488
  const assetLines = kit.assets.map(
10129
- (a) => `${a.destination} \u2192 ${a.path} (${a.w}x${a.h}, ${(a.bytes / 1024).toFixed(0)} KB${a.seconds !== null ? `, ${a.seconds.toFixed(1)}s` : ""}${a.frameTime !== null ? `, frame ${a.frameTime.toFixed(2)}s` : ""}${a.source === "poster" ? ", poster" : ""})`
9489
+ (a) => `${a.destination} \u2192 ${a.path} (${a.w}x${a.h}, ${(a.bytes / 1024).toFixed(0)} KB${a.seconds !== null ? `, ${a.seconds.toFixed(1)}s` : ""}${a.frameTime !== null ? `, frame ${a.frameTime.toFixed(2)}s` : ""}${a.poster ? `, poster ${a.poster.class}` : ""})`
10130
9490
  );
10131
9491
  const skippedLines = kit.skipped.map((s) => `skipped: ${s}`);
10132
9492
  r.done(
@@ -10149,14 +9509,14 @@ Store uploads stay manual: hand the human this directory and the manifest.`
10149
9509
  async function resolveStyleRef(flags) {
10150
9510
  const styleRef = strFlag(flags, "style");
10151
9511
  if (!styleRef) return null;
10152
- const file = existsSync13(styleRef) ? resolve8(
9512
+ const file = existsSync14(styleRef) ? resolve9(
10153
9513
  styleRef,
10154
- existsSync13(join16(styleRef, "doc.json")) ? "doc.json" : ""
9514
+ existsSync14(join17(styleRef, "doc.json")) ? "doc.json" : ""
10155
9515
  ) : null;
10156
- if (file && existsSync13(file)) {
9516
+ if (file && existsSync14(file)) {
10157
9517
  return {
10158
9518
  from: file,
10159
- doc: JSON.parse(await readFile13(file, "utf8"))
9519
+ doc: JSON.parse(await readFile14(file, "utf8"))
10160
9520
  };
10161
9521
  }
10162
9522
  const origin = platformOrigin({
@@ -10179,7 +9539,7 @@ async function resolveStyleRef(flags) {
10179
9539
  throw new UsageError(`--style: ${styleRef} carries no doc (is it a take?)`);
10180
9540
  return {
10181
9541
  from: `${origin}/vos/${styleRef}`,
10182
- doc: migrateHostedDoc4(doc.body)
9542
+ doc: migrateHostedDoc5(doc.body)
10183
9543
  };
10184
9544
  }
10185
9545
  async function cmdDigest(argv) {
@@ -10193,7 +9553,7 @@ async function cmdDigest(argv) {
10193
9553
  const take = await loadTake(dir);
10194
9554
  if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
10195
9555
  const transcriptPath = strFlag(flags, "transcript");
10196
- const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile13(transcriptPath, "utf8"))) : null;
9556
+ const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile14(transcriptPath, "utf8"))) : null;
10197
9557
  const style = await resolveStyleRef(flags);
10198
9558
  const noFrames = flags["no-frames"] === true;
10199
9559
  let browser = null;
@@ -10224,7 +9584,7 @@ async function cmdDigest(argv) {
10224
9584
  kinds,
10225
9585
  frames: d.images.full,
10226
9586
  crops: d.images.crop,
10227
- sheet: d.images.sheet ? join16(result.outDir, d.images.sheet) : null,
9587
+ sheet: d.images.sheet ? join17(result.outDir, d.images.sheet) : null,
10228
9588
  scenes: kinds.scene ?? 0,
10229
9589
  sourceDuration: d.take.sourceDuration,
10230
9590
  outputDuration: d.take.outputDuration,
@@ -10248,14 +9608,14 @@ async function cmdOpen(argv) {
10248
9608
  if (!dir) throw new UsageError("vos open <take> [--studio <url>]");
10249
9609
  const r = createReporter(flags.json === true);
10250
9610
  const take = await loadTake(dir);
10251
- if (!existsSync13(take.paths.recording)) {
9611
+ if (!existsSync14(take.paths.recording)) {
10252
9612
  throw new UsageError(`${dir} has no recording.webm \u2014 re-run record`);
10253
9613
  }
10254
9614
  const studio = (strFlag(flags, "studio") ?? "http://localhost:6060").replace(
10255
9615
  /\/+$/,
10256
9616
  ""
10257
9617
  );
10258
- const server = await startTakeServer(resolve8(dir), {});
9618
+ const server = await startTakeServer(resolve9(dir), {});
10259
9619
  const url = `${studio}/studio?take=${encodeURIComponent(server.base)}`;
10260
9620
  r.event({ event: "open", server: server.base, url });
10261
9621
  r.log(`take served at ${server.base}`);
@@ -10289,11 +9649,12 @@ async function cmdJudge(argv) {
10289
9649
  "vos judge <kit.json> --against <MANIFEST.json> [--out dir] [--json]\n(the manifest names the reference set: id, file, role, layout, facts, rule per asset)"
10290
9650
  );
10291
9651
  const r = createReporter(flags.json === true);
10292
- const kitFile = target.endsWith("kit.json") ? target : join16(target, "kit.json");
10293
- if (!existsSync13(kitFile)) throw new UsageError(`${kitFile}: no kit manifest`);
10294
- if (!existsSync13(against)) throw new UsageError(`${against}: no reference manifest`);
9652
+ const kitFile = target.endsWith("kit.json") ? target : join17(target, "kit.json");
9653
+ if (!existsSync14(kitFile)) throw new UsageError(`${kitFile}: no kit manifest`);
9654
+ if (!existsSync14(against))
9655
+ throw new UsageError(`${against}: no reference manifest`);
10295
9656
  const result = await judgeKit(kitFile, against, strFlag(flags, "out"));
10296
- const verdicts = JSON.parse(await readFile13(result.verdictFile, "utf8")).verdicts;
9657
+ const verdicts = JSON.parse(await readFile14(result.verdictFile, "utf8")).verdicts;
10297
9658
  const rate = winRate(verdicts);
10298
9659
  const lines = result.sheets.map(
10299
9660
  (s) => `${s.asset} vs ${s.reference}: ${s.sheetA}, ${s.sheetB}, ${s.rubric}`
@@ -10313,7 +9674,7 @@ async function cmdValidate(argv) {
10313
9674
  const target = positionals[0];
10314
9675
  if (!target) throw new UsageError("vos validate <actions.json|take>");
10315
9676
  const r = createReporter(flags.json === true);
10316
- const kitTarget = target.endsWith("kit.json") ? target : existsSync13(join16(target, "kit.json")) && !isTakeDir(target) ? join16(target, "kit.json") : null;
9677
+ const kitTarget = target.endsWith("kit.json") ? target : existsSync14(join17(target, "kit.json")) && !isTakeDir(target) ? join17(target, "kit.json") : null;
10317
9678
  if (kitTarget) {
10318
9679
  const picture = flags.picture === true;
10319
9680
  const verdict = await validateKit(kitTarget, { picture });
@@ -10345,9 +9706,9 @@ async function cmdValidate(argv) {
10345
9706
  r.done({ valid: true, target }, `${target}: valid actions file`);
10346
9707
  return EXIT_OK;
10347
9708
  }
10348
- if (!isTakeDir(target) && existsSync13(join16(target, "config.json"))) {
9709
+ if (!isTakeDir(target) && existsSync14(join17(target, "config.json"))) {
10349
9710
  const parsed = JSON.parse(
10350
- await readFile13(join16(target, "config.json"), "utf8")
9711
+ await readFile14(join17(target, "config.json"), "utf8")
10351
9712
  );
10352
9713
  const pre = preflightConfig(parsed);
10353
9714
  const problems2 = pre.ok ? [] : pre.issues.map((i) => `config.json: ${i}`);
@@ -10388,7 +9749,7 @@ async function cmdValidate(argv) {
10388
9749
  const take = await loadTake(target);
10389
9750
  const problems = [];
10390
9751
  const warnings = [];
10391
- if (!existsSync13(take.paths.recording))
9752
+ if (!existsSync14(take.paths.recording))
10392
9753
  problems.push("missing recording.webm (re-run record)");
10393
9754
  if (!take.doc) problems.push("missing doc.json (run plan)");
10394
9755
  if (take.doc) {
@@ -10433,7 +9794,7 @@ async function cmdPush3(argv) {
10433
9794
  const r = createReporter(flags.json === true);
10434
9795
  const overrides = multi.override;
10435
9796
  const result = await pushTake(
10436
- resolve8(dir),
9797
+ resolve9(dir),
10437
9798
  {
10438
9799
  key: strFlag(flags, "key"),
10439
9800
  api: strFlag(flags, "api"),
@@ -10447,11 +9808,15 @@ async function cmdPush3(argv) {
10447
9808
  },
10448
9809
  r
10449
9810
  );
9811
+ const pushedTo = platformOrigin({
9812
+ origin: strFlag(flags, "origin"),
9813
+ api: strFlag(flags, "api")
9814
+ }).replace(/\/+$/, "");
10450
9815
  r.done(
10451
9816
  { ...result },
10452
9817
  `pushed v${result.versionNumber} \u2192 vos ${result.vosId}
10453
- review: https://vos.so/vos/${result.vosId}
10454
- studio: https://vos.so/studio?vos=${result.vosId}`
9818
+ review: ${pushedTo}/vos/${result.vosId}
9819
+ studio: ${pushedTo}/studio?vos=${result.vosId}`
10455
9820
  );
10456
9821
  return EXIT_OK;
10457
9822
  }
@@ -10460,7 +9825,7 @@ async function cmdPull2(argv) {
10460
9825
  const dir = positionals[0] ?? ".";
10461
9826
  const r = createReporter(flags.json === true);
10462
9827
  const result = await pullTake(
10463
- resolve8(dir),
9828
+ resolve9(dir),
10464
9829
  {
10465
9830
  key: strFlag(flags, "key"),
10466
9831
  api: strFlag(flags, "api"),
@@ -10595,4 +9960,4 @@ export {
10595
9960
  convertAgentBrowser,
10596
9961
  run
10597
9962
  };
10598
- //# sourceMappingURL=chunk-Y7BXFF2W.js.map
9963
+ //# sourceMappingURL=chunk-4LLBPFCW.js.map