@vosjs/cli 0.13.1 → 0.14.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 mkdir7, readFile as readFile9, rm as rm4 } from "fs/promises";
10
- import { existsSync as existsSync10 } from "fs";
11
- import { join as join14, resolve as resolve6 } from "path";
9
+ import { mkdir as mkdir8, readFile as readFile12, rm as rm4 } from "fs/promises";
10
+ import { existsSync as existsSync12 } from "fs";
11
+ import { join as join15, resolve as resolve7 } from "path";
12
12
  import { totalDuration as totalDuration2 } from "@vosjs/timeline";
13
- import { migrateHostedDoc as migrateHostedDoc4, ratedSegments as ratedSegments4 } from "@vosjs/studio-core";
13
+ import { migrateHostedDoc as migrateHostedDoc4, ratedSegments as ratedSegments6 } from "@vosjs/studio-core";
14
14
 
15
15
  // src/plugin/args.ts
16
16
  var UsageError = class extends Error {
@@ -127,22 +127,22 @@ function validateActions(value) {
127
127
  }
128
128
  const seenIds = /* @__PURE__ */ new Set();
129
129
  obj.steps.forEach((raw, i) => {
130
- const at = `steps[${i}]`;
130
+ const at2 = `steps[${i}]`;
131
131
  if (typeof raw !== "object" || raw === null) {
132
- errors.push(`${at}: must be an object`);
132
+ errors.push(`${at2}: must be an object`);
133
133
  return;
134
134
  }
135
135
  const s = raw;
136
136
  if (typeof s.do !== "string" || !VERBS.has(s.do)) {
137
- errors.push(`${at}: "do" must be one of ${[...VERBS].join(", ")}`);
137
+ errors.push(`${at2}: "do" must be one of ${[...VERBS].join(", ")}`);
138
138
  return;
139
139
  }
140
140
  if (s.id !== void 0) {
141
141
  if (typeof s.id !== "string" || !s.id.trim()) {
142
- errors.push(`${at}: id must be a non-empty string`);
142
+ errors.push(`${at2}: id must be a non-empty string`);
143
143
  } else if (seenIds.has(s.id)) {
144
144
  errors.push(
145
- `${at}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
145
+ `${at2}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
146
146
  );
147
147
  } else {
148
148
  seenIds.add(s.id);
@@ -150,26 +150,26 @@ function validateActions(value) {
150
150
  }
151
151
  const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
152
152
  if (needSelector && typeof s.selector !== "string")
153
- errors.push(`${at}: ${s.do} needs a selector`);
153
+ errors.push(`${at2}: ${s.do} needs a selector`);
154
154
  if (s.do === "wait" && typeof s.ms !== "number")
155
- errors.push(`${at}: wait needs ms`);
155
+ errors.push(`${at2}: wait needs ms`);
156
156
  if (s.do === "type" && typeof s.text !== "string")
157
- errors.push(`${at}: type needs text`);
157
+ errors.push(`${at2}: type needs text`);
158
158
  if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
159
- errors.push(`${at}: type focus must be true or false`);
159
+ errors.push(`${at2}: type focus must be true or false`);
160
160
  if (s.do === "scroll" && typeof s.dy !== "number")
161
- errors.push(`${at}: scroll needs dy`);
161
+ errors.push(`${at2}: scroll needs dy`);
162
162
  if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
163
- errors.push(`${at}: move needs x and y (CSS px)`);
163
+ errors.push(`${at2}: move needs x and y (CSS px)`);
164
164
  }
165
165
  if (s.do === "drag") {
166
166
  if (typeof s.tx !== "number" || typeof s.ty !== "number") {
167
- errors.push(`${at}: drag needs tx and ty (CSS px)`);
167
+ errors.push(`${at2}: drag needs tx and ty (CSS px)`);
168
168
  }
169
169
  const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
170
170
  if (!hasStart)
171
171
  errors.push(
172
- `${at}: drag needs a selector OR x and y as the start point`
172
+ `${at2}: drag needs a selector OR x and y as the start point`
173
173
  );
174
174
  }
175
175
  });
@@ -317,6 +317,33 @@ function lintDoc(docIn) {
317
317
  );
318
318
  }
319
319
  }
320
+ for (const [i, seg] of (Array.isArray(doc.segments) ? doc.segments : []).entries()) {
321
+ const hold = seg.hold;
322
+ if (hold !== void 0 && (!isNum(hold) || hold < 0 || hold > 10)) {
323
+ problems.push(
324
+ `segments[${i}].hold must be 0..10 output seconds (got ${String(hold)})`
325
+ );
326
+ }
327
+ }
328
+ const endCard = doc.endCard;
329
+ if (endCard !== void 0) {
330
+ if (typeof endCard !== "object" || endCard === null) {
331
+ problems.push("endCard must be an object: {seconds?, headline?, sub?, wordmark?}");
332
+ } else {
333
+ const ec = endCard;
334
+ 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`);
336
+ }
337
+ for (const k of ["headline", "sub", "wordmark"]) {
338
+ if (ec[k] !== void 0 && typeof ec[k] !== "string") {
339
+ problems.push(`endCard.${k} must be a string`);
340
+ }
341
+ }
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");
344
+ }
345
+ }
346
+ }
320
347
  if (doc.segments !== void 0 && !Array.isArray(doc.segments)) {
321
348
  problems.push("segments must be an array of {in, out} spans");
322
349
  }
@@ -524,9 +551,69 @@ function lintDoc(docIn) {
524
551
  "frame.borderWidth/borderColor are set but frame.border is 0 \u2014 border is the switch AND the alpha, so nothing is drawn"
525
552
  );
526
553
  }
554
+ const sc = frame.shadowContact;
555
+ if (sc !== void 0 && (!isNum(sc) || sc < 0 || sc > 1)) {
556
+ problems.push(
557
+ `frame.shadowContact must be 0..1 (got ${String(sc)}); absent = no contact layer`
558
+ );
559
+ }
560
+ const shc = frame.shadowColor;
561
+ if (shc !== void 0 && !/^#[0-9a-fA-F]{6}$/.test(String(shc))) {
562
+ problems.push(
563
+ `frame.shadowColor must be a #rrggbb hex (got ${String(shc)}); absent = black`
564
+ );
565
+ }
566
+ const ent = frame.entrance;
567
+ if (ent !== void 0) {
568
+ const kinds = ["tilt-in", "pull-out", "rise", "none"];
569
+ if (typeof ent !== "object" || ent === null || !kinds.includes(String(ent.kind))) {
570
+ problems.push(
571
+ `frame.entrance.kind must be one of ${kinds.join(" | ")} (got ${JSON.stringify(ent)})`
572
+ );
573
+ } else {
574
+ const secs = ent.seconds;
575
+ if (secs !== void 0 && (!isNum(secs) || secs < 0.2 || secs > 3)) {
576
+ problems.push(
577
+ `frame.entrance.seconds must be 0.2..3 (got ${String(secs)}); absent = 1.2`
578
+ );
579
+ }
580
+ }
581
+ }
582
+ if (frame.focusFollow !== void 0 && frame.focusFollow !== "camera") {
583
+ problems.push(
584
+ `frame.focusFollow must be "camera" (got ${String(frame.focusFollow)}); it reads under fit: cover only`
585
+ );
586
+ }
587
+ const ins = frame.inset;
588
+ if (ins !== void 0) {
589
+ if (typeof ins !== "object" || ins === null || Array.isArray(ins)) {
590
+ problems.push(
591
+ "frame.inset must be an object of {top, right, bottom, left} fractions"
592
+ );
593
+ } else {
594
+ const sides = ins;
595
+ for (const side of ["top", "right", "bottom", "left"]) {
596
+ const v = sides[side];
597
+ if (v !== void 0 && (!isNum(v) || v < -2 || v > 0.9)) {
598
+ problems.push(
599
+ `frame.inset.${side} must be a fraction of the frame in -2..0.9 (got ${String(v)}); negative bleeds the card past the edge`
600
+ );
601
+ }
602
+ }
603
+ const l = isNum(sides.left) ? sides.left : 0;
604
+ const r = isNum(sides.right) ? sides.right : 0;
605
+ const t = isNum(sides.top) ? sides.top : 0;
606
+ const b = isNum(sides.bottom) ? sides.bottom : 0;
607
+ if (l + r >= 1 || t + b >= 1) {
608
+ problems.push(
609
+ `frame.inset leaves no room for the card (left+right ${l + r}, top+bottom ${t + b}; each pair must stay under 1)`
610
+ );
611
+ }
612
+ }
613
+ }
527
614
  const ebw = isNum(bw) && bw > 0 && bw <= 24 ? bw : 1.5;
528
615
  const pad = isNum(frame.padding) ? frame.padding : 0;
