@vosjs/cli 0.14.0 → 0.16.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.
@@ -2359,8 +2359,13 @@ function parseTranscript(raw) {
2359
2359
 
2360
2360
  // src/plugin/framesTake.ts
2361
2361
  var VIDEO_TOKEN = "__VOILA_CLI_VIDEO__";
2362
+ function stillSupersample(w, h) {
2363
+ return Math.min(3, Math.max(1, Math.ceil(1800 / Math.max(1, w, h))));
2364
+ }
2362
2365
  async function stillsInPage(opts) {
2363
2366
  const w = window;
2367
+ const RW = opts.W * opts.ss;
2368
+ const RH = opts.H * opts.ss;
2364
2369
  try {
2365
2370
  const vblob = await (await fetch(opts.videoUrl)).blob();
2366
2371
  const vurl = URL.createObjectURL(vblob);
@@ -2377,11 +2382,11 @@ async function stillsInPage(opts) {
2377
2382
  THREE: w.__THREE__,
2378
2383
  gsap: w.__gsap__,
2379
2384
  resolution: {
2380
- width: opts.W,
2381
- height: opts.H,
2385
+ width: RW,
2386
+ height: RH,
2382
2387
  pixelRatio: 1,
2383
- drawingBufferWidth: opts.W,
2384
- drawingBufferHeight: opts.H
2388
+ drawingBufferWidth: RW,
2389
+ drawingBufferHeight: RH
2385
2390
  },
2386
2391
  preserveDrawingBuffer: true
2387
2392
  };
@@ -2391,32 +2396,44 @@ async function stillsInPage(opts) {
2391
2396
  timeline.pause();
2392
2397
  timeline.seek(0, false);
2393
2398
  const canvas = document.querySelector("canvas");
2394
- canvas.width = opts.W;
2395
- canvas.height = opts.H;
2396
- canvas.style.width = opts.W + "px";
2397
- canvas.style.height = opts.H + "px";
2399
+ canvas.width = RW;
2400
+ canvas.height = RH;
2401
+ canvas.style.width = RW + "px";
2402
+ canvas.style.height = RH + "px";
2403
+ const out = document.createElement("canvas");
2404
+ out.width = opts.W;
2405
+ out.height = opts.H;
2398
2406
  const raf = () => new Promise((r) => requestAnimationFrame(r));
2399
2407
  const wvr = () => w.__vos__?.waitForVideosReady ? w.__vos__.waitForVideosReady() : null;
2400
2408
  const pending = () => w.__vos__?.pendingDecodes?.size ?? 0;
2401
- const toPng = () => new Promise(
2402
- (res, rej) => canvas.toBlob(
2409
+ const toPng = () => new Promise((res, rej) => {
2410
+ let src = canvas;
2411
+ if (opts.ss > 1) {
2412
+ const g = out.getContext("2d");
2413
+ g.imageSmoothingEnabled = true;
2414
+ g.imageSmoothingQuality = "high";
2415
+ g.clearRect(0, 0, opts.W, opts.H);
2416
+ g.drawImage(canvas, 0, 0, opts.W, opts.H);
2417
+ src = out;
2418
+ }
2419
+ src.toBlob(
2403
2420
  (b) => b ? res(b) : rej(new Error("toBlob failed")),
2404
2421
  "image/png"
2405
- )
2406
- );
2422
+ );
2423
+ });
2407
2424
  const collectVideos = () => {
2408
- const out = /* @__PURE__ */ new Set();
2425
+ const out2 = /* @__PURE__ */ new Set();
2409
2426
  const cache = w.__vos__?.videoCache;
2410
2427
  if (cache) {
2411
2428
  const vals = cache instanceof Map ? [...cache.values()] : Object.values(cache);
2412
2429
  for (const v of vals) {
2413
- if (v?.tagName === "VIDEO") out.add(v);
2414
- else if (v?.el?.tagName === "VIDEO") out.add(v.el);
2415
- else if (v?.video?.tagName === "VIDEO") out.add(v.video);
2430
+ if (v?.tagName === "VIDEO") out2.add(v);
2431
+ else if (v?.el?.tagName === "VIDEO") out2.add(v.el);
2432
+ else if (v?.video?.tagName === "VIDEO") out2.add(v.video);
2416
2433
  }
2417
2434
  }
2418
- document.querySelectorAll("video").forEach((v) => out.add(v));
2419
- return [...out];
2435
+ document.querySelectorAll("video").forEach((v) => out2.add(v));
2436
+ return [...out2];
2420
2437
  };
2421
2438
  const videosSettled = () => collectVideos().every((v) => !v.seeking && v.readyState >= 2);
2422
2439
  const waitVideosSettled = async (maxMs) => {
@@ -2502,7 +2519,10 @@ async function framesTake(browser, dir, opts) {
2502
2519
  const server = await startTakeServer(dir, {
2503
2520
  "/render.html": renderPageHtml()
2504
2521
  });
2505
- const context = await browser.newContext({ viewport: { width, height } });
2522
+ const ss = stillSupersample(width, height);
2523
+ const context = await browser.newContext({
2524
+ viewport: { width: width * ss, height: height * ss }
2525
+ });
2506
2526
  try {
2507
2527
  const page = await context.newPage();
2508
2528
  page.on("console", (m) => {
@@ -2522,6 +2542,7 @@ async function framesTake(browser, dir, opts) {
2522
2542
  videoUrl: `/${RECORDING_NAME}`,
2523
2543
  W: width,
2524
2544
  H: height,
2545
+ ss,
2525
2546
  shots: named.map(({ time, name }) => ({ time, name }))
2526
2547
  }).catch(() => {
2527
2548
  });
@@ -2592,14 +2613,7 @@ async function writeIndexJson(result) {
2592
2613
  }
2593
2614
 
2594
2615
  // src/plugin/deliver.ts
2595
- import {
2596
- mkdir as mkdir4,
2597
- mkdtemp,
2598
- rename as rename4,
2599
- rm as rm3,
2600
- stat,
2601
- writeFile as writeFile6
2602
- } from "fs/promises";
2616
+ import { mkdir as mkdir4, mkdtemp, rename as rename4, rm as rm3, stat, writeFile as writeFile6 } from "fs/promises";
2603
2617
  import { tmpdir } from "os";
2604
2618
  import { join as join5, relative, resolve as resolve3 } from "path";
2605
2619
  import { totalDuration } from "@vosjs/timeline";
@@ -2720,6 +2734,8 @@ async function renderTake(browser, dir, outFile, opts) {
2720
2734
  import { compileVosConfig as compileVosConfig3 } from "@vosjs/core";
2721
2735
  async function posterStillInPage(opts) {
2722
2736
  const w = window;
2737
+ const RW = opts.W * opts.ss;
2738
+ const RH = opts.H * opts.ss;
2723
2739
  try {
2724
2740
  const mod = await import(
2725
2741
  /* @vite-ignore */
@@ -2733,11 +2749,11 @@ async function posterStillInPage(opts) {
2733
2749
  THREE: w.__THREE__,
2734
2750
  gsap: w.__gsap__,
2735
2751
  resolution: {
2736
- width: opts.W,
2737
- height: opts.H,
2752
+ width: RW,
2753
+ height: RH,
2738
2754
  pixelRatio: 1,
2739
- drawingBufferWidth: opts.W,
2740
- drawingBufferHeight: opts.H
2755
+ drawingBufferWidth: RW,
2756
+ drawingBufferHeight: RH
2741
2757
  },
2742
2758
  preserveDrawingBuffer: true
2743
2759
  };
@@ -2747,16 +2763,27 @@ async function posterStillInPage(opts) {
2747
2763
  timeline.pause();
2748
2764
  timeline.seek(opts.time, false);
2749
2765
  const canvas = document.querySelector("canvas");
2750
- canvas.width = opts.W;
2751
- canvas.height = opts.H;
2752
- canvas.style.width = opts.W + "px";
2753
- canvas.style.height = opts.H + "px";
2766
+ canvas.width = RW;
2767
+ canvas.height = RH;
2768
+ canvas.style.width = RW + "px";
2769
+ canvas.style.height = RH + "px";
2754
2770
  const raf = () => new Promise((r) => requestAnimationFrame(r));
2755
2771
  await raf();
2756
2772
  await raf();
2757
2773
  await raf();
2774
+ let src = canvas;
2775
+ if (opts.ss > 1) {
2776
+ const out = document.createElement("canvas");
2777
+ out.width = opts.W;
2778
+ out.height = opts.H;
2779
+ const g = out.getContext("2d");
2780
+ g.imageSmoothingEnabled = true;
2781
+ g.imageSmoothingQuality = "high";
2782
+ g.drawImage(canvas, 0, 0, opts.W, opts.H);
2783
+ src = out;
2784
+ }
2758
2785
  const png = await new Promise(
2759
- (res) => canvas.toBlob((b) => res(b), "image/png")
2786
+ (res) => src.toBlob((b) => res(b), "image/png")
2760
2787
  );
2761
2788
  if (!png) throw new Error("toBlob returned null");
2762
2789
  await fetch("/save?name=" + opts.outName, { method: "POST", body: png });
@@ -2774,8 +2801,9 @@ async function renderPosterStills(browser, config, serveDir, shots, time) {
2774
2801
  });
2775
2802
  try {
2776
2803
  for (const shot of shots) {
2804
+ const ss = stillSupersample(shot.width, shot.height);
2777
2805
  const context = await browser.newContext({
2778
- viewport: { width: shot.width, height: shot.height }
2806
+ viewport: { width: shot.width * ss, height: shot.height * ss }
2779
2807
  });
2780
2808
  try {
2781
2809
  const page = await context.newPage();
@@ -2794,6 +2822,7 @@ async function renderPosterStills(browser, config, serveDir, shots, time) {
2794
2822
  animationCode,
2795
2823
  W: shot.width,
2796
2824
  H: shot.height,
2825
+ ss,
2797
2826
  time,
2798
2827
  outName: shot.name
2799
2828
  }).catch(() => {
@@ -3325,7 +3354,8 @@ function blurPlane(src, w, h, r, passes = 3) {
3325
3354
  for (let y = 0; y < h; y++) {
3326
3355
  let acc = 0;
3327
3356
  const row = y * w;
3328
- for (let x = -r; x <= r; x++) acc += src[row + Math.min(w - 1, Math.max(0, x))];
3357
+ for (let x = -r; x <= r; x++)
3358
+ acc += src[row + Math.min(w - 1, Math.max(0, x))];
3329
3359
  for (let x = 0; x < w; x++) {
3330
3360
  tmp[row + x] = acc / (2 * r + 1);
3331
3361
  const add = src[row + Math.min(w - 1, x + r + 1)];
@@ -3335,7 +3365,8 @@ function blurPlane(src, w, h, r, passes = 3) {
3335
3365
  }
3336
3366
  for (let x = 0; x < w; x++) {
3337
3367
  let acc = 0;
3338
- for (let y = -r; y <= r; y++) acc += tmp[Math.min(h - 1, Math.max(0, y)) * w + x];
3368
+ for (let y = -r; y <= r; y++)
3369
+ acc += tmp[Math.min(h - 1, Math.max(0, y)) * w + x];
3339
3370
  for (let y = 0; y < h; y++) {
3340
3371
  src[y * w + x] = acc / (2 * r + 1);
3341
3372
  const add = tmp[Math.min(h - 1, y + r + 1) * w + x];
@@ -3351,7 +3382,8 @@ function roundedCoverage(x, y, rect, r) {
3351
3382
  for (const dx of [0.25, 0.75]) {
3352
3383
  const px = x + dx;
3353
3384
  const py = y + dy;
3354
- if (px < rect.x || py < rect.y || px > rect.x + rect.w || py > rect.y + rect.h) continue;
3385
+ if (px < rect.x || py < rect.y || px > rect.x + rect.w || py > rect.y + rect.h)
3386
+ continue;
3355
3387
  const cx = Math.min(Math.max(px, rect.x + r), rect.x + rect.w - r);
3356
3388
  const cy = Math.min(Math.max(py, rect.y + r), rect.y + rect.h - r);
3357
3389
  if ((px - cx) ** 2 + (py - cy) ** 2 <= r * r) inside++;
@@ -3362,24 +3394,34 @@ function roundedCoverage(x, y, rect, r) {
3362
3394
  function bakeShot(shot, opts = {}) {
3363
3395
  const margin = Math.round(shot.w * (opts.margin ?? 0.06));
3364
3396
  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));
3397
+ const shadowA = opts.shadow ?? 0.3;
3398
+ const blur = Math.round(shot.w * (opts.blur ?? 0.05));
3399
+ const offsetY = Math.round(shot.w * (opts.offsetY ?? 0.02));
3368
3400
  const hair = opts.hairline ?? 0;
3369
3401
  const w = shot.w + margin * 2;
3370
3402
  const h = shot.h + margin * 2;
3371
3403
  const out = new Uint8Array(w * h * 4);
3372
3404
  const rect = { x: margin, y: margin, w: shot.w, h: shot.h };
3373
3405
  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)));
3406
+ const layers = [
3407
+ [Math.max(1, Math.round(blur / 4)), Math.round(offsetY / 5), 0.4],
3408
+ [blur, offsetY, 0.6]
3409
+ ];
3410
+ const acc = new Float32Array(w * h);
3411
+ for (const [lb, lo, share] of layers) {
3412
+ const mask = new Float32Array(w * h);
3413
+ for (let y = 0; y < h; y++)
3414
+ for (let x = 0; x < w; x++) {
3415
+ const c = roundedCoverage(x, y - lo, rect, radius);
3416
+ if (c > 0) mask[y * w + x] = c;
3417
+ }
3418
+ blurPlane(mask, w, h, Math.max(1, Math.round(lb / 2)));
3419
+ const a = shadowA * share;
3420
+ for (let i = 0; i < w * h; i++)
3421
+ acc[i] = 1 - (1 - acc[i]) * (1 - mask[i] * a);
3422
+ }
3381
3423
  for (let i = 0; i < w * h; i++) {
3382
- const a = mask[i] * shadowA;
3424
+ const a = acc[i];
3383
3425
  if (a <= 2e-3) continue;
3384
3426
  out[i * 4] = 0;
3385
3427
  out[i * 4 + 1] = 0;
@@ -3399,7 +3441,12 @@ function bakeShot(shot, opts = {}) {
3399
3441
  let g = shot.data[si + 1];
3400
3442
  let b = shot.data[si + 2];
3401
3443
  if (hair > 0) {
3402
- const edge = Math.min(x - rect.x, rect.x + rect.w - x, y - rect.y, rect.y + rect.h - y);
3444
+ const edge = Math.min(
3445
+ x - rect.x,
3446
+ rect.x + rect.w - x,
3447
+ y - rect.y,
3448
+ rect.y + rect.h - y
3449
+ );
3403
3450
  if (edge < 1.5) {
3404
3451
  r = Math.round(r * (1 - hair));
3405
3452
  g = Math.round(g * (1 - hair));
@@ -3924,12 +3971,18 @@ var rgb = (hex2) => {
3924
3971
  const n = parseInt(m[1], 16);
3925
3972
  return [n >> 16 & 255, n >> 8 & 255, n & 255];
3926
3973
  };
3927
- var toHex = (c) => "#" + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")).join("");
3974
+ var toHex = (c) => "#" + c.map(
3975
+ (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0")
3976
+ ).join("");
3928
3977
  function mixHex(a, b, t) {
3929
3978
  const pa = rgb(a);
3930
3979
  const pb = rgb(b);
3931
3980
  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]);
3981
+ return toHex([
3982
+ pa[0] + (pb[0] - pa[0]) * t,
3983
+ pa[1] + (pb[1] - pa[1]) * t,
3984
+ pa[2] + (pb[2] - pa[2]) * t
3985
+ ]);
3933
3986
  }
3934
3987
  function rgba(hex2, alpha) {
3935
3988
  const c = rgb(hex2) ?? [255, 255, 255];
@@ -3945,12 +3998,14 @@ function resolveFace(family, weights) {
3945
3998
  const first = family.split(",")[0].replace(/['"]/g, "").replace(/\s+variable$/i, "").trim();
3946
3999
  const entry = findFontFamily2(first);
3947
4000
  if (!entry) return null;
3948
- const fonts = [...new Set(weights.map((w) => nearestFontWeight(entry, w)))].map((w) => ({
4001
+ const fonts = [
4002
+ ...new Set(weights.map((w) => nearestFontWeight(entry, w)))
4003
+ ].map((w) => ({
3949
4004
  family: entry.family,
3950
4005
  url: fontFaceUrl(entry.slug, w),
3951
4006
  weight: w
3952
4007
  }));
3953
- return { stack: fontStack(entry), fonts };
4008
+ return { stack: fontStack(entry), category: entry.category, fonts };
3954
4009
  }
3955
4010
  function posterValues(brand, words) {
3956
4011
  const b = brand ?? {};
@@ -3973,7 +4028,11 @@ function posterValues(brand, words) {
3973
4028
  if (bgA) values.grain = lightGround ? 10 : 22;
3974
4029
  if (accent) values.blobA = rgba(accent, lightGround ? 0.28 : 0.4);
3975
4030
  if (bgC) values.blobB = rgba(bgC, 0.75);
3976
- if (accent) values.blobC = rgba(mixHex(accent, "#ffffff", 0.55), lightGround ? 0.35 : 0.25);
4031
+ if (accent)
4032
+ values.blobC = rgba(
4033
+ mixHex(accent, "#ffffff", 0.55),
4034
+ lightGround ? 0.35 : 0.25
4035
+ );
3977
4036
  if (wordmark) values.brand = wordmark;
3978
4037
  const headline = (words.headline ?? "").trim();
3979
4038
  if (headline) values.headline = headline;
@@ -3993,6 +4052,224 @@ function posterValues(brand, words) {
3993
4052
  return { values, fonts, lightGround };
3994
4053
  }
3995
4054
 
4055
+ // src/plugin/stages.ts
4056
+ var str = (v, fallback) => typeof v === "string" && v.trim() ? v.trim() : fallback;
4057
+ var firstFamily = (stack) => stack.split(",")[0].replace(/['"]/g, "").trim();
4058
+ function stageSplitCover(input) {
4059
+ const R = input.size.w / Math.max(1, input.size.h);
4060
+ const portrait = R < 0.87;
4061
+ const square = !portrait && R <= 1.15;
4062
+ const v = input.values;
4063
+ const ground = `linear-gradient(135deg, ${str(v.bgA, "#8a3d2a")}, ${str(v.bgC, str(v.bgB, "#5c5a2e"))})`;
4064
+ const ink = str(v.ink, "#fff6ec");
4065
+ const inkSoft = str(v.inkSoft, ink);
4066
+ const display = firstFamily(str(v.fontDisplay, "Lexend"));
4067
+ const body = firstFamily(str(v.fontBody, "Lexend"));
4068
+ const headline = str(v.headline, "");
4069
+ const kicker = str(v.kicker, "");
4070
+ const brand = str(v.brand, "");
4071
+ const inset = portrait ? { left: 0.08, right: -0.08, top: 0.44, bottom: -0.1 } : square ? { left: 0.1, right: -0.1, top: 0.5, bottom: -0.12 } : { left: 0.44, right: -0.06, top: 0.3, bottom: -0.12 };
4072
+ const shot = {
4073
+ x: inset.left,
4074
+ y: inset.top,
4075
+ w: 1 - inset.left - inset.right,
4076
+ h: 1 - inset.top - inset.bottom
4077
+ };
4078
+ const set = [
4079
+ `frame.background=${JSON.stringify(ground)}`,
4080
+ "frame.backgroundMedia=null",
4081
+ "frame.fit=cover",
4082
+ `frame.inset=${JSON.stringify(inset)}`,
4083
+ 'frame.focus={"cx":0,"cy":0}',
4084
+ "frame.radius=16",
4085
+ "frame.shadow=0.5",
4086
+ "frame.shadowContact=0",
4087
+ "frame.border=0",
4088
+ "zoom=[]",
4089
+ `tilt=[{"id":"stage","in":0,"out":${input.sourceSeconds.toFixed(3)},"rx":3,"ry":${portrait || square ? 0 : 10}}]`,
4090
+ "cursor.visible=false",
4091
+ "cursor.clickFx.style=none"
4092
+ ];
4093
+ const designW = 1080 * input.size.w / input.size.h;
4094
+ const boxes = [];
4095
+ const clips = [];
4096
+ const column = portrait || square ? 0.84 : 0.36;
4097
+ const left = portrait || square ? 0.08 : 0.07;
4098
+ const size = portrait ? 60 : square ? 68 : 84;
4099
+ const word = (id, text, preset, family, px, y, color, extra, role) => {
4100
+ if (!text) return;
4101
+ const lines = text.split("\n");
4102
+ const longest = Math.max(...lines.map((l) => l.length));
4103
+ const widest = Math.min(
4104
+ column,
4105
+ longest * px * (preset === "title" ? 0.5 : 0.62) / designW
4106
+ );
4107
+ const lh = preset === "title" ? 1.05 : 1.2;
4108
+ const h = lines.length * px * lh / 1080;
4109
+ clips.push({
4110
+ id,
4111
+ kind: "text",
4112
+ text,
4113
+ preset,
4114
+ family,
4115
+ size: px,
4116
+ color,
4117
+ align: "left",
4118
+ maxWidth: column,
4119
+ lineHeight: lh,
4120
+ transform: { x: left + widest / 2, y, scale: 1, rotation: 0 },
4121
+ // Words on the ground carry no footage shadow: a drop shadow under a
4122
+ // headline on a plate is the old-web tell.
4123
+ shadow: 0,
4124
+ start: 0,
4125
+ duration: +input.outputSeconds.toFixed(3),
4126
+ enter: "none",
4127
+ exit: "none",
4128
+ ...extra
4129
+ });
4130
+ boxes.push({
4131
+ x: left,
4132
+ y: y - h / 2,
4133
+ w: widest,
4134
+ h,
4135
+ color,
4136
+ role,
4137
+ label: text.length > 24 ? `${text.slice(0, 24)}\u2026` : text
4138
+ });
4139
+ };
4140
+ const kickerY = portrait ? 0.1 : square ? 0.12 : 0.24;
4141
+ const titleY = portrait ? 0.24 : square ? 0.28 : 0.46;
4142
+ const brandY = portrait ? 0.94 : square ? 0.94 : 0.86;
4143
+ word(
4144
+ "stage-kicker",
4145
+ kicker,
4146
+ "label",
4147
+ "JetBrains Mono",
4148
+ 19,
4149
+ kickerY,
4150
+ inkSoft,
4151
+ { letterSpacing: 6, weight: 400 },
4152
+ "body"
4153
+ );
4154
+ word(
4155
+ "stage-title",
4156
+ headline,
4157
+ "title",
4158
+ display,
4159
+ size,
4160
+ titleY,
4161
+ ink,
4162
+ { weight: 600, letterSpacing: -Math.round(size * 0.02) },
4163
+ "headline"
4164
+ );
4165
+ word(
4166
+ "stage-brand",
4167
+ brand,
4168
+ "label",
4169
+ body,
4170
+ 25,
4171
+ brandY,
4172
+ ink,
4173
+ { weight: 700, letterSpacing: 1 },
4174
+ "body"
4175
+ );
4176
+ set.push(`overlays=${JSON.stringify(clips)}`);
4177
+ return { set, text: boxes, shot };
4178
+ }
4179
+ var TILE_MAX_PX = 700;
4180
+ function isTileSize(size) {
4181
+ return Math.max(size.w, size.h) < TILE_MAX_PX;
4182
+ }
4183
+ function stageTile(input) {
4184
+ const R = input.size.w / Math.max(1, input.size.h);
4185
+ const square = R <= 1.15;
4186
+ const v = input.values;
4187
+ const ground = `linear-gradient(135deg, ${str(v.bgA, "#8a3d2a")}, ${str(v.bgC, str(v.bgB, "#5c5a2e"))})`;
4188
+ const ink = str(v.ink, "#fff6ec");
4189
+ const display = firstFamily(str(v.fontDisplay, "Lexend"));
4190
+ const headline = str(v.headline, "") || str(v.brand, "Release");
4191
+ const wordless = input.text === "none";
4192
+ const aspect = input.footageAspect && input.footageAspect > 0 ? input.footageAspect : 16 / 9;
4193
+ const left = square ? 0.08 : 0.07;
4194
+ const top = wordless ? square ? 0.1 : 0.12 : square ? 0.42 : 0.47;
4195
+ const visible = wordless ? square ? 0.66 : 0.8 : square ? 0.56 : 0.62;
4196
+ const cardW = (1 - left) / visible;
4197
+ const cardH = cardW * R / aspect;
4198
+ const inset = {
4199
+ left,
4200
+ right: +(1 - left - cardW).toFixed(4),
4201
+ top,
4202
+ bottom: +(1 - top - cardH).toFixed(4)
4203
+ };
4204
+ const shot = {
4205
+ x: inset.left,
4206
+ y: inset.top,
4207
+ w: 1 - inset.left - inset.right,
4208
+ h: 1 - inset.top - inset.bottom
4209
+ };
4210
+ const set = [
4211
+ `frame.background=${JSON.stringify(ground)}`,
4212
+ "frame.backgroundMedia=null",
4213
+ "frame.fit=cover",
4214
+ `frame.inset=${JSON.stringify(inset)}`,
4215
+ 'frame.focus={"cx":0,"cy":0}',
4216
+ "frame.radius=24",
4217
+ "frame.shadow=0.5",
4218
+ "frame.shadowContact=0",
4219
+ "frame.border=0",
4220
+ "zoom=[]",
4221
+ "tilt=[]",
4222
+ "cursor.visible=false",
4223
+ "cursor.clickFx.style=none"
4224
+ ];
4225
+ const designW = 1080 * input.size.w / input.size.h;
4226
+ const column = square ? 0.84 : 0.86;
4227
+ const px = square ? 96 : 108;
4228
+ const lines = headline.split("\n");
4229
+ const longest = Math.max(...lines.map((l) => l.length));
4230
+ const widest = Math.min(column, longest * px * 0.5 / designW);
4231
+ const lh = 1.05;
4232
+ const h = lines.length * px * lh / 1080;
4233
+ const y = square ? 0.2 : 0.24;
4234
+ const clip = {
4235
+ id: "stage-title",
4236
+ kind: "text",
4237
+ text: headline,
4238
+ preset: "title",
4239
+ family: display,
4240
+ size: px,
4241
+ color: ink,
4242
+ align: "left",
4243
+ maxWidth: column,
4244
+ lineHeight: lh,
4245
+ weight: 600,
4246
+ letterSpacing: -Math.round(px * 0.02),
4247
+ transform: { x: left + widest / 2, y, scale: 1, rotation: 0 },
4248
+ shadow: 0,
4249
+ start: 0,
4250
+ duration: +input.outputSeconds.toFixed(3),
4251
+ enter: "none",
4252
+ exit: "none"
4253
+ };
4254
+ if (wordless) {
4255
+ set.push("overlays=[]");
4256
+ return { set, text: [], shot };
4257
+ }
4258
+ set.push(`overlays=${JSON.stringify([clip])}`);
4259
+ const text = [
4260
+ {
4261
+ x: left,
4262
+ y: y - h / 2,
4263
+ w: widest,
4264
+ h,
4265
+ color: ink,
4266
+ role: "headline",
4267
+ label: headline.length > 24 ? `${headline.slice(0, 24)}\u2026` : headline
4268
+ }
4269
+ ];
4270
+ return { set, text, shot };
4271
+ }
4272
+
3996
4273
  // src/plugin/motionPlan.ts
3997
4274
  import { ratedSegments as ratedSegments4, spanOutputExtent as spanOutputExtent4 } from "@vosjs/studio-core";
3998
4275
  var SOUND_DESTINATIONS = /* @__PURE__ */ new Set([
@@ -4208,6 +4485,10 @@ function templateForCard(d, opts) {
4208
4485
  if (opts.poster === null) return null;
4209
4486
  if (opts.poster) return { config: opts.poster.config, from: opts.poster.from };
4210
4487
  if (!d.template) return null;
4488
+ if (isTileSize(d.px)) {
4489
+ const config2 = templateByName(d.template) ?? templateByName("card-on-gradient");
4490
+ if (config2) return { config: config2, from: "stage tile", stage: "tile" };
4491
+ }
4211
4492
  let name = d.template;
4212
4493
  let note;
4213
4494
  const wants = templateOf(templateByName(name) ?? {});
@@ -4218,6 +4499,8 @@ function templateForCard(d, opts) {
4218
4499
  }
4219
4500
  const config = templateByName(name);
4220
4501
  if (!config) return null;
4502
+ if (name === "split-cover")
4503
+ return { config, from: "stage split-cover", note, stage: "split-cover" };
4221
4504
  return { config, from: `template ${name}`, note };
4222
4505
  }
4223
4506
  function lookOverrides(look, placement, size, video, opts) {
@@ -4238,7 +4521,12 @@ function lookOverrides(look, placement, size, video, opts) {
4238
4521
  }
4239
4522
  if (!opts.keepMedia) set.push("frame.backgroundMedia=null");
4240
4523
  if (opts.still) {
4241
- set.push("zoom=[]", "tilt=[]", "cursor.visible=false", "cursor.clickFx.style=none");
4524
+ set.push(
4525
+ "zoom=[]",
4526
+ "tilt=[]",
4527
+ "cursor.visible=false",
4528
+ "cursor.clickFx.style=none"
4529
+ );
4242
4530
  }
4243
4531
  return set;
4244
4532
  }
@@ -4410,9 +4698,14 @@ async function deliverTake(browser, dir, opts) {
4410
4698
  if (!set.length) return opts.overrides;
4411
4699
  return { ...opts.overrides, set };
4412
4700
  };
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);
4701
+ const cardPlans = destinations.filter(
4702
+ (d) => d.kind !== "video" && d.genre === "card" && !NOT_FROM_FOOTAGE[d.id]
4703
+ ).map((d) => ({ d, plan: templateForCard(d, opts) })).filter(
4704
+ (p) => p.plan !== null
4705
+ );
4414
4706
  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}`);
4707
+ for (const p of cardPlans)
4708
+ if (p.plan.note) skipped.push(`note: ${p.plan.note}`);
4416
4709
  if (cardPlans.length) {
4417
4710
  const meta = doc.source.meta;
4418
4711
  const heroTime = opts.shotTime ?? (stillTimes.length ? stillTimes[0] : duration / 2);
@@ -4448,11 +4741,66 @@ async function deliverTake(browser, dir, opts) {
4448
4741
  const baked = bakeShot(raw, {
4449
4742
  margin: PAD,
4450
4743
  hairline: fill.lightGround ? 0.14 : 0,
4451
- shadow: fill.lightGround ? 0.32 : 0.45
4744
+ shadow: fill.lightGround ? 0.28 : 0.4
4452
4745
  });
4453
4746
  await writeFile6(join5(serveDir, "shot.png"), encodePng(baked));
4454
4747
  const shotAspect = raw.w / raw.h;
4455
4748
  for (const { d, plan } of cardPlans) {
4749
+ if (plan.stage) {
4750
+ const stageInput = {
4751
+ size: d.px,
4752
+ values: fill.values,
4753
+ sourceSeconds: meta.durationMs / 1e3,
4754
+ outputSeconds: duration,
4755
+ text: d.text,
4756
+ footageAspect: (meta.captureWidth ?? meta.width) / (meta.captureHeight ?? meta.height)
4757
+ };
4758
+ const staged = plan.stage === "tile" ? stageTile(stageInput) : stageSplitCover(stageInput);
4759
+ opts.onPhase?.(
4760
+ `${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}`
4761
+ );
4762
+ const shotDir = await mkdtemp(join5(tmpdir(), "vos-stage-"));
4763
+ try {
4764
+ const captured = await framesTake(browser, dir, {
4765
+ times: [heroTime],
4766
+ width: d.px.w,
4767
+ height: d.px.h,
4768
+ outDir: shotDir,
4769
+ overrides: {
4770
+ ...opts.overrides,
4771
+ set: [...staged.set, ...opts.overrides?.set ?? []]
4772
+ }
4773
+ });
4774
+ const to2 = join5(outDir, `${d.id}.png`);
4775
+ await rename4(captured.frames[0].file, to2);
4776
+ const bytes2 = (await stat(to2)).size;
4777
+ if (d.maxBytes !== void 0 && bytes2 > d.maxBytes) {
4778
+ skipped.push(
4779
+ `${d.channel} ${d.asset}: ${overCeiling(bytes2, d.maxBytes)} (kept at ${to2})`
4780
+ );
4781
+ continue;
4782
+ }
4783
+ assets.push({
4784
+ channel: d.channel,
4785
+ asset: d.asset,
4786
+ destination: d.id,
4787
+ path: relative(outDir, to2),
4788
+ w: d.px.w,
4789
+ h: d.px.h,
4790
+ bytes: bytes2,
4791
+ seconds: null,
4792
+ frameTime: heroTime,
4793
+ source: "stage",
4794
+ template: `${plan.stage}-stage`,
4795
+ text: staged.text,
4796
+ shot: staged.shot,
4797
+ ...plan.stage === "tile" ? { crop: true } : {}
4798
+ });
4799
+ } finally {
4800
+ await rm3(shotDir, { recursive: true, force: true });
4801
+ }
4802
+ continue;
4803
+ }
4456
4804
  const problems = templateProblems(plan.config);
4457
4805
  if (problems.length) {
4458
4806
  skipped.push(
@@ -4485,7 +4833,9 @@ async function deliverTake(browser, dir, opts) {
4485
4833
  opts.posterTime ?? posterDuration * 0.9,
4486
4834
  Math.max(0, posterDuration - 0.05)
4487
4835
  );
4488
- opts.onPhase?.(`${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}, ${filled.aspect}`);
4836
+ opts.onPhase?.(
4837
+ `${d.channel} ${d.asset} (${specWords(d)}) from ${plan.from}, ${filled.aspect}`
4838
+ );
4489
4839
  await renderPosterStills(
4490
4840
  browser,
4491
4841
  config,
@@ -4677,7 +5027,8 @@ var RUBRIC = [
4677
5027
  "Name three ways this asset acknowledges THIS product (its data, its state, its brand), not a generic window."
4678
5028
  ];
4679
5029
  function rolesFor(asset) {
4680
- if (asset.source === "poster" && asset.template) return [asset.template];
5030
+ if ((asset.source === "poster" || asset.source === "stage") && asset.template)
5031
+ return [asset.template.replace(/-stage$/, "")];
4681
5032
  const spec = DESTINATIONS2.find((d) => d.id === asset.destination);
4682
5033
  if (spec?.kind === "video") {
4683
5034
  const portrait = spec.px.w < spec.px.h;
@@ -4844,9 +5195,11 @@ async function judgeKit(kitPath, manifestPath, outDir) {
4844
5195
  return { sheets, skipped, outDir: out, verdictFile };
4845
5196
  }
4846
5197
  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 };
5198
+ const judgedList = verdicts.filter((v) => v.win !== null || (v.reasons?.length ?? 0) > 0);
5199
+ const wins = judgedList.filter((v) => v.win === true).length;
5200
+ const ties = judgedList.filter((v) => v.win === null).length;
5201
+ const judged = judgedList.length;
5202
+ return { wins, ties, judged, rate: judged ? (wins + ties / 2) / judged : null };
4850
5203
  }
4851
5204
 
4852
5205
  // src/plugin/kitPicture.ts
@@ -4897,7 +5250,7 @@ function stillFindings(a, img, m) {
4897
5250
  const sh = Math.min(1, a.shot.y + a.shot.h) * img.h - sy;
4898
5251
  const rect = { x: sx, y: sy, w: Math.max(1, sw), h: Math.max(1, sh) };
4899
5252
  const ink = inkCoverage(img, rect);
4900
- if (ink < BLANK_INK) {
5253
+ if (ink < (a.crop ? BLANK_INK / 2 : BLANK_INK)) {
4901
5254
  out.push({
4902
5255
  code: "blank",
4903
5256
  severity: "error",
@@ -4907,7 +5260,7 @@ function stillFindings(a, img, m) {
4907
5260
  bbox: rect
4908
5261
  });
4909
5262
  }
4910
- if (a.shot.w < 0.5 || a.shot.w > 1.2) {
5263
+ if (!a.crop && (a.shot.w < 0.5 || a.shot.w > 1.2)) {
4911
5264
  out.push({
4912
5265
  code: "subject",
4913
5266
  severity: "error",
@@ -4995,7 +5348,12 @@ function stillFindings(a, img, m) {
4995
5348
  function textFindings(a, img) {
4996
5349
  const out = [];
4997
5350
  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 };
5351
+ const box = {
5352
+ x: t.x * img.w,
5353
+ y: t.y * img.h,
5354
+ w: t.w * img.w,
5355
+ h: t.h * img.h
5356
+ };
4999
5357
  const name = t.label ? `"${t.label}"` : "a text box";
5000
5358
  if (box.x < 0 || box.y < 0 || box.x + box.w > img.w + 0.5 || box.y + box.h > img.h + 0.5) {
5001
5359
  out.push({
@@ -5069,19 +5427,38 @@ function duplicateFindings(stills) {
5069
5427
  }
5070
5428
  if (g.length > 1) groups.push(g);
5071
5429
  }
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
- }));
5430
+ return groups.map((g) => {
5431
+ const composed = g.every(
5432
+ (s) => stills.find((x) => x.destination === s.destination)?.composed
5433
+ );
5434
+ return {
5435
+ code: "duplicate",
5436
+ severity: composed ? "info" : g.length >= 3 ? "error" : "warning",
5437
+ asset: g.map((s) => s.destination).join(", "),
5438
+ message: composed ? `${g.length} channels share one cover: ${g.map((s) => s.destination).join(", ")}` : `${g.length} assets share one frame${g[0].time !== null ? ` (${g[0].time.toFixed(2)}s)` : ""}: ${g.map((s) => s.destination).join(", ")}`,
5439
+ 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"
5440
+ };
5441
+ });
5079
5442
  }
5080
5443
  async function videoFrame(file, at2, ffmpeg) {
5081
5444
  try {
5082
5445
  const { stdout } = await execFileP(
5083
5446
  ffmpeg,
5084
- ["-v", "error", "-ss", at2.toFixed(3), "-i", file, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "-"],
5447
+ [
5448
+ "-v",
5449
+ "error",
5450
+ "-ss",
5451
+ at2.toFixed(3),
5452
+ "-i",
5453
+ file,
5454
+ "-frames:v",
5455
+ "1",
5456
+ "-f",
5457
+ "image2pipe",
5458
+ "-vcodec",
5459
+ "png",
5460
+ "-"
5461
+ ],
5085
5462
  { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 }
5086
5463
  );
5087
5464
  return decodePng(new Uint8Array(stdout));
@@ -5124,7 +5501,8 @@ async function pictureChecks(assets) {
5124
5501
  destination: a.destination,
5125
5502
  hash: m.hash,
5126
5503
  time: null,
5127
- genre: a.spec?.genre
5504
+ genre: a.spec?.genre,
5505
+ composed: !!a.shot
5128
5506
  });
5129
5507
  continue;
5130
5508
  }
@@ -5143,14 +5521,19 @@ async function pictureChecks(assets) {
5143
5521
  }
5144
5522
  const seconds = a.seconds ?? 0;
5145
5523
  const first = await videoFrame(a.file, 0, "ffmpeg");
5146
- const last = await videoFrame(a.file, Math.max(0, seconds - 0.1), "ffmpeg");
5524
+ const last = await videoFrame(
5525
+ a.file,
5526
+ Math.max(0, seconds - 0.1),
5527
+ "ffmpeg"
5528
+ );
5147
5529
  for (const [name, img] of [
5148
5530
  ["first", first],
5149
5531
  ["last", last]
5150
5532
  ]) {
5151
5533
  if (!img) continue;
5152
5534
  const m = measureStill(img);
5153
- if (name === "first") measured.push({ destination: a.destination, measure: m });
5535
+ if (name === "first")
5536
+ measured.push({ destination: a.destination, measure: m });
5154
5537
  const subject = m.card ?? { x: 0, y: 0, w: img.w, h: img.h };
5155
5538
  const ink = inkCoverage(img, subject);
5156
5539
  if (ink < BLANK_INK) {
@@ -5176,7 +5559,11 @@ async function pictureChecks(assets) {
5176
5559
  }
5177
5560
  }
5178
5561
  findings.push(...duplicateFindings(stills));
5179
- const order = { error: 0, warning: 1, info: 2 };
5562
+ const order = {
5563
+ error: 0,
5564
+ warning: 1,
5565
+ info: 2
5566
+ };
5180
5567
  findings.sort((a, b) => order[a.severity] - order[b.severity]);
5181
5568
  return { findings, measured };
5182
5569
  }
@@ -5328,7 +5715,8 @@ async function validateKit(kitPath, opts = {}) {
5328
5715
  text: a.text,
5329
5716
  seconds,
5330
5717
  composed: a.composed,
5331
- shot: a.source === "poster" ? a.shot : void 0
5718
+ shot: a.source === "poster" || a.source === "stage" ? a.shot : void 0,
5719
+ crop: a.crop
5332
5720
  });
5333
5721
  if (!spec) {
5334
5722
  if (a.channel !== "demo")
@@ -7900,7 +8288,7 @@ function mostCommon(values) {
7900
8288
  }
7901
8289
  return best;
7902
8290
  }
7903
- function firstFamily(fontFamily) {
8291
+ function firstFamily2(fontFamily) {
7904
8292
  const first = fontFamily.split(",")[0]?.trim() ?? "";
7905
8293
  return first.replace(/^["']|["']$/g, "");
7906
8294
  }
@@ -8032,10 +8420,10 @@ function composeBrand(input) {
8032
8420
  const bgC = mixHex2(bgA, accent, 0.14);
8033
8421
  p.bgC = `bgA tinted 14% toward the accent (a highlight ground)`;
8034
8422
  p.ink = w.h1 ? `the h1's colour` : `the body's colour`;
8035
- const fontDisplay = design?.fonts[0] ?? (w.h1 ? firstFamily(w.h1.fontFamily) : firstFamily(w.body.fontFamily));
8423
+ const fontDisplay = design?.fonts[0] ?? (w.h1 ? firstFamily2(w.h1.fontFamily) : firstFamily2(w.body.fontFamily));
8036
8424
  p.fontDisplay = design?.fonts[0] ? `named in design.md` : w.h1 ? `the h1's computed face` : `the body's computed face (no h1)`;
8037
8425
  const designBody = design?.fonts.slice(1).find((f) => !/\bmono\b/i.test(f));
8038
- const fontBody = designBody ?? firstFamily(w.body.fontFamily);
8426
+ const fontBody = designBody ?? firstFamily2(w.body.fontFamily);
8039
8427
  p.fontBody = designBody ? `named in design.md` : `the body's computed face`;
8040
8428
  const logoUrl = design?.logos.find((u) => /wordmark|logo/i.test(u)) ?? null;
8041
8429
  const iconUrl = w.icons.find((u) => /apple-touch/i.test(u)) ?? (w.icons.length ? w.icons[0] : null);
@@ -8847,9 +9235,10 @@ ground (a paper site is a plate, a dark site is dark), and with no brand
8847
9235
  the house gradient. Screenshot-genre stills never take a look.
8848
9236
  Card-genre destinations (OG, LinkedIn, X, YouTube thumbnail, the CWS
8849
9237
  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
9238
+ from its destination's poster TEMPLATE (split-cover is a STAGE, the
9239
+ take's own card leaning in perspective with its chrome and shadow beside
9240
+ a serif headline column, on the brand's ground; card-on-gradient is the
9241
+ shot alone on a mesh, the store's tile rule), filled with BRAND.md's colours and faces and the
8853
9242
  release's words, the shot baked as an object (padded, rounded, shadowed,
8854
9243
  a hairline on a light ground), PNG at exact pixels; kit.json records the
8855
9244
  template and the text boxes. The headline is LAUNCH.md's headline role
@@ -9717,7 +10106,7 @@ async function cmdJudge(argv) {
9717
10106
  ${lines.join("\n ")}` : "") + (result.skipped.length ? `
9718
10107
  skipped: ${result.skipped.join("\n skipped: ")}` : "") + `
9719
10108
  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%` : "")
10109
+ Win rate so far: ${rate.wins} win(s), ${rate.ties} tie(s) of ${rate.judged} judged (${(rate.rate * 100).toFixed(0)}%; a tie counts a half); parity with the references is 50%, the marketability bar is 40%` : "")
9721
10110
  );
9722
10111
  return EXIT_OK;
9723
10112
  }
@@ -10008,4 +10397,4 @@ export {
10008
10397
  convertAgentBrowser,
10009
10398
  run
10010
10399
  };
10011
- //# sourceMappingURL=chunk-MMKDYE57.js.map
10400
+ //# sourceMappingURL=chunk-PKHORUJ2.js.map