529
- if (frame.border && ebw > pad) {
616
+ if (frame.border && ebw > pad && ins === void 0) {
530
617
  warnings.push(
531
618
  `frame.borderWidth (${ebw}) is wider than frame.padding (${pad}) \u2014 the border grows outward from the card, so the frame edge crops it; raise the padding to at least the width to show the whole stroke`
532
619
  );
@@ -1260,10 +1347,10 @@ function startTakeServer(rootDir, pages) {
1260
1347
  res.writeHead(404).end();
1261
1348
  });
1262
1349
  return new Promise(
1263
- (resolve7) => server.listen(0, () => {
1350
+ (resolve8) => server.listen(0, () => {
1264
1351
  const addr = server.address();
1265
1352
  const port = typeof addr === "object" && addr ? addr.port : 0;
1266
- resolve7({ base: `http://localhost:${port}`, close: () => server.close() });
1353
+ resolve8({ base: `http://localhost:${port}`, close: () => server.close() });
1267
1354
  })
1268
1355
  );
1269
1356
  }
@@ -1277,10 +1364,10 @@ async function waitForPageDone(page, label, onProgress, timeoutMs) {
1277
1364
  if (state.error) throw new Error(`${label} failed: ${state.error}`);
1278
1365
  if (state.done) return state.done;
1279
1366
  if (typeof state.progress === "number") {
1280
- const pct = Math.floor(state.progress * 10) * 10;
1281
- if (pct !== last) {
1367
+ const pct2 = Math.floor(state.progress * 10) * 10;
1368
+ if (pct2 !== last) {
1282
1369
  onProgress(state.progress);
1283
- last = pct;
1370
+ last = pct2;
1284
1371
  }
1285
1372
  }
1286
1373
  if (Date.now() - start > timeoutMs) {
@@ -1500,14 +1587,14 @@ async function renderInPage(opts) {
1500
1587
  w.__error = String(e instanceof Error && e.stack || e);
1501
1588
  }
1502
1589
  }
1503
- async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
1504
- const outName = `render-chunk-${chunk.index}.tmp`;
1590
+ async function renderChunk(context, serverBase, chunk2, opts, onChunkProgress) {
1591
+ const outName = `render-chunk-${chunk2.index}.tmp`;
1505
1592
  const page = await context.newPage();
1506
1593
  try {
1507
1594
  page.on("console", (m) => {
1508
1595
  if (m.type() === "error" || process.env.VOS_RENDER_DEBUG)
1509
1596
  process.stderr.write(
1510
- ` [render page ${chunk.index} ${m.type()}] ${m.text()}
1597
+ ` [render page ${chunk2.index} ${m.type()}] ${m.text()}
1511
1598
  `
1512
1599
  );
1513
1600
  });
@@ -1515,7 +1602,7 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
1515
1602
  page.on(
1516
1603
  "pageerror",
1517
1604
  (e) => process.stderr.write(
1518
- ` [render pageerror ${chunk.index}] ${String(e)}
1605
+ ` [render pageerror ${chunk2.index}] ${String(e)}
1519
1606
  `
1520
1607
  )
1521
1608
  );
@@ -1534,8 +1621,8 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
1534
1621
  W: opts.width,
1535
1622
  H: opts.height,
1536
1623
  fps: opts.fps,
1537
- startFrame: chunk.startFrame + frameOffset,
1538
- endFrame: chunk.endFrame + frameOffset,
1624
+ startFrame: chunk2.startFrame + frameOffset,
1625
+ endFrame: chunk2.endFrame + frameOffset,
1539
1626
  format: opts.format,
1540
1627
  bitrate: opts.bitrate ?? 1e7,
1541
1628
  mediabunnyUrl: MEDIABUNNY_URL,
@@ -1546,10 +1633,10 @@ async function renderChunk(context, serverBase, chunk, opts, onChunkProgress) {
1546
1633
  audioDuration: audio?.duration
1547
1634
  }).catch(() => {
1548
1635
  });
1549
- const timeoutMs = 12e4 + chunk.frameCount * 500;
1636
+ const timeoutMs = 12e4 + chunk2.frameCount * 500;
1550
1637
  await waitForPageDone(
1551
1638
  page,
1552
- `render chunk ${chunk.index}`,
1639
+ `render chunk ${chunk2.index}`,
1553
1640
  onChunkProgress,
1554
1641
  timeoutMs
1555
1642
  );
@@ -1581,8 +1668,8 @@ async function renderAnimation(browser, opts) {
1581
1668
  };
1582
1669
  const outNames = await Promise.all(
1583
1670
  chunks.map(
1584
- (chunk) => renderChunk(context, server.base, chunk, effOpts, (fraction) => {
1585
- progress[chunk.index] = fraction;
1671
+ (chunk2) => renderChunk(context, server.base, chunk2, effOpts, (fraction) => {
1672
+ progress[chunk2.index] = fraction;
1586
1673
  reportProgress();
1587
1674
  })
1588
1675
  )
@@ -1597,9 +1684,9 @@ async function renderAnimation(browser, opts) {
1597
1684
  return { bytes: new Uint8Array(files[0]), totalFrames, chunks: 1 };
1598
1685
  }
1599
1686
  const { bytes } = await concatEncodedVideo(
1600
- chunks.map((chunk, i) => ({
1687
+ chunks.map((chunk2, i) => ({
1601
1688
  data: new Uint8Array(files[i]),
1602
- duration: chunk.duration
1689
+ duration: chunk2.duration
1603
1690
  })),
1604
1691
  { format: opts.format, frameRate: opts.fps }
1605
1692
  );
@@ -2506,7 +2593,6 @@ async function writeIndexJson(result) {
2506
2593
 
2507
2594
  // src/plugin/deliver.ts
2508
2595
  import {
2509
- copyFile,
2510
2596
  mkdir as mkdir4,
2511
2597
  mkdtemp,
2512
2598
  rename as rename4,
@@ -2517,12 +2603,18 @@ import {
2517
2603
  import { tmpdir } from "os";
2518
2604
  import { join as join5, relative, resolve as resolve3 } from "path";
2519
2605
  import { totalDuration } from "@vosjs/timeline";
2606
+ import { existsSync as existsSync5 } from "fs";
2607
+ import { readFile as readFile5 } from "fs/promises";
2608
+ import { parseFrontmatter } from "@vosjs/shared/frontmatter";
2520
2609
  import {
2521
2610
  CHANNEL_SPECS_VERIFIED,
2522
2611
  DESTINATIONS,
2612
+ cardInset,
2523
2613
  destinationsForChannel,
2524
- ratedSegments as ratedSegments3,
2525
- spanOutputExtent as spanOutputExtent3
2614
+ houseLook,
2615
+ isLookKind,
2616
+ lookFromBrand,
2617
+ ratedSegments as ratedSegments5
2526
2618
  } from "@vosjs/studio-core";
2527
2619
 
2528
2620
  // src/plugin/renderTake.ts
@@ -2717,6 +2809,1336 @@ async function renderPosterStills(browser, config, serveDir, shots, time) {
2717
2809
  }
2718
2810
  }
2719
2811
 
2812
+ // src/plugin/moments.ts
2813
+ import { ratedSegments as ratedSegments3, spanOutputExtent as spanOutputExtent3 } from "@vosjs/studio-core";
2814
+
2815
+ // src/plugin/picture.ts
2816
+ import { inflateSync } from "zlib";
2817
+ var PNG_SIG = [137, 80, 78, 71, 13, 10, 26, 10];
2818
+ function decodePng(bytes) {
2819
+ if (bytes.length < 33) return null;
2820
+ for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return null;
2821
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
2822
+ let pos = 8;
2823
+ let w = 0;
2824
+ let h = 0;
2825
+ let depth = 0;
2826
+ let colorType = 0;
2827
+ let interlace = 0;
2828
+ const idat = [];
2829
+ while (pos + 8 <= bytes.length) {
2830
+ const len = view.getUint32(pos);
2831
+ const type = String.fromCharCode(
2832
+ bytes[pos + 4],
2833
+ bytes[pos + 5],
2834
+ bytes[pos + 6],
2835
+ bytes[pos + 7]
2836
+ );
2837
+ const start = pos + 8;
2838
+ if (type === "IHDR") {
2839
+ w = view.getUint32(start);
2840
+ h = view.getUint32(start + 4);
2841
+ depth = bytes[start + 8];
2842
+ colorType = bytes[start + 9];
2843
+ interlace = bytes[start + 12];
2844
+ } else if (type === "IDAT") {
2845
+ idat.push(bytes.subarray(start, start + len));
2846
+ } else if (type === "IEND") break;
2847
+ pos = start + len + 4;
2848
+ }
2849
+ if (!w || !h || depth !== 8 || interlace !== 0) return null;
2850
+ const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 4 ? 2 : colorType === 0 ? 1 : 0;
2851
+ if (!channels) return null;
2852
+ const total = idat.reduce((n, c) => n + c.length, 0);
2853
+ const joined = new Uint8Array(total);
2854
+ let off2 = 0;
2855
+ for (const c of idat) {
2856
+ joined.set(c, off2);
2857
+ off2 += c.length;
2858
+ }
2859
+ let raw;
2860
+ try {
2861
+ raw = new Uint8Array(inflateSync(joined));
2862
+ } catch {
2863
+ return null;
2864
+ }
2865
+ const stride = w * channels;
2866
+ if (raw.length < (stride + 1) * h) return null;
2867
+ const out = new Uint8Array(w * h * 4);
2868
+ const prev = new Uint8Array(stride);
2869
+ const cur = new Uint8Array(stride);
2870
+ for (let y = 0; y < h; y++) {
2871
+ const filter = raw[y * (stride + 1)];
2872
+ const rowStart = y * (stride + 1) + 1;
2873
+ for (let i = 0; i < stride; i++) {
2874
+ const x = raw[rowStart + i];
2875
+ const a = i >= channels ? cur[i - channels] : 0;
2876
+ const b = prev[i];
2877
+ const c = i >= channels ? prev[i - channels] : 0;
2878
+ let v;
2879
+ switch (filter) {
2880
+ case 0:
2881
+ v = x;
2882
+ break;
2883
+ case 1:
2884
+ v = x + a;
2885
+ break;
2886
+ case 2:
2887
+ v = x + b;
2888
+ break;
2889
+ case 3:
2890
+ v = x + (a + b >> 1);
2891
+ break;
2892
+ case 4: {
2893
+ const p = a + b - c;
2894
+ const pa = Math.abs(p - a);
2895
+ const pb = Math.abs(p - b);
2896
+ const pc = Math.abs(p - c);
2897
+ v = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
2898
+ break;
2899
+ }
2900
+ default:
2901
+ return null;
2902
+ }
2903
+ cur[i] = v & 255;
2904
+ }
2905
+ for (let px = 0; px < w; px++) {
2906
+ const o = (y * w + px) * 4;
2907
+ const s = px * channels;
2908
+ if (channels >= 3) {
2909
+ out[o] = cur[s];
2910
+ out[o + 1] = cur[s + 1];
2911
+ out[o + 2] = cur[s + 2];
2912
+ out[o + 3] = channels === 4 ? cur[s + 3] : 255;
2913
+ } else {
2914
+ out[o] = out[o + 1] = out[o + 2] = cur[s];
2915
+ out[o + 3] = channels === 2 ? cur[s + 1] : 255;
2916
+ }
2917
+ }
2918
+ prev.set(cur);
2919
+ }
2920
+ return { w, h, data: out };
2921
+ }
2922
+ var at = (img, x, y) => {
2923
+ const o = (y * img.w + x) * 4;
2924
+ return [img.data[o], img.data[o + 1], img.data[o + 2]];
2925
+ };
2926
+ var median = (a) => {
2927
+ const s = [...a].sort((x, y) => x - y);
2928
+ return s[s.length >> 1] ?? 0;
2929
+ };
2930
+ function groundColour(img) {
2931
+ const ring = Math.max(1, Math.round(Math.min(img.w, img.h) * 0.01));
2932
+ const r = [];
2933
+ const g = [];
2934
+ const b = [];
2935
+ const step = Math.max(1, Math.round(Math.max(img.w, img.h) / 400));
2936
+ for (let y = 0; y < img.h; y += step) {
2937
+ for (let x = 0; x < img.w; x += step) {
2938
+ if (x < ring || y < ring || x >= img.w - ring || y >= img.h - ring) {
2939
+ const p = at(img, x, y);
2940
+ r.push(p[0]);
2941
+ g.push(p[1]);
2942
+ b.push(p[2]);
2943
+ }
2944
+ }
2945
+ }
2946
+ return [median(r), median(g), median(b)];
2947
+ }
2948
+ var hex = (c) => "#" + c.map((v) => v.toString(16).padStart(2, "0")).join("");
2949
+ var delta = (p, q) => Math.abs(p[0] - q[0]) + Math.abs(p[1] - q[1]) + Math.abs(p[2] - q[2]);
2950
+ function cardBounds(img, ground, threshold = 60) {
2951
+ let x0 = img.w;
2952
+ let y0 = img.h;
2953
+ let x1 = -1;
2954
+ let y1 = -1;
2955
+ const step = Math.max(1, Math.round(Math.max(img.w, img.h) / 700));
2956
+ for (let y = 0; y < img.h; y += step) {
2957
+ for (let x = 0; x < img.w; x += step) {
2958
+ if (delta(at(img, x, y), ground) > threshold) {
2959
+ if (x < x0) x0 = x;
2960
+ if (y < y0) y0 = y;
2961
+ if (x > x1) x1 = x;
2962
+ if (y > y1) y1 = y;
2963
+ }
2964
+ }
2965
+ }
2966
+ if (x1 < 0) return null;
2967
+ return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
2968
+ }
2969
+ function haloReading(img, ground, card) {
2970
+ const band = Math.max(2, Math.round(Math.min(img.w, img.h) * 0.012));
2971
+ const meanRect = (x0, y0, x1, y1) => {
2972
+ let sum = 0;
2973
+ let n = 0;
2974
+ const sx = Math.max(1, Math.round((x1 - x0) / 60));
2975
+ const sy = Math.max(1, Math.round((y1 - y0) / 60));
2976
+ for (let y = Math.max(0, y0); y < Math.min(img.h, y1); y += sy) {
2977
+ for (let x = Math.max(0, x0); x < Math.min(img.w, x1); x += sx) {
2978
+ sum += delta(at(img, x, y), ground);
2979
+ n++;
2980
+ }
2981
+ }
2982
+ return n ? sum / n : 0;
2983
+ };
2984
+ const rise = (bands) => {
2985
+ const [outer, mid] = bands;
2986
+ return outer >= 3 && mid - outer >= 1.5 ? mid : 0;
2987
+ };
2988
+ const readings = [];
2989
+ const yA = card.y + card.h * 0.2;
2990
+ const yB = card.y + card.h * 0.8;
2991
+ if (card.x >= 1 && card.w > band * 4) {
2992
+ const x0 = card.x;
2993
+ readings.push(
2994
+ rise([
2995
+ meanRect(x0, yA, x0 + band, yB),
2996
+ meanRect(x0 + band, yA, x0 + band * 2, yB),
2997
+ meanRect(x0 + band * 2, yA, x0 + band * 3, yB)
2998
+ ])
2999
+ );
3000
+ }
3001
+ if (card.x + card.w <= img.w - 1 && card.w > band * 4) {
3002
+ const x1 = card.x + card.w;
3003
+ readings.push(
3004
+ rise([
3005
+ meanRect(x1 - band, yA, x1, yB),
3006
+ meanRect(x1 - band * 2, yA, x1 - band, yB),
3007
+ meanRect(x1 - band * 3, yA, x1 - band * 2, yB)
3008
+ ])
3009
+ );
3010
+ }
3011
+ if (card.y + card.h <= img.h - 1 && card.h > band * 4) {
3012
+ const y1 = card.y + card.h;
3013
+ const xA = card.x + card.w * 0.2;
3014
+ const xB = card.x + card.w * 0.8;
3015
+ readings.push(
3016
+ rise([
3017
+ meanRect(xA, y1 - band, xB, y1),
3018
+ meanRect(xA, y1 - band * 2, xB, y1 - band),
3019
+ meanRect(xA, y1 - band * 3, xB, y1 - band * 2)
3020
+ ])
3021
+ );
3022
+ }
3023
+ return readings.length ? Math.max(...readings) : null;
3024
+ }
3025
+ function edgeContrast(img, ground, card) {
3026
+ const yA = Math.max(0, Math.round(card.y + card.h * 0.2));
3027
+ const yB = Math.min(img.h, Math.round(card.y + card.h * 0.8));
3028
+ const col = (x) => {
3029
+ if (x < 0 || x >= img.w) return 0;
3030
+ const ds = [];
3031
+ const step = Math.max(1, Math.round((yB - yA) / 80));
3032
+ for (let y = yA; y < yB; y += step) ds.push(delta(at(img, x, y), ground));
3033
+ return median(ds);
3034
+ };
3035
+ const depth = Math.min(24, Math.floor(card.w / 8));
3036
+ let best = 0;
3037
+ for (let d = 0; d < depth; d++) {
3038
+ best = Math.max(best, col(card.x + d), col(card.x + card.w - 1 - d));
3039
+ }
3040
+ return best;
3041
+ }
3042
+ function inkCoverage(img, rect, threshold = 48) {
3043
+ const x0 = Math.max(0, Math.floor(rect.x));
3044
+ const y0 = Math.max(0, Math.floor(rect.y));
3045
+ const x1 = Math.min(img.w, Math.ceil(rect.x + rect.w));
3046
+ const y1 = Math.min(img.h, Math.ceil(rect.y + rect.h));
3047
+ if (x1 - x0 < 2 || y1 - y0 < 2) return 0;
3048
+ const step = Math.max(1, Math.round(Math.max(x1 - x0, y1 - y0) / 300));
3049
+ const r = [];
3050
+ const g = [];
3051
+ const b = [];
3052
+ const pts = [];
3053
+ for (let y = y0; y < y1; y += step) {
3054
+ for (let x = x0; x < x1; x += step) {
3055
+ const p = at(img, x, y);
3056
+ pts.push(p);
3057
+ r.push(p[0]);
3058
+ g.push(p[1]);
3059
+ b.push(p[2]);
3060
+ }
3061
+ }
3062
+ const base = [median(r), median(g), median(b)];
3063
+ let ink = 0;
3064
+ for (const p of pts) if (delta(p, base) > threshold) ink++;
3065
+ return pts.length ? ink / pts.length : 0;
3066
+ }
3067
+ function differenceHash(img, rect) {
3068
+ const r = rect ?? { x: 0, y: 0, w: img.w, h: img.h };
3069
+ const cols = 9;
3070
+ const rows = 8;
3071
+ const cell = [];
3072
+ for (let cy = 0; cy < rows; cy++) {
3073
+ for (let cx = 0; cx < cols; cx++) {
3074
+ const x0 = Math.floor(r.x + cx * r.w / cols);
3075
+ const x1 = Math.max(x0 + 1, Math.floor(r.x + (cx + 1) * r.w / cols));
3076
+ const y0 = Math.floor(r.y + cy * r.h / rows);
3077
+ const y1 = Math.max(y0 + 1, Math.floor(r.y + (cy + 1) * r.h / rows));
3078
+ let sum = 0;
3079
+ let n = 0;
3080
+ const sx = Math.max(1, Math.floor((x1 - x0) / 8));
3081
+ const sy = Math.max(1, Math.floor((y1 - y0) / 8));
3082
+ for (let y = y0; y < y1 && y < img.h; y += sy) {
3083
+ for (let x = x0; x < x1 && x < img.w; x += sx) {
3084
+ const p = at(img, Math.max(0, x), Math.max(0, y));
3085
+ sum += 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2];
3086
+ n++;
3087
+ }
3088
+ }
3089
+ cell.push(n ? sum / n : 0);
3090
+ }
3091
+ }
3092
+ let bits = "";
3093
+ for (let cy = 0; cy < rows; cy++) {
3094
+ let byte = 0;
3095
+ for (let cx = 0; cx < cols - 1; cx++) {
3096
+ const l = cell[cy * cols + cx];
3097
+ const rr = cell[cy * cols + cx + 1];
3098
+ byte = byte << 1 | (l > rr ? 1 : 0);
3099
+ }
3100
+ bits += byte.toString(16).padStart(2, "0");
3101
+ }
3102
+ return bits;
3103
+ }
3104
+ function hammingDistance(a, b) {
3105
+ let d = 0;
3106
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
3107
+ let x = parseInt(a[i], 16) ^ parseInt(b[i], 16);
3108
+ while (x) {
3109
+ d += x & 1;
3110
+ x >>= 1;
3111
+ }
3112
+ }
3113
+ return d + Math.abs(a.length - b.length) * 4;
3114
+ }
3115
+ function luminance(c) {
3116
+ const lin = (v) => {
3117
+ const s = v / 255;
3118
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
3119
+ };
3120
+ return 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
3121
+ }
3122
+ function lightness(c) {
3123
+ const y = luminance(c);
3124
+ return y > 8856e-6 ? 116 * Math.cbrt(y) - 16 : 903.3 * y;
3125
+ }
3126
+ function medianColour(img, rect) {
3127
+ const r = [];
3128
+ const g = [];
3129
+ const b = [];
3130
+ const x0 = Math.max(0, Math.floor(rect.x));
3131
+ const y0 = Math.max(0, Math.floor(rect.y));
3132
+ const x1 = Math.min(img.w, Math.ceil(rect.x + rect.w));
3133
+ const y1 = Math.min(img.h, Math.ceil(rect.y + rect.h));
3134
+ const step = Math.max(1, Math.round(Math.max(x1 - x0, y1 - y0) / 200));
3135
+ for (let y = y0; y < y1; y += step) {
3136
+ for (let x = x0; x < x1; x += step) {
3137
+ const p = at(img, x, y);
3138
+ r.push(p[0]);
3139
+ g.push(p[1]);
3140
+ b.push(p[2]);
3141
+ }
3142
+ }
3143
+ return [median(r), median(g), median(b)];
3144
+ }
3145
+ function edgeEnergy(img, width) {
3146
+ const cols = Math.max(2, Math.min(width, img.w));
3147
+ const rows = Math.max(2, Math.round(cols * img.h / img.w));
3148
+ const lum = [];
3149
+ for (let cy = 0; cy < rows; cy++) {
3150
+ for (let cx = 0; cx < cols; cx++) {
3151
+ const x = Math.min(img.w - 1, Math.floor((cx + 0.5) * img.w / cols));
3152
+ const y = Math.min(img.h - 1, Math.floor((cy + 0.5) * img.h / rows));
3153
+ const p = at(img, x, y);
3154
+ lum.push(0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]);
3155
+ }
3156
+ }
3157
+ let sum = 0;
3158
+ let n = 0;
3159
+ for (let cy = 0; cy < rows; cy++) {
3160
+ for (let cx = 1; cx < cols; cx++) {
3161
+ sum += Math.abs(lum[cy * cols + cx] - lum[cy * cols + cx - 1]);
3162
+ n++;
3163
+ }
3164
+ }
3165
+ return n ? sum / n : 0;
3166
+ }
3167
+ function measureStill(img) {
3168
+ const g = groundColour(img);
3169
+ const strong = cardBounds(img, g, 60);
3170
+ const faint = cardBounds(img, g, 12) ?? strong;
3171
+ const card = faint;
3172
+ const bleed = [];
3173
+ let pad = null;
3174
+ let widthPct = null;
3175
+ let shadow = null;
3176
+ let edge = 0;
3177
+ let separation = null;
3178
+ if (card) {
3179
+ pad = {
3180
+ left: card.x / img.w,
3181
+ right: (img.w - card.x - card.w) / img.w,
3182
+ top: card.y / img.h,
3183
+ bottom: (img.h - card.y - card.h) / img.h
3184
+ };
3185
+ for (const side of ["left", "right", "top", "bottom"])
3186
+ if (pad[side] < 5e-3) bleed.push(side);
3187
+ widthPct = card.w / img.w;
3188
+ shadow = haloReading(img, g, card);
3189
+ edge = edgeContrast(img, g, card);
3190
+ const body = strong ?? card;
3191
+ const inner = {
3192
+ x: body.x + body.w * 0.1,
3193
+ y: body.y + body.h * 0.1,
3194
+ w: body.w * 0.8,
3195
+ h: body.h * 0.8
3196
+ };
3197
+ separation = Math.abs(lightness(medianColour(img, inner)) - lightness(g));
3198
+ }
3199
+ const inkRect = strong ?? card ?? { x: 0, y: 0, w: img.w, h: img.h };
3200
+ return {
3201
+ w: img.w,
3202
+ h: img.h,
3203
+ ground: hex(g),
3204
+ card,
3205
+ widthPct,
3206
+ pad,
3207
+ bleed,
3208
+ shadow,
3209
+ edge,
3210
+ ink: inkCoverage(img, inkRect),
3211
+ separation,
3212
+ hash: differenceHash(img, inkRect)
3213
+ };
3214
+ }
3215
+
3216
+ // src/plugin/moments.ts
3217
+ var STEP_SETTLE_SECONDS = 0.4;
3218
+ var NOT_A_GESTURE = /* @__PURE__ */ new Set(["wait", "goto", "sleep"]);
3219
+ function momentCandidates(doc, duration) {
3220
+ const rated = ratedSegments3(doc);
3221
+ const out = [];
3222
+ const push = (c) => {
3223
+ if (c.time < 0 || c.time > duration) return;
3224
+ if (out.some((o) => Math.abs(o.time - c.time) < 0.25)) return;
3225
+ out.push(c);
3226
+ };
3227
+ const steps = doc.source.meta.steps ?? [];
3228
+ for (let i = 0; i < steps.length; i++) {
3229
+ const s = steps[i];
3230
+ if (s.skipped || NOT_A_GESTURE.has(s.do)) continue;
3231
+ const t = stepOutputTime(rated, s, settleAfter(steps, i));
3232
+ if (t === null) continue;
3233
+ push({ time: t, source: "step", step: s.id ?? s.step });
3234
+ }
3235
+ let dropped = 0;
3236
+ const apexes = [];
3237
+ for (const z of doc.zoom) {
3238
+ const ext = spanOutputExtent3(rated, z.in, z.out);
3239
+ if (ext) apexes.push((ext.start + ext.end) / 2);
3240
+ else dropped++;
3241
+ }
3242
+ for (const t of apexes.sort((a, b) => a - b))
3243
+ push({ time: t, source: "zoom" });
3244
+ if (!out.length) {
3245
+ for (const p of [0.1, 0.3, 0.5, 0.7, 0.9])
3246
+ push({ time: p * duration, source: "spread" });
3247
+ }
3248
+ return { candidates: out, dropped };
3249
+ }
3250
+ function settleAfter(steps, i) {
3251
+ const next = steps[i + 1];
3252
+ if (next && next.do === "wait" && !next.skipped)
3253
+ return Math.max(0, next.tEnd - steps[i].tEnd);
3254
+ return STEP_SETTLE_SECONDS;
3255
+ }
3256
+ function stepOutputTime(rated, step, settle) {
3257
+ const src = step.tEnd + settle;
3258
+ const ext = spanOutputExtent3(rated, src, src + 1e-3);
3259
+ if (!ext) return null;
3260
+ return ext.start;
3261
+ }
3262
+ function resolveStepTime(doc, entry) {
3263
+ const m = /^step:([^+-]+)([+-]\d+(?:\.\d+)?)?$/.exec(entry.trim());
3264
+ if (!m) return null;
3265
+ const key = m[1];
3266
+ const offset = m[2] ? Number(m[2]) : STEP_SETTLE_SECONDS;
3267
+ const steps = doc.source.meta.steps ?? [];
3268
+ const step = steps.find((s) => s.id === key) ?? (/^\d+$/.test(key) ? steps.find((s) => s.step === Number(key)) : void 0);
3269
+ if (!step) {
3270
+ const known = steps.map((s) => s.id ?? String(s.step)).filter((s) => s.length);
3271
+ throw new Error(
3272
+ `--times ${entry}: no step "${key}" in the take's step timeline${known.length ? ` (steps: ${known.join(", ")})` : " (the take carries none: record it with the current CLI)"}`
3273
+ );
3274
+ }
3275
+ const t = stepOutputTime(ratedSegments3(doc), step, offset);
3276
+ if (t === null)
3277
+ throw new Error(
3278
+ `--times ${entry}: step "${key}" (${step.tEnd.toFixed(2)}s in the recording) falls outside the cut`
3279
+ );
3280
+ return t;
3281
+ }
3282
+ var DUPLICATE_BITS = 6;
3283
+ var POPULATED_INK = 0.12;
3284
+ function pickMoments(measured, opts = {}) {
3285
+ const minInk = opts.minInk ?? 0.06;
3286
+ const relative2 = opts.relativeInk ?? 0.4;
3287
+ const bits = opts.duplicateBits ?? DUPLICATE_BITS;
3288
+ const inks = measured.map((m) => m.ink).sort((a, b) => a - b);
3289
+ const median2 = inks[inks.length >> 1] ?? 0;
3290
+ const floor = Math.max(minInk, Math.min(POPULATED_INK, median2 * relative2));
3291
+ const kept = [];
3292
+ const dropped = [];
3293
+ for (const m of measured) {
3294
+ if (m.ink < floor) {
3295
+ dropped.push(
3296
+ `blank at ${m.time.toFixed(2)}s: ${Math.round(m.ink * 100)}% ink (the floor is ${Math.round(floor * 100)}%; the take's median is ${Math.round(median2 * 100)}%)`
3297
+ );
3298
+ continue;
3299
+ }
3300
+ const twin = kept.find((k) => hammingDistance(k.hash, m.hash) <= bits);
3301
+ if (twin) {
3302
+ dropped.push(
3303
+ `${m.time.toFixed(2)}s is the same frame as ${twin.time.toFixed(2)}s`
3304
+ );
3305
+ continue;
3306
+ }
3307
+ kept.push(m);
3308
+ }
3309
+ if (!kept.length && measured.length) {
3310
+ const best = [...measured].sort((a, b) => b.ink - a.ink)[0];
3311
+ kept.push(best);
3312
+ dropped.push(
3313
+ `every candidate is under the blank floor; kept ${best.time.toFixed(2)}s (${Math.round(best.ink * 100)}% ink) as the least empty`
3314
+ );
3315
+ }
3316
+ return { times: kept.map((k) => k.time), dropped };
3317
+ }
3318
+
3319
+ // src/plugin/shotBake.ts
3320
+ import { deflateSync } from "zlib";
3321
+ function blurPlane(src, w, h, r, passes = 3) {
3322
+ if (r < 1) return;
3323
+ const tmp = new Float32Array(src.length);
3324
+ for (let p = 0; p < passes; p++) {
3325
+ for (let y = 0; y < h; y++) {
3326
+ let acc = 0;
3327
+ const row = y * w;
3328
+ for (let x = -r; x <= r; x++) acc += src[row + Math.min(w - 1, Math.max(0, x))];
3329
+ for (let x = 0; x < w; x++) {
3330
+ tmp[row + x] = acc / (2 * r + 1);
3331
+ const add = src[row + Math.min(w - 1, x + r + 1)];
3332
+ const sub = src[row + Math.max(0, x - r)];
3333
+ acc += add - sub;
3334
+ }
3335
+ }
3336
+ for (let x = 0; x < w; x++) {
3337
+ let acc = 0;
3338
+ for (let y = -r; y <= r; y++) acc += tmp[Math.min(h - 1, Math.max(0, y)) * w + x];
3339
+ for (let y = 0; y < h; y++) {
3340
+ src[y * w + x] = acc / (2 * r + 1);
3341
+ const add = tmp[Math.min(h - 1, y + r + 1) * w + x];
3342
+ const sub = tmp[Math.max(0, y - r) * w + x];
3343
+ acc += add - sub;
3344
+ }
3345
+ }
3346
+ }
3347
+ }
3348
+ function roundedCoverage(x, y, rect, r) {
3349
+ let inside = 0;
3350
+ for (const dy of [0.25, 0.75]) {
3351
+ for (const dx of [0.25, 0.75]) {
3352
+ const px = x + dx;
3353
+ const py = y + dy;
3354
+ if (px < rect.x || py < rect.y || px > rect.x + rect.w || py > rect.y + rect.h) continue;
3355
+ const cx = Math.min(Math.max(px, rect.x + r), rect.x + rect.w - r);
3356
+ const cy = Math.min(Math.max(py, rect.y + r), rect.y + rect.h - r);
3357
+ if ((px - cx) ** 2 + (py - cy) ** 2 <= r * r) inside++;
3358
+ }
3359
+ }
3360
+ return inside / 4;
3361
+ }
3362
+ function bakeShot(shot, opts = {}) {
3363
+ const margin = Math.round(shot.w * (opts.margin ?? 0.06));
3364
+ const radius = shot.w * (opts.radius ?? 0.014);
3365
+ const shadowA = opts.shadow ?? 0.38;
3366
+ const blur = Math.round(shot.w * (opts.blur ?? 0.03));
3367
+ const offsetY = Math.round(shot.w * (opts.offsetY ?? 0.012));
3368
+ const hair = opts.hairline ?? 0;
3369
+ const w = shot.w + margin * 2;
3370
+ const h = shot.h + margin * 2;
3371
+ const out = new Uint8Array(w * h * 4);
3372
+ const rect = { x: margin, y: margin, w: shot.w, h: shot.h };
3373
+ if (shadowA > 0) {
3374
+ const mask = new Float32Array(w * h);
3375
+ for (let y = 0; y < h; y++)
3376
+ for (let x = 0; x < w; x++) {
3377
+ const c = roundedCoverage(x, y - offsetY, rect, radius);
3378
+ if (c > 0) mask[y * w + x] = c;
3379
+ }
3380
+ blurPlane(mask, w, h, Math.max(1, Math.round(blur / 2)));
3381
+ for (let i = 0; i < w * h; i++) {
3382
+ const a = mask[i] * shadowA;
3383
+ if (a <= 2e-3) continue;
3384
+ out[i * 4] = 0;
3385
+ out[i * 4 + 1] = 0;
3386
+ out[i * 4 + 2] = 0;
3387
+ out[i * 4 + 3] = Math.round(a * 255);
3388
+ }
3389
+ }
3390
+ for (let y = 0; y < h; y++) {
3391
+ for (let x = 0; x < w; x++) {
3392
+ const cov = roundedCoverage(x, y, rect, radius);
3393
+ if (cov <= 0) continue;
3394
+ const sx = Math.min(shot.w - 1, Math.max(0, x - margin));
3395
+ const sy = Math.min(shot.h - 1, Math.max(0, y - margin));
3396
+ const si = (sy * shot.w + sx) * 4;
3397
+ const o = (y * w + x) * 4;
3398
+ let r = shot.data[si];
3399
+ let g = shot.data[si + 1];
3400
+ let b = shot.data[si + 2];
3401
+ if (hair > 0) {
3402
+ const edge = Math.min(x - rect.x, rect.x + rect.w - x, y - rect.y, rect.y + rect.h - y);
3403
+ if (edge < 1.5) {
3404
+ r = Math.round(r * (1 - hair));
3405
+ g = Math.round(g * (1 - hair));
3406
+ b = Math.round(b * (1 - hair));
3407
+ }
3408
+ }
3409
+ const a = cov;
3410
+ const ba = out[o + 3] / 255;
3411
+ const outA = a + ba * (1 - a);
3412
+ const mix = (fg, bg) => outA > 0 ? Math.round((fg * a + bg * ba * (1 - a)) / outA) : 0;
3413
+ out[o] = mix(r, out[o]);
3414
+ out[o + 1] = mix(g, out[o + 1]);
3415
+ out[o + 2] = mix(b, out[o + 2]);
3416
+ out[o + 3] = Math.round(outA * 255);
3417
+ }
3418
+ }
3419
+ return { w, h, data: out };
3420
+ }
3421
+ var crcTable = (() => {
3422
+ const t = new Uint32Array(256);
3423
+ for (let n = 0; n < 256; n++) {
3424
+ let c = n;
3425
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
3426
+ t[n] = c >>> 0;
3427
+ }
3428
+ return t;
3429
+ })();
3430
+ function crc32(buf) {
3431
+ let c = 4294967295;
3432
+ for (const b of buf) c = crcTable[(c ^ b) & 255] ^ c >>> 8;
3433
+ return (c ^ 4294967295) >>> 0;
3434
+ }
3435
+ function chunk(type, body) {
3436
+ const out = new Uint8Array(12 + body.length);
3437
+ const dv = new DataView(out.buffer);
3438
+ dv.setUint32(0, body.length);
3439
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
3440
+ out.set(body, 8);
3441
+ dv.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)));
3442
+ return out;
3443
+ }
3444
+ function encodePng(img) {
3445
+ const stride = img.w * 4;
3446
+ const raw = new Uint8Array((stride + 1) * img.h);
3447
+ for (let y = 0; y < img.h; y++) {
3448
+ raw[y * (stride + 1)] = 2;
3449
+ for (let i = 0; i < stride; i++) {
3450
+ const cur = img.data[y * stride + i];
3451
+ const up = y ? img.data[(y - 1) * stride + i] : 0;
3452
+ raw[y * (stride + 1) + 1 + i] = cur - up & 255;
3453
+ }
3454
+ }
3455
+ const ihdr = new Uint8Array(13);
3456
+ const dv = new DataView(ihdr.buffer);
3457
+ dv.setUint32(0, img.w);
3458
+ dv.setUint32(4, img.h);
3459
+ ihdr[8] = 8;
3460
+ ihdr[9] = 6;
3461
+ const parts = [
3462
+ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
3463
+ chunk("IHDR", ihdr),
3464
+ chunk("IDAT", new Uint8Array(deflateSync(raw))),
3465
+ chunk("IEND", new Uint8Array(0))
3466
+ ];
3467
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
3468
+ let o = 0;
3469
+ for (const p of parts) {
3470
+ out.set(p, o);
3471
+ o += p.length;
3472
+ }
3473
+ return out;
3474
+ }
3475
+
3476
+ // src/plugin/template.ts
3477
+ function templateOf(config) {
3478
+ const t = config.template;
3479
+ if (!t || typeof t !== "object") return null;
3480
+ return t;
3481
+ }
3482
+ function aspectOf(size) {
3483
+ const r = size.w / Math.max(1, size.h);
3484
+ if (r > 1.15) return "landscape";
3485
+ if (r < 0.87) return "portrait";
3486
+ return "square";
3487
+ }
3488
+ function templateProblems(config) {
3489
+ const t = templateOf(config);
3490
+ if (!t) return ["no template block: a poster template declares config.template"];
3491
+ const out = [];
3492
+ const elements = Array.isArray(config.elements) ? config.elements : [];
3493
+ const byId = new Map(elements.filter((e) => e.id).map((e) => [e.id, e]));
3494
+ const params = Array.isArray(config.params) ? config.params : [];
3495
+ const keys = new Set(params.map((p) => p.key).filter(Boolean));
3496
+ if (!t.family) out.push("template.family is missing");
3497
+ for (const s of t.slots ?? []) {
3498
+ const el = byId.get(s.id);
3499
+ if (!el) out.push(`template.slots: no element with id "${s.id}"`);
3500
+ else if (el.type !== s.kind)
3501
+ out.push(`template.slots: "${s.id}" is a ${String(el.type)} element, the slot wants ${s.kind}`);
3502
+ }
3503
+ for (const x of t.text ?? []) {
3504
+ const el = byId.get(x.element);
3505
+ if (!el) out.push(`template.text: no element with id "${x.element}"`);
3506
+ else if (el.type !== "text") out.push(`template.text: "${x.element}" is not a text element`);
3507
+ if (!keys.has(x.param)) out.push(`template.text: param "${x.param}" is not declared in config.params`);
3508
+ const bound = el?.content;
3509
+ if (el && (!bound || typeof bound !== "object" || bound.$data !== x.param))
3510
+ out.push(`template.text: "${x.element}" must bind its content to {$data: "${x.param}"}`);
3511
+ }
3512
+ for (const k of t.params?.required ?? []) {
3513
+ if (!keys.has(k)) out.push(`template.params.required: "${k}" is not declared in config.params`);
3514
+ }
3515
+ if (!t.layouts?.landscape) out.push("template.layouts.landscape is required");
3516
+ for (const [name, layout] of Object.entries(t.layouts ?? {})) {
3517
+ for (const s of t.slots ?? []) {
3518
+ if (!layout?.slots?.[s.id]) out.push(`template.layouts.${name}: slot "${s.id}" is not placed`);
3519
+ }
3520
+ }
3521
+ return out;
3522
+ }
3523
+ var pctOf = (v) => {
3524
+ if (typeof v === "number") return null;
3525
+ if (typeof v !== "string") return null;
3526
+ const m = /^(-?\d+(?:\.\d+)?)%$/.exec(v.trim());
3527
+ return m ? Number(m[1]) / 100 : null;
3528
+ };
3529
+ function fillTemplate(config, input) {
3530
+ const t = templateOf(config);
3531
+ if (!t) throw new Error("fillTemplate: the config carries no template block");
3532
+ const aspect = aspectOf(input.size);
3533
+ const layout = t.layouts[aspect] ?? t.layouts.landscape;
3534
+ const out = structuredClone(config);
3535
+ const designH = 1080;
3536
+ const designW = designH * input.size.w / input.size.h;
3537
+ const elements = Array.isArray(out.elements) ? out.elements : [];
3538
+ const byId = new Map(elements.filter((e) => e.id).map((e) => [e.id, e]));
3539
+ const slotRects = {};
3540
+ for (const s of t.slots) {
3541
+ const el = byId.get(s.id);
3542
+ const place = layout.slots[s.id];
3543
+ const src = input.slots[s.id];
3544
+ if (!el || !place) continue;
3545
+ if (src) el.src = src.src;
3546
+ const pad = src?.pad ?? 0;
3547
+ slotRects[s.id] = {
3548
+ x: place.x,
3549
+ y: place.y,
3550
+ w: place.w,
3551
+ h: place.w * (input.size.w / input.size.h) / (src?.aspect ?? 16 / 9)
3552
+ };
3553
+ const w = place.w * designW * (1 + 2 * pad);
3554
+ const x = place.x - place.w * pad;
3555
+ const y = place.y - place.w * pad * (input.size.w / input.size.h) / (src?.aspect ?? 16 / 9);
3556
+ el.position = { x: `${(x * 100).toFixed(2)}%`, y: `${(y * 100).toFixed(2)}%` };
3557
+ el.anchor = "top-left";
3558
+ el.size = { ...el.size ?? {}, width: Math.round(w), height: "auto" };
3559
+ if (!src && s.required) el.opacity = 0;
3560
+ }
3561
+ const data = out.data && typeof out.data === "object" ? { ...out.data } : {};
3562
+ const params = Array.isArray(out.params) ? out.params : [];
3563
+ const missing = [];
3564
+ for (const k of [...t.params.required ?? [], ...t.params.brand ?? []]) {
3565
+ if (input.values[k] === void 0 || input.values[k] === null || input.values[k] === "") {
3566
+ if ((t.params.required ?? []).includes(k)) missing.push(k);
3567
+ continue;
3568
+ }
3569
+ data[k] = input.values[k];
3570
+ const p = params.find((q) => q.key === k);
3571
+ if (p) p.default = input.values[k];
3572
+ }
3573
+ for (const [k, v] of Object.entries(input.values)) {
3574
+ if (v !== void 0 && v !== null && v !== "") {
3575
+ data[k] = v;
3576
+ const p = params.find((q) => q.key === k);
3577
+ if (p) p.default = v;
3578
+ }
3579
+ }
3580
+ out.data = data;
3581
+ const boxes = [];
3582
+ for (const x of t.text) {
3583
+ const el = byId.get(x.element);
3584
+ if (!el) continue;
3585
+ const pos = layout.text?.[x.element];
3586
+ if (pos) el.position = { x: pos.x, y: pos.y };
3587
+ const size = layout.size?.[x.element];
3588
+ if (size) el.font = { ...el.font ?? {}, size };
3589
+ if (layout.hide?.includes(x.element)) {
3590
+ el.opacity = 0;
3591
+ continue;
3592
+ }
3593
+ const value = data[x.param];
3594
+ if (typeof value !== "string" || !value.trim()) continue;
3595
+ const font = el.font ?? {};
3596
+ const px = typeof font.size === "number" ? font.size : 40;
3597
+ const lines = value.split("\n");
3598
+ const longest = Math.max(...lines.map((l) => l.length));
3599
+ const lh = typeof font.lineHeight === "number" ? font.lineHeight : 1.15;
3600
+ const boxW = longest * px * 0.56;
3601
+ const boxH = lines.length * px * lh;
3602
+ const ex = pctOf(el.position?.x) ?? 0;
3603
+ const ey = pctOf(el.position?.y) ?? 0;
3604
+ const anchor = String(el.anchor ?? "center");
3605
+ let left = ex * designW;
3606
+ let top = ey * designH;
3607
+ if (anchor.includes("right")) left -= boxW;
3608
+ else if (!anchor.includes("left")) left -= boxW / 2;
3609
+ if (anchor.includes("bottom")) top -= boxH;
3610
+ else if (!anchor.includes("top")) top -= boxH / 2;
3611
+ const rawColor = font.color;
3612
+ const color = typeof rawColor === "string" ? rawColor : rawColor && typeof rawColor === "object" && typeof rawColor.$data === "string" ? data[rawColor.$data] : void 0;
3613
+ boxes.push({
3614
+ x: left / designW,
3615
+ y: top / designH,
3616
+ w: boxW / designW,
3617
+ h: boxH / designH,
3618
+ color: color && /^#[0-9a-f]{6}$/i.test(color) ? color : void 0,
3619
+ role: x.role,
3620
+ label: value.length > 24 ? `${value.slice(0, 24)}\u2026` : value
3621
+ });
3622
+ }
3623
+ for (const id of layout.hide ?? []) {
3624
+ const el = byId.get(id);
3625
+ if (el && !t.text.some((x) => x.element === id)) el.opacity = 0;
3626
+ }
3627
+ return { config: out, text: boxes, slots: slotRects, aspect, missing };
3628
+ }
3629
+ function textLimitProblems(t, values) {
3630
+ const out = [];
3631
+ for (const x of t.text) {
3632
+ const v = values[x.param];
3633
+ if (typeof v !== "string") continue;
3634
+ const words = v.trim().split(/\s+/).filter(Boolean).length;
3635
+ const lines = v.split("\n").length;
3636
+ if (x.maxWords && words > x.maxWords)
3637
+ out.push(`${x.param}: ${words} words, the template holds ${x.maxWords}`);
3638
+ if (x.lines && lines > x.lines)
3639
+ out.push(`${x.param}: ${lines} lines, the template holds ${x.lines}`);
3640
+ }
3641
+ return out;
3642
+ }
3643
+
3644
+ // src/plugin/templates/cardOnGradient.ts
3645
+ var GROUND = `(ctx) => {
3646
+ const THREE = ctx.THREE
3647
+ const d = ctx.data
3648
+ const canvas = document.createElement('canvas')
3649
+ canvas.width = 1280
3650
+ canvas.height = 720
3651
+ const c = canvas.getContext('2d')
3652
+ c.fillStyle = d.bgA
3653
+ c.fillRect(0, 0, 1280, 720)
3654
+ const blob = (x, y, r, color) => {
3655
+ const g = c.createRadialGradient(x, y, 0, x, y, r)
3656
+ g.addColorStop(0, color)
3657
+ g.addColorStop(1, 'rgba(0,0,0,0)')
3658
+ c.fillStyle = g
3659
+ c.fillRect(0, 0, 1280, 720)
3660
+ }
3661
+ blob(180, 120, 760, d.blobA)
3662
+ blob(1120, 620, 820, d.blobB)
3663
+ blob(760, 60, 620, d.blobC)
3664
+ let seed = 7
3665
+ const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 }
3666
+ const img = c.getImageData(0, 0, 1280, 720)
3667
+ const px = img.data
3668
+ const grain = d.grain
3669
+ for (let i = 0; i < px.length; i += 4) {
3670
+ const n = (rand() - 0.5) * grain
3671
+ px[i] += n; px[i + 1] += n; px[i + 2] += n
3672
+ }
3673
+ c.putImageData(img, 0, 0)
3674
+ const tex = new THREE.CanvasTexture(canvas)
3675
+ tex.colorSpace = THREE.SRGBColorSpace
3676
+ const plane = new THREE.Mesh(
3677
+ new THREE.PlaneGeometry(2, 2),
3678
+ new THREE.MeshBasicMaterial({ map: tex, depthTest: false }),
3679
+ )
3680
+ plane.renderOrder = -10
3681
+ ctx.scene.add(plane)
3682
+ return { refs: { plane }, dispose: () => { tex.dispose(); plane.geometry.dispose(); plane.material.dispose() } }
3683
+ }`;
3684
+ var TIMELINE = `(ctx, content, duration) => {
3685
+ const { gsap, elements } = ctx
3686
+ const tl = gsap.timeline({ paused: true })
3687
+ const shot = elements.get('shot')
3688
+ 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)
3689
+ return tl
3690
+ }`;
3691
+ function cardOnGradient() {
3692
+ return {
3693
+ version: 2,
3694
+ duration: 4,
3695
+ camera: { preset: "fullscreen" },
3696
+ template: {
3697
+ family: "card-on-gradient",
3698
+ slots: [{ id: "shot", kind: "image", required: true }],
3699
+ params: { required: [], brand: ["bgA", "blobA", "blobB", "blobC", "grain"] },
3700
+ text: [],
3701
+ layouts: {
3702
+ landscape: { slots: { shot: { x: 0.09, y: 0.12, w: 0.82 } } },
3703
+ square: { slots: { shot: { x: 0.08, y: 0.27, w: 0.84 } } },
3704
+ portrait: { slots: { shot: { x: 0.06, y: 0.3, w: 0.88 } } }
3705
+ }
3706
+ },
3707
+ params: [
3708
+ { key: "bgA", type: "color", label: "Ground", default: "#f3efe8" },
3709
+ { key: "blobA", type: "color", label: "Blob A", default: "rgba(255,183,146,0.55)" },
3710
+ { key: "blobB", type: "color", label: "Blob B", default: "rgba(170,196,255,0.5)" },
3711
+ { key: "blobC", type: "color", label: "Blob C", default: "rgba(255,236,170,0.45)" },
3712
+ { key: "grain", type: "number", label: "Grain", default: 14, min: 0, max: 40, step: 1 }
3713
+ ],
3714
+ data: {
3715
+ bgA: "#f3efe8",
3716
+ blobA: "rgba(255,183,146,0.55)",
3717
+ blobB: "rgba(170,196,255,0.5)",
3718
+ blobC: "rgba(255,236,170,0.45)",
3719
+ grain: 14
3720
+ },
3721
+ elements: [
3722
+ {
3723
+ type: "image",
3724
+ id: "shot",
3725
+ src: "",
3726
+ position: { x: "9%", y: "12%" },
3727
+ anchor: "top-left",
3728
+ size: { width: 1574, height: "auto", fit: "contain" },
3729
+ zIndex: 60,
3730
+ opacity: 0
3731
+ }
3732
+ ],
3733
+ createContent: GROUND,
3734
+ createTimeline: TIMELINE
3735
+ };
3736
+ }
3737
+
3738
+ // src/plugin/templates/splitCover.ts
3739
+ var GROUND2 = `(ctx) => {
3740
+ const THREE = ctx.THREE
3741
+ const d = ctx.data
3742
+ const canvas = document.createElement('canvas')
3743
+ canvas.width = 1280
3744
+ canvas.height = 720
3745
+ const c = canvas.getContext('2d')
3746
+ const g = c.createLinearGradient(0, 0, 1280, 720)
3747
+ g.addColorStop(0, d.bgA)
3748
+ g.addColorStop(0.55, d.bgB)
3749
+ g.addColorStop(1, d.bgC)
3750
+ c.fillStyle = g
3751
+ c.fillRect(0, 0, 1280, 720)
3752
+ const rg = c.createRadialGradient(980, 120, 40, 980, 120, 820)
3753
+ rg.addColorStop(0, d.streak)
3754
+ rg.addColorStop(1, 'rgba(0,0,0,0)')
3755
+ c.fillStyle = rg
3756
+ c.fillRect(0, 0, 1280, 720)
3757
+ let seed = 41
3758
+ const rand = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 }
3759
+ const img = c.getImageData(0, 0, 1280, 720)
3760
+ const px = img.data
3761
+ const grain = d.grain
3762
+ for (let i = 0; i < px.length; i += 4) {
3763
+ const n = (rand() - 0.5) * grain
3764
+ px[i] += n; px[i + 1] += n; px[i + 2] += n
3765
+ }
3766
+ c.putImageData(img, 0, 0)
3767
+ const tex = new THREE.CanvasTexture(canvas)
3768
+ tex.colorSpace = THREE.SRGBColorSpace
3769
+ const plane = new THREE.Mesh(
3770
+ new THREE.PlaneGeometry(2, 2),
3771
+ new THREE.MeshBasicMaterial({ map: tex, depthTest: false }),
3772
+ )
3773
+ plane.renderOrder = -10
3774
+ ctx.scene.add(plane)
3775
+ return { refs: { plane }, dispose: () => { tex.dispose(); plane.geometry.dispose(); plane.material.dispose() } }
3776
+ }`;
3777
+ var TIMELINE2 = `(ctx, content, duration) => {
3778
+ const { gsap, elements } = ctx
3779
+ const tl = gsap.timeline({ paused: true })
3780
+ const kicker = elements.get('kicker')
3781
+ const title = elements.get('title')
3782
+ const brand = elements.get('brand')
3783
+ const shot = elements.get('shot')
3784
+ if (kicker) tl.fromTo(kicker.props, { opacity: 0, translateY: 14 }, { opacity: 1, translateY: 0, duration: 0.7, ease: 'power2.out' }, 0.25)
3785
+ if (title) tl.fromTo(title.props, { opacity: 0, translateY: 26 }, { opacity: 1, translateY: 0, duration: 0.9, ease: 'power3.out' }, 0.45)
3786
+ if (brand) tl.fromTo(brand.props, { opacity: 0 }, { opacity: 1, duration: 0.8, ease: 'power2.out' }, 0.9)
3787
+ if (shot) tl.fromTo(shot.props, { opacity: 0, translateX: 70 }, { opacity: 1, translateX: 0, duration: 1.1, ease: 'power3.out' }, 0.55)
3788
+ return tl
3789
+ }`;
3790
+ function splitCover() {
3791
+ return {
3792
+ version: 2,
3793
+ duration: 6,
3794
+ camera: { preset: "fullscreen" },
3795
+ fonts: [
3796
+ { family: "Fraunces", url: "https://assets.vos.so/fonts/fraunces/600.woff2", weight: 600 },
3797
+ { family: "Lexend", url: "https://assets.vos.so/fonts/lexend/700.woff2", weight: 700 },
3798
+ { family: "JetBrains Mono", url: "https://assets.vos.so/fonts/jetbrains-mono/400.woff2", weight: 400 }
3799
+ ],
3800
+ template: {
3801
+ family: "split-cover",
3802
+ slots: [{ id: "shot", kind: "image", required: true }],
3803
+ params: {
3804
+ required: ["headline", "brand"],
3805
+ brand: ["bgA", "bgB", "bgC", "ink", "inkSoft", "accent", "streak", "fontDisplay", "fontBody", "grain"]
3806
+ },
3807
+ text: [
3808
+ { element: "title", param: "headline", role: "headline", maxWords: 8, lines: 3 },
3809
+ { element: "kicker", param: "kicker", role: "body" },
3810
+ { element: "brand", param: "brand", role: "body" }
3811
+ ],
3812
+ layouts: {
3813
+ landscape: {
3814
+ slots: { shot: { x: 0.46, y: 0.28, w: 1.02 } },
3815
+ text: { kicker: { x: "7%", y: "24%" }, title: { x: "7%", y: "44%" }, brand: { x: "7%", y: "86%" } },
3816
+ size: { title: 84, kicker: 19, brand: 25 }
3817
+ },
3818
+ square: {
3819
+ slots: { shot: { x: 0.1, y: 0.5, w: 1 } },
3820
+ text: { kicker: { x: "8%", y: "12%" }, title: { x: "8%", y: "27%" }, brand: { x: "8%", y: "93%" } },
3821
+ size: { title: 72, kicker: 18, brand: 23 }
3822
+ },
3823
+ portrait: {
3824
+ slots: { shot: { x: 0.08, y: 0.44, w: 1.06 } },
3825
+ text: { kicker: { x: "8%", y: "12%" }, title: { x: "8%", y: "26%" }, brand: { x: "8%", y: "94%" } },
3826
+ size: { title: 64, kicker: 17, brand: 22 }
3827
+ }
3828
+ }
3829
+ },
3830
+ params: [
3831
+ { key: "kicker", type: "text", label: "Kicker", default: "RELEASE" },
3832
+ { key: "headline", type: "text", label: "Headline", default: "What shipped,\nin three lines" },
3833
+ { key: "brand", type: "text", label: "Wordmark", default: "brand" },
3834
+ { key: "bgA", type: "color", label: "Ground A", default: "#8a3d2a" },
3835
+ { key: "bgB", type: "color", label: "Ground B", default: "#c0632f" },
3836
+ { key: "bgC", type: "color", label: "Ground C", default: "#5c5a2e" },
3837
+ { key: "ink", type: "color", label: "Ink", default: "#fff6ec" },
3838
+ { key: "inkSoft", type: "color", label: "Ink, softened", default: "#e9d9c8" },
3839
+ { key: "streak", type: "color", label: "Light streak", default: "rgba(255,220,170,0.18)" },
3840
+ { key: "fontDisplay", type: "text", label: "Headline face", default: "Fraunces, serif" },
3841
+ { key: "fontBody", type: "text", label: "Body face", default: "Lexend, sans-serif" },
3842
+ { key: "grain", type: "number", label: "Grain", default: 22, min: 0, max: 40, step: 1 }
3843
+ ],
3844
+ data: {
3845
+ kicker: "RELEASE",
3846
+ headline: "What shipped,\nin three lines",
3847
+ brand: "brand",
3848
+ bgA: "#8a3d2a",
3849
+ bgB: "#c0632f",
3850
+ bgC: "#5c5a2e",
3851
+ ink: "#fff6ec",
3852
+ inkSoft: "#e9d9c8",
3853
+ streak: "rgba(255,220,170,0.18)",
3854
+ fontDisplay: "Fraunces, serif",
3855
+ fontBody: "Lexend, sans-serif",
3856
+ grain: 22
3857
+ },
3858
+ elements: [
3859
+ {
3860
+ type: "text",
3861
+ id: "kicker",
3862
+ content: { $data: "kicker" },
3863
+ position: { x: "7%", y: "24%" },
3864
+ anchor: "center-left",
3865
+ font: { family: "JetBrains Mono, monospace", size: 19, weight: 400, color: { $data: "inkSoft" }, letterSpacing: 6, align: "left" },
3866
+ opacity: 0
3867
+ },
3868
+ {
3869
+ type: "text",
3870
+ id: "title",
3871
+ content: { $data: "headline" },
3872
+ position: { x: "7%", y: "44%" },
3873
+ anchor: "center-left",
3874
+ font: { family: { $data: "fontDisplay" }, size: 84, weight: 600, color: { $data: "ink" }, lineHeight: 1.08, align: "left", letterSpacing: 0 },
3875
+ opacity: 0
3876
+ },
3877
+ {
3878
+ type: "text",
3879
+ id: "brand",
3880
+ content: { $data: "brand" },
3881
+ position: { x: "7%", y: "86%" },
3882
+ anchor: "center-left",
3883
+ font: { family: { $data: "fontBody" }, size: 25, weight: 700, color: { $data: "ink" }, letterSpacing: 1, align: "left" },
3884
+ opacity: 0
3885
+ },
3886
+ {
3887
+ type: "image",
3888
+ id: "shot",
3889
+ src: "",
3890
+ position: { x: "46%", y: "28%" },
3891
+ anchor: "top-left",
3892
+ size: { width: 1400, height: "auto", fit: "contain" },
3893
+ zIndex: 60,
3894
+ opacity: 0
3895
+ }
3896
+ ],
3897
+ createContent: GROUND2,
3898
+ createTimeline: TIMELINE2
3899
+ };
3900
+ }
3901
+
3902
+ // src/plugin/templates/index.ts
3903
+ var TEMPLATES = {
3904
+ "split-cover": splitCover,
3905
+ "card-on-gradient": cardOnGradient
3906
+ };
3907
+ var TEMPLATE_NAMES = Object.keys(TEMPLATES);
3908
+ function templateByName(name) {
3909
+ const make = TEMPLATES[name];
3910
+ return make ? make() : null;
3911
+ }
3912
+
3913
+ // src/plugin/posterValues.ts
3914
+ import {
3915
+ findFontFamily as findFontFamily2,
3916
+ fontFaceUrl,
3917
+ fontStack,
3918
+ nearestFontWeight
3919
+ } from "@vosjs/shared";
3920
+ var HEX = /^#([0-9a-f]{6})$/i;
3921
+ var rgb = (hex2) => {
3922
+ const m = HEX.exec(hex2.trim());
3923
+ if (!m) return null;
3924
+ const n = parseInt(m[1], 16);
3925
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
3926
+ };
3927
+ var toHex = (c) => "#" + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("");
3928
+ function mixHex(a, b, t) {
3929
+ const pa = rgb(a);
3930
+ const pb = rgb(b);
3931
+ if (!pa || !pb) return a;
3932
+ return toHex([pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t, pa[2] + (pb[2] - pa[2]) * t]);
3933
+ }
3934
+ function rgba(hex2, alpha) {
3935
+ const c = rgb(hex2) ?? [255, 255, 255];
3936
+ return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`;
3937
+ }
3938
+ function isLightHex(hex2) {
3939
+ const c = rgb(hex2);
3940
+ if (!c) return true;
3941
+ return (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) / 255 >= 0.6;
3942
+ }
3943
+ function resolveFace(family, weights) {
3944
+ if (!family) return null;
3945
+ const first = family.split(",")[0].replace(/['"]/g, "").replace(/\s+variable$/i, "").trim();
3946
+ const entry = findFontFamily2(first);
3947
+ if (!entry) return null;
3948
+ const fonts = [...new Set(weights.map((w) => nearestFontWeight(entry, w)))].map((w) => ({
3949
+ family: entry.family,
3950
+ url: fontFaceUrl(entry.slug, w),
3951
+ weight: w
3952
+ }));
3953
+ return { stack: fontStack(entry), fonts };
3954
+ }
3955
+ function posterValues(brand, words) {
3956
+ const b = brand ?? {};
3957
+ const bgA = b.bgA && HEX.test(b.bgA) ? b.bgA : null;
3958
+ const bgB = b.bgB && HEX.test(b.bgB) ? b.bgB : bgA;
3959
+ const bgC = b.bgC && HEX.test(b.bgC) ? b.bgC : bgB;
3960
+ const ink = b.ink && HEX.test(b.ink) ? b.ink : null;
3961
+ const accent = b.accent && HEX.test(b.accent) ? b.accent : null;
3962
+ const wordmark = (words.brand ?? b.wordmark ?? b.name ?? "").trim();
3963
+ const release = (words.release ?? "").trim();
3964
+ const lightGround = bgA ? isLightHex(bgA) : false;
3965
+ const values = {};
3966
+ if (bgA) values.bgA = bgA;
3967
+ if (bgB) values.bgB = bgB;
3968
+ if (bgC) values.bgC = bgC;
3969
+ if (ink) values.ink = ink;
3970
+ if (accent) values.accent = accent;
3971
+ if (ink && bgA) values.inkSoft = mixHex(ink, bgA, 0.28);
3972
+ if (accent) values.streak = rgba(accent, lightGround ? 0.12 : 0.18);
3973
+ if (bgA) values.grain = lightGround ? 10 : 22;
3974
+ if (accent) values.blobA = rgba(accent, lightGround ? 0.28 : 0.4);
3975
+ if (bgC) values.blobB = rgba(bgC, 0.75);
3976
+ if (accent) values.blobC = rgba(mixHex(accent, "#ffffff", 0.55), lightGround ? 0.35 : 0.25);
3977
+ if (wordmark) values.brand = wordmark;
3978
+ const headline = (words.headline ?? "").trim();
3979
+ if (headline) values.headline = headline;
3980
+ const kicker = (words.kicker ?? "").trim();
3981
+ values.kicker = kicker ? kicker : [wordmark, release].filter(Boolean).join(" ").toUpperCase();
3982
+ const fonts = [];
3983
+ const display = resolveFace(b.fontDisplay, [600, 700]);
3984
+ if (display) {
3985
+ values.fontDisplay = display.stack;
3986
+ fonts.push(...display.fonts);
3987
+ }
3988
+ const body = resolveFace(b.fontBody, [700]);
3989
+ if (body) {
3990
+ values.fontBody = body.stack;
3991
+ fonts.push(...body.fonts);
3992
+ }
3993
+ return { values, fonts, lightGround };
3994
+ }
3995
+
3996
+ // src/plugin/motionPlan.ts
3997
+ import { ratedSegments as ratedSegments4, spanOutputExtent as spanOutputExtent4 } from "@vosjs/studio-core";
3998
+ var SOUND_DESTINATIONS = /* @__PURE__ */ new Set([
3999
+ "x-feed-cut",
4000
+ "youtube-main-demo",
4001
+ "shorts-linkedin-vertical-cut"
4002
+ ]);
4003
+ var LOOP_DESTINATIONS = /* @__PURE__ */ new Set(["github-readme-loop"]);
4004
+ var off = (v) => v !== void 0 && /^(none|off|false|no)$/i.test(v.trim());
4005
+ function pickTrack(catalog, ask) {
4006
+ if (!catalog || !ask || off(ask)) return null;
4007
+ const want = ask.trim().toLowerCase();
4008
+ return catalog.tracks.find((t) => t.slug.toLowerCase() === want) ?? catalog.tracks.find((t) => (t.mood ?? "").toLowerCase() === want) ?? null;
4009
+ }
4010
+ function clickTimes(doc, range) {
4011
+ const rated = ratedSegments4(doc);
4012
+ const out = [];
4013
+ for (const e of doc.source.cursor) {
4014
+ if (e.type !== "down") continue;
4015
+ const src = e.t / 1e3;
4016
+ const ext = spanOutputExtent4(rated, src, src + 1e-3);
4017
+ if (!ext) continue;
4018
+ const t = ext.start;
4019
+ if (t < range[0] || t > range[1]) continue;
4020
+ if (out.length && t - out[out.length - 1] < 0.12) continue;
4021
+ out.push(+(t - range[0]).toFixed(3));
4022
+ }
4023
+ return out;
4024
+ }
4025
+ function planMotion(input) {
4026
+ const { destination: d, doc, range, words, launch, catalog } = input;
4027
+ const set = [];
4028
+ const notes = [];
4029
+ const skipped = [];
4030
+ if (d.kind !== "video") return { set, notes, skipped };
4031
+ const loop = LOOP_DESTINATIONS.has(d.id);
4032
+ const sound = SOUND_DESTINATIONS.has(d.id);
4033
+ const length = range[1] - range[0];
4034
+ const portrait = d.px.w / d.px.h < 0.9;
4035
+ const entrance = launch.entrance;
4036
+ if (!loop && !off(entrance)) {
4037
+ const kind = entrance && /^(tilt-in|pull-out|rise)$/.test(entrance.trim()) ? entrance.trim() : "tilt-in";
4038
+ set.push(`frame.entrance={"kind":"${kind}"}`);
4039
+ notes.push(`entrance ${kind}`);
4040
+ }
4041
+ if (!loop && !off(launch.endCard)) {
4042
+ const headline = (words.headline ?? "").trim();
4043
+ const brand = (words.brand ?? "").trim();
4044
+ const sub = [brand, (words.release ?? "").trim()].filter(Boolean).join(" ");
4045
+ if (headline || brand) {
4046
+ const card = { seconds: 2.5 };
4047
+ if (input.ink) card.ink = input.ink;
4048
+ if (headline) card.headline = headline;
4049
+ if (sub && sub !== headline) card.sub = sub;
4050
+ if (brand) card.wordmark = brand;
4051
+ set.push(`endCard=${JSON.stringify(card)}`);
4052
+ notes.push("end card");
4053
+ } else {
4054
+ skipped.push(`${d.id}: no end card (no headline or wordmark in LAUNCH.md, BRAND.md or the flags)`);
4055
+ }
4056
+ }
4057
+ if (!loop && d.text !== "none" && input.captions.length && !off(launch.captions)) {
4058
+ const rated = ratedSegments4(doc);
4059
+ const steps = doc.source.meta.steps ?? [];
4060
+ const clips = [];
4061
+ for (const c of input.captions) {
4062
+ const step = steps.find((s) => c.id !== void 0 && s.id === c.id || s.step === c.step);
4063
+ if (!step || step.skipped) continue;
4064
+ const t = stepOutputTime(rated, step, 0.2);
4065
+ if (t === null || t < range[0] || t > range[1] - 1) continue;
4066
+ const start = +(t - range[0]).toFixed(3);
4067
+ clips.push({
4068
+ id: `caption-${c.step}`,
4069
+ kind: "text",
4070
+ text: c.caption,
4071
+ preset: "caption",
4072
+ start,
4073
+ duration: Math.min(3.5, Math.max(2.5, range[1] - t - 0.2)),
4074
+ transform: { x: 0.5, y: portrait ? 0.8 : 0.86, scale: 1, rotation: 0 },
4075
+ enter: "rise",
4076
+ exit: "fade",
4077
+ align: "center",
4078
+ box: { color: "rgba(17,17,17,0.72)" }
4079
+ });
4080
+ }
4081
+ if (clips.length) {
4082
+ const existing = Array.isArray(doc.overlays) ? doc.overlays : [];
4083
+ set.push(`overlays=${JSON.stringify([...existing, ...clips])}`);
4084
+ notes.push(`${clips.length} caption(s)`);
4085
+ }
4086
+ }
4087
+ if (sound && !loop) {
4088
+ const track = pickTrack(catalog, launch.music);
4089
+ const clips = Array.isArray(doc.audio) ? [...doc.audio] : [];
4090
+ if (track) {
4091
+ const hasMic = !!doc.source.micKey;
4092
+ const fadeOut = Math.min(2.5, length * 0.15);
4093
+ clips.push({
4094
+ id: "bed",
4095
+ key: track.url,
4096
+ name: track.title,
4097
+ start: 0,
4098
+ in: 0,
4099
+ out: Math.min(track.duration, length),
4100
+ duration: track.duration,
4101
+ gain: hasMic ? 0.35 : 0.5,
4102
+ fadeIn: 0.6,
4103
+ fadeOut,
4104
+ loop: track.duration < length,
4105
+ loopLen: track.duration < length ? length : void 0,
4106
+ duck: hasMic
4107
+ });
4108
+ notes.push(`bed ${track.slug}`);
4109
+ } else if (launch.music && !off(launch.music)) {
4110
+ skipped.push(`${d.id}: music "${launch.music}" is not a catalog track or mood${catalog ? "" : " (the catalog could not be read)"}`);
4111
+ }
4112
+ const click = catalog?.sfx.find((s) => s.slug === "sfx-click");
4113
+ if (click && !doc.source.micKey && !off(launch.clicks)) {
4114
+ const times = clickTimes(doc, range);
4115
+ for (const [i, t] of times.entries()) {
4116
+ clips.push({
4117
+ id: `click-${i}`,
4118
+ key: click.url,
4119
+ name: click.title,
4120
+ start: t,
4121
+ in: 0,
4122
+ out: click.duration,
4123
+ duration: click.duration,
4124
+ gain: 0.4,
4125
+ fadeIn: 0,
4126
+ fadeOut: 0
4127
+ });
4128
+ }
4129
+ if (times.length) notes.push(`${times.length} click sound(s)`);
4130
+ }
4131
+ if (clips.length) set.push(`audio=${JSON.stringify(clips)}`);
4132
+ }
4133
+ if (portrait) {
4134
+ set.push("frame.fit=cover");
4135
+ set.push('frame.inset={"left":0.06,"right":0.06,"top":0.17,"bottom":0.17}');
4136
+ set.push("frame.focusFollow=camera");
4137
+ notes.push("vertical reframe follows the camera");
4138
+ }
4139
+ return { set, notes, skipped };
4140
+ }
4141
+
2720
4142
  // src/plugin/deliver.ts
2721
4143
  var CHANNEL_ALIASES = {
2722
4144
  ph: "producthunt",
@@ -2744,20 +4166,153 @@ function resolveChannels(raw) {
2744
4166
  }
2745
4167
  return out;
2746
4168
  }
2747
- function defaultStillTimes(doc, duration) {
2748
- const rated = ratedSegments3(doc);
2749
- const apexes = [];
2750
- for (const z of doc.zoom) {
2751
- const ext = spanOutputExtent3(rated, z.in, z.out);
2752
- if (ext) apexes.push((ext.start + ext.end) / 2);
4169
+ async function readBrandBesideTake(dir, explicit) {
4170
+ const candidates = explicit ? [explicit] : [join5(dir, "BRAND.md"), join5(dir, "..", "BRAND.md")];
4171
+ for (const file of candidates) {
4172
+ if (!existsSync5(file)) continue;
4173
+ const roles = parseFrontmatter(await readFile5(file, "utf8"));
4174
+ return { file, roles };
4175
+ }
4176
+ return null;
4177
+ }
4178
+ async function readLaunchBesideTake(dir, explicit) {
4179
+ const candidates = explicit ? [explicit] : [join5(dir, "LAUNCH.md"), join5(dir, "..", "LAUNCH.md")];
4180
+ for (const file of candidates) {
4181
+ if (!existsSync5(file)) continue;
4182
+ return { file, roles: parseFrontmatter(await readFile5(file, "utf8")) };
4183
+ }
4184
+ return null;
4185
+ }
4186
+ async function resolveLook(dir, opts) {
4187
+ const brand = await readBrandBesideTake(dir, opts.brand);
4188
+ const roles = brand?.roles ?? null;
4189
+ if (opts.look === "none") return { look: null, from: "--look none", roles };
4190
+ if (opts.look !== void 0) {
4191
+ if (!isLookKind(opts.look))
4192
+ throw new Error(
4193
+ `--look "${opts.look}" \u2014 one of plate | gradient | dark | none`
4194
+ );
4195
+ return { look: houseLook(opts.look), from: `--look ${opts.look}`, roles };
4196
+ }
4197
+ if (brand) {
4198
+ const look = lookFromBrand(brand.roles);
4199
+ return { look, from: `${brand.file} (${look.kind})`, roles };
2753
4200
  }
2754
- const dropped = doc.zoom.length - apexes.length;
2755
- if (apexes.length) return { times: apexes.sort((a, b) => a - b), dropped };
2756
4201
  return {
2757
- times: [0.1, 0.3, 0.5, 0.7, 0.9].map((p) => p * duration),
2758
- dropped
4202
+ look: houseLook("gradient"),
4203
+ from: "the house gradient (no BRAND.md beside the take)",
4204
+ roles
2759
4205
  };
2760
4206
  }
4207
+ function templateForCard(d, opts) {
4208
+ if (opts.poster === null) return null;
4209
+ if (opts.poster) return { config: opts.poster.config, from: opts.poster.from };
4210
+ if (!d.template) return null;
4211
+ let name = d.template;
4212
+ let note;
4213
+ const wants = templateOf(templateByName(name) ?? {});
4214
+ const needsHeadline = wants?.text.some((t) => t.role === "headline");
4215
+ if (needsHeadline && !opts.words?.headline?.trim()) {
4216
+ name = "card-on-gradient";
4217
+ note = `${d.id}: no headline (LAUNCH.md headline: or --headline), so the ${d.template} template stands down for card-on-gradient`;
4218
+ }
4219
+ const config = templateByName(name);
4220
+ if (!config) return null;
4221
+ return { config, from: `template ${name}`, note };
4222
+ }
4223
+ function lookOverrides(look, placement, size, video, opts) {
4224
+ const inset = cardInset(look, size, video, placement);
4225
+ const set = [
4226
+ "frame.fit=contain",
4227
+ `frame.background=${JSON.stringify(look.ground)}`,
4228
+ `frame.inset=${JSON.stringify(inset)}`,
4229
+ `frame.radius=${look.radius}`,
4230
+ `frame.shadow=${look.shadow}`,
4231
+ `frame.shadowContact=${look.shadowContact}`,
4232
+ `frame.border=${look.border}`
4233
+ ];
4234
+ if (look.shadowColor) set.push(`frame.shadowColor=${look.shadowColor}`);
4235
+ if (look.border > 0) {
4236
+ set.push("frame.borderWidth=1");
4237
+ set.push(`frame.borderColor=${look.borderColor ?? "#000000"}`);
4238
+ }
4239
+ if (!opts.keepMedia) set.push("frame.backgroundMedia=null");
4240
+ if (opts.still) {
4241
+ set.push("zoom=[]", "tilt=[]", "cursor.visible=false", "cursor.clickFx.style=none");
4242
+ }
4243
+ return set;
4244
+ }
4245
+ function endCardInk(look, brand) {
4246
+ if (!look) return null;
4247
+ const ground = look.ground;
4248
+ const m = /#([0-9a-f]{6})/i.exec(ground);
4249
+ const hex2 = m ? m[0] : null;
4250
+ const light = look.kind === "plate" || (hex2 ? isLightHexGround(hex2) : look.kind === "gradient");
4251
+ if (!light) return "#ffffff";
4252
+ const ink = brand?.ink;
4253
+ return ink && /^#[0-9a-f]{6}$/i.test(ink) ? ink : "#111111";
4254
+ }
4255
+ function isLightHexGround(hex2) {
4256
+ const n = parseInt(hex2.slice(1), 16);
4257
+ const r = n >> 16 & 255;
4258
+ const g = n >> 8 & 255;
4259
+ const b = n & 255;
4260
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 >= 0.6;
4261
+ }
4262
+ var PROBE_WIDTH = 640;
4263
+ async function pickStillTimes(browser, dir, doc, duration, opts) {
4264
+ const { candidates, dropped } = momentCandidates(doc, duration);
4265
+ const notes = [];
4266
+ if (dropped > 0)
4267
+ notes.push(
4268
+ `${dropped} of ${doc.zoom.length} zoom apex(es) fall outside the cut`
4269
+ );
4270
+ if (!candidates.length) return { times: [], moments: [], notes };
4271
+ const meta = doc.source.meta;
4272
+ const vw = meta.captureWidth ?? meta.width;
4273
+ const vh = meta.captureHeight ?? meta.height;
4274
+ const probeDir = await mkdtemp(join5(tmpdir(), "vos-moments-"));
4275
+ try {
4276
+ const probe = await framesTake(browser, dir, {
4277
+ times: candidates.map((c) => c.time),
4278
+ width: PROBE_WIDTH,
4279
+ height: Math.max(2, Math.round(PROBE_WIDTH * vh / vw / 2) * 2),
4280
+ outDir: probeDir,
4281
+ overrides: {
4282
+ ...opts.overrides,
4283
+ set: [...SCREENSHOT_DEFAULTS, ...opts.overrides?.set ?? []]
4284
+ }
4285
+ });
4286
+ const byTime = /* @__PURE__ */ new Map();
4287
+ for (const frame of probe.frames) {
4288
+ const img = decodePng(new Uint8Array(await readFile5(frame.file)));
4289
+ if (!img) continue;
4290
+ const whole = { x: 0, y: 0, w: img.w, h: img.h };
4291
+ byTime.set(+frame.time.toFixed(3), {
4292
+ ink: inkCoverage(img, whole),
4293
+ hash: differenceHash(img, whole)
4294
+ });
4295
+ }
4296
+ const measured = [];
4297
+ for (const c of candidates) {
4298
+ const m = byTime.get(+c.time.toFixed(3));
4299
+ if (m) measured.push({ time: c.time, ...m });
4300
+ }
4301
+ const pick = pickMoments(measured);
4302
+ const kept = candidates.filter((c) => pick.times.includes(c.time));
4303
+ const bySource = (s) => candidates.filter((c) => c.source === s).length;
4304
+ notes.push(
4305
+ `${candidates.length} candidate(s): ${bySource("step")} from steps, ${bySource("zoom")} from zoom apexes, ${bySource("spread")} from the spread; ${kept.length} kept`
4306
+ );
4307
+ return {
4308
+ times: pick.times,
4309
+ moments: kept,
4310
+ notes: [...notes, ...pick.dropped.map((d) => `moment: ${d}`)]
4311
+ };
4312
+ } finally {
4313
+ await rm3(probeDir, { recursive: true, force: true });
4314
+ }
4315
+ }
2761
4316
  var overCeiling = (bytes, maxBytes) => `${Math.ceil(bytes / 1024)} KB exceeds the ${Math.floor(maxBytes / 1024)} KB ceiling`;
2762
4317
  var specWords = (d) => {
2763
4318
  const parts = [`${d.px.w}x${d.px.h}`];
@@ -2777,12 +4332,22 @@ var FULL_BLEED = [
2777
4332
  "cursor.clickFx.style=none"
2778
4333
  ];
2779
4334
  var SCREENSHOT_DEFAULTS = [...FULL_BLEED, "zoom=[]", "tilt=[]"];
2780
- function stillOverridesFor(d, opts) {
4335
+ function stillOverridesFor(d, opts, video) {
2781
4336
  const set = [];
2782
- if (d.fit === "cover") set.push("frame.fit=cover");
2783
- if (d.genre === "screenshot" && !opts.composed)
4337
+ if (d.genre === "screenshot" && !opts.composed) {
4338
+ if (d.fit === "cover") set.push("frame.fit=cover");
2784
4339
  set.push(...SCREENSHOT_DEFAULTS);
2785
- else if (d.genre === "card" && !opts.composed) set.push(...FULL_BLEED);
4340
+ } else if (d.genre === "card" && !opts.composed && opts.look && video && d.px) {
4341
+ set.push(
4342
+ ...lookOverrides(opts.look, "card", d.px, video, {
4343
+ still: true,
4344
+ keepMedia: opts.overrides?.background !== void 0
4345
+ })
4346
+ );
4347
+ } else {
4348
+ if (d.fit === "cover") set.push("frame.fit=cover");
4349
+ if (d.genre === "card" && !opts.composed) set.push(...FULL_BLEED);
4350
+ }
2786
4351
  set.push(...opts.overrides?.set ?? []);
2787
4352
  if (!set.length) return opts.overrides;
2788
4353
  return { ...opts.overrides, set };
@@ -2791,34 +4356,69 @@ async function deliverTake(browser, dir, opts) {
2791
4356
  const take = await loadTake(dir);
2792
4357
  if (!take.doc) throw new Error(`${dir} has no doc.json \u2014 run plan first`);
2793
4358
  const doc = take.doc;
2794
- const duration = totalDuration(ratedSegments3(doc));
4359
+ const duration = totalDuration(ratedSegments5(doc));
2795
4360
  const videoSeconds = opts.range ? Math.min(opts.range[1], duration) - Math.min(opts.range[0], duration) : duration;
4361
+ const outDir = resolve3(opts.outDir ?? join5(dir, "kit"));
4362
+ await mkdir4(outDir, { recursive: true });
4363
+ const destinations = opts.channels.flatMap((c) => destinationsForChannel(c));
4364
+ const assets = [];
4365
+ const skipped = [];
2796
4366
  let stillTimes;
4367
+ let moments;
2797
4368
  if (opts.times?.length) {
2798
4369
  stillTimes = opts.times;
4370
+ moments = opts.times.map((time) => ({ time, source: "times" }));
2799
4371
  } else {
2800
- const derived = defaultStillTimes(doc, duration);
2801
- stillTimes = derived.times;
2802
- if (derived.dropped > 0) {
2803
- opts.onPhase?.(
2804
- `note: ${derived.dropped} of ${doc.zoom.length} zoom apex(es) fall outside the cut \u2014 pass --times for more moments`
4372
+ opts.onPhase?.("moments (the step timeline, the zoom apexes, the spread)");
4373
+ const picked = await pickStillTimes(browser, dir, doc, duration, opts);
4374
+ stillTimes = picked.times;
4375
+ moments = picked.moments;
4376
+ for (const n of picked.notes) {
4377
+ if (n.startsWith("moment: ")) skipped.push(n);
4378
+ else opts.onPhase?.(`note: ${n}`);
4379
+ }
4380
+ }
4381
+ const meta0 = doc.source.meta;
4382
+ const video = {
4383
+ w: meta0.captureWidth ?? meta0.width,
4384
+ h: meta0.captureHeight ?? meta0.height
4385
+ };
4386
+ const videoOverrides = (d, range) => {
4387
+ const set = [];
4388
+ if (opts.look) {
4389
+ set.push(
4390
+ ...lookOverrides(opts.look, "hero", d.px, video, {
4391
+ still: false,
4392
+ keepMedia: opts.overrides?.background !== void 0
4393
+ })
2805
4394
  );
2806
4395
  }
2807
- }
2808
- const outDir = resolve3(opts.outDir ?? join5(dir, "kit"));
2809
- await mkdir4(outDir, { recursive: true });
2810
- const destinations = opts.channels.flatMap((c) => destinationsForChannel(c));
2811
- const assets = [];
2812
- const skipped = [];
2813
- const posterCards = opts.poster !== void 0 ? destinations.filter(
2814
- (d) => d.kind !== "video" && d.genre === "card" && !NOT_FROM_FOOTAGE[d.id]
2815
- ) : [];
2816
- const posterCardIds = new Set(posterCards.map((d) => d.id));
2817
- if (opts.poster && posterCards.length) {
4396
+ const plan = planMotion({
4397
+ destination: d,
4398
+ doc,
4399
+ range: range ?? [0, duration],
4400
+ words: opts.words ?? {},
4401
+ launch: opts.launchRoles ?? {},
4402
+ captions: opts.captions ?? [],
4403
+ catalog: opts.catalog ?? null,
4404
+ ink: endCardInk(opts.look, opts.brandRoles)
4405
+ });
4406
+ set.push(...plan.set);
4407
+ if (plan.notes.length) opts.onPhase?.(`${d.id}: ${plan.notes.join(", ")}`);
4408
+ for (const s of plan.skipped) skipped.push(`note: ${s}`);
4409
+ set.push(...opts.overrides?.set ?? []);
4410
+ if (!set.length) return opts.overrides;
4411
+ return { ...opts.overrides, set };
4412
+ };
4413
+ const cardPlans = destinations.filter((d) => d.kind !== "video" && d.genre === "card" && !NOT_FROM_FOOTAGE[d.id]).map((d) => ({ d, plan: templateForCard(d, opts) })).filter((p) => p.plan !== null);
4414
+ const posterCardIds = new Set(cardPlans.map((p) => p.d.id));
4415
+ for (const p of cardPlans) if (p.plan.note) skipped.push(`note: ${p.plan.note}`);
4416
+ if (cardPlans.length) {
2818
4417
  const meta = doc.source.meta;
2819
4418
  const heroTime = opts.shotTime ?? (stillTimes.length ? stillTimes[0] : duration / 2);
4419
+ const fill = posterValues(opts.brandRoles, opts.words ?? {});
2820
4420
  opts.onPhase?.(
2821
- `poster shot (full bleed at ${heroTime.toFixed(2)}s) from ${opts.poster.from}`
4421
+ `poster shot (full bleed at ${heroTime.toFixed(2)}s), baked as an object`
2822
4422
  );
2823
4423
  const serveDir = await mkdtemp(join5(tmpdir(), "vos-poster-"));
2824
4424
  try {
@@ -2836,71 +4436,88 @@ async function deliverTake(browser, dir, opts) {
2836
4436
  "frame.shadow=0",
2837
4437
  "frame.border=0",
2838
4438
  "frame.browserBar.kind=none",
4439
+ "cursor.visible=false",
4440
+ "cursor.clickFx.style=none",
2839
4441
  ...opts.overrides?.set ?? []
2840
4442
  ]
2841
4443
  }
2842
4444
  });
2843
- await copyFile(shot.frames[0].file, join5(serveDir, "shot.png"));
2844
- const config = structuredClone(opts.poster.config);
2845
- const elements = Array.isArray(config.elements) ? config.elements : [];
2846
- const shotEl = elements.find(
2847
- (e) => e.id === "shot"
2848
- ) ?? elements.find(
2849
- (e) => e.type === "image"
2850
- );
2851
- if (!shotEl) {
2852
- skipped.push(
2853
- `poster ${opts.poster.from}: no image element (id "shot") to carry the release's screenshot \u2014 card destinations kept from the take`
2854
- );
2855
- for (const d of posterCards) posterCardIds.delete(d.id);
2856
- } else {
2857
- shotEl.src = "/shot.png";
2858
- const data = config.data && typeof config.data === "object" ? config.data : {};
2859
- data.shotUrl = "/shot.png";
2860
- config.data = data;
4445
+ const raw = decodePng(new Uint8Array(await readFile5(shot.frames[0].file)));
4446
+ if (!raw) throw new Error("the poster shot could not be decoded");
4447
+ const PAD = 0.06;
4448
+ const baked = bakeShot(raw, {
4449
+ margin: PAD,
4450
+ hairline: fill.lightGround ? 0.14 : 0,
4451
+ shadow: fill.lightGround ? 0.32 : 0.45
4452
+ });
4453
+ await writeFile6(join5(serveDir, "shot.png"), encodePng(baked));
4454
+ const shotAspect = raw.w / raw.h;
4455
+ for (const { d, plan } of cardPlans) {
4456
+ const problems = templateProblems(plan.config);
4457
+ if (problems.length) {
4458
+ skipped.push(
4459
+ `${d.channel} ${d.asset}: ${plan.from} is not a valid template (${problems[0]}) \u2014 kept from the take`
4460
+ );
4461
+ posterCardIds.delete(d.id);
4462
+ continue;
4463
+ }
4464
+ const filled = fillTemplate(plan.config, {
4465
+ size: d.px,
4466
+ slots: { shot: { src: "/shot.png", aspect: shotAspect, pad: PAD } },
4467
+ values: fill.values
4468
+ });
4469
+ const limits = textLimitProblems(templateOf(plan.config), fill.values);
4470
+ for (const l of limits) skipped.push(`note: ${d.id}: ${l}`);
4471
+ if (filled.missing.length) {
4472
+ skipped.push(
4473
+ `${d.channel} ${d.asset}: ${plan.from} needs ${filled.missing.join(", ")} \u2014 kept from the take`
4474
+ );
4475
+ posterCardIds.delete(d.id);
4476
+ continue;
4477
+ }
4478
+ const config = filled.config;
4479
+ if (fill.fonts.length) {
4480
+ const declared = Array.isArray(config.fonts) ? config.fonts : [];
4481
+ config.fonts = [...declared, ...fill.fonts];
4482
+ }
2861
4483
  const posterDuration = typeof config.duration === "number" ? config.duration : 6;
2862
4484
  const time = Math.min(
2863
4485
  opts.posterTime ?? posterDuration * 0.9,
2864
4486
  Math.max(0, posterDuration - 0.05)
2865
4487
  );
2866
- opts.onPhase?.(
2867
- `poster cards (${posterCards.map((d) => d.id).join(", ")}) at ${time.toFixed(2)}s`
2868
- );
4488
+ opts.onPhase?.(`${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}, ${filled.aspect}`);
2869
4489
  await renderPosterStills(
2870
4490
  browser,
2871
4491
  config,
2872
4492
  serveDir,
2873
- posterCards.map((d) => ({
2874
- name: `${d.id}.png`,
2875
- width: d.px.w,
2876
- height: d.px.h
2877
- })),
4493
+ [{ name: `${d.id}.png`, width: d.px.w, height: d.px.h }],
2878
4494
  time
2879
4495
  );
2880
- for (const d of posterCards) {
2881
- const from = join5(serveDir, `${d.id}.png`);
2882
- const to = join5(outDir, `${d.id}.png`);
2883
- await rename4(from, to);
2884
- const bytes = (await stat(to)).size;
2885
- if (d.maxBytes !== void 0 && bytes > d.maxBytes) {
2886
- skipped.push(
2887
- `${d.channel} ${d.asset}: ${overCeiling(bytes, d.maxBytes)} (kept at ${to})`
2888
- );
2889
- continue;
2890
- }
2891
- assets.push({
2892
- channel: d.channel,
2893
- asset: d.asset,
2894
- destination: d.id,
2895
- path: relative(outDir, to),
2896
- w: d.px.w,
2897
- h: d.px.h,
2898
- bytes,
2899
- seconds: null,
2900
- frameTime: null,
2901
- source: "poster"
2902
- });
4496
+ const from = join5(serveDir, `${d.id}.png`);
4497
+ const to = join5(outDir, `${d.id}.png`);
4498
+ await rename4(from, to);
4499
+ const bytes = (await stat(to)).size;
4500
+ if (d.maxBytes !== void 0 && bytes > d.maxBytes) {
4501
+ skipped.push(
4502
+ `${d.channel} ${d.asset}: ${overCeiling(bytes, d.maxBytes)} (kept at ${to})`
4503
+ );
4504
+ continue;
2903
4505
  }
4506
+ assets.push({
4507
+ channel: d.channel,
4508
+ asset: d.asset,
4509
+ destination: d.id,
4510
+ path: relative(outDir, to),
4511
+ w: d.px.w,
4512
+ h: d.px.h,
4513
+ bytes,
4514
+ seconds: null,
4515
+ frameTime: heroTime,
4516
+ source: "poster",
4517
+ template: templateOf(plan.config)?.family ?? plan.from,
4518
+ text: filled.text,
4519
+ shot: filled.slots.shot
4520
+ });
2904
4521
  }
2905
4522
  } finally {
2906
4523
  await rm3(serveDir, { recursive: true, force: true });
@@ -2921,26 +4538,36 @@ async function deliverTake(browser, dir, opts) {
2921
4538
  );
2922
4539
  continue;
2923
4540
  }
4541
+ let range = opts.range;
4542
+ let seconds = videoSeconds;
2924
4543
  if (d.maxSeconds !== void 0 && videoSeconds > d.maxSeconds) {
2925
- skipped.push(
2926
- `${label}: spec caps at ${d.maxSeconds}s, the take is ${videoSeconds.toFixed(0)}s \u2014 cut it (--range, or trim segments in doc.json)`
2927
- );
2928
- continue;
4544
+ if (LOOP_DESTINATIONS.has(d.id) && !opts.range) {
4545
+ range = [0, d.maxSeconds];
4546
+ seconds = d.maxSeconds;
4547
+ opts.onPhase?.(
4548
+ `note: ${label} takes the first ${d.maxSeconds}s of the ${videoSeconds.toFixed(0)}s take (a loop's cap)`
4549
+ );
4550
+ } else {
4551
+ skipped.push(
4552
+ `${label}: spec caps at ${d.maxSeconds}s, the take is ${videoSeconds.toFixed(0)}s \u2014 cut it (--range, or trim segments in doc.json)`
4553
+ );
4554
+ continue;
4555
+ }
2929
4556
  }
2930
4557
  opts.onPhase?.(`${label} (${specWords(d)})`);
2931
4558
  const outFile = join5(outDir, `${d.id}.${d.format}`);
2932
4559
  const bitrate = d.maxBytes !== void 0 ? Math.min(
2933
4560
  1e7,
2934
- Math.floor(d.maxBytes * 8 / videoSeconds * 0.85)
4561
+ Math.floor(d.maxBytes * 8 / seconds * 0.85)
2935
4562
  ) : void 0;
2936
4563
  const result = await renderTake(browser, dir, outFile, {
2937
4564
  width: d.px.w,
2938
4565
  height: d.px.h,
2939
4566
  format: "mp4",
2940
4567
  parallel: opts.parallel,
2941
- range: opts.range,
4568
+ range,
2942
4569
  bitrate,
2943
- overrides: opts.overrides,
4570
+ overrides: videoOverrides(d, range),
2944
4571
  onProgress: opts.onProgress
2945
4572
  });
2946
4573
  if (d.maxBytes !== void 0 && result.bytes > d.maxBytes) {
@@ -2964,7 +4591,7 @@ async function deliverTake(browser, dir, opts) {
2964
4591
  }
2965
4592
  const wanted = d.kind === "still-set" ? stillTimes.slice(0, d.count?.max ?? stillTimes.length) : stillTimes.slice(0, 1);
2966
4593
  opts.onPhase?.(`${label} (${specWords(d)}, ${wanted.length} still(s))`);
2967
- const overrides = stillOverridesFor(d, opts);
4594
+ const overrides = stillOverridesFor(d, opts, video);
2968
4595
  const captured = await framesTake(browser, dir, {
2969
4596
  times: wanted,
2970
4597
  width: d.px.w,
@@ -2993,7 +4620,8 @@ async function deliverTake(browser, dir, opts) {
2993
4620
  h: captured.height,
2994
4621
  bytes,
2995
4622
  seconds: null,
2996
- frameTime: frame.time
4623
+ frameTime: frame.time,
4624
+ ...d.genre === "screenshot" && opts.composed ? { composed: true } : {}
2997
4625
  });
2998
4626
  }
2999
4627
  if (d.count && captured.frames.length < d.count.min) {
@@ -3004,9 +4632,11 @@ async function deliverTake(browser, dir, opts) {
3004
4632
  }
3005
4633
  const kit = {
3006
4634
  release: opts.release ?? null,
4635
+ look: opts.look?.kind ?? null,
3007
4636
  take: dir,
3008
4637
  produced: (/* @__PURE__ */ new Date()).toISOString(),
3009
4638
  specsVerified: CHANNEL_SPECS_VERIFIED,
4639
+ moments,
3010
4640
  skipped,
3011
4641
  assets
3012
4642
  };
@@ -3015,19 +4645,560 @@ async function deliverTake(browser, dir, opts) {
3015
4645
  return { kit, kitFile, outDir };
3016
4646
  }
3017
4647
 
3018
- // src/plugin/validateKit.ts
3019
- import { existsSync as existsSync5 } from "fs";
3020
- import { readFile as readFile5, stat as stat2 } from "fs/promises";
3021
- import { dirname, isAbsolute, join as join6 } from "path";
4648
+ // src/plugin/music.ts
4649
+ async function fetchMusicCatalog(origin) {
4650
+ const base = origin.replace(/\/+$/, "");
4651
+ const res = await fetch(`${base}/api/music`);
4652
+ if (!res.ok) throw new Error(`GET ${base}/api/music answered ${res.status}`);
4653
+ const body = await res.json();
4654
+ return {
4655
+ tracks: Array.isArray(body.tracks) ? body.tracks : [],
4656
+ sfx: Array.isArray(body.sfx) ? body.sfx : []
4657
+ };
4658
+ }
4659
+
4660
+ // src/plugin/judge.ts
4661
+ import { existsSync as existsSync6 } from "fs";
4662
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7 } from "fs/promises";
4663
+ import { dirname, isAbsolute, join as join6, resolve as resolve4 } from "path";
3022
4664
  import { DESTINATIONS as DESTINATIONS2 } from "@vosjs/studio-core";
3023
- var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
4665
+ var RUBRIC = [
4666
+ "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.",
4667
+ "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).",
4668
+ "It enters: the first frame is not a static, centred, flat window (a tilt-in, a pull-out, a rise).",
4669
+ "The camera zooms to the affordance, holds through the response, and pulls out to show the consequence.",
4670
+ "Beats are cut at stillness; no wipes.",
4671
+ "Content is staged: real data, a populated state, never an empty canvas or a wallpaper.",
4672
+ "The cursor is a character: visible, smoothed, a press on click, never drifting through dead space.",
4673
+ "The last frame is a poster: a resolved state you could ship as the still.",
4674
+ "The message has its own column or its own frame; the headline is two to six words over up to three lines at about 40% of the height; the wordmark is anchored.",
4675
+ "The poster test: would you post this frame as a still, alone?",
4676
+ "The thumbnail test: does it read at half size?",
4677
+ "Name three ways this asset acknowledges THIS product (its data, its state, its brand), not a generic window."
4678
+ ];
4679
+ function rolesFor(asset) {
4680
+ if (asset.source === "poster" && asset.template) return [asset.template];
4681
+ const spec = DESTINATIONS2.find((d) => d.id === asset.destination);
4682
+ if (spec?.kind === "video") {
4683
+ const portrait = spec.px.w < spec.px.h;
4684
+ return portrait ? ["feature-clip", "site-walkthrough"] : ["feature-clip", "site-walkthrough", "feature-clip-dark"];
4685
+ }
4686
+ if (spec?.genre === "card") return ["window-in-scene", "card-on-gradient", "framed-screenshot"];
4687
+ if (spec?.genre === "screenshot") return ["framed-screenshot", "app-session"];
4688
+ return [];
4689
+ }
4690
+ function resample(img, w, h) {
4691
+ const out = new Uint8Array(w * h * 4);
4692
+ const sx = img.w / w;
4693
+ const sy = img.h / h;
4694
+ for (let y = 0; y < h; y++) {
4695
+ const y0 = Math.floor(y * sy);
4696
+ const y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
4697
+ for (let x = 0; x < w; x++) {
4698
+ const x0 = Math.floor(x * sx);
4699
+ const x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
4700
+ let r = 0;
4701
+ let g = 0;
4702
+ let b = 0;
4703
+ let a = 0;
4704
+ let n = 0;
4705
+ for (let yy = y0; yy < y1 && yy < img.h; yy++) {
4706
+ for (let xx = x0; xx < x1 && xx < img.w; xx++) {
4707
+ const o2 = (yy * img.w + xx) * 4;
4708
+ r += img.data[o2];
4709
+ g += img.data[o2 + 1];
4710
+ b += img.data[o2 + 2];
4711
+ a += img.data[o2 + 3];
4712
+ n++;
4713
+ }
4714
+ }
4715
+ const o = (y * w + x) * 4;
4716
+ out[o] = n ? r / n : 0;
4717
+ out[o + 1] = n ? g / n : 0;
4718
+ out[o + 2] = n ? b / n : 0;
4719
+ out[o + 3] = n ? a / n : 0;
4720
+ }
4721
+ }
4722
+ return { w, h, data: out };
4723
+ }
4724
+ function composeSheet(left, right, height = 540, gutter = 32) {
4725
+ const lw = Math.max(1, Math.round(left.w * height / left.h));
4726
+ const rw = Math.max(1, Math.round(right.w * height / right.h));
4727
+ const L = resample(left, lw, height);
4728
+ const R = resample(right, rw, height);
4729
+ const margin = 24;
4730
+ const band = 8;
4731
+ const w = margin * 2 + lw + gutter + rw;
4732
+ const h = margin * 2 + height + band + 6;
4733
+ const out = new Uint8Array(w * h * 4);
4734
+ for (let i = 0; i < w * h; i++) {
4735
+ out[i * 4] = 240;
4736
+ out[i * 4 + 1] = 242;
4737
+ out[i * 4 + 2] = 244;
4738
+ out[i * 4 + 3] = 255;
4739
+ }
4740
+ const blit = (src, x0, y0) => {
4741
+ for (let y = 0; y < src.h; y++) {
4742
+ for (let x = 0; x < src.w; x++) {
4743
+ const si = (y * src.w + x) * 4;
4744
+ const a = src.data[si + 3] / 255;
4745
+ const o = ((y0 + y) * w + x0 + x) * 4;
4746
+ for (let c = 0; c < 3; c++) out[o + c] = Math.round(src.data[si + c] * a + out[o + c] * (1 - a));
4747
+ }
4748
+ }
4749
+ };
4750
+ blit(L, margin, margin);
4751
+ blit(R, margin + lw + gutter, margin);
4752
+ const fill = (x0, x1, y0, y1, v) => {
4753
+ for (let y = y0; y < y1; y++)
4754
+ for (let x = x0; x < x1; x++) {
4755
+ const o = (y * w + x) * 4;
4756
+ out[o] = v;
4757
+ out[o + 1] = v;
4758
+ out[o + 2] = v;
4759
+ }
4760
+ };
4761
+ fill(margin, margin + lw, margin + height + 6, margin + height + 6 + band, 40);
4762
+ fill(margin + lw + gutter, margin + lw + gutter + rw, margin + height + 6, margin + height + 6 + band, 200);
4763
+ return { w, h, data: out };
4764
+ }
4765
+ async function judgeKit(kitPath, manifestPath, outDir) {
4766
+ const kit = JSON.parse(await readFile6(kitPath, "utf8"));
4767
+ const manifest = JSON.parse(await readFile6(manifestPath, "utf8"));
4768
+ const manifestDir = dirname(resolve4(manifestPath));
4769
+ const kitDir = dirname(resolve4(kitPath));
4770
+ const out = resolve4(outDir ?? join6(kitDir, "judge"));
4771
+ await mkdir5(out, { recursive: true });
4772
+ const sheets = [];
4773
+ const skipped = [];
4774
+ const refCache = /* @__PURE__ */ new Map();
4775
+ const loadRef = async (file) => {
4776
+ if (refCache.has(file)) return refCache.get(file) ?? null;
4777
+ const p = join6(manifestDir, file);
4778
+ const img = existsSync6(p) ? decodePng(new Uint8Array(await readFile6(p))) : null;
4779
+ refCache.set(file, img);
4780
+ return img;
4781
+ };
4782
+ for (const a of kit.assets) {
4783
+ if (!/\.png$/i.test(a.path)) continue;
4784
+ const file = isAbsolute(a.path) && existsSync6(a.path) ? a.path : join6(kitDir, a.path.split("/").pop() ?? a.path);
4785
+ const img = existsSync6(file) ? decodePng(new Uint8Array(await readFile6(file))) : null;
4786
+ if (!img) {
4787
+ skipped.push(`${a.destination ?? a.path}: unreadable`);
4788
+ continue;
4789
+ }
4790
+ const roles = rolesFor({ destination: a.destination, source: a.source, template: a.template, path: a.path });
4791
+ const ref = manifest.assets.find((r) => roles.includes(r.role) && r.genre !== "context");
4792
+ if (!ref) {
4793
+ skipped.push(`${a.destination ?? a.path}: no reference of role ${roles.join("|") || "(none)"}`);
4794
+ continue;
4795
+ }
4796
+ const refImg = await loadRef(ref.file);
4797
+ if (!refImg) {
4798
+ skipped.push(`${a.destination ?? a.path}: reference ${ref.file} unreadable`);
4799
+ continue;
4800
+ }
4801
+ const stem = (a.path.split("/").pop() ?? a.path).replace(/\.png$/i, "");
4802
+ const id = stem.replace(/[^A-Za-z0-9_-]+/g, "-") || a.destination || "asset";
4803
+ const nameA = `${id}--A.png`;
4804
+ const nameB = `${id}--B.png`;
4805
+ await writeFile7(join6(out, nameA), encodePng(composeSheet(img, refImg)));
4806
+ await writeFile7(join6(out, nameB), encodePng(composeSheet(refImg, img)));
4807
+ const rubric = [
4808
+ `# ${id} against ${ref.id} (${ref.file})`,
4809
+ "",
4810
+ `Sheet A: the kit asset LEFT (dark band), the reference RIGHT (light band). Sheet B: the reverse.`,
4811
+ `Judge each sheet on its own, both orders, and write the verdict where the two agree; a disagreement is a tie.`,
4812
+ "",
4813
+ `## The reference`,
4814
+ ref.layout,
4815
+ ...ref.facts ? ["", "```json", JSON.stringify(ref.facts, null, 2), "```"] : [],
4816
+ "",
4817
+ `Rule: ${ref.rule}`,
4818
+ "",
4819
+ "## The rubric",
4820
+ ...RUBRIC.map((r, i) => `${i + 1}. ${r}`),
4821
+ "",
4822
+ "## Verdict",
4823
+ "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.",
4824
+ ""
4825
+ ].join("\n");
4826
+ await writeFile7(join6(out, `${id}.md`), rubric);
4827
+ sheets.push({ asset: id, reference: ref.id, sheetA: nameA, sheetB: nameB, rubric: `${id}.md` });
4828
+ }
4829
+ const verdictFile = join6(out, "judge.json");
4830
+ if (!existsSync6(verdictFile)) {
4831
+ await writeFile7(
4832
+ verdictFile,
4833
+ JSON.stringify(
4834
+ {
4835
+ kit: kitPath,
4836
+ manifest: manifestPath,
4837
+ verdicts: sheets.map((s) => ({ asset: s.asset, reference: s.reference, A: null, B: null, win: null, reasons: [] }))
4838
+ },
4839
+ null,
4840
+ 2
4841
+ )
4842
+ );
4843
+ }
4844
+ return { sheets, skipped, outDir: out, verdictFile };
4845
+ }
4846
+ function winRate(verdicts) {
4847
+ const judged = verdicts.filter((v) => v.win !== null).length;
4848
+ const wins = verdicts.filter((v) => v.win === true).length;
4849
+ return { wins, judged, rate: judged ? wins / judged : null };
4850
+ }
4851
+
4852
+ // src/plugin/kitPicture.ts
4853
+ import { execFile } from "child_process";
4854
+ import { promisify } from "util";
4855
+ import { readFile as readFile7 } from "fs/promises";
4856
+ var execFileP = promisify(execFile);
4857
+ var SUBJECT_BAND = { min: 0.6, max: 0.92 };
4858
+ var BLANK_INK = 0.12;
4859
+ var SEPARATION_L = 8;
4860
+ var SHADOW_PRESENT = 6;
4861
+ var EDGE_PRESENT = 40;
4862
+ var DUPLICATE_BITS2 = 6;
4863
+ var HALFSIZE_KEEP = 0.45;
4864
+ function apcaContrast(text, bg) {
4865
+ const lum = (c) => {
4866
+ const ch = (v) => Math.pow(v / 255, 2.4);
4867
+ let y = 0.2126729 * ch(c[0]) + 0.7151522 * ch(c[1]) + 0.072175 * ch(c[2]);
4868
+ if (y < 0.022) y += Math.pow(0.022 - y, 1.414);
4869
+ return y;
4870
+ };
4871
+ const yt = lum(text);
4872
+ const yb = lum(bg);
4873
+ if (Math.abs(yb - yt) < 5e-4) return 0;
4874
+ let sapc;
4875
+ if (yb > yt) {
4876
+ sapc = (Math.pow(yb, 0.56) - Math.pow(yt, 0.57)) * 1.14;
4877
+ return sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
4878
+ }
4879
+ sapc = (Math.pow(yb, 0.65) - Math.pow(yt, 0.62)) * 1.14;
4880
+ return sapc > -0.1 ? 0 : -(sapc + 0.027) * 100;
4881
+ }
4882
+ var pct = (v) => `${Math.round(v * 100)}%`;
4883
+ var hexToRgb = (h) => {
4884
+ const m = /^#([0-9a-f]{6})$/i.exec(h.trim());
4885
+ if (!m) return null;
4886
+ const n = parseInt(m[1], 16);
4887
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
4888
+ };
4889
+ function stillFindings(a, img, m) {
4890
+ const out = [];
4891
+ const genre = a.spec?.genre;
4892
+ const bledAll = m.bleed.length === 4;
4893
+ if (a.shot) {
4894
+ const sx = Math.max(0, a.shot.x) * img.w;
4895
+ const sy = Math.max(0, a.shot.y) * img.h;
4896
+ const sw = Math.min(1, a.shot.x + a.shot.w) * img.w - sx;
4897
+ const sh = Math.min(1, a.shot.y + a.shot.h) * img.h - sy;
4898
+ const rect = { x: sx, y: sy, w: Math.max(1, sw), h: Math.max(1, sh) };
4899
+ const ink = inkCoverage(img, rect);
4900
+ if (ink < BLANK_INK) {
4901
+ out.push({
4902
+ code: "blank",
4903
+ severity: "error",
4904
+ asset: a.destination,
4905
+ message: `${pct(ink)} ink inside the shot: the moment shows a wallpaper, an empty canvas or a flat panel`,
4906
+ fixHint: "pick a moment after a gesture landed (--shot-time, or --times step:<id>) and stage the set before recording",
4907
+ bbox: rect
4908
+ });
4909
+ }
4910
+ if (a.shot.w < 0.5 || a.shot.w > 1.2) {
4911
+ out.push({
4912
+ code: "subject",
4913
+ severity: "error",
4914
+ asset: a.destination,
4915
+ message: `the shot is ${pct(a.shot.w)} of the width; a card sits at 60 to 92%, a bleed a little past the edge`,
4916
+ fixHint: "the template layout places the slot; fix its place for this aspect",
4917
+ bbox: rect
4918
+ });
4919
+ }
4920
+ for (const f of textFindings(a, img)) out.push(f);
4921
+ return out;
4922
+ }
4923
+ const subject = m.card ?? { x: 0, y: 0, w: img.w, h: img.h };
4924
+ if (m.ink < BLANK_INK) {
4925
+ out.push({
4926
+ code: "blank",
4927
+ severity: "error",
4928
+ asset: a.destination,
4929
+ message: `${pct(m.ink)} ink inside the ${m.card ? "card" : "frame"}: a wallpaper, an empty canvas or a flat panel, not the product doing something`,
4930
+ fixHint: "pick a moment after a gesture landed (--times step:<id>) and stage the set before recording: real data, a populated state",
4931
+ bbox: subject
4932
+ });
4933
+ }
4934
+ if (genre === "card") {
4935
+ if (bledAll || m.widthPct === null) {
4936
+ out.push({
4937
+ code: "subject",
4938
+ severity: "error",
4939
+ asset: a.destination,
4940
+ message: bledAll ? "the picture fills the frame on all four sides: a crop, not a card on a ground" : "no card found: the frame is one flat tone",
4941
+ fixHint: "present the card in a look (vos deliver reads BRAND.md or --look) or render this destination from a poster template"
4942
+ });
4943
+ } else if (m.widthPct < SUBJECT_BAND.min || m.widthPct > SUBJECT_BAND.max) {
4944
+ out.push({
4945
+ code: "subject",
4946
+ severity: "error",
4947
+ asset: a.destination,
4948
+ message: `the card is ${pct(m.widthPct)} of the width; the references sit at ${pct(SUBJECT_BAND.min)} to ${pct(SUBJECT_BAND.max)}`,
4949
+ fixHint: "the look places the card at 84% (frame.inset); a poster template sets its own placement",
4950
+ bbox: m.card ?? void 0
4951
+ });
4952
+ }
4953
+ if (m.card && !bledAll) {
4954
+ const sep = m.separation ?? 0;
4955
+ const shadow = m.shadow ?? 0;
4956
+ if (sep < SEPARATION_L && shadow < SHADOW_PRESENT && m.edge < EDGE_PRESENT) {
4957
+ out.push({
4958
+ code: "separation",
4959
+ severity: "error",
4960
+ asset: a.destination,
4961
+ message: `the card and the ground are ${sep.toFixed(1)} L* apart with no shadow halo (${shadow.toFixed(1)}) and no drawn edge (${m.edge.toFixed(0)}): the card dissolves into the plate`,
4962
+ fixHint: "a contact shadow (frame.shadowContact) plus a hairline (frame.border with borderColor) makes a light card sit on a light ground; the plate look sets both",
4963
+ bbox: m.card
4964
+ });
4965
+ }
4966
+ }
4967
+ }
4968
+ if (genre === "screenshot" && a.composed && m.card && (m.widthPct ?? 0) < 0.98) {
4969
+ out.push({
4970
+ code: "subject",
4971
+ severity: "error",
4972
+ asset: a.destination,
4973
+ message: `a store screenshot must be the real page full bleed; this one shows a ${pct(m.widthPct ?? 0)} card on a ground`,
4974
+ fixHint: "drop --composed: the screenshot genre renders the page with no chrome and no padding",
4975
+ bbox: m.card
4976
+ });
4977
+ }
4978
+ if (img.w <= 500 && (a.spec?.text === "none" || genre === "card")) {
4979
+ const full = edgeEnergy(img, Math.min(img.w, 400));
4980
+ const half = edgeEnergy(img, Math.min(img.w, 400) >> 1);
4981
+ const keep = full > 0 ? half / full : 1;
4982
+ if (keep < HALFSIZE_KEEP) {
4983
+ out.push({
4984
+ code: "halfsize",
4985
+ severity: "warning",
4986
+ asset: a.destination,
4987
+ message: `at half size the tile keeps ${pct(keep)} of its edges: fine text and thin lines vanish where the store shows it small`,
4988
+ fixHint: "a tile is the subject large and saturated with no text (the store rule); render it from the card-on-gradient template"
4989
+ });
4990
+ }
4991
+ }
4992
+ for (const f of textFindings(a, img)) out.push(f);
4993
+ return out;
4994
+ }
4995
+ function textFindings(a, img) {
4996
+ const out = [];
4997
+ for (const t of a.text ?? []) {
4998
+ const box = { x: t.x * img.w, y: t.y * img.h, w: t.w * img.w, h: t.h * img.h };
4999
+ const name = t.label ? `"${t.label}"` : "a text box";
5000
+ if (box.x < 0 || box.y < 0 || box.x + box.w > img.w + 0.5 || box.y + box.h > img.h + 0.5) {
5001
+ out.push({
5002
+ code: "sliced",
5003
+ severity: "error",
5004
+ asset: a.destination,
5005
+ message: `${name} crosses the frame edge: cut mid-word`,
5006
+ fixHint: "shorten the line, or let the template recompose (headline lines at 12 to 14% of the height, inside the safe rect)",
5007
+ bbox: box
5008
+ });
5009
+ }
5010
+ const s = a.spec?.safe;
5011
+ if (s) {
5012
+ const sx = s.x * img.w;
5013
+ const sy = s.y * img.h;
5014
+ const sw = s.w * img.w;
5015
+ const sh = s.h * img.h;
5016
+ if (box.x < sx - 0.5 || box.y < sy - 0.5 || box.x + box.w > sx + sw + 0.5 || box.y + box.h > sy + sh + 0.5) {
5017
+ out.push({
5018
+ code: "safe",
5019
+ severity: "warning",
5020
+ asset: a.destination,
5021
+ message: `${name} sits outside the destination's safe rect (${pct(s.w)}x${pct(s.h)} at ${pct(s.x)},${pct(s.y)}): the platform's chrome or crop covers it`,
5022
+ fixHint: "move the text inside the safe rect; the template reads channel-specs safe",
5023
+ bbox: box
5024
+ });
5025
+ }
5026
+ }
5027
+ const rgb2 = t.color ? hexToRgb(t.color) : null;
5028
+ if (rgb2) {
5029
+ const ground = medianColour(img, box);
5030
+ const lc = apcaContrast(rgb2, ground);
5031
+ const floor = t.role === "body" ? 75 : 60;
5032
+ if (lc < floor) {
5033
+ out.push({
5034
+ code: "contrast",
5035
+ severity: "warning",
5036
+ asset: a.destination,
5037
+ message: `${name} reads APCA Lc ${lc.toFixed(0)} on its ground; ${t.role === "body" ? "body" : "a headline"} wants ${floor}`,
5038
+ fixHint: "darken the ink or lighten the ground under the text (BRAND.md ink on bgA/bgB, never on the shot)",
5039
+ bbox: box
5040
+ });
5041
+ }
5042
+ }
5043
+ }
5044
+ if (a.spec?.text === "none" && (a.text?.length ?? 0) > 0) {
5045
+ out.push({
5046
+ code: "safe",
5047
+ severity: "warning",
5048
+ asset: a.destination,
5049
+ message: "this destination wants no text (the picture carries it alone) and the kit put words on it",
5050
+ fixHint: "render the tile from the card-on-gradient template with no headline"
5051
+ });
5052
+ }
5053
+ return out;
5054
+ }
5055
+ function duplicateFindings(stills) {
5056
+ const groups = [];
5057
+ const seen = /* @__PURE__ */ new Set();
5058
+ const comparable = (a, b) => a.genre === "screenshot" || b.genre === "screenshot" ? a.destination === b.destination : true;
5059
+ for (let i = 0; i < stills.length; i++) {
5060
+ if (seen.has(i)) continue;
5061
+ const g = [stills[i]];
5062
+ for (let j = i + 1; j < stills.length; j++) {
5063
+ if (seen.has(j)) continue;
5064
+ if (!comparable(stills[i], stills[j])) continue;
5065
+ if (hammingDistance(stills[i].hash, stills[j].hash) <= DUPLICATE_BITS2) {
5066
+ g.push(stills[j]);
5067
+ seen.add(j);
5068
+ }
5069
+ }
5070
+ if (g.length > 1) groups.push(g);
5071
+ }
5072
+ return groups.map((g) => ({
5073
+ code: "duplicate",
5074
+ severity: g.length >= 3 ? "error" : "warning",
5075
+ asset: g.map((s) => s.destination).join(", "),
5076
+ message: `${g.length} assets share one frame${g[0].time !== null ? ` (${g[0].time.toFixed(2)}s)` : ""}: ${g.map((s) => s.destination).join(", ")}`,
5077
+ 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"
5078
+ }));
5079
+ }
5080
+ async function videoFrame(file, at2, ffmpeg) {
5081
+ try {
5082
+ const { stdout } = await execFileP(
5083
+ ffmpeg,
5084
+ ["-v", "error", "-ss", at2.toFixed(3), "-i", file, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"],
5085
+ { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }
5086
+ );
5087
+ return decodePng(new Uint8Array(stdout));
5088
+ } catch {
5089
+ return null;
5090
+ }
5091
+ }
5092
+ async function onPath(bin) {
5093
+ try {
5094
+ await execFileP(bin, ["-version"]);
5095
+ return true;
5096
+ } catch {
5097
+ return false;
5098
+ }
5099
+ }
5100
+ async function pictureChecks(assets) {
5101
+ const findings = [];
5102
+ const measured = [];
5103
+ const stills = [];
5104
+ let ffmpeg = null;
5105
+ for (const a of assets) {
5106
+ if (/\.png$/i.test(a.file)) {
5107
+ const img = decodePng(new Uint8Array(await readFile7(a.file)));
5108
+ if (!img) {
5109
+ findings.push({
5110
+ code: "unreadable",
5111
+ severity: "info",
5112
+ asset: a.destination,
5113
+ message: `${a.path} is not an 8-bit non-interlaced PNG; the picture checks cannot read it`,
5114
+ fixHint: "render stills through vos deliver or vos frames, which write readable PNGs"
5115
+ });
5116
+ measured.push({ destination: a.destination, measure: null });
5117
+ continue;
5118
+ }
5119
+ const m = measureStill(img);
5120
+ measured.push({ destination: a.destination, measure: m });
5121
+ findings.push(...stillFindings(a, img, m));
5122
+ if (a.spec?.kind !== "video")
5123
+ stills.push({
5124
+ destination: a.destination,
5125
+ hash: m.hash,
5126
+ time: null,
5127
+ genre: a.spec?.genre
5128
+ });
5129
+ continue;
5130
+ }
5131
+ if (/\.(mp4|webm|mov)$/i.test(a.file)) {
5132
+ if (ffmpeg === null) ffmpeg = await onPath("ffmpeg");
5133
+ if (!ffmpeg) {
5134
+ findings.push({
5135
+ code: "firstlast",
5136
+ severity: "info",
5137
+ asset: a.destination,
5138
+ message: "ffmpeg is not on PATH, so the first and last frames were not read",
5139
+ fixHint: "install ffmpeg to have the video checks run"
5140
+ });
5141
+ measured.push({ destination: a.destination, measure: null });
5142
+ continue;
5143
+ }
5144
+ const seconds = a.seconds ?? 0;
5145
+ const first = await videoFrame(a.file, 0, "ffmpeg");
5146
+ const last = await videoFrame(a.file, Math.max(0, seconds - 0.1), "ffmpeg");
5147
+ for (const [name, img] of [
5148
+ ["first", first],
5149
+ ["last", last]
5150
+ ]) {
5151
+ if (!img) continue;
5152
+ const m = measureStill(img);
5153
+ if (name === "first") measured.push({ destination: a.destination, measure: m });
5154
+ const subject = m.card ?? { x: 0, y: 0, w: img.w, h: img.h };
5155
+ const ink = inkCoverage(img, subject);
5156
+ if (ink < BLANK_INK) {
5157
+ findings.push({
5158
+ code: "firstlast",
5159
+ severity: "error",
5160
+ asset: a.destination,
5161
+ message: `the ${name} frame is ${pct(ink)} ink: a ${name === "first" ? "cold open on nothing" : "clip that ends on nothing"}`,
5162
+ fixHint: name === "first" ? "open on the product (trim the head, or an entrance over a populated frame)" : "end on a resolved state or an end card (the last frame is the poster)"
5163
+ });
5164
+ }
5165
+ if (m.bleed.length === 4 && a.spec?.genre !== "screenshot") {
5166
+ findings.push({
5167
+ code: "firstlast",
5168
+ severity: "warning",
5169
+ asset: a.destination,
5170
+ message: `the ${name} frame fills the frame on all four sides: no ground, no room`,
5171
+ fixHint: "present the cut in a look (vos deliver reads BRAND.md or --look)"
5172
+ });
5173
+ }
5174
+ }
5175
+ continue;
5176
+ }
5177
+ }
5178
+ findings.push(...duplicateFindings(stills));
5179
+ const order = { error: 0, warning: 1, info: 2 };
5180
+ findings.sort((a, b) => order[a.severity] - order[b.severity]);
5181
+ return { findings, measured };
5182
+ }
5183
+ function formatFinding(f) {
5184
+ const where = f.bbox ? ` @ ${Math.round(f.bbox.x)},${Math.round(f.bbox.y)} ${Math.round(f.bbox.w)}x${Math.round(f.bbox.h)}` : "";
5185
+ return `${f.severity} ${f.code} ${f.asset}${where}: ${f.message}
5186
+ fix: ${f.fixHint}`;
5187
+ }
5188
+
5189
+ // src/plugin/validateKit.ts
5190
+ import { existsSync as existsSync7 } from "fs";
5191
+ import { readFile as readFile8, stat as stat2 } from "fs/promises";
5192
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join7 } from "path";
5193
+ import { DESTINATIONS as DESTINATIONS3 } from "@vosjs/studio-core";
5194
+ var PNG_SIG2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
3024
5195
  function pngDimensions(bytes) {
3025
- if (bytes.length < 24 || !bytes.subarray(0, 8).equals(PNG_SIG)) return null;
5196
+ if (bytes.length < 24 || !bytes.subarray(0, 8).equals(PNG_SIG2)) return null;
3026
5197
  if (bytes.toString("ascii", 12, 16) !== "IHDR") return null;
3027
5198
  return { w: bytes.readUInt32BE(16), h: bytes.readUInt32BE(20) };
3028
5199
  }
3029
5200
  function sniffImage(bytes) {
3030
- if (bytes.subarray(0, 8).equals(PNG_SIG)) return "png";
5201
+ if (bytes.subarray(0, 8).equals(PNG_SIG2)) return "png";
3031
5202
  if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP")
3032
5203
  return "webp";
3033
5204
  if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216) return "jpeg";
@@ -3037,7 +5208,7 @@ async function probeVideo(path) {
3037
5208
  const MB = await import("mediabunny");
3038
5209
  const input = new MB.Input({
3039
5210
  formats: MB.ALL_FORMATS,
3040
- source: new MB.BufferSource(new Uint8Array(await readFile5(path)))
5211
+ source: new MB.BufferSource(new Uint8Array(await readFile8(path)))
3041
5212
  });
3042
5213
  try {
3043
5214
  const track = await input.getPrimaryVideoTrack();
@@ -3049,16 +5220,16 @@ async function probeVideo(path) {
3049
5220
  }
3050
5221
  }
3051
5222
  var specById = new Map(
3052
- DESTINATIONS2.map((d) => [d.id, d])
5223
+ DESTINATIONS3.map((d) => [d.id, d])
3053
5224
  );
3054
5225
  var near = (a, b, tol) => Math.abs(a - b) <= tol;
3055
- async function validateKit(kitPath) {
5226
+ async function validateKit(kitPath, opts = {}) {
3056
5227
  const problems = [];
3057
5228
  const warnings = [];
3058
5229
  const measured = [];
3059
5230
  let kit;
3060
5231
  try {
3061
- kit = JSON.parse(await readFile5(kitPath, "utf8"));
5232
+ kit = JSON.parse(await readFile8(kitPath, "utf8"));
3062
5233
  } catch (e) {
3063
5234
  return {
3064
5235
  valid: false,
@@ -3075,13 +5246,14 @@ async function validateKit(kitPath) {
3075
5246
  measured
3076
5247
  };
3077
5248
  }
3078
- const base = dirname(kitPath);
5249
+ const base = dirname2(kitPath);
3079
5250
  const resolvePath = (p) => {
3080
- if (isAbsolute(p) && existsSync5(p)) return p;
3081
- const beside = join6(base, p.split("/").pop() ?? p);
5251
+ if (isAbsolute2(p) && existsSync7(p)) return p;
5252
+ const beside = join7(base, p.split("/").pop() ?? p);
3082
5253
  return beside;
3083
5254
  };
3084
5255
  const perDestination = /* @__PURE__ */ new Map();
5256
+ const pictureAssets = [];
3085
5257
  for (const a of kit.assets) {
3086
5258
  const id = a.destination ?? `${a.channel}-${a.asset}`;
3087
5259
  const label = `${a.channel} ${a.asset}`;
@@ -3103,7 +5275,7 @@ async function validateKit(kitPath) {
3103
5275
  let h = null;
3104
5276
  let seconds = null;
3105
5277
  if (/\.(png|jpe?g|webp)$/i.test(file)) {
3106
- const head = Buffer.from(await readFile5(file));
5278
+ const head = Buffer.from(await readFile8(file));
3107
5279
  const kind = sniffImage(head);
3108
5280
  if (file.toLowerCase().endsWith(".png") && kind !== "png")
3109
5281
  problems.push(
@@ -3148,6 +5320,16 @@ async function validateKit(kitPath) {
3148
5320
  );
3149
5321
  }
3150
5322
  measured.push({ destination: id, path: a.path, w, h, bytes, seconds });
5323
+ pictureAssets.push({
5324
+ destination: id,
5325
+ path: a.path,
5326
+ file,
5327
+ spec,
5328
+ text: a.text,
5329
+ seconds,
5330
+ composed: a.composed,
5331
+ shot: a.source === "poster" ? a.shot : void 0
5332
+ });
3151
5333
  if (!spec) {
3152
5334
  if (a.channel !== "demo")
3153
5335
  warnings.push(
@@ -3185,13 +5367,25 @@ async function validateKit(kitPath) {
3185
5367
  `${spec.channel} ${spec.asset}: spec wants ${spec.count.min}-${spec.count.max}, the kit has ${n}`
3186
5368
  );
3187
5369
  }
3188
- return { valid: problems.length === 0, problems, warnings, measured };
5370
+ if (!opts.picture) {
5371
+ return { valid: problems.length === 0, problems, warnings, measured };
5372
+ }
5373
+ const picture = await pictureChecks(pictureAssets);
5374
+ const pictureErrors = picture.findings.filter((f) => f.severity === "error");
5375
+ return {
5376
+ valid: problems.length === 0 && pictureErrors.length === 0,
5377
+ problems,
5378
+ warnings,
5379
+ measured,
5380
+ picture: picture.findings,
5381
+ pictureMeasured: picture.measured
5382
+ };
3189
5383
  }
3190
5384
 
3191
5385
  // src/plugin/platform.ts
3192
5386
  import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
3193
5387
  import { homedir } from "os";
3194
- import { join as join7 } from "path";
5388
+ import { join as join8 } from "path";
3195
5389
  function platformOrigin(flags = {}) {
3196
5390
  const legacyEnv = process.env.VOS_API_BASE?.trim();
3197
5391
  const raw = flags.origin ?? flags.api ?? process.env.VOS_ORIGIN?.trim() ?? legacyEnv ?? "https://vos.so";
@@ -3224,7 +5418,7 @@ function parseVosId(input) {
3224
5418
  function deriveSlug(title) {
3225
5419
  return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50).replace(/-+$/, "") || "remix";
3226
5420
  }
3227
- var CREDENTIALS_PATH = join7(homedir(), ".config", "vos", "credentials");
5421
+ var CREDENTIALS_PATH = join8(homedir(), ".config", "vos", "credentials");
3228
5422
  function resolveCredential(explicit) {
3229
5423
  const flag = explicit?.trim();
3230
5424
  if (flag) return flag;
@@ -3247,7 +5441,7 @@ function requireCredential(explicit) {
3247
5441
  return key;
3248
5442
  }
3249
5443
  function writeCredential(key) {
3250
- mkdirSync(join7(homedir(), ".config", "vos"), { recursive: true, mode: 448 });
5444
+ mkdirSync(join8(homedir(), ".config", "vos"), { recursive: true, mode: 448 });
3251
5445
  writeFileSync(CREDENTIALS_PATH, `${key.trim()}
3252
5446
  `, { mode: 384 });
3253
5447
  return CREDENTIALS_PATH;
@@ -3291,7 +5485,7 @@ function readJsonFile(file) {
3291
5485
  }
3292
5486
  }
3293
5487
  function readSyncState(dir) {
3294
- const own = readJsonFile(join7(dir, SYNC_STATE_NAME));
5488
+ const own = readJsonFile(join8(dir, SYNC_STATE_NAME));
3295
5489
  if (own && typeof own.vosId === "string") {
3296
5490
  return {
3297
5491
  vosId: own.vosId,
@@ -3302,7 +5496,7 @@ function readSyncState(dir) {
3302
5496
  ...typeof own.remixOfId === "string" ? { remixOfId: own.remixOfId } : {}
3303
5497
  };
3304
5498
  }
3305
- const push = readJsonFile(join7(dir, "push.json"));
5499
+ const push = readJsonFile(join8(dir, "push.json"));
3306
5500
  if (push && typeof push.vosId === "string") {
3307
5501
  return {
3308
5502
  vosId: push.vosId,
@@ -3310,7 +5504,7 @@ function readSyncState(dir) {
3310
5504
  ...typeof push.pushedAt === "string" ? { pushedAt: push.pushedAt } : {}
3311
5505
  };
3312
5506
  }
3313
- const meta = readJsonFile(join7(dir, "meta.json"));
5507
+ const meta = readJsonFile(join8(dir, "meta.json"));
3314
5508
  if (meta && typeof meta.id === "string") {
3315
5509
  return {
3316
5510
  vosId: meta.id,
@@ -3328,7 +5522,7 @@ function writeSyncState(dir, patch) {
3328
5522
  ...patch
3329
5523
  };
3330
5524
  writeFileSync(
3331
- join7(dir, SYNC_STATE_NAME),
5525
+ join8(dir, SYNC_STATE_NAME),
3332
5526
  `${JSON.stringify(merged, null, 2)}
3333
5527
  `
3334
5528
  );
@@ -3349,7 +5543,7 @@ function formatChanges(changes) {
3349
5543
 
3350
5544
  // src/plugin/recorder.ts
3351
5545
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
3352
- import { join as join8 } from "path";
5546
+ import { join as join9 } from "path";
3353
5547
 
3354
5548
  // src/plugin/recordingCap.ts
3355
5549
  var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
@@ -3437,7 +5631,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
3437
5631
  cdp.on("Page.screencastFrame", (ev) => {
3438
5632
  const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
3439
5633
  const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
3440
- writeFileSync2(join8(paths.framesDir, file), Buffer.from(ev.data, "base64"));
5634
+ writeFileSync2(join9(paths.framesDir, file), Buffer.from(ev.data, "base64"));
3441
5635
  frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
3442
5636
  cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
3443
5637
  });
@@ -3660,7 +5854,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
3660
5854
  await sleep(200);
3661
5855
  const pageTitle = await page.title().catch(() => "");
3662
5856
  await context.close();
3663
- const firstFrame = frames[0] ? jpegDims(readFileSync3(join8(paths.framesDir, frames[0].file))) : null;
5857
+ const firstFrame = frames[0] ? jpegDims(readFileSync3(join9(paths.framesDir, frames[0].file))) : null;
3664
5858
  const meta = {
3665
5859
  dpr: 1,
3666
5860
  zoom: 1,
@@ -3768,9 +5962,9 @@ async function encodeRecording(browser, takeDir, onProgress) {
3768
5962
  }
3769
5963
 
3770
5964
  // src/plugin/plan.ts
3771
- import { existsSync as existsSync6 } from "fs";
3772
- import { readFile as readFile6 } from "fs/promises";
3773
- import { join as join9 } from "path";
5965
+ import { existsSync as existsSync8 } from "fs";
5966
+ import { readFile as readFile9 } from "fs/promises";
5967
+ import { join as join10 } from "path";
3774
5968
  import {
3775
5969
  DEFAULT_FRAME_STYLE,
3776
5970
  STYLE_FIELDS,
@@ -3963,10 +6157,10 @@ function retimeCut(prev, newSteps, newDurationMs) {
3963
6157
  // src/plugin/plan.ts
3964
6158
  var overlaps = (a, b) => a.in < b.out && b.in < a.out;
3965
6159
  async function readDigestActivity(dir) {
3966
- const file = join9(dir, "digest", "digest.json");
3967
- if (!existsSync6(file)) return null;
6160
+ const file = join10(dir, "digest", "digest.json");
6161
+ if (!existsSync8(file)) return null;
3968
6162
  try {
3969
- const d = JSON.parse(await readFile6(file, "utf8"));
6163
+ const d = JSON.parse(await readFile9(file, "utf8"));
3970
6164
  return Array.isArray(d.activity) && d.activity.every((v) => typeof v === "number") ? d.activity : null;
3971
6165
  } catch {
3972
6166
  return null;
@@ -4079,16 +6273,16 @@ async function planTake(dir, opts = {}) {
4079
6273
 
4080
6274
  // src/plugin/sync.ts
4081
6275
  import { createHash } from "crypto";
4082
- import { existsSync as existsSync8 } from "fs";
4083
- import { readFile as readFile7 } from "fs/promises";
6276
+ import { existsSync as existsSync10 } from "fs";
6277
+ import { readFile as readFile10 } from "fs/promises";
4084
6278
  import { createInterface } from "readline/promises";
4085
- import { basename, join as join12 } from "path";
6279
+ import { basename, join as join13 } from "path";
4086
6280
  import { lowerToComposition as lowerToComposition3, migrateHostedDoc as migrateHostedDoc2 } from "@vosjs/studio-core";
4087
6281
 
4088
6282
  // src/plugin/media.ts
4089
- import { createWriteStream, existsSync as existsSync7 } from "fs";
4090
- import { writeFile as writeFile7 } from "fs/promises";
4091
- import { join as join10 } from "path";
6283
+ import { createWriteStream, existsSync as existsSync9 } from "fs";
6284
+ import { writeFile as writeFile8 } from "fs/promises";
6285
+ import { join as join11 } from "path";
4092
6286
  import { Readable } from "stream";
4093
6287
  import { pipeline } from "stream/promises";
4094
6288
  var MIC_NAME = "mic.webm";
@@ -4109,8 +6303,8 @@ async function pullMedia(ctx, dir, doc, log) {
4109
6303
  const url = doc.source[key];
4110
6304
  const assetId = assetIdOf(url);
4111
6305
  if (!assetId) continue;
4112
- const target = join10(dir, file);
4113
- if (existsSync7(target)) {
6306
+ const target = join11(dir, file);
6307
+ if (existsSync9(target)) {
4114
6308
  result.kept.push(file);
4115
6309
  } else {
4116
6310
  const abs = /^https?:/.test(url) ? url : `${ctx.origin}${url}`;
@@ -4132,17 +6326,17 @@ async function pullMedia(ctx, dir, doc, log) {
4132
6326
  }
4133
6327
  doc.source[key] = file;
4134
6328
  }
4135
- const metaPath = join10(dir, "meta.json");
4136
- if (!existsSync7(metaPath)) await writeJson(metaPath, doc.source.meta, true);
4137
- const cursorPath = join10(dir, "cursor.json");
4138
- if (!existsSync7(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
4139
- await writeFile7(join10(dir, "doc.json"), JSON.stringify(doc, null, 2));
6329
+ const metaPath = join11(dir, "meta.json");
6330
+ if (!existsSync9(metaPath)) await writeJson(metaPath, doc.source.meta, true);
6331
+ const cursorPath = join11(dir, "cursor.json");
6332
+ if (!existsSync9(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
6333
+ await writeFile8(join11(dir, "doc.json"), JSON.stringify(doc, null, 2));
4140
6334
  return result;
4141
6335
  }
4142
6336
 
4143
6337
  // src/plugin/folder.ts
4144
- import { mkdir as mkdir5, writeFile as writeFile8 } from "fs/promises";
4145
- import { join as join11 } from "path";
6338
+ import { mkdir as mkdir6, writeFile as writeFile9 } from "fs/promises";
6339
+ import { join as join12 } from "path";
4146
6340
  import { recipeHints } from "@vosjs/shared/frontmatter";
4147
6341
  import { migrateHostedDoc } from "@vosjs/studio-core";
4148
6342
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "media"]);
@@ -4318,25 +6512,25 @@ async function cmdPull(argv) {
4318
6512
  throw new Error(apiError(`pull folder ${folder.slug}`, res));
4319
6513
  const payload = res.body;
4320
6514
  const out = strFlag(flags, "out") ?? folder.slug;
4321
- await mkdir5(join11(out, "recipes"), { recursive: true });
6515
+ await mkdir6(join12(out, "recipes"), { recursive: true });
4322
6516
  const lines = [];
4323
6517
  const recipes = [];
4324
6518
  for (const rec of payload.recipes) {
4325
- const path = join11(out, "recipes", safeName(rec.filename));
4326
- await writeFile8(path, rec.body ?? "");
6519
+ const path = join12(out, "recipes", safeName(rec.filename));
6520
+ await writeFile9(path, rec.body ?? "");
4327
6521
  recipes.push({ path, line: recipeLine(rec.filename, rec.body ?? "") });
4328
6522
  }
4329
6523
  for (const rec of payload.inheritedRecipes) {
4330
6524
  const from = safeName(rec.folderSlug ?? "inherited");
4331
- await mkdir5(join11(out, "recipes", "_inherited", from), { recursive: true });
4332
- const path = join11(
6525
+ await mkdir6(join12(out, "recipes", "_inherited", from), { recursive: true });
6526
+ const path = join12(
4333
6527
  out,
4334
6528
  "recipes",
4335
6529
  "_inherited",
4336
6530
  from,
4337
6531
  safeName(rec.filename)
4338
6532
  );
4339
- await writeFile8(path, rec.body ?? "");
6533
+ await writeFile9(path, rec.body ?? "");
4340
6534
  recipes.push({
4341
6535
  path,
4342
6536
  line: `${recipeLine(rec.filename, rec.body ?? "")} [inherited from ${rec.folderName ?? from}]`
@@ -4344,15 +6538,15 @@ async function cmdPull(argv) {
4344
6538
  }
4345
6539
  const members = [];
4346
6540
  for (const v of payload.voses) {
4347
- const dir = join11(out, "members", safeName(v.slug || v.title || v.id));
4348
- await mkdir5(dir, { recursive: true });
6541
+ const dir = join12(out, "members", safeName(v.slug || v.title || v.id));
6542
+ await mkdir6(dir, { recursive: true });
4349
6543
  const cfg = await apiJson(origin, `/api/vos/${v.id}/config`, { key });
4350
6544
  if (cfg.status !== 200) {
4351
6545
  lines.push(`skipped ${v.title}: ${apiError("fetch config", cfg)}`);
4352
6546
  continue;
4353
6547
  }
4354
- await writeFile8(
4355
- join11(dir, "config.json"),
6548
+ await writeFile9(
6549
+ join12(dir, "config.json"),
4356
6550
  JSON.stringify(cfg.body.config, null, 2)
4357
6551
  );
4358
6552
  let take = false;
@@ -4363,8 +6557,8 @@ async function cmdPull(argv) {
4363
6557
  { key }
4364
6558
  );
4365
6559
  if (doc.status === 200) {
4366
- await writeFile8(
4367
- join11(dir, "doc.json"),
6560
+ await writeFile9(
6561
+ join12(dir, "doc.json"),
4368
6562
  JSON.stringify(doc.body, null, 2)
4369
6563
  );
4370
6564
  take = true;
@@ -4481,7 +6675,7 @@ async function pushTake(dir, flags, r) {
4481
6675
  throw new Error(`doc.json fails lint:
4482
6676
  ${lint.problems.join("\n ")}`);
4483
6677
  }
4484
- if (!existsSync8(take.paths.recording)) {
6678
+ if (!existsSync10(take.paths.recording)) {
4485
6679
  throw new Error("no recording.webm in this take");
4486
6680
  }
4487
6681
  const ctx = apiContext(flags);
@@ -4513,7 +6707,7 @@ async function pushTake(dir, flags, r) {
4513
6707
  throw new Error("push cancelled");
4514
6708
  }
4515
6709
  }
4516
- const bytes = await readFile7(take.paths.recording);
6710
+ const bytes = await readFile10(take.paths.recording);
4517
6711
  const hash = createHash("sha256").update(bytes).digest("hex");
4518
6712
  r.log(`uploading recording (${Math.round(bytes.length / 1024)} kB)\u2026`);
4519
6713
  const upload = await api(ctx, "/assets/recording", {
@@ -4664,9 +6858,9 @@ async function pullTake(dir, flags, r) {
4664
6858
  };
4665
6859
  }
4666
6860
  let media2;
4667
- if (flags.media && existsSync8(join12(dir, "doc.json"))) {
6861
+ if (flags.media && existsSync10(join13(dir, "doc.json"))) {
4668
6862
  const local = JSON.parse(
4669
- await readFile7(join12(dir, "doc.json"), "utf8")
6863
+ await readFile10(join13(dir, "doc.json"), "utf8")
4670
6864
  );
4671
6865
  media2 = await pullMedia(ctx, dir, local, r.log);
4672
6866
  }
@@ -4726,10 +6920,10 @@ async function pullTake(dir, flags, r) {
4726
6920
  }
4727
6921
  const hostedDoc = migrateHostedDoc2(docRes.json);
4728
6922
  const doc = hostedDoc;
4729
- if (existsSync8(join12(dir, RECORDING_NAME))) {
6923
+ if (existsSync10(join13(dir, RECORDING_NAME))) {
4730
6924
  doc.source.videoKey = RECORDING_NAME;
4731
6925
  }
4732
- await writeJson(join12(dir, "doc.json"), doc, true);
6926
+ await writeJson(join13(dir, "doc.json"), doc, true);
4733
6927
  const media = flags.media ? await pullMedia(ctx, dir, doc, r.log) : void 0;
4734
6928
  const versions = await api(ctx, `/vos/${vosId}/versions`);
4735
6929
  const headRow = (versions.json.versions ?? []).find((v) => v.id === head);
@@ -4748,10 +6942,10 @@ async function pullTake(dir, flags, r) {
4748
6942
  }
4749
6943
 
4750
6944
  // src/plugin/program.ts
4751
- import { existsSync as existsSync9 } from "fs";
4752
- import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile9 } from "fs/promises";
6945
+ import { existsSync as existsSync11 } from "fs";
6946
+ import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile10 } from "fs/promises";
4753
6947
  import { createInterface as createInterface2 } from "readline/promises";
4754
- import { basename as basename2, dirname as dirname2, join as join13 } from "path";
6948
+ import { basename as basename2, dirname as dirname3, join as join14 } from "path";
4755
6949
  import {
4756
6950
  CURRENT_CONFIG_VERSION,
4757
6951
  migrateConfig,
@@ -4921,8 +7115,8 @@ function preflightConfig(parsed) {
4921
7115
  }
4922
7116
  function resolveConfigPath(target) {
4923
7117
  if (target.endsWith(".json")) return target;
4924
- const inDir = join13(target, "config.json");
4925
- if (existsSync9(inDir)) return inDir;
7118
+ const inDir = join14(target, "config.json");
7119
+ if (existsSync11(inDir)) return inDir;
4926
7120
  throw new UsageError(
4927
7121
  `${target} has no config.json (and is not a take \u2014 no doc.json)`
4928
7122
  );
@@ -4948,9 +7142,9 @@ async function cmdFetch(argv) {
4948
7142
  throw new Error(apiError(`fetch config for ${vosId}`, cfg));
4949
7143
  const slug = typeof vosMeta.slug === "string" && vosMeta.slug ? vosMeta.slug : vosId;
4950
7144
  const out = strFlag(flags, "out") ?? slug;
4951
- await mkdir6(out, { recursive: true });
4952
- await writeFile9(
4953
- join13(out, "config.json"),
7145
+ await mkdir7(out, { recursive: true });
7146
+ await writeFile10(
7147
+ join14(out, "config.json"),
4954
7148
  JSON.stringify(cfg.body.config, null, 2)
4955
7149
  );
4956
7150
  const title = typeof vosMeta.title === "string" ? vosMeta.title : "";
@@ -4975,12 +7169,12 @@ async function cmdFetch(argv) {
4975
7169
  const hosted = migrateHostedDoc3(doc.body);
4976
7170
  const { config: own } = await writeProgramDoc(out, hosted);
4977
7171
  if (own) {
4978
- await writeFile9(join13(out, "config.json"), JSON.stringify(own, null, 2));
7172
+ await writeFile10(join14(out, "config.json"), JSON.stringify(own, null, 2));
4979
7173
  }
4980
7174
  } else if (doc.status === 200) {
4981
7175
  take = true;
4982
7176
  const hosted = migrateHostedDoc3(doc.body);
4983
- await writeFile9(join13(out, "doc.json"), JSON.stringify(hosted, null, 2));
7177
+ await writeFile10(join14(out, "doc.json"), JSON.stringify(hosted, null, 2));
4984
7178
  if (flags.media === true) {
4985
7179
  const media = await pullMedia({ origin, key }, out, hosted, r.log);
4986
7180
  mediaLine = media.downloaded.length ? ` + ${media.downloaded.map((m) => m.file).join(", ")}` : "";
@@ -5091,8 +7285,8 @@ async function cmdPushProgram(argv) {
5091
7285
  origin: strFlag(flags, "origin"),
5092
7286
  api: strFlag(flags, "api")
5093
7287
  });
5094
- const dir = dirname2(source);
5095
- const parsed = JSON.parse(await readFile8(source, "utf8"));
7288
+ const dir = dirname3(source);
7289
+ const parsed = JSON.parse(await readFile11(source, "utf8"));
5096
7290
  const pre = preflightConfig(parsed);
5097
7291
  if (!pre.ok || !pre.config) {
5098
7292
  for (const issue of pre.issues) r.log(`error ${issue}`);
@@ -5261,7 +7455,7 @@ Iterate with: vos push ${source} --vos ${created.id}`
5261
7455
  async function cmdPullProgram(argv) {
5262
7456
  const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS2);
5263
7457
  const target = positionals[0] ?? ".";
5264
- const dir = target.endsWith(".json") ? dirname2(target) : target;
7458
+ const dir = target.endsWith(".json") ? dirname3(target) : target;
5265
7459
  const r = createReporter(flags.json === true);
5266
7460
  const origin = platformOrigin({
5267
7461
  origin: strFlag(flags, "origin"),
@@ -5325,10 +7519,10 @@ async function cmdPullProgram(argv) {
5325
7519
  const cfg = await apiJson(origin, `/api/vos/${vosId}/config`, { key });
5326
7520
  if (cfg.status !== 200)
5327
7521
  throw new Error(apiError(`fetch head config for ${vosId}`, cfg));
5328
- const configPath = join13(dir, "config.json");
7522
+ const configPath = join14(dir, "config.json");
5329
7523
  let backedUp = false;
5330
- if (existsSync9(configPath)) {
5331
- await writeFile9(join13(dir, "config.backup.json"), await readFile8(configPath));
7524
+ if (existsSync11(configPath)) {
7525
+ await writeFile10(join14(dir, "config.backup.json"), await readFile11(configPath));
5332
7526
  backedUp = true;
5333
7527
  }
5334
7528
  let headConfig = cfg.body.config;
@@ -5347,7 +7541,7 @@ async function cmdPullProgram(argv) {
5347
7541
  if (own) headConfig = own;
5348
7542
  }
5349
7543
  }
5350
- await writeFile9(configPath, JSON.stringify(headConfig, null, 2));
7544
+ await writeFile10(configPath, JSON.stringify(headConfig, null, 2));
5351
7545
  writeSyncState(dir, {
5352
7546
  vosId,
5353
7547
  versionId: typeof head.id === "string" ? head.id : since
@@ -5361,7 +7555,7 @@ async function cmdPullProgram(argv) {
5361
7555
  protected: protectedIds,
5362
7556
  changes,
5363
7557
  out: configPath,
5364
- backup: backedUp ? join13(dir, "config.backup.json") : null
7558
+ backup: backedUp ? join14(dir, "config.backup.json") : null
5365
7559
  },
5366
7560
  `Pulled ${changes.length} version${changes.length === 1 ? "" : "s"} \u2192 ${configPath}` + (backedUp ? ` (previous copy: config.backup.json)` : "") + `
5367
7561
  Re-apply your edit on the new head, then: vos push ${configPath} --vos ${vosId}`
@@ -5418,9 +7612,9 @@ function isTakeDir(target) {
5418
7612
  return directoryKind(target) === "take";
5419
7613
  }
5420
7614
  async function readProgramDoc(dir, config) {
5421
- const docPath = join13(dir, "doc.json");
5422
- if (!existsSync9(docPath)) return null;
5423
- const parsed = JSON.parse(await readFile8(docPath, "utf8"));
7615
+ const docPath = join14(dir, "doc.json");
7616
+ if (!existsSync11(docPath)) return null;
7617
+ const parsed = JSON.parse(await readFile11(docPath, "utf8"));
5424
7618
  if (typeof parsed !== "object" || parsed === null || "source" in parsed)
5425
7619
  return null;
5426
7620
  const raw = parsed;
@@ -5431,7 +7625,7 @@ async function writeProgramDoc(dir, hosted) {
5431
7625
  const program = hosted.program && typeof hosted.program === "object" ? hosted.program : {};
5432
7626
  const { config, ...rest } = program;
5433
7627
  const onDisk = { ...hosted, program: rest };
5434
- await writeFile9(join13(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
7628
+ await writeFile10(join14(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
5435
7629
  return {
5436
7630
  config: config && typeof config === "object" ? config : null
5437
7631
  };
@@ -5638,9 +7832,10 @@ async function cmdRecipe(argv) {
5638
7832
  }
5639
7833
 
5640
7834
  // src/plugin/brand.ts
5641
- import { writeFile as writeFile10 } from "fs/promises";
5642
- import { resolve as resolve4 } from "path";
5643
- import { parseFrontmatter } from "@vosjs/shared/frontmatter";
7835
+ import { writeFile as writeFile11 } from "fs/promises";
7836
+ import { resolve as resolve5 } from "path";
7837
+ import { parseFrontmatter as parseFrontmatter2 } from "@vosjs/shared/frontmatter";
7838
+ import { lookKindForGround } from "@vosjs/studio-core";
5644
7839
  var HEX_RE = /#(?:[0-9a-f]{6}|[0-9a-f]{3})\b/gi;
5645
7840
  function extractHexes(text) {
5646
7841
  const out = [];
@@ -5661,13 +7856,13 @@ function rgbToHex(raw) {
5661
7856
  if (!m) return normalizeHex(raw);
5662
7857
  const alpha = m[4];
5663
7858
  if (alpha !== void 0 && Number(alpha) === 0) return null;
5664
- const hex = (n) => Number(n).toString(16).padStart(2, "0");
5665
- return `#${hex(m[1])}${hex(m[2])}${hex(m[3])}`;
7859
+ const hex2 = (n) => Number(n).toString(16).padStart(2, "0");
7860
+ return `#${hex2(m[1])}${hex2(m[2])}${hex2(m[3])}`;
5666
7861
  }
5667
- function hsl(hex) {
5668
- const r = parseInt(hex.slice(1, 3), 16) / 255;
5669
- const g = parseInt(hex.slice(3, 5), 16) / 255;
5670
- const b = parseInt(hex.slice(5, 7), 16) / 255;
7862
+ function hsl(hex2) {
7863
+ const r = parseInt(hex2.slice(1, 3), 16) / 255;
7864
+ const g = parseInt(hex2.slice(3, 5), 16) / 255;
7865
+ const b = parseInt(hex2.slice(5, 7), 16) / 255;
5671
7866
  const max = Math.max(r, g, b);
5672
7867
  const min = Math.min(r, g, b);
5673
7868
  const l = (max + min) / 2;
@@ -5682,12 +7877,12 @@ function hsl(hex) {
5682
7877
  }
5683
7878
  return { h, s, l };
5684
7879
  }
5685
- function isSaturated(hex) {
5686
- const { s, l } = hsl(hex);
7880
+ function isSaturated(hex2) {
7881
+ const { s, l } = hsl(hex2);
5687
7882
  return s >= 0.25 && l > 0.12 && l < 0.88;
5688
7883
  }
5689
- var isLight = (hex) => hsl(hex).l >= 0.5;
5690
- function mixHex(a, b, t) {
7884
+ var isLight = (hex2) => hsl(hex2).l >= 0.5;
7885
+ function mixHex2(a, b, t) {
5691
7886
  const ch = (i) => Math.round(
5692
7887
  parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t
5693
7888
  ).toString(16).padStart(2, "0");
@@ -5713,7 +7908,7 @@ var FONT_HINT_RE = /\b(?:[Uu]se|[Uu]ses|[Ss]et in)\s+([A-Z][A-Za-z0-9]+(?:\s+[A-
5713
7908
  var FONT_FAMILY_RE = /font-family\s*:\s*["']?([^;"',\n]+)/gi;
5714
7909
  var LOGO_URL_RE = /https?:\/\/[^\s)"'<>]+?(?:logo|wordmark|mark|brand|icon)[^\s)"'<>]*?\.(?:svg|png|webp)/gi;
5715
7910
  function parseDesignMd(text) {
5716
- const fm = parseFrontmatter(text);
7911
+ const fm = parseFrontmatter2(text);
5717
7912
  const fonts = [];
5718
7913
  for (const m of text.matchAll(FONT_HINT_RE)) {
5719
7914
  const f = m[1].trim();
@@ -5824,7 +8019,7 @@ function composeBrand(input) {
5824
8019
  p.bgA = `the body's background`;
5825
8020
  const surfaceHexes = w.surfaces.map(rgbToHex).filter((h) => h !== null && h !== bgA);
5826
8021
  const neutralSurface = mostCommon(surfaceHexes.filter((h) => !isSaturated(h)));
5827
- const bgB = neutralSurface ?? mixHex(bgA, isLight(bgA) ? "#000000" : "#ffffff", 0.04);
8022
+ const bgB = neutralSurface ?? mixHex2(bgA, isLight(bgA) ? "#000000" : "#ffffff", 0.04);
5828
8023
  p.bgB = neutralSurface ? `the most common section / card ground` : `no distinct surface found; bgA stepped 4% toward ${isLight(bgA) ? "black" : "white"}`;
5829
8024
  const accentHexes = w.accents.map(rgbToHex).filter((h) => h !== null && isSaturated(h));
5830
8025
  const themeHex = w.themeColor ? rgbToHex(w.themeColor) : null;
@@ -5834,7 +8029,7 @@ function composeBrand(input) {
5834
8029
  const inkHex = rgbToHex(w.h1?.color ?? w.body.color) ?? (isLight(bgA) ? "#111111" : "#f5f5f5");
5835
8030
  if (!accent) accent = inkHex;
5836
8031
  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`;
5837
- const bgC = mixHex(bgA, accent, 0.14);
8032
+ const bgC = mixHex2(bgA, accent, 0.14);
5838
8033
  p.bgC = `bgA tinted 14% toward the accent (a highlight ground)`;
5839
8034
  p.ink = w.h1 ? `the h1's colour` : `the body's colour`;
5840
8035
  const fontDisplay = design?.fonts[0] ?? (w.h1 ? firstFamily(w.h1.fontFamily) : firstFamily(w.body.fontFamily));
@@ -5865,8 +8060,10 @@ function composeBrand(input) {
5865
8060
  ogImage: w.ogImage,
5866
8061
  wordmark,
5867
8062
  designMd: input.designUrl,
5868
- llmsTxt: input.llmsUrl
8063
+ llmsTxt: input.llmsUrl,
8064
+ look: lookKindForGround(bgA)
5869
8065
  };
8066
+ p.look = `from the body's ground (${bgA}): a paper site is a plate, a dark site is dark, else the gradient`;
5870
8067
  return { kit, provenance: p, avoid: design?.avoid ?? [] };
5871
8068
  }
5872
8069
  function renderBrandMd(c, claim) {
@@ -5893,6 +8090,7 @@ function renderBrandMd(c, claim) {
5893
8090
  `wordmark: ${q(k.wordmark)}`,
5894
8091
  `designMd: ${q(k.designMd)}`,
5895
8092
  `llmsTxt: ${q(k.llmsTxt)}`,
8093
+ `look: ${q(k.look)}`,
5896
8094
  "---"
5897
8095
  ].join("\n");
5898
8096
  const lines = [
@@ -5943,7 +8141,7 @@ async function cmdBrand(argv) {
5943
8141
  throw new UsageError(`not a URL: ${raw}`);
5944
8142
  }
5945
8143
  const r = createReporter(flags.json === true);
5946
- const out = resolve4(strFlag(flags, "out") ?? "BRAND.md");
8144
+ const out = resolve5(strFlag(flags, "out") ?? "BRAND.md");
5947
8145
  r.log(`reading ${origin}/design.md and /llms.txt\u2026`);
5948
8146
  const [designText, llmsText] = await Promise.all([
5949
8147
  fetchText(`${origin}/design.md`),
@@ -5977,7 +8175,7 @@ async function cmdBrand(argv) {
5977
8175
  composition,
5978
8176
  llms?.claim ?? design?.description ?? null
5979
8177
  );
5980
- await writeFile10(out, md);
8178
+ await writeFile11(out, md);
5981
8179
  const k = composition.kit;
5982
8180
  r.done(
5983
8181
  {
@@ -5995,8 +8193,8 @@ async function cmdBrand(argv) {
5995
8193
  }
5996
8194
 
5997
8195
  // src/plugin/agentBrowser.ts
5998
- import { writeFile as writeFile11 } from "fs/promises";
5999
- import { resolve as resolve5 } from "path";
8196
+ import { writeFile as writeFile12 } from "fs/promises";
8197
+ import { resolve as resolve6 } from "path";
6000
8198
  function parseAgentBrowserLog(text) {
6001
8199
  const records = [];
6002
8200
  const problems = [];
@@ -6466,7 +8664,7 @@ async function cmdActions(argv) {
6466
8664
  );
6467
8665
  }
6468
8666
  const r = createReporter(flags.json === true);
6469
- const out = resolve5(strFlag(flags, "out") ?? "actions.json");
8667
+ const out = resolve6(strFlag(flags, "out") ?? "actions.json");
6470
8668
  const viewportFlag = strFlag(flags, "viewport");
6471
8669
  let viewport;
6472
8670
  if (viewportFlag) {
@@ -6474,8 +8672,8 @@ async function cmdActions(argv) {
6474
8672
  if (!m) throw new UsageError("--viewport expects WxH, e.g. 1280x720");
6475
8673
  viewport = { width: Number(m[1]), height: Number(m[2]) };
6476
8674
  }
6477
- const { readFile: readFile10 } = await import("fs/promises");
6478
- const text = await readFile10(resolve5(input), "utf8");
8675
+ const { readFile: readFile13 } = await import("fs/promises");
8676
+ const text = await readFile13(resolve6(input), "utf8");
6479
8677
  const { records, problems } = parseAgentBrowserLog(text);
6480
8678
  for (const p of problems) r.log(` ${p}`);
6481
8679
  const result = convertAgentBrowser(records, {
@@ -6492,7 +8690,7 @@ async function cmdActions(argv) {
6492
8690
  );
6493
8691
  return EXIT_ERROR;
6494
8692
  }
6495
- await writeFile11(out, `${JSON.stringify(result.actions, null, 2)}
8693
+ await writeFile12(out, `${JSON.stringify(result.actions, null, 2)}
6496
8694
  `);
6497
8695
  r.done(
6498
8696
  { ok: true, out, url: result.actions.url ?? null, ...summary(result) },
@@ -6515,6 +8713,7 @@ function summary(result) {
6515
8713
  var BOOLEAN_FLAGS5 = /* @__PURE__ */ new Set([
6516
8714
  "json",
6517
8715
  "help",
8716
+ "picture",
6518
8717
  "fresh",
6519
8718
  "reuse",
6520
8719
  "strict",
@@ -6537,11 +8736,12 @@ Take pipeline
6537
8736
  vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--background <slug|url|none>] [--json]
6538
8737
  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]
6539
8738
  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]
6540
- vos deliver <take> --to cws,producthunt,x,linkedin,og,github,youtube (or all) [--poster <config.json|vosId>] [--shot-time <t>] [--poster-time <t>] [--composed] [--set path=value] [--release v2.1] [--out dir] [--times a,b] [--range a..b] [--parallel N] [--json]
8739
+ 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]
6541
8740
  vos digest <take> [--out dir] [--full 960] [--crop 640] [--no-frames] [--transcript <file.json>] [--style <doc.json|vosId>] [--json]
6542
8741
  vos brand <url> [--out BRAND.md] [--json]
6543
8742
  vos open <take> [--studio <url>] [--print]
6544
- vos validate <actions.json|take> [--json]
8743
+ vos validate <actions.json|take|kit.json> [--picture] [--json]
8744
+ vos judge <kit.json> --against <MANIFEST.json> [--out dir] [--json]
6545
8745
  vos actions from-agent-browser <steps.jsonl> [--out actions.json] [--url <url>] [--viewport WxH] [--json]
6546
8746
 
6547
8747
  Platform (vos.so) \u2014 fetch, edit, push, pull, repeat
@@ -6629,13 +8829,47 @@ channel specs (schema/channel-specs.json, verified sizes for the Chrome
6629
8829
  Web Store, Product Hunt, X, LinkedIn, OG, GitHub, YouTube) drive stills at
6630
8830
  exact pixels and video cuts, every artifact is VERIFIED against its spec
6631
8831
  (px, bytes, duration; misses land in skipped[] with the reason), and
6632
- kit.json beside the assets is the manifest. Still times default to the
6633
- zoom apexes (--times overrides); --range cuts every video destination.
6634
- --poster <config.json|vosId> is the CARD half: card-genre destinations
6635
- (OG, LinkedIn, X, YouTube thumbnail, the CWS tile + marquee, GitHub
6636
- social preview) render from your poster PROGRAM \u2014 the split-cover family
6637
- \u2014 with this release's shot baked into its image element (id "shot"), PNG
6638
- at exact pixels. --shot-time <t> picks the take moment (OUTPUT seconds;
8832
+ kit.json beside the assets is the manifest. Still times come from the
8833
+ STORY: every step's end plus a 0.4 s settle (the response, not the
8834
+ travel), then the zoom apexes, then an even spread; each candidate is
8835
+ read once as the real page, blank ones (a wallpaper, an empty canvas) are
8836
+ dropped and two of one frame collapse to one, with every drop said in
8837
+ skipped[]. --times overrides with seconds, percents or step:<id>[+offset]
8838
+ (the id from actions.json); --range cuts every video destination.
8839
+ The LOOK presents the card: card-genre stills with no poster and every
8840
+ video cut sit on a ground (a cream plate, the house gradient, a dark plate
8841
+ with a light streak) at ~84% of the width with headroom, a soft ambient
8842
+ shadow plus a tight contact shadow, and a hairline when card and ground
8843
+ are both light; a wide frame runs the card off the bottom. --look picks a
8844
+ house look (or none for the pre-look crops); with no flag the BRAND.md
8845
+ beside the take (or --brand <file>) decides from its look role or its own
8846
+ ground (a paper site is a plate, a dark site is dark), and with no brand
8847
+ the house gradient. Screenshot-genre stills never take a look.
8848
+ Card-genre destinations (OG, LinkedIn, X, YouTube thumbnail, the CWS
8849
+ tile + marquee, GitHub social preview) COMPOSE by default: each renders
8850
+ from its destination's poster TEMPLATE (split-cover carries a headline
8851
+ column beside the shot; card-on-gradient is the shot alone on a mesh, the
8852
+ store's tile rule), filled with BRAND.md's colours and faces and the
8853
+ release's words, the shot baked as an object (padded, rounded, shadowed,
8854
+ a hairline on a light ground), PNG at exact pixels; kit.json records the
8855
+ template and the text boxes. The headline is LAUNCH.md's headline role
8856
+ beside the take or --headline (with none, the headline templates stand
8857
+ down for card-on-gradient, said); --kicker overrides the wordmark plus
8858
+ --release line. --poster names a bundled template for every card, your
8859
+ own template (a config.json or a hosted vos id carrying a template
8860
+ block), or none to keep the take path.
8861
+ Every video cut but the README loop opens on an ENTRANCE (tilt-in by
8862
+ default: the card swings in from a perspective pose and settles) and
8863
+ closes on an END CARD (the last frame holds 2.5 s while the card recedes
8864
+ and the headline, the release line and the wordmark rise); LAUNCH.md's
8865
+ entrance and endCard roles, or --entrance and --end-card, change or
8866
+ switch them off. A step's caption in actions.json lands as a lower-third
8867
+ at the step's moment on cuts that take words (--captions none to skip).
8868
+ Destinations that play sound (the X cut, the YouTube demo, the vertical
8869
+ cut) take a music bed from LAUNCH.md's music role (a catalog slug or a
8870
+ mood; --music overrides) and a click sound on every press when the take
8871
+ has no mic (--clicks none). The 9:16 cut is a reframe, not a letterbox:
8872
+ the crop follows the camera. --shot-time <t> picks the take moment (OUTPUT seconds;
6639
8873
  default the first still time \u2014 pick a zoom apex, the cut's camera makes
6640
8874
  the shot the feature, not the whole page); --poster-time <t> is the instant
6641
8875
  inside the poster's OWN timeline (default 90% through it). Screenshot-genre
@@ -6645,7 +8879,26 @@ policy); --composed keeps the cut's camera and chrome instead. --set
6645
8879
  path=value overrides the doc in memory for every render here (the user's
6646
8880
  sets apply last). Store uploads stay manual: hand the human the kit
6647
8881
  directory, then vos validate <kit.json> re-measures every asset from its
6648
- bytes against the channel specs.
8882
+ bytes against the channel specs; --picture adds what each asset LOOKS
8883
+ like, read from its pixels: blank (a wallpaper or an empty canvas where
8884
+ the product should be), duplicate (two stills of one frame), subject (the
8885
+ card off the 60 to 92% band, or a crop where a card was asked for),
8886
+ separation (a light card on a light ground with no shadow), halfsize (a
8887
+ tile that loses its edges when the store shrinks it), and, where the kit
8888
+ records its text boxes, sliced, safe and contrast (APCA Lc 60/75); a
8889
+ video's first and last frames are read through ffmpeg. Every finding
8890
+ carries a code, a severity, a fix hint and a box; an error fails the
8891
+ verdict beside the spec problems.
8892
+ judge puts the kit beside its REFERENCES: for every still that has a
8893
+ reference of its role in the manifest (a template family, a card genre),
8894
+ two sheets at a common height (the asset left, then right, so order
8895
+ cannot bias the call) and the rubric in words, plus judge.json, a slot
8896
+ per pair the judge fills (win true, false or null for a tie, with the
8897
+ rule numbers). No model runs inside the verb: the skill judges the
8898
+ sheets pairwise, both orders, and the verb reports the win rate beside
8899
+ the spec and picture counts. The reference set is the maker's own
8900
+ fixture folder with a MANIFEST.json (id, file, role, layout, facts,
8901
+ rule per asset).
6649
8902
  brand writes the product's BRAND.md, witnessed: it reads /design.md when
6650
8903
  the site publishes one (the convention beside /llms.txt: fonts, logo assets,
6651
8904
  an avoid list), /llms.txt for the name and the claim, then the page itself
@@ -6707,7 +8960,7 @@ lint-gated, so a bad override fails like a bad doc.json):
6707
8960
  first ready loop (the house backdrop the studio opens on), or a flat ground offline
6708
8961
  `;
6709
8962
  async function loadActions(file) {
6710
- const raw = JSON.parse(await readFile9(file, "utf8"));
8963
+ const raw = JSON.parse(await readFile12(file, "utf8"));
6711
8964
  const errors = validateActions(raw);
6712
8965
  if (errors.length)
6713
8966
  throw new UsageError(`invalid actions file:
@@ -6763,16 +9016,16 @@ async function cmdRecord(argv) {
6763
9016
  const url = strFlag(flags, "url") ?? actions.url;
6764
9017
  if (!url)
6765
9018
  throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
6766
- const outDir = resolve6(strFlag(flags, "out") ?? "take");
9019
+ const outDir = resolve7(strFlag(flags, "out") ?? "take");
6767
9020
  const backdrop = await takeBackdrop(flags, r);
6768
9021
  const maxDurationSeconds = await maxDuration(flags, r);
6769
- if (existsSync10(join14(outDir, "meta.json"))) {
9022
+ if (existsSync12(join15(outDir, "meta.json"))) {
6770
9023
  const { prevDoc, kept } = await prepareReRecord(outDir);
6771
9024
  r.log(
6772
9025
  `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" : "")
6773
9026
  );
6774
9027
  }
6775
- await mkdir7(outDir, { recursive: true });
9028
+ await mkdir8(outDir, { recursive: true });
6776
9029
  const paths = await ensureTakeDir(outDir);
6777
9030
  const browser = await launchBrowser();
6778
9031
  try {
@@ -6813,7 +9066,7 @@ async function cmdRecord(argv) {
6813
9066
  },
6814
9067
  strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
6815
9068
  ${(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}
6816
- Next: edit ${join14(outDir, "doc.json")} (optional), then: vos render ${outDir}`
9069
+ Next: edit ${join15(outDir, "doc.json")} (optional), then: vos render ${outDir}`
6817
9070
  );
6818
9071
  return strictFail ? EXIT_USAGE : EXIT_OK;
6819
9072
  } finally {
@@ -6837,25 +9090,25 @@ async function cmdCreate2(argv) {
6837
9090
  const url = strFlag(flags, "url") ?? actions.url;
6838
9091
  if (!url)
6839
9092
  throw new UsageError('no URL \u2014 set "url" in the actions file or pass --url');
6840
- const outDir = resolve6(strFlag(flags, "out") ?? "take");
9093
+ const outDir = resolve7(strFlag(flags, "out") ?? "take");
6841
9094
  const fmtRaw = strFlag(flags, "format") ?? "webm";
6842
9095
  if (fmtRaw !== "webm" && fmtRaw !== "mp4")
6843
9096
  throw new UsageError("--format must be webm or mp4");
6844
9097
  const format = fmtRaw;
6845
- const out = positionals[0] ?? join14(outDir, `out.${format}`);
9098
+ const out = positionals[0] ?? join15(outDir, `out.${format}`);
6846
9099
  const parallel = numFlag(flags, "parallel", 1);
6847
9100
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
6848
9101
  throw new UsageError("--parallel expects an integer between 1 and 16");
6849
9102
  }
6850
9103
  const backdrop = await takeBackdrop(flags, r);
6851
9104
  const maxDurationSeconds = await maxDuration(flags, r);
6852
- if (existsSync10(join14(outDir, "meta.json"))) {
9105
+ if (existsSync12(join15(outDir, "meta.json"))) {
6853
9106
  const { prevDoc, kept } = await prepareReRecord(outDir);
6854
9107
  r.log(
6855
9108
  `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" : "")
6856
9109
  );
6857
9110
  }
6858
- await mkdir7(outDir, { recursive: true });
9111
+ await mkdir8(outDir, { recursive: true });
6859
9112
  const paths = await ensureTakeDir(outDir);
6860
9113
  const browser = await launchBrowser();
6861
9114
  try {
@@ -6936,24 +9189,24 @@ async function cmdPlan(argv) {
6936
9189
  );
6937
9190
  const r = createReporter(flags.json === true);
6938
9191
  if (flags.fresh === true) {
6939
- await rm4(join14(dir, "doc.json"), { force: true });
9192
+ await rm4(join15(dir, "doc.json"), { force: true });
6940
9193
  r.log("note: --fresh discarded the existing doc.json");
6941
9194
  }
6942
9195
  let reuse;
6943
9196
  if (flags.reuse === true) {
6944
- const from = resolve6(strFlag(flags, "from") ?? join14(dir, PREV_DOC_NAME));
6945
- if (!existsSync10(from)) {
9197
+ const from = resolve7(strFlag(flags, "from") ?? join15(dir, PREV_DOC_NAME));
9198
+ if (!existsSync12(from)) {
6946
9199
  throw new UsageError(
6947
9200
  `--reuse: ${from} does not exist \u2014 a re-record into this take writes doc.prev.json, or pass --from <doc.json>`
6948
9201
  );
6949
9202
  }
6950
9203
  reuse = {
6951
9204
  from,
6952
- doc: JSON.parse(await readFile9(from, "utf8"))
9205
+ doc: JSON.parse(await readFile12(from, "utf8"))
6953
9206
  };
6954
9207
  }
6955
9208
  const style = await resolveStyleRef(flags);
6956
- const backdrop = existsSync10(join14(dir, "doc.json")) ? null : await takeBackdrop(flags, r);
9209
+ const backdrop = existsSync12(join15(dir, "doc.json")) ? null : await takeBackdrop(flags, r);
6957
9210
  const s = await planTake(dir, {
6958
9211
  ...style ? { style } : {},
6959
9212
  ...reuse ? { reuse } : {},
@@ -6965,7 +9218,7 @@ async function cmdPlan(argv) {
6965
9218
  ${s.reuse.flagged.join("\n ")}` : "") : "";
6966
9219
  r.done(
6967
9220
  {
6968
- take: resolve6(dir),
9221
+ take: resolve7(dir),
6969
9222
  fresh: s.fresh,
6970
9223
  cursorKept: s.cursorKept,
6971
9224
  zoomAuto: s.zoomAuto,
@@ -6975,7 +9228,7 @@ async function cmdPlan(argv) {
6975
9228
  ...s.styleFrom ? { styleFrom: s.styleFrom, styleFields: s.styleFields } : {},
6976
9229
  ...s.reuse ? { reuse: s.reuse } : {}
6977
9230
  },
6978
- `${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join14(dir, "doc.json")}: ${s.zoomAuto} auto + ${s.zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${reuseLines}`
9231
+ `${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join15(dir, "doc.json")}: ${s.zoomAuto} auto + ${s.zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${reuseLines}`
6979
9232
  );
6980
9233
  return EXIT_OK;
6981
9234
  }
@@ -6995,7 +9248,7 @@ async function cmdRender(argv) {
6995
9248
  if (fmtRaw !== "webm" && fmtRaw !== "mp4")
6996
9249
  throw new UsageError("--format must be webm or mp4");
6997
9250
  const format = fmtRaw;
6998
- const out = positionals[1] ?? join14(dir, `out.${format}`);
9251
+ const out = positionals[1] ?? join15(dir, `out.${format}`);
6999
9252
  const parallel = numFlag(flags, "parallel", 1);
7000
9253
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
7001
9254
  throw new UsageError("--parallel expects an integer between 1 and 16");
@@ -7071,7 +9324,7 @@ async function cmdFrames(argv) {
7071
9324
  if (!m) throw new UsageError("--size expects WxH (e.g. --size 1280x800)");
7072
9325
  size = { width: Number(m[1]), height: Number(m[2]) };
7073
9326
  }
7074
- const duration = totalDuration2(ratedSegments4(take.doc));
9327
+ const duration = totalDuration2(ratedSegments6(take.doc));
7075
9328
  const frameRaw = strFlag(flags, "frame");
7076
9329
  const timesRaw = strFlag(flags, "times");
7077
9330
  const atZooms = flags["at-zooms"] === true;
@@ -7158,12 +9411,13 @@ async function cmdDeliver(argv) {
7158
9411
  }
7159
9412
  const take = await loadTake(dir);
7160
9413
  if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
7161
- const duration = totalDuration2(ratedSegments4(take.doc));
9414
+ const duration = totalDuration2(ratedSegments6(take.doc));
7162
9415
  const timesRaw = strFlag(flags, "times");
7163
9416
  let times;
7164
9417
  if (timesRaw !== void 0) {
7165
9418
  try {
7166
- times = parseTimes(timesRaw, duration);
9419
+ const doc = take.doc;
9420
+ times = timesRaw.split(",").map((s) => s.trim()).filter(Boolean).map((s) => resolveStepTime(doc, s) ?? parseTimes(s, duration)[0]);
7167
9421
  } catch (e) {
7168
9422
  throw new UsageError(e instanceof Error ? e.message : String(e));
7169
9423
  }
@@ -7174,11 +9428,15 @@ async function cmdDeliver(argv) {
7174
9428
  }
7175
9429
  let poster;
7176
9430
  const posterRef = strFlag(flags, "poster");
7177
- if (posterRef !== void 0) {
7178
- if (existsSync10(posterRef)) {
9431
+ if (posterRef === "none") {
9432
+ poster = null;
9433
+ } else if (posterRef !== void 0 && templateByName(posterRef)) {
9434
+ poster = { from: `template ${posterRef}`, config: templateByName(posterRef) };
9435
+ } else if (posterRef !== void 0) {
9436
+ if (existsSync12(posterRef)) {
7179
9437
  poster = {
7180
- from: resolve6(posterRef),
7181
- config: JSON.parse(await readFile9(posterRef, "utf8"))
9438
+ from: resolve7(posterRef),
9439
+ config: JSON.parse(await readFile12(posterRef, "utf8"))
7182
9440
  };
7183
9441
  } else {
7184
9442
  const origin = platformOrigin({
@@ -7196,9 +9454,65 @@ async function cmdDeliver(argv) {
7196
9454
  poster = { from: posterRef, config: res.config };
7197
9455
  }
7198
9456
  }
9457
+ let lookPick;
9458
+ try {
9459
+ lookPick = await resolveLook(dir, {
9460
+ look: strFlag(flags, "look"),
9461
+ brand: strFlag(flags, "brand")
9462
+ });
9463
+ } catch (e) {
9464
+ throw new UsageError(e instanceof Error ? e.message : String(e));
9465
+ }
9466
+ r.log(`look: ${lookPick.from}`);
9467
+ const launch = await readLaunchBesideTake(dir, strFlag(flags, "launch"));
9468
+ const lines = (v) => v === null || v === void 0 ? null : v.replace(/\\n/g, "\n");
9469
+ const words = {
9470
+ headline: lines(strFlag(flags, "headline") ?? launch?.roles.headline),
9471
+ kicker: lines(strFlag(flags, "kicker") ?? launch?.roles.kicker),
9472
+ brand: strFlag(flags, "brand-name") ?? lookPick.roles?.wordmark ?? null,
9473
+ release: strFlag(flags, "release") ?? null
9474
+ };
9475
+ if (launch) r.log(`words: ${launch.file}`);
9476
+ const launchRoles = { ...launch?.roles ?? {} };
9477
+ for (const [flag, role] of [
9478
+ ["music", "music"],
9479
+ ["entrance", "entrance"],
9480
+ ["end-card", "endCard"],
9481
+ ["captions", "captions"],
9482
+ ["clicks", "clicks"]
9483
+ ]) {
9484
+ const v = strFlag(flags, flag);
9485
+ if (v !== void 0) launchRoles[role] = v;
9486
+ }
9487
+ const soundWanted = channels.some(
9488
+ (c) => ["x", "youtube", "shorts-linkedin"].includes(c)
9489
+ );
9490
+ let catalog = null;
9491
+ if (soundWanted && !/^(none|off|no|false)$/i.test(launchRoles.music ?? "") && !/^(none|off|no|false)$/i.test(launchRoles.clicks ?? "")) {
9492
+ try {
9493
+ catalog = await fetchMusicCatalog(
9494
+ platformOrigin({
9495
+ origin: strFlag(flags, "origin"),
9496
+ api: strFlag(flags, "api")
9497
+ })
9498
+ );
9499
+ } catch (e) {
9500
+ r.log(`music catalog: ${e instanceof Error ? e.message : String(e)} \u2014 the cuts stay silent`);
9501
+ }
9502
+ }
9503
+ const captions = (take.actions?.steps ?? []).flatMap((s, i) => {
9504
+ const step = s;
9505
+ return typeof step.caption === "string" && step.caption.trim() ? [{ step: i, id: step.id, caption: step.caption.trim() }] : [];
9506
+ });
7199
9507
  const browser = await launchBrowser();
7200
9508
  try {
7201
9509
  const result = await deliverTake(browser, dir, {
9510
+ look: lookPick.look,
9511
+ brandRoles: lookPick.roles,
9512
+ launchRoles,
9513
+ catalog,
9514
+ captions,
9515
+ words,
7202
9516
  channels,
7203
9517
  outDir: strFlag(flags, "out"),
7204
9518
  release: strFlag(flags, "release"),
@@ -7248,14 +9562,14 @@ Store uploads stay manual: hand the human this directory and the manifest.`
7248
9562
  async function resolveStyleRef(flags) {
7249
9563
  const styleRef = strFlag(flags, "style");
7250
9564
  if (!styleRef) return null;
7251
- const file = existsSync10(styleRef) ? resolve6(
9565
+ const file = existsSync12(styleRef) ? resolve7(
7252
9566
  styleRef,
7253
- existsSync10(join14(styleRef, "doc.json")) ? "doc.json" : ""
9567
+ existsSync12(join15(styleRef, "doc.json")) ? "doc.json" : ""
7254
9568
  ) : null;
7255
- if (file && existsSync10(file)) {
9569
+ if (file && existsSync12(file)) {
7256
9570
  return {
7257
9571
  from: file,
7258
- doc: JSON.parse(await readFile9(file, "utf8"))
9572
+ doc: JSON.parse(await readFile12(file, "utf8"))
7259
9573
  };
7260
9574
  }
7261
9575
  const origin = platformOrigin({
@@ -7292,7 +9606,7 @@ async function cmdDigest(argv) {
7292
9606
  const take = await loadTake(dir);
7293
9607
  if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
7294
9608
  const transcriptPath = strFlag(flags, "transcript");
7295
- const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile9(transcriptPath, "utf8"))) : null;
9609
+ const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile12(transcriptPath, "utf8"))) : null;
7296
9610
  const style = await resolveStyleRef(flags);
7297
9611
  const noFrames = flags["no-frames"] === true;
7298
9612
  let browser = null;
@@ -7323,7 +9637,7 @@ async function cmdDigest(argv) {
7323
9637
  kinds,
7324
9638
  frames: d.images.full,
7325
9639
  crops: d.images.crop,
7326
- sheet: d.images.sheet ? join14(result.outDir, d.images.sheet) : null,
9640
+ sheet: d.images.sheet ? join15(result.outDir, d.images.sheet) : null,
7327
9641
  scenes: kinds.scene ?? 0,
7328
9642
  sourceDuration: d.take.sourceDuration,
7329
9643
  outputDuration: d.take.outputDuration,
@@ -7347,14 +9661,14 @@ async function cmdOpen(argv) {
7347
9661
  if (!dir) throw new UsageError("vos open <take> [--studio <url>]");
7348
9662
  const r = createReporter(flags.json === true);
7349
9663
  const take = await loadTake(dir);
7350
- if (!existsSync10(take.paths.recording)) {
9664
+ if (!existsSync12(take.paths.recording)) {
7351
9665
  throw new UsageError(`${dir} has no recording.webm \u2014 re-run record`);
7352
9666
  }
7353
9667
  const studio = (strFlag(flags, "studio") ?? "http://localhost:6060").replace(
7354
9668
  /\/+$/,
7355
9669
  ""
7356
9670
  );
7357
- const server = await startTakeServer(resolve6(dir), {});
9671
+ const server = await startTakeServer(resolve7(dir), {});
7358
9672
  const url = `${studio}/studio?take=${encodeURIComponent(server.base)}`;
7359
9673
  r.event({ event: "open", server: server.base, url });
7360
9674
  r.log(`take served at ${server.base}`);
@@ -7379,22 +9693,57 @@ async function cmdOpen(argv) {
7379
9693
  });
7380
9694
  return EXIT_OK;
7381
9695
  }
9696
+ async function cmdJudge(argv) {
9697
+ const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
9698
+ const target = positionals[0];
9699
+ const against = strFlag(flags, "against");
9700
+ if (!target || !against)
9701
+ throw new UsageError(
9702
+ "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)"
9703
+ );
9704
+ const r = createReporter(flags.json === true);
9705
+ const kitFile = target.endsWith("kit.json") ? target : join15(target, "kit.json");
9706
+ if (!existsSync12(kitFile)) throw new UsageError(`${kitFile}: no kit manifest`);
9707
+ if (!existsSync12(against)) throw new UsageError(`${against}: no reference manifest`);
9708
+ const result = await judgeKit(kitFile, against, strFlag(flags, "out"));
9709
+ const verdicts = JSON.parse(await readFile12(result.verdictFile, "utf8")).verdicts;
9710
+ const rate = winRate(verdicts);
9711
+ const lines = result.sheets.map(
9712
+ (s) => `${s.asset} vs ${s.reference}: ${s.sheetA}, ${s.sheetB}, ${s.rubric}`
9713
+ );
9714
+ r.done(
9715
+ { ...result, winRate: rate },
9716
+ `Wrote ${result.sheets.length} sheet pair(s) to ${result.outDir}` + (lines.length ? `
9717
+ ${lines.join("\n ")}` : "") + (result.skipped.length ? `
9718
+ skipped: ${result.skipped.join("\n skipped: ")}` : "") + `
9719
+ Judge each pair both ways (the rubric is beside it) and fill ${result.verdictFile}` + (rate.judged ? `
9720
+ Win rate so far: ${rate.wins}/${rate.judged} (${(rate.rate * 100).toFixed(0)}%); parity with the references is 50%, the marketability bar is 40%` : "")
9721
+ );
9722
+ return EXIT_OK;
9723
+ }
7382
9724
  async function cmdValidate(argv) {
7383
9725
  const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
7384
9726
  const target = positionals[0];
7385
9727
  if (!target) throw new UsageError("vos validate <actions.json|take>");
7386
9728
  const r = createReporter(flags.json === true);
7387
- const kitTarget = target.endsWith("kit.json") ? target : existsSync10(join14(target, "kit.json")) && !isTakeDir(target) ? join14(target, "kit.json") : null;
9729
+ const kitTarget = target.endsWith("kit.json") ? target : existsSync12(join15(target, "kit.json")) && !isTakeDir(target) ? join15(target, "kit.json") : null;
7388
9730
  if (kitTarget) {
7389
- const verdict = await validateKit(kitTarget);
7390
- const tail = verdict.warnings.length ? `
9731
+ const picture = flags.picture === true;
9732
+ const verdict = await validateKit(kitTarget, { picture });
9733
+ const pictureLines = (verdict.picture ?? []).map(formatFinding);
9734
+ const pictureErrors = (verdict.picture ?? []).filter(
9735
+ (f) => f.severity === "error"
9736
+ ).length;
9737
+ const tail = (verdict.warnings.length ? `
7391
9738
  warnings:
7392
- ${verdict.warnings.join("\n ")}` : "";
9739
+ ${verdict.warnings.join("\n ")}` : "") + (picture ? `
9740
+ picture: ${pictureErrors} problem(s), ${(verdict.picture ?? []).length - pictureErrors} note(s)` + (pictureLines.length ? `
9741
+ ${pictureLines.join("\n ")}` : "") : "");
7393
9742
  if (!verdict.valid) {
7394
9743
  r.done(
7395
9744
  { ...verdict, target: kitTarget },
7396
- `${kitTarget}:
7397
- ${verdict.problems.join("\n ")}${tail}`
9745
+ `${kitTarget}:${verdict.problems.length ? `
9746
+ ${verdict.problems.join("\n ")}` : ""}${tail}`
7398
9747
  );
7399
9748
  return EXIT_ERROR;
7400
9749
  }
@@ -7409,9 +9758,9 @@ async function cmdValidate(argv) {
7409
9758
  r.done({ valid: true, target }, `${target}: valid actions file`);
7410
9759
  return EXIT_OK;
7411
9760
  }
7412
- if (!isTakeDir(target) && existsSync10(join14(target, "config.json"))) {
9761
+ if (!isTakeDir(target) && existsSync12(join15(target, "config.json"))) {
7413
9762
  const parsed = JSON.parse(
7414
- await readFile9(join14(target, "config.json"), "utf8")
9763
+ await readFile12(join15(target, "config.json"), "utf8")
7415
9764
  );
7416
9765
  const pre = preflightConfig(parsed);
7417
9766
  const problems2 = pre.ok ? [] : pre.issues.map((i) => `config.json: ${i}`);
@@ -7452,7 +9801,7 @@ async function cmdValidate(argv) {
7452
9801
  const take = await loadTake(target);
7453
9802
  const problems = [];
7454
9803
  const warnings = [];
7455
- if (!existsSync10(take.paths.recording))
9804
+ if (!existsSync12(take.paths.recording))
7456
9805
  problems.push("missing recording.webm (re-run record)");
7457
9806
  if (!take.doc) problems.push("missing doc.json (run plan)");
7458
9807
  if (take.doc) {
@@ -7497,7 +9846,7 @@ async function cmdPush3(argv) {
7497
9846
  const r = createReporter(flags.json === true);
7498
9847
  const overrides = multi.override;
7499
9848
  const result = await pushTake(
7500
- resolve6(dir),
9849
+ resolve7(dir),
7501
9850
  {
7502
9851
  key: strFlag(flags, "key"),
7503
9852
  api: strFlag(flags, "api"),
@@ -7524,7 +9873,7 @@ async function cmdPull2(argv) {
7524
9873
  const dir = positionals[0] ?? ".";
7525
9874
  const r = createReporter(flags.json === true);
7526
9875
  const result = await pullTake(
7527
- resolve6(dir),
9876
+ resolve7(dir),
7528
9877
  {
7529
9878
  key: strFlag(flags, "key"),
7530
9879
  api: strFlag(flags, "api"),
@@ -7576,6 +9925,8 @@ async function run(argv) {
7576
9925
  return await cmdOpen(rest);
7577
9926
  case "validate":
7578
9927
  return await cmdValidate(rest);
9928
+ case "judge":
9929
+ return await cmdJudge(rest);
7579
9930
  case "fetch":
7580
9931
  return await cmdFetch(rest);
7581
9932
  case "duplicate":
@@ -7657,4 +10008,4 @@ export {
7657
10008
  convertAgentBrowser,
7658
10009
  run
7659
10010
  };
7660
- //# sourceMappingURL=chunk-YICRU4ER.js.map
10011
+ //# sourceMappingURL=chunk-MMKDYE57.js.map