pro-visu 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { cac } from "cac";
5
5
 
6
6
  // src/version.ts
7
- var TOOL_VERSION = true ? "0.3.1" : "0.0.0-dev";
7
+ var TOOL_VERSION = true ? "0.5.0" : "0.0.0-dev";
8
8
 
9
9
  // src/cli/update-check.ts
10
10
  import updateNotifier from "update-notifier";
@@ -31,6 +31,7 @@ function checkForUpdates(version = TOOL_VERSION) {
31
31
  import path17 from "path";
32
32
  import { existsSync as existsSync4, readFileSync } from "fs";
33
33
  import { readFile as readFile5, writeFile as writeFile6 } from "fs/promises";
34
+ import { createRequire as createRequire2 } from "module";
34
35
 
35
36
  // src/utils/paths.ts
36
37
  import path from "path";
@@ -155,7 +156,11 @@ async function ensureChromium(opts) {
155
156
  child.on("error", reject);
156
157
  child.on(
157
158
  "close",
158
- (code) => code === 0 ? resolve() : reject(new Error(`Chromium install failed (exit code ${code}).`))
159
+ (code) => code === 0 ? resolve() : reject(
160
+ new Error(
161
+ `Chromium install failed (exit code ${code}). If you're offline or behind a proxy, set HTTPS_PROXY, or point PLAYWRIGHT_DOWNLOAD_HOST at a mirror.`
162
+ )
163
+ )
159
164
  );
160
165
  });
161
166
  opts.logger.success("Chromium installed.");
@@ -163,17 +168,111 @@ async function ensureChromium(opts) {
163
168
  }
164
169
 
165
170
  // src/media/ensure-ffmpeg.ts
166
- import path5 from "path";
167
171
  import { existsSync as existsSync2 } from "fs";
168
- import { rm as rm3 } from "fs/promises";
169
172
  import { spawn as spawn3 } from "child_process";
170
- import { createRequire as createRequire2 } from "module";
171
173
 
172
174
  // src/media/ffmpeg.ts
173
- import path4 from "path";
175
+ import path5 from "path";
174
176
  import { spawn as spawn2 } from "child_process";
175
- import { rm as rm2, writeFile as writeFile2 } from "fs/promises";
176
- import ffmpegStatic from "ffmpeg-static";
177
+ import { rm as rm3, writeFile as writeFile2 } from "fs/promises";
178
+
179
+ // src/media/ffmpeg-binary.ts
180
+ import os from "os";
181
+ import path4 from "path";
182
+ import https from "https";
183
+ import { createWriteStream } from "fs";
184
+ import { mkdir as mkdir2, rename, rm as rm2, chmod } from "fs/promises";
185
+ import { createGunzip } from "zlib";
186
+ import { pipeline } from "stream/promises";
187
+ var FFMPEG_RELEASE = process.env.FFMPEG_BINARY_RELEASE || "b6.1.1";
188
+ var BINARIES_URL = process.env.FFMPEG_BINARIES_URL || "https://github.com/eugeneware/ffmpeg-static/releases/download";
189
+ var SUPPORTED = {
190
+ darwin: ["x64", "arm64"],
191
+ linux: ["x64", "ia32", "arm64", "arm"],
192
+ win32: ["x64", "ia32"],
193
+ freebsd: ["x64"]
194
+ };
195
+ function ffmpegIsSupported() {
196
+ return (SUPPORTED[process.platform] ?? []).includes(process.arch);
197
+ }
198
+ function ffmpegCacheDir() {
199
+ const base = process.env.PROVISU_FFMPEG_DIR || path4.join(os.homedir(), ".cache", "pro-visu", "ffmpeg");
200
+ return path4.join(base, FFMPEG_RELEASE);
201
+ }
202
+ function ffmpegCachedBinary() {
203
+ return path4.join(ffmpegCacheDir(), process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
204
+ }
205
+ function ffmpegBinaryPath() {
206
+ return process.env.FFMPEG_BIN || ffmpegCachedBinary();
207
+ }
208
+ function ffmpegDownloadUrl() {
209
+ if (!ffmpegIsSupported()) return null;
210
+ return `${BINARIES_URL}/${FFMPEG_RELEASE}/ffmpeg-${process.platform}-${process.arch}.gz`;
211
+ }
212
+ function getFollowing(url, redirects = 5) {
213
+ return new Promise((resolve, reject) => {
214
+ const req = https.get(url, { headers: { "User-Agent": "pro-visu" } }, (res) => {
215
+ const status = res.statusCode ?? 0;
216
+ if (status >= 300 && status < 400 && res.headers.location) {
217
+ res.resume();
218
+ if (redirects <= 0) return reject(new Error("Too many redirects fetching ffmpeg."));
219
+ resolve(getFollowing(new URL(res.headers.location, url).toString(), redirects - 1));
220
+ return;
221
+ }
222
+ if (status !== 200) {
223
+ res.resume();
224
+ reject(new Error(`ffmpeg download failed: HTTP ${status} for ${url}`));
225
+ return;
226
+ }
227
+ resolve(res);
228
+ });
229
+ req.on("error", reject);
230
+ });
231
+ }
232
+ var NETWORK_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"]);
233
+ function withNetworkHint(err) {
234
+ const code = err.code;
235
+ if (code && NETWORK_CODES.has(code)) {
236
+ return new Error(
237
+ `${err.message} \u2014 you appear to be offline or behind a proxy. Set FFMPEG_BINARIES_URL to a reachable mirror, or FFMPEG_BIN to a local ffmpeg binary.`
238
+ );
239
+ }
240
+ return err;
241
+ }
242
+ async function downloadFfmpeg(onProgress) {
243
+ const url = ffmpegDownloadUrl();
244
+ if (!url) {
245
+ throw new Error(
246
+ `No prebuilt ffmpeg for ${process.platform}/${process.arch}. Set FFMPEG_BIN to a local ffmpeg binary.`
247
+ );
248
+ }
249
+ const dest = ffmpegCachedBinary();
250
+ const tmp = `${dest}.download`;
251
+ await mkdir2(path4.dirname(dest), { recursive: true });
252
+ await rm2(tmp, { force: true });
253
+ try {
254
+ const res = await getFollowing(url);
255
+ if (onProgress) {
256
+ const totalHeader = Number(res.headers["content-length"]);
257
+ const total = Number.isFinite(totalHeader) && totalHeader > 0 ? totalHeader : void 0;
258
+ let downloaded = 0;
259
+ res.on("data", (chunk) => {
260
+ downloaded += chunk.length;
261
+ onProgress(downloaded, total);
262
+ });
263
+ }
264
+ await pipeline(res, createGunzip(), createWriteStream(tmp));
265
+ } catch (err) {
266
+ throw withNetworkHint(err);
267
+ }
268
+ await chmod(tmp, 493).catch(() => {
269
+ });
270
+ await rm2(dest, { force: true });
271
+ await rename(tmp, dest);
272
+ return dest;
273
+ }
274
+
275
+ // src/media/ffmpeg.ts
177
276
  var SCALE_COLOR = "out_color_matrix=bt709:out_range=tv";
178
277
  var COLOR_TAGS = [
179
278
  "-colorspace",
@@ -186,11 +285,7 @@ var COLOR_TAGS = [
186
285
  "tv"
187
286
  ];
188
287
  function ffmpegPath() {
189
- const p = ffmpegStatic;
190
- if (!p) {
191
- throw new Error("ffmpeg-static did not provide a binary for this platform.");
192
- }
193
- return p;
288
+ return ffmpegBinaryPath();
194
289
  }
195
290
  function buildTranscodeArgs(args) {
196
291
  const seek = args.startOffsetSeconds && args.startOffsetSeconds > 0 ? ["-ss", args.startOffsetSeconds.toFixed(3)] : [];
@@ -332,7 +427,7 @@ function buildConcatArgs(listFile, outPath) {
332
427
  ];
333
428
  }
334
429
  async function concatMp4(segments, outPath, logger, signal) {
335
- await ensureDir(path4.dirname(outPath));
430
+ await ensureDir(path5.dirname(outPath));
336
431
  const listFile = `${outPath}.concat.txt`;
337
432
  const list = segments.map((s) => `file '${s.replace(/\\/g, "/")}'`).join("\n");
338
433
  await writeFile2(listFile, `${list}
@@ -340,7 +435,7 @@ async function concatMp4(segments, outPath, logger, signal) {
340
435
  try {
341
436
  await runFfmpeg(buildConcatArgs(listFile, outPath), logger, signal);
342
437
  } finally {
343
- await rm2(listFile, { force: true });
438
+ await rm3(listFile, { force: true });
344
439
  }
345
440
  }
346
441
  async function runFfmpeg(argv, logger, signal) {
@@ -362,7 +457,7 @@ ${stderr.slice(-2e3)}`));
362
457
  });
363
458
  }
364
459
  async function transcodeToMp4(args) {
365
- await ensureDir(path4.dirname(args.outputPath));
460
+ await ensureDir(path5.dirname(args.outputPath));
366
461
  await runFfmpeg(buildTranscodeArgs(args), args.logger, args.signal);
367
462
  }
368
463
  function aspectTarget(aspect) {
@@ -467,7 +562,6 @@ function buildStillSegmentArgs(a) {
467
562
  }
468
563
 
469
564
  // src/media/ensure-ffmpeg.ts
470
- var require3 = createRequire2(import.meta.url);
471
565
  async function ffmpegWorks() {
472
566
  let bin;
473
567
  try {
@@ -488,42 +582,35 @@ async function ffmpegWorks() {
488
582
  child.on("close", (code) => resolve(code === 0));
489
583
  });
490
584
  }
491
- function resolveInstallScript() {
492
- try {
493
- return require3.resolve("ffmpeg-static/install.js");
494
- } catch {
495
- try {
496
- const pkg = require3.resolve("ffmpeg-static/package.json");
497
- return path5.join(path5.dirname(pkg), "install.js");
498
- } catch {
499
- return null;
500
- }
501
- }
502
- }
503
585
  async function ensureFfmpeg(opts) {
504
586
  if (await ffmpegWorks()) return true;
505
587
  if (opts.checkOnly) return false;
506
- const installScript = resolveInstallScript();
507
- if (!installScript) {
508
- opts.logger.error("ffmpeg-static is not installed; cannot fetch an ffmpeg binary.");
588
+ if (process.env.FFMPEG_BIN) {
589
+ opts.logger.error(`FFMPEG_BIN is set to "${process.env.FFMPEG_BIN}" but that binary won't run.`);
590
+ return false;
591
+ }
592
+ if (!ffmpegIsSupported()) {
593
+ opts.logger.error(
594
+ `No prebuilt ffmpeg for ${process.platform}/${process.arch}. Set FFMPEG_BIN to a local ffmpeg binary.`
595
+ );
509
596
  return false;
510
597
  }
511
598
  opts.logger.info("Fetching ffmpeg (one-time, ~80 MB)\u2026");
512
599
  try {
513
- await rm3(ffmpegPath(), { force: true });
514
- } catch {
515
- }
516
- await new Promise((resolve, reject) => {
517
- const child = spawn3(process.execPath, [installScript], {
518
- cwd: path5.dirname(installScript),
519
- stdio: "inherit"
600
+ let lastQuartile = 0;
601
+ await downloadFfmpeg((downloaded, total) => {
602
+ if (!total) return;
603
+ const quartile = Math.floor(downloaded / total * 4);
604
+ if (quartile > lastQuartile && quartile < 4) {
605
+ lastQuartile = quartile;
606
+ const mb = (n) => (n / 1024 / 1024).toFixed(0);
607
+ opts.logger.info(`ffmpeg download: ${quartile * 25}% (${mb(downloaded)}/${mb(total)} MB)`);
608
+ }
520
609
  });
521
- child.on("error", reject);
522
- child.on(
523
- "close",
524
- (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg download failed (exit code ${code}).`))
525
- );
526
- });
610
+ } catch (err) {
611
+ opts.logger.error(`ffmpeg download failed: ${err.message}`);
612
+ return false;
613
+ }
527
614
  if (!await ffmpegWorks()) {
528
615
  opts.logger.error("ffmpeg was downloaded but still won't run on this platform.");
529
616
  return false;
@@ -578,6 +665,16 @@ var serverSettingsSchema = z.object({
578
665
  /** If a server is already reachable at the URL, use it as-is (don't start or stop one). */
579
666
  reuseExisting: z.boolean().default(true).describe("If a server is already reachable at the URL, use it as-is (don't start or stop one). Default true.")
580
667
  }).strict();
668
+ var captureSettingsSchema = z.object({
669
+ /** Query params appended to every URL-based asset (e.g. `{ capture: "1" }` → `?capture=1`). */
670
+ query: z.record(z.string(), z.string()).optional().describe('Query params appended to every URL-based asset, e.g. { capture: "1" }.'),
671
+ /** Cookies set on every capture context before navigation, scoped to the asset's origin. */
672
+ cookies: z.array(z.object({ name: z.string().min(1), value: z.string() }).strict()).optional().describe("Cookies set on every capture context before navigation (scoped to the asset's origin)."),
673
+ /** localStorage entries seeded before the page's own scripts run. */
674
+ localStorage: z.record(z.string(), z.string()).optional().describe("localStorage entries seeded (per origin) before the page's own scripts run."),
675
+ /** JS source run in every page before its own scripts — e.g. set a global capture flag. */
676
+ initScript: z.string().optional().describe("JS run in every page before its own scripts (e.g. `window.__PV_CAPTURE__ = true`).")
677
+ }).strict();
581
678
  var settingsSchema = z.object({
582
679
  /** Output directory, relative to the repo root. */
583
680
  outDir: z.string().min(1).default("pro-visu").describe('Output directory for generated assets, relative to the repo root (default "pro-visu").'),
@@ -599,17 +696,19 @@ var settingsSchema = z.object({
599
696
  defaults: z.record(z.string(), z.record(z.string(), z.unknown())).default({}).describe("Per-generator option defaults, keyed by generator id, merged underneath each asset's own options."),
600
697
  /** Optional managed dev/prod server lifecycle (build → start → wait → … → stop). */
601
698
  server: serverSettingsSchema.optional().describe("Build \u2192 start \u2192 wait \u2192 capture \u2192 stop a server automatically."),
699
+ /** "Capture mode" toggles (query/cookies/localStorage/init script) applied to every URL capture. */
700
+ capture: captureSettingsSchema.optional().describe("Capture-mode toggles applied to every URL-based asset (e.g. disable animations / hide the cookie banner)."),
602
701
  /** Render quality. "draft" lowers fps/scale and speeds the encoder for fast iteration. */
603
702
  quality: z.enum(["draft", "final"]).default("final").describe('Render quality; "draft" lowers fps/scale and speeds the encoder for fast iteration (default "final").'),
604
703
  /** Skip assets whose inputs+options+tool fingerprint is unchanged (opt-in; can be stale). */
605
704
  cache: z.boolean().default(false).describe("Skip assets whose inputs+options+tool fingerprint is unchanged (opt-in; can be stale). Default false.")
606
- });
705
+ }).strict();
607
706
  var assetSpecSchema = z.object({
608
707
  name: z.string().min(1),
609
708
  /**
610
709
  * Page to capture: an absolute `https://…` URL, or a path like `/shop` resolved against the
611
710
  * managed server's URL. Optional — with a managed server, a url-based asset that omits it
612
- * captures the server root; local generators (`scene`, `palette`) need no url.
711
+ * captures the server root; local generators (`specimen`, `palette`, `wall`, …) need no url.
613
712
  */
614
713
  url: z.string().min(1).refine((s) => /^https?:\/\//i.test(s) || s.startsWith("/"), {
615
714
  message: 'url must be an absolute http(s) URL or a path starting with "/" (resolved against the managed server)'
@@ -624,9 +723,11 @@ var assetSpecSchema = z.object({
624
723
  inputs: z.record(z.string(), z.string()).default({})
625
724
  }).strict();
626
725
  var showcaseConfigSchema = z.object({
726
+ /** JSON configs may carry an editor `$schema` pointer; accepted and ignored at runtime. */
727
+ $schema: z.string().optional(),
627
728
  settings: settingsSchema.default({}),
628
729
  assets: z.array(assetSpecSchema).min(1, "Define at least one asset in `assets`.")
629
- }).superRefine((cfg, ctx) => {
730
+ }).strict().superRefine((cfg, ctx) => {
630
731
  const seen = /* @__PURE__ */ new Set();
631
732
  for (const [i, asset] of cfg.assets.entries()) {
632
733
  if (seen.has(asset.name)) {
@@ -648,12 +749,12 @@ import { stat as stat2 } from "fs/promises";
648
749
  import { z as z2 } from "zod";
649
750
  var easingSchema = z2.enum([
650
751
  "linear",
651
- "easeInOutCubic",
652
- "easeInOutQuad",
653
- "easeOutCubic",
654
- "easeInOutSine",
655
- "easeInOutExpo",
656
- "easeOutQuint"
752
+ "ease-in-out-cubic",
753
+ "ease-in-out-quad",
754
+ "ease-out-cubic",
755
+ "ease-in-out-sine",
756
+ "ease-in-out-expo",
757
+ "ease-out-quint"
657
758
  ]);
658
759
  var choreographyStepSchema = z2.object({
659
760
  /** Target: a 0..1 number, an "NN%" string, or a CSS selector to bring into view. */
@@ -662,7 +763,7 @@ var choreographyStepSchema = z2.object({
662
763
  durationMs: z2.number().int().nonnegative().optional().describe("Travel time to this target (ms). Default 1200."),
663
764
  /** Hold time at this target after arriving (ms). Default 800. */
664
765
  holdMs: z2.number().int().nonnegative().optional().describe("Hold time at this target after arriving (ms). Default 800."),
665
- easing: easingSchema.optional().describe('Easing for the travel to this target. Default "easeInOutCubic".')
766
+ easing: easingSchema.optional().describe('Easing for the travel to this target. Default "ease-in-out-cubic".')
666
767
  }).strict();
667
768
  var interactionActionSchema = z2.object({
668
769
  do: z2.enum(["move", "click", "hover", "type", "scrollTo", "wait"]).describe("What this step does."),
@@ -728,8 +829,8 @@ var scrollReelOptionsSchema = z2.object({
728
829
  /** Output frames per second (re-encoded from the recording). */
729
830
  fps: z2.number().int().positive().max(120).default(30).describe("Output frames per second (re-encoded from the recording). Default 30."),
730
831
  /** Time to scroll from top to bottom (ms). */
731
- duration: z2.number().int().positive().default(6e3).describe("Time to scroll from top to bottom (ms). Default 6000."),
732
- easing: easingSchema.default("easeInOutCubic").describe('Easing for the default top\u2192bottom scroll. Default "easeInOutCubic".'),
832
+ durationMs: z2.number().int().positive().default(6e3).describe("Time to scroll from top to bottom (ms). Default 6000."),
833
+ easing: easingSchema.default("ease-in-out-cubic").describe('Easing for the default top\u2192bottom scroll. Default "ease-in-out-cubic".'),
733
834
  /** Dwell at the top before scrolling (ms). */
734
835
  startDelayMs: z2.number().int().nonnegative().default(500).describe("Dwell at the top before scrolling (ms). Default 500."),
735
836
  /** Dwell at the bottom after scrolling (ms). */
@@ -788,7 +889,7 @@ var scrollReelOptionsSchema = z2.object({
788
889
  scaleFrom: z2.number().positive().optional().describe("Start scale (1 = no zoom). Default 1."),
789
890
  /** End scale. Default 1.08. */
790
891
  scaleTo: z2.number().positive().optional().describe("End scale. Default 1.08."),
791
- easing: easingSchema.optional().describe('Easing for the zoom ramp. Default "easeInOutCubic".'),
892
+ easing: easingSchema.optional().describe('Easing for the zoom ramp. Default "ease-in-out-cubic".'),
792
893
  /** Zoom origin X within the viewport (0 = left, 1 = right). Default 0.5. */
793
894
  originX: z2.number().min(0).max(1).optional().describe("Zoom origin X within the viewport (0 = left, 1 = right). Default 0.5."),
794
895
  /** Zoom origin Y within the viewport (0 = top, 1 = bottom). Default 0.5. */
@@ -943,30 +1044,67 @@ var scrollReelOptionsSchema = z2.object({
943
1044
  import path6 from "path";
944
1045
  import { mkdtemp } from "fs/promises";
945
1046
 
1047
+ // src/pipeline/capture.ts
1048
+ function withCaptureQuery(url, capture) {
1049
+ if (!url || !capture?.query) return url;
1050
+ try {
1051
+ const u = new URL(url);
1052
+ for (const [k, v] of Object.entries(capture.query)) u.searchParams.set(k, v);
1053
+ return u.toString();
1054
+ } catch {
1055
+ return url;
1056
+ }
1057
+ }
1058
+ async function applyCapture(context, capture, url) {
1059
+ if (!capture) return;
1060
+ if (capture.cookies?.length && url) {
1061
+ try {
1062
+ const origin = new URL(url).origin;
1063
+ await context.addCookies(
1064
+ capture.cookies.map((c) => ({ name: c.name, value: c.value, url: origin }))
1065
+ );
1066
+ } catch {
1067
+ }
1068
+ }
1069
+ const scripts = [];
1070
+ if (capture.localStorage) {
1071
+ scripts.push(
1072
+ `try{var e=${JSON.stringify(capture.localStorage)};for(var k in e)localStorage.setItem(k,e[k]);}catch(_){}`
1073
+ );
1074
+ }
1075
+ if (capture.initScript) scripts.push(capture.initScript);
1076
+ if (scripts.length) await context.addInitScript(scripts.join("\n"));
1077
+ }
1078
+
946
1079
  // src/generators/scroll-reel/scroll.ts
947
1080
  var EASINGS = {
948
1081
  linear: (t) => t,
949
- easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
950
- easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
951
- easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
952
- easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
953
- easeInOutExpo: (t) => t === 0 ? 0 : t === 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2,
954
- easeOutQuint: (t) => 1 - Math.pow(1 - t, 5)
1082
+ "ease-in-out-cubic": (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
1083
+ "ease-in-out-quad": (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
1084
+ "ease-out-cubic": (t) => 1 - Math.pow(1 - t, 3),
1085
+ "ease-in-out-sine": (t) => -(Math.cos(Math.PI * t) - 1) / 2,
1086
+ "ease-in-out-expo": (t) => t === 0 ? 0 : t === 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2,
1087
+ "ease-out-quint": (t) => 1 - Math.pow(1 - t, 5)
955
1088
  };
956
1089
  async function prepareScroll(args) {
957
1090
  const target = findScrollTarget(document, getComputedStyle);
958
1091
  forceInstant(target);
959
1092
  const sleep2 = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
1093
+ const withCap = (p, ms) => Promise.race([p ?? Promise.resolve(), sleep2(ms)]);
960
1094
  scrollTargetTo(target, maxScrollOf(target));
961
1095
  await sleep2(Math.max(0, args.settleMs));
962
1096
  try {
963
- await (document.fonts?.ready ?? Promise.resolve());
1097
+ await withCap(document.fonts?.ready ?? null, 5e3);
964
1098
  } catch {
965
1099
  }
966
1100
  try {
967
1101
  const imgs = Array.from(document.images ?? []);
968
- await Promise.all(imgs.map((im) => im.decode ? im.decode().catch(() => {
969
- }) : null));
1102
+ await withCap(
1103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1104
+ Promise.all(imgs.map((im) => im.decode ? im.decode().catch(() => {
1105
+ }) : null)),
1106
+ 4e3
1107
+ );
970
1108
  } catch {
971
1109
  }
972
1110
  scrollTargetTo(target, 0);
@@ -1311,17 +1449,17 @@ async function pageScroll(args) {
1311
1449
  const { durationMs, easing, startDelayMs, endDwellMs } = args;
1312
1450
  const ease = (t) => {
1313
1451
  switch (easing) {
1314
- case "easeInOutCubic":
1452
+ case "ease-in-out-cubic":
1315
1453
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1316
- case "easeInOutQuad":
1454
+ case "ease-in-out-quad":
1317
1455
  return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
1318
- case "easeOutCubic":
1456
+ case "ease-out-cubic":
1319
1457
  return 1 - Math.pow(1 - t, 3);
1320
- case "easeInOutSine":
1458
+ case "ease-in-out-sine":
1321
1459
  return -(Math.cos(Math.PI * t) - 1) / 2;
1322
- case "easeInOutExpo":
1460
+ case "ease-in-out-expo":
1323
1461
  return t === 0 ? 0 : t === 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2;
1324
- case "easeOutQuint":
1462
+ case "ease-out-quint":
1325
1463
  return 1 - Math.pow(1 - t, 5);
1326
1464
  default:
1327
1465
  return t;
@@ -1410,6 +1548,7 @@ async function captureScrollWebm(args) {
1410
1548
  size: { width: options.width, height: options.height }
1411
1549
  }
1412
1550
  });
1551
+ await applyCapture(context, args.capture, url);
1413
1552
  const page = await context.newPage();
1414
1553
  const video = page.video();
1415
1554
  const recStart = Date.now();
@@ -1424,7 +1563,7 @@ async function captureScrollWebm(args) {
1424
1563
  await page.evaluate(prepareScroll, { settleMs: PREWARM_SETTLE_MS });
1425
1564
  leadSeconds = (Date.now() - recStart) / 1e3;
1426
1565
  const distance = await page.evaluate(pageScroll, {
1427
- durationMs: options.duration,
1566
+ durationMs: options.durationMs,
1428
1567
  easing: options.easing,
1429
1568
  startDelayMs: options.startDelayMs,
1430
1569
  endDwellMs: options.endDwellMs
@@ -1444,10 +1583,10 @@ async function captureScrollWebm(args) {
1444
1583
  }
1445
1584
 
1446
1585
  // src/media/frame-capture.ts
1447
- import os from "os";
1586
+ import os2 from "os";
1448
1587
  import path7 from "path";
1449
1588
  function autoWorkers() {
1450
- const cores = os.cpus()?.length ?? 2;
1589
+ const cores = os2.cpus()?.length ?? 2;
1451
1590
  return Math.max(1, Math.min(6, Math.floor(cores / 2)));
1452
1591
  }
1453
1592
  function planFrames(totalFrames, workers) {
@@ -1895,7 +2034,7 @@ function scrollTimelineTotalMs(o) {
1895
2034
  if (o.autoSections) {
1896
2035
  return autoSectionsBudgetMs(o.autoSections);
1897
2036
  }
1898
- return o.startDelayMs + o.duration + o.endDwellMs;
2037
+ return o.startDelayMs + o.durationMs + o.endDwellMs;
1899
2038
  }
1900
2039
  function boomerangSpec(spec) {
1901
2040
  const forward = spec.segments.map((s) => ({ ...s, durationFraction: s.durationFraction / 2 }));
@@ -1991,7 +2130,7 @@ async function buildScrollTimeline(page, options, totalSeconds, logger) {
1991
2130
  return finalize(
1992
2131
  defaultTimelineSpec({
1993
2132
  startDelayMs: options.startDelayMs,
1994
- durationMs: options.duration,
2133
+ durationMs: options.durationMs,
1995
2134
  endDwellMs: options.endDwellMs,
1996
2135
  easing: options.easing
1997
2136
  })
@@ -2019,6 +2158,7 @@ async function captureScrollFrames(a) {
2019
2158
  signal: a.signal,
2020
2159
  prepare: async (page, { logger }) => {
2021
2160
  if (a.colorScheme) await page.emulateMedia({ colorScheme: a.colorScheme });
2161
+ await applyCapture(page.context(), a.capture, a.url);
2022
2162
  await installNetworkHygiene(page, options);
2023
2163
  await installPreNav(page, options);
2024
2164
  logger.debug(`navigating to ${a.url} (waitUntil=${options.waitUntil})`);
@@ -2483,6 +2623,7 @@ async function captureInteractionWebm(args) {
2483
2623
  deviceScaleFactor: options.deviceScaleFactor,
2484
2624
  recordVideo: { dir: recordDir, size: { width: options.width, height: options.height } }
2485
2625
  });
2626
+ await applyCapture(context, args.capture, url);
2486
2627
  const page = await context.newPage();
2487
2628
  const video = page.video();
2488
2629
  const actions = options.actions ?? [];
@@ -2545,6 +2686,7 @@ async function captureFocusWebm(args) {
2545
2686
  deviceScaleFactor: options.deviceScaleFactor,
2546
2687
  recordVideo: { dir: recordDir, size: { width: options.width, height: options.height } }
2547
2688
  });
2689
+ await applyCapture(context, args.capture, url);
2548
2690
  const page = await context.newPage();
2549
2691
  const video = page.video();
2550
2692
  const actions = focus.actions ?? [];
@@ -2639,6 +2781,7 @@ async function run(ctx, options) {
2639
2781
  ctx.logger.info(`recording ${url} (focus: ${options.focus.selector})`);
2640
2782
  const { webmPath, leadSeconds, durationSeconds, cropBox } = await captureFocusWebm({
2641
2783
  browser: ctx.browser,
2784
+ capture: ctx.capture,
2642
2785
  url,
2643
2786
  options,
2644
2787
  colorScheme: scheme,
@@ -2692,6 +2835,7 @@ async function run(ctx, options) {
2692
2835
  ctx.logger.info(`recording ${url} (interaction, ${options.actions.length} action(s))`);
2693
2836
  const { webmPath, leadSeconds, durationSeconds } = await captureInteractionWebm({
2694
2837
  browser: ctx.browser,
2838
+ capture: ctx.capture,
2695
2839
  url,
2696
2840
  options,
2697
2841
  colorScheme: scheme,
@@ -2747,7 +2891,7 @@ async function run(ctx, options) {
2747
2891
  ...options,
2748
2892
  choreography: r.choreography,
2749
2893
  autoSections: r.autoSections,
2750
- duration: r.durationMs ?? options.duration
2894
+ durationMs: r.durationMs ?? options.durationMs
2751
2895
  };
2752
2896
  const segPath = path10.join(ctx.tmpDir, `${slugify(ctx.target.name)}-route-${i}.mp4`);
2753
2897
  ctx.logger.info(
@@ -2755,6 +2899,7 @@ async function run(ctx, options) {
2755
2899
  );
2756
2900
  await captureScrollFrames({
2757
2901
  browser: ctx.browser,
2902
+ capture: ctx.capture,
2758
2903
  url: routeUrl,
2759
2904
  options: routeOpts,
2760
2905
  outPath: segPath,
@@ -2806,10 +2951,11 @@ async function run(ctx, options) {
2806
2951
  }
2807
2952
  const fileName = options.fileName ?? `${slugify(ctx.target.name)}.mp4`;
2808
2953
  const outPath = ctx.resolveOutPath(fileName);
2809
- const durationSeconds = (options.startDelayMs + options.duration + options.endDwellMs) / 1e3;
2954
+ const durationSeconds = (options.startDelayMs + options.durationMs + options.endDwellMs) / 1e3;
2810
2955
  ctx.logger.info(`recording ${url} (realtime)`);
2811
2956
  const { webmPath, leadSeconds } = await captureScrollWebm({
2812
2957
  browser: ctx.browser,
2958
+ capture: ctx.capture,
2813
2959
  url,
2814
2960
  options,
2815
2961
  tmpDir: ctx.tmpDir,
@@ -2839,7 +2985,7 @@ async function run(ctx, options) {
2839
2985
  format: "mp4",
2840
2986
  width: options.width,
2841
2987
  height: options.height,
2842
- durationMs: options.startDelayMs + options.duration + options.endDwellMs,
2988
+ durationMs: options.startDelayMs + options.durationMs + options.endDwellMs,
2843
2989
  bytes: stats.size,
2844
2990
  contentHash,
2845
2991
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2873,6 +3019,7 @@ async function run(ctx, options) {
2873
3019
  ctx.logger.info(`recording ${url}${label} (frame-stepped, ${workers} worker(s))`);
2874
3020
  await captureScrollFrames({
2875
3021
  browser: ctx.browser,
3022
+ capture: ctx.capture,
2876
3023
  url,
2877
3024
  options: vopts,
2878
3025
  outPath: captureMp4,
@@ -2922,7 +3069,7 @@ import { imageSize } from "image-size";
2922
3069
 
2923
3070
  // src/generators/screenshots/options.ts
2924
3071
  import { z as z3 } from "zod";
2925
- var breakpointSchema = z3.object({
3072
+ var viewportSchema = z3.object({
2926
3073
  name: z3.string().min(1).describe('Label for this viewport \u2014 used in the filename + manifest id (e.g. "desktop").'),
2927
3074
  width: z3.number().int().positive().describe("Viewport width in CSS px."),
2928
3075
  /** Viewport height. Note: ignored for `fullPage` shots (Playwright resizes to the page height);
@@ -2930,8 +3077,8 @@ var breakpointSchema = z3.object({
2930
3077
  height: z3.number().int().positive().default(900).describe(
2931
3078
  "Viewport height in CSS px. Default 900. Ignored for fullPage shots; only affects viewport/element captures."
2932
3079
  ),
2933
- /** Override the generator-level deviceScaleFactor for this breakpoint. */
2934
- deviceScaleFactor: z3.number().positive().max(4).optional().describe("Override the generator-level deviceScaleFactor for this breakpoint. Omit to inherit it.")
3080
+ /** Override the generator-level deviceScaleFactor for this viewport. */
3081
+ deviceScaleFactor: z3.number().positive().max(4).optional().describe("Override the generator-level deviceScaleFactor for this viewport. Omit to inherit it.")
2935
3082
  }).strict();
2936
3083
  var elementShotSchema = z3.object({
2937
3084
  selector: z3.string().min(1).describe("CSS selector of the element to shoot."),
@@ -2939,7 +3086,7 @@ var elementShotSchema = z3.object({
2939
3086
  name: z3.string().min(1).describe("Name used in the filename + manifest id for this element shot.")
2940
3087
  }).strict();
2941
3088
  var screenshotsOptionsSchema = z3.object({
2942
- breakpoints: z3.array(breakpointSchema).min(1).default([
3089
+ viewports: z3.array(viewportSchema).min(1).default([
2943
3090
  { name: "desktop", width: 1440, height: 900 },
2944
3091
  { name: "mobile", width: 390, height: 844 }
2945
3092
  ]).describe(
@@ -2953,8 +3100,8 @@ var screenshotsOptionsSchema = z3.object({
2953
3100
  deviceScaleFactor: z3.number().positive().max(4).default(2).describe("Render scale (2 = retina-crisp). Default 2."),
2954
3101
  waitUntil: z3.enum(["load", "domcontentloaded", "networkidle", "commit"]).default("networkidle").describe('Page-load milestone to wait for before capturing. Default "networkidle".'),
2955
3102
  waitForSelector: z3.string().optional().describe("Optional element to wait for before capturing (e.g. a hero image). Omit to skip."),
2956
- /** Element captures taken at every breakpoint. */
2957
- elements: z3.array(elementShotSchema).default([]).describe("Specific elements to crop (in addition to the page) at every breakpoint. Default none."),
3103
+ /** Element captures taken at every viewport. */
3104
+ elements: z3.array(elementShotSchema).default([]).describe("Specific elements to crop (in addition to the page) at every viewport. Default none."),
2958
3105
  /** png only: capture with a transparent background. */
2959
3106
  omitBackground: z3.boolean().default(false).describe("Capture with a transparent background (png only). Default false."),
2960
3107
  /** Extra settle time after load before capturing (ms). */
@@ -2984,14 +3131,14 @@ async function mapLimit(items, limit, fn) {
2984
3131
  var MIN_PREPARE_SETTLE_MS = 600;
2985
3132
  async function captureScreenshots(args) {
2986
3133
  const { options } = args;
2987
- const perBreakpoint = await mapLimit(
2988
- options.breakpoints,
2989
- Math.min(3, options.breakpoints.length),
2990
- (bp) => captureBreakpoint(args, bp)
3134
+ const perViewport = await mapLimit(
3135
+ options.viewports,
3136
+ Math.min(3, options.viewports.length),
3137
+ (bp) => captureViewport(args, bp)
2991
3138
  );
2992
- return perBreakpoint.flat();
3139
+ return perViewport.flat();
2993
3140
  }
2994
- async function captureBreakpoint(args, bp) {
3141
+ async function captureViewport(args, bp) {
2995
3142
  const { browser, url, options, logger } = args;
2996
3143
  const shots = [];
2997
3144
  {
@@ -2999,6 +3146,7 @@ async function captureBreakpoint(args, bp) {
2999
3146
  viewport: { width: bp.width, height: bp.height },
3000
3147
  deviceScaleFactor: bp.deviceScaleFactor ?? options.deviceScaleFactor
3001
3148
  });
3149
+ await applyCapture(context, args.capture, url);
3002
3150
  const page = await context.newPage();
3003
3151
  try {
3004
3152
  logger.debug(`[${bp.name}] navigating to ${url}`);
@@ -3069,7 +3217,8 @@ async function run2(ctx, options) {
3069
3217
  browser: ctx.browser,
3070
3218
  url,
3071
3219
  options,
3072
- logger: ctx.logger
3220
+ logger: ctx.logger,
3221
+ capture: ctx.capture
3073
3222
  });
3074
3223
  const records = [];
3075
3224
  for (const shot of shots) {
@@ -3114,276 +3263,148 @@ var screenshotsGenerator = {
3114
3263
  };
3115
3264
 
3116
3265
  // src/generators/wall/options.ts
3117
- import { z as z6 } from "zod";
3118
-
3119
- // src/generators/scene/scene-options.ts
3120
3266
  import { z as z5 } from "zod";
3121
3267
 
3122
- // src/generators/specimen/options.ts
3268
+ // src/generators/scene/scene-options.ts
3123
3269
  import { z as z4 } from "zod";
3124
- var pulseSchema = z4.object({
3125
- /** Human label for the beat, e.g. "color sweep" — purely to keep the config readable. */
3126
- name: z4.string().default("").describe('Human label for the beat, e.g. "color sweep" \u2014 purely to keep the config readable.'),
3127
- /** Length of this beat, in seconds. */
3128
- duration: z4.number().positive().describe("Length of this beat, in seconds."),
3129
- /** Fraction of cells whose glyph changes during this beat (0..1; 1 = every cell once). */
3130
- chars: z4.number().nonnegative().default(0).describe("Fraction of cells whose glyph changes during this beat (0..1; 1 = every cell once; 0 = a hold). Default 0."),
3131
- /** Fraction of cells whose color changes during this beat (0..1; 1 = every cell once). */
3132
- colors: z4.number().nonnegative().default(0).describe("Fraction of cells whose color changes during this beat (0..1; 1 = every cell once). Default 0."),
3133
- /**
3134
- * Target color for this beat's color changes. When set, every color change in the beat goes to
3135
- * this exact token (a deliberate sweep) instead of a weighted-random pick. Set `colors: 1` with
3136
- * `pacing: "even"` to wash the whole specimen to one color evenly. Omit for the default
3137
- * scattered, weighted-random recoloring.
3138
- */
3139
- color: z4.enum(["foreground", "muted", "accent"]).optional().describe("Target color token for this beat's color changes (a deliberate sweep); omit for the default weighted-random recoloring."),
3140
- /**
3141
- * How the changes are distributed in time across the beat — like a CSS easing curve:
3142
- * "linear"/"even" = uniform, "ease-in" = front-loaded, "ease-out" = back-loaded,
3143
- * "ease-in-out" = bunched at both ends, "random" = scattered.
3144
- */
3145
- pacing: z4.enum(["even", "linear", "ease-in", "ease-out", "ease-in-out", "random"]).default("even").describe("How changes are distributed in time across the beat (CSS-easing-like): even/linear, ease-in, ease-out, ease-in-out, random. Default even.")
3270
+ var specimenWirePulseSchema = z4.object({
3271
+ name: z4.string().default(""),
3272
+ /** Length of this beat, in seconds (wire unit). */
3273
+ duration: z4.number().positive(),
3274
+ chars: z4.number().nonnegative().default(0),
3275
+ colors: z4.number().nonnegative().default(0),
3276
+ color: z4.enum(["foreground", "muted", "accent"]).optional(),
3277
+ pacing: z4.enum(["even", "linear", "ease-in", "ease-out", "ease-in-out", "random"]).default("even")
3146
3278
  }).strict();
3147
- var P = (name, duration, chars = 0, colors = 0) => ({
3148
- name,
3149
- duration,
3150
- chars,
3151
- colors,
3152
- pacing: "even"
3153
- });
3154
- var DEFAULT_PULSES = [
3155
- P("intro hold", 0.8),
3156
- P("first letters", 0.8, 0.15, 0),
3157
- P("settle", 1.5),
3158
- P("ripple", 1, 0.08, 0.04),
3159
- P("color sweep", 1.2, 0, 0.13),
3160
- P("rest", 1.2),
3161
- P("quick burst", 0.6, 0.18, 0),
3162
- P("drift", 1.2, 0, 0.08),
3163
- P("finale", 1.2, 0.18, 0.13),
3164
- P("outro hold", 0.5)
3165
- ];
3166
- var DEMO_PULSES = [
3167
- { name: "linear", duration: 5, chars: 0.5, pacing: "linear" },
3168
- { name: "hold", duration: 2 },
3169
- { name: "ease-in", duration: 5, chars: 0.5, pacing: "ease-in" },
3170
- { name: "hold", duration: 2 },
3171
- { name: "ease-out", duration: 5, chars: 0.5, pacing: "ease-out" },
3172
- { name: "hold", duration: 2 },
3173
- { name: "ease-in-out", duration: 5, chars: 0.5, pacing: "ease-in-out" },
3174
- { name: "hold", duration: 2 },
3175
- { name: "random", duration: 5, chars: 0.5, pacing: "random" },
3176
- { name: "hold", duration: 2 },
3177
- // Even, per-character color sweeps: each washes every glyph to one token, evenly across the beat.
3178
- { name: "sweep \u2192 muted", duration: 4, colors: 1, color: "muted", pacing: "even" },
3179
- { name: "sweep \u2192 accent", duration: 4, colors: 1, color: "accent", pacing: "even" },
3180
- { name: "sweep \u2192 foreground", duration: 4, colors: 1, color: "foreground", pacing: "even" },
3181
- { name: "hold", duration: 2 },
3182
- { name: "weighted recolor", duration: 5, colors: 0.6 },
3183
- { name: "hold", duration: 2 },
3184
- { name: "mingle", duration: 5, chars: 0.3, colors: 0.3 }
3185
- ];
3186
- var SWEEP_COLORS = {
3187
- background: "#0b0b0f",
3188
- foreground: "#f4f4f5",
3189
- muted: "#6b7280",
3190
- accent: "#7c9cff"
3191
- };
3192
- var SWEEP_PULSES = [
3193
- { name: "hold", duration: 0.8 },
3194
- { name: "to muted", duration: 2.2, colors: 1, color: "muted", pacing: "ease-in-out" },
3195
- { name: "settle", duration: 0.6 },
3196
- { name: "to accent", duration: 2.2, colors: 1, color: "accent", pacing: "ease-in-out" },
3197
- { name: "settle", duration: 0.6 },
3198
- { name: "to foreground", duration: 2.2, colors: 1, color: "foreground", pacing: "ease-in-out" },
3199
- { name: "glyph drift", duration: 1.4, chars: 0.5, pacing: "even" },
3200
- { name: "hold", duration: 0.6 }
3201
- ];
3202
- var SPECIMEN_TEMPLATES = {
3203
- demo: { demo: true, mirror: false, lines: 4, pulses: DEMO_PULSES },
3204
- sweep: {
3205
- mirror: true,
3206
- lines: 4,
3207
- colors: SWEEP_COLORS,
3208
- pulses: SWEEP_PULSES
3209
- }
3210
- };
3211
- function applyTemplate(raw) {
3212
- if (!raw || typeof raw !== "object") return raw;
3213
- const r = raw;
3214
- const tmpl = typeof r.template === "string" ? SPECIMEN_TEMPLATES[r.template] : void 0;
3215
- return tmpl ? { ...tmpl, ...r } : raw;
3216
- }
3217
- var specimenObjectSchema = z4.object({
3218
- font: z4.string().min(1).describe("Font file to showcase (path relative to the working dir, or absolute). Required."),
3219
- template: z4.enum(["demo", "sweep"]).optional().describe("Load a named option preset (demo or sweep); your explicit options still override what it sets."),
3220
- name: z4.string().default("").describe('Display name shown bottom-left (e.g. "ABC Oracle"). Default none.'),
3221
- demo: z4.boolean().default(false).describe("Demo mode: overlay the active pulse's name bottom-right, to see which beat is playing. Default false."),
3222
- fps: z4.number().int().positive().max(120).default(30).describe("Output frames per second. Default 30."),
3223
- durationSeconds: z4.number().positive().optional().describe("Clip length in seconds. Defaults to the (mirrored) sum of the pulse durations; set to override."),
3224
- width: z4.number().int().positive().default(1920).describe("Output frame width in px. Default 1920."),
3225
- height: z4.number().int().positive().default(1080).describe("Output frame height in px. Default 1080."),
3226
- deviceScaleFactor: z4.number().positive().max(4).default(1).describe("Render scale (1 = 1:1; higher = crisper capture, downscaled into the video). Default 1."),
3227
- weight: z4.number().int().min(1).max(1e3).default(820).describe("Glyph weight on the variable-font axis, 1\u20131000. Default 820."),
3228
- lines: z4.number().int().min(1).max(40).default(3).describe("Number of glyph rows; glyph size is derived so rows fill the top 80% of the frame. Default 3."),
3229
- leading: z4.number().positive().default(0.78).describe("Line-height of the glyph block. Default 0.78 (tight, cap-height-hugging)."),
3230
- blacklist: z4.string().default("").describe('Glyphs to exclude from the showcase, e.g. "QXZ" (case-insensitive). Default none.'),
3231
- characterPool: z4.string().refine((s) => (/* @__PURE__ */ new Set([...s.trim()])).size >= 2, "characterPool needs \u22652 distinct characters").optional().describe("Override the glyph pool the specimen draws from (\u22652 distinct characters). Default A\u2013Z 0\u20139 + symbols."),
3232
- seed: z4.number().int().default(1).describe("Schedule seed \u2014 same seed \u21D2 identical animation. Change for a different deterministic take. Default 1."),
3279
+ var specimenSceneOptionsSchema = z4.object({
3280
+ label: z4.string().default(""),
3281
+ demo: z4.boolean().default(false),
3282
+ weight: z4.number().min(1).max(1e3).default(820),
3283
+ lines: z4.number().int().min(1).max(40).default(3),
3284
+ blacklist: z4.string().default(""),
3233
3285
  colors: z4.object({
3234
- background: z4.string().default("#eceef1").describe("Backdrop behind the glyphs."),
3235
- foreground: z4.string().default("#16181d").describe("Primary glyph color \u2014 the resting majority."),
3236
- muted: z4.string().default("#a7adb6").describe("Muted/secondary glyph color."),
3237
- accent: z4.string().optional().describe("Accent color for occasional pops; defaults to `background` (accent glyphs blend in) if unset."),
3238
- // defaults to `background` at render (accent glyphs blend in)
3239
- label: z4.string().optional().describe("Color of the font-name label (bottom corner); defaults to `foreground` if unset.")
3240
- // defaults to `foreground` at render
3241
- }).default({}).describe("Color tokens the glyphs cycle through (any CSS colors). Override any subset. Default: light-grey palette."),
3242
- colorWeights: z4.object({
3243
- foreground: z4.number().nonnegative().default(2).describe("Relative likelihood of the foreground token on a random color change. Default 2."),
3244
- muted: z4.number().nonnegative().default(2).describe("Relative likelihood of the muted token on a random color change. Default 2."),
3245
- accent: z4.number().nonnegative().default(1).describe("Relative likelihood of the accent token on a random color change. Default 1.")
3246
- }).default({}).describe("Relative likelihood of each color token on a random (non-targeted) color change. Default 2 / 2 / 1."),
3247
- pulses: z4.array(pulseSchema).min(1).default(DEFAULT_PULSES).describe("The animation storyboard: an ordered sequence of pulses (beats). Default: a lively built-in storyboard."),
3248
- characterIntensity: z4.number().nonnegative().default(1).describe("Multiply every pulse's glyph-change fraction (1 = baseline, 2 = twice as busy, 0 = none). Default 1."),
3249
- colorIntensity: z4.number().nonnegative().default(1).describe("Multiply every pulse's color-change fraction (1 = baseline, 2 = twice as busy, 0 = none). Default 1."),
3250
- maxLineDrift: z4.number().positive().max(0.5).default(0.05).describe("Max fraction a line's total width may drift as its glyphs change; swaps are width-compensated. Default 0.05."),
3251
- mirror: z4.boolean().default(true).describe("Mirror the pulses (play out and back) for a seamless loop; doubles the clip length. Default true."),
3252
- crf: z4.number().int().min(0).max(51).default(18).describe("x264 quality, 0\u201351 (lower = better quality / larger file). Default 18."),
3253
- fileName: z4.string().optional().describe('Output filename; defaults to "<slug(asset name)>.mp4".')
3254
- }).strict();
3255
- var specimenOptionsSchema = z4.preprocess(applyTemplate, specimenObjectSchema);
3256
-
3257
- // src/generators/scene/scene-options.ts
3258
- var specimenSceneOptionsSchema = z5.object({
3259
- label: z5.string().default(""),
3260
- demo: z5.boolean().default(false),
3261
- weight: z5.number().min(1).max(1e3).default(820),
3262
- lines: z5.number().int().min(1).max(40).default(3),
3263
- blacklist: z5.string().default(""),
3264
- colors: z5.object({
3265
- background: z5.string(),
3266
- foreground: z5.string(),
3267
- muted: z5.string(),
3268
- accent: z5.string().optional(),
3269
- label: z5.string().optional()
3286
+ background: z4.string(),
3287
+ foreground: z4.string(),
3288
+ muted: z4.string(),
3289
+ accent: z4.string().optional(),
3290
+ label: z4.string().optional()
3270
3291
  }).partial().default({}),
3271
- colorWeights: z5.object({
3272
- foreground: z5.number().nonnegative(),
3273
- muted: z5.number().nonnegative(),
3274
- accent: z5.number().nonnegative()
3292
+ colorWeights: z4.object({
3293
+ foreground: z4.number().nonnegative(),
3294
+ muted: z4.number().nonnegative(),
3295
+ accent: z4.number().nonnegative()
3275
3296
  }).partial().default({}),
3276
- pulses: z5.array(pulseSchema).default([]),
3277
- mirror: z5.boolean().default(true),
3278
- characterIntensity: z5.number().nonnegative().default(1),
3279
- colorIntensity: z5.number().nonnegative().default(1),
3297
+ pulses: z4.array(specimenWirePulseSchema).default([]),
3298
+ mirror: z4.boolean().default(true),
3299
+ characterIntensity: z4.number().nonnegative().default(1),
3300
+ colorIntensity: z4.number().nonnegative().default(1),
3280
3301
  /** Max fraction a line's width may drift as glyphs change (right-edge stability). */
3281
- maxLineDrift: z5.number().positive().max(0.5).default(0.05),
3302
+ maxLineDrift: z4.number().positive().max(0.5).default(0.05),
3282
3303
  /** Schedule seed — same seed ⇒ identical animation (parallel workers must agree). */
3283
- seed: z5.number().int().default(1),
3304
+ seed: z4.number().int().default(1),
3284
3305
  /** Line-height of the glyph block. */
3285
- leading: z5.number().positive().default(0.78),
3306
+ leading: z4.number().positive().default(0.78),
3286
3307
  /** Glyph pool override (≥2 distinct characters after the blacklist). */
3287
- characterPool: z5.string().min(2).optional()
3308
+ characterPool: z4.string().min(2).optional()
3288
3309
  }).strict();
3289
- var wallEasingEnum = z5.enum([
3310
+ var wallEasingEnum = z4.enum([
3290
3311
  "linear",
3291
3312
  "ease-in",
3292
3313
  "ease-out",
3293
3314
  "ease-in-out",
3294
3315
  "ease-in-out-strong"
3295
3316
  ]);
3296
- var wallPulseSchema = z5.object({
3317
+ var wallPulseSchema = z4.object({
3297
3318
  /** When the pulse starts, as a fraction of the clip (0..1). */
3298
- at: z5.number().min(0).max(1).describe("When the pulse starts, as a fraction of the clip (0..1)."),
3319
+ at: z4.number().min(0).max(1).describe("When the pulse starts, as a fraction of the clip (0..1)."),
3299
3320
  /** How long the move takes, as a fraction of the clip (0..1). */
3300
- duration: z5.number().positive().max(1).describe("How long the move takes, as a fraction of the clip (0..1)."),
3321
+ span: z4.number().positive().max(1).describe("How long the move takes, as a fraction of the clip (0..1)."),
3301
3322
  /** How far it travels, in periods (1 = one full tile-set / one wrap). Usually 0..1. */
3302
- distance: z5.number().nonnegative().describe("How far it travels, in periods (1 = one full tile-set / wrap). Usually 0..1."),
3323
+ distance: z4.number().nonnegative().describe("How far it travels, in periods (1 = one full tile-set / wrap). Usually 0..1."),
3303
3324
  /** Easing of the move's ramp. */
3304
3325
  easing: wallEasingEnum.default("ease-in-out").describe("Easing of the move's ramp. Default 'ease-in-out'.")
3305
3326
  }).strict();
3306
- var wallPanSchema = z5.object({
3327
+ var wallPanSchema = z4.object({
3307
3328
  /** Pan direction. */
3308
- direction: z5.enum(["left", "right"]).default("left").describe("Pan direction. Default 'left'."),
3329
+ direction: z4.enum(["left", "right"]).default("left").describe("Pan direction. Default 'left'."),
3309
3330
  /** Continuous whole-clip horizontal loops (0 = no pan unless `pulses` move it). */
3310
- loops: z5.number().nonnegative().default(0).describe("Continuous whole-clip horizontal loops (0 = no pan unless pulses move it). Default 0."),
3331
+ loops: z4.number().nonnegative().default(0).describe("Continuous whole-clip horizontal loops (0 = no pan unless pulses move it). Default 0."),
3311
3332
  /** Pulses added on top of the base loops. */
3312
- pulses: z5.array(wallPulseSchema).default([]).describe("Pulses added on top of the base loops.")
3333
+ pulses: z4.array(wallPulseSchema).default([]).describe("Pulses added on top of the base loops.")
3313
3334
  }).strict();
3314
- var wallColumnSchema = z5.object({
3335
+ var wallColumnSchema = z4.object({
3315
3336
  /** Assets stacked in this column, by name (cycled to fill the height). At least one. */
3316
- tiles: z5.array(z5.string().min(1)).min(1).describe("Assets stacked in this column, by name (cycled to fill the height). At least one."),
3337
+ tiles: z4.array(z4.string().min(1)).min(1).describe("Assets stacked in this column, by name (cycled to fill the height). At least one."),
3317
3338
  /** Constant start-position shift, 0..1 of a tile-set — de-aligns columns with similar content. */
3318
- stagger: z5.number().min(0).max(1).default(0).describe("Start-position shift (0..1 of a tile-set) that de-aligns columns with similar content. Default 0."),
3339
+ stagger: z4.number().min(0).max(1).default(0).describe("Start-position shift (0..1 of a tile-set) that de-aligns columns with similar content. Default 0."),
3319
3340
  /** Scroll direction. Defaults to "down". */
3320
- direction: z5.enum(["up", "down"]).optional().describe("Scroll direction. Default 'down'."),
3341
+ direction: z4.enum(["up", "down"]).optional().describe("Scroll direction. Default 'down'."),
3321
3342
  /** Continuous whole-clip loops for this column. Omit to inherit the wall-level `loops`. */
3322
- loops: z5.number().nonnegative().optional().describe("Continuous whole-clip loops for this column. Omit to inherit the wall-level loops."),
3343
+ loops: z4.number().nonnegative().optional().describe("Continuous whole-clip loops for this column. Omit to inherit the wall-level loops."),
3323
3344
  /** This column's pulses. Omit to inherit the wall-level `pulses`. */
3324
- pulses: z5.array(wallPulseSchema).optional().describe("This column's pulses. Omit to inherit the wall-level pulses.")
3345
+ pulses: z4.array(wallPulseSchema).optional().describe("This column's pulses. Omit to inherit the wall-level pulses.")
3325
3346
  }).strict();
3326
- var fauxTileSchema = z5.object({
3347
+ var fauxTileSchema = z4.object({
3327
3348
  /** Box fill (any CSS color). Omit to auto-derive a distinct color from the tile name. */
3328
- color: z5.string().optional().describe("Box fill (any CSS color). Omit to auto-derive a distinct color from the tile name."),
3349
+ color: z4.string().optional().describe("Box fill (any CSS color). Omit to auto-derive a distinct color from the tile name."),
3329
3350
  /** Optional caption shown under the name (e.g. "16:9") — purely cosmetic. */
3330
- size: z5.string().optional().describe("Optional caption shown under the name (e.g. 16:9) \u2014 purely cosmetic."),
3351
+ size: z4.string().optional().describe("Optional caption shown under the name (e.g. 16:9) \u2014 purely cosmetic."),
3331
3352
  /** This faux tile's aspect ratio (width / height): 1.78 = 16:9 (short), 0.56 = 9:16 (tall), 1 =
3332
3353
  * square. Omit to use the wall's `tileAspect` default. Real tiles use their media's own aspect. */
3333
- aspect: z5.number().positive().optional().describe("Faux tile aspect (w/h): 1.78 = 16:9, 0.56 = 9:16, 1 = square. Omit to use the wall's tileAspect.")
3354
+ aspect: z4.number().positive().optional().describe("Faux tile aspect (w/h): 1.78 = 16:9, 0.56 = 9:16, 1 = square. Omit to use the wall's tileAspect.")
3334
3355
  }).strict();
3335
- var wallSceneOptionsSchema = z5.object({
3356
+ var wallSceneOptionsSchema = z4.object({
3336
3357
  /** The columns (≥3) — each its own tiles + motion. Count = array length (fewer = bigger tiles). */
3337
- columns: z5.array(wallColumnSchema).min(3),
3358
+ columns: z4.array(wallColumnSchema).min(3),
3338
3359
  /** Gap between columns and between their tile contents (px). */
3339
- gap: z5.number().nonnegative().default(16),
3360
+ gap: z4.number().nonnegative().default(16),
3340
3361
  /** Default/fallback tile aspect (width / height). Tiles fit the column width and take their OWN
3341
3362
  * height from their media's aspect (16:9 → short, 9:16 → tall); this is only used for faux
3342
3363
  * (`test`) tiles that don't set their own `aspect`. 1.6 = 16:10 landscape, <1 = portrait. */
3343
- tileAspect: z5.number().positive().default(1.6),
3364
+ tileAspect: z4.number().positive().default(1.6),
3344
3365
  /** Tile corner radius (px). */
3345
- cornerRadius: z5.number().nonnegative().default(12),
3366
+ cornerRadius: z4.number().nonnegative().default(12),
3346
3367
  /** Backdrop shown in the gap gutters and behind tiles. Defaults to the scene's `background`. */
3347
- background: z5.string().optional(),
3368
+ background: z4.string().optional(),
3348
3369
  /** System 1 — the X pan. */
3349
3370
  pan: wallPanSchema.default({}),
3350
3371
  /** Default continuous whole-clip loops for columns that omit their own `loops` (0 = static unless
3351
3372
  * a pulse moves it; one pulse then rounds the total up to a single loop). */
3352
- loops: z5.number().nonnegative().default(0),
3373
+ loops: z4.number().nonnegative().default(0),
3353
3374
  /** Default pulses for columns that omit their own `pulses` (the uniform wall-level motion). */
3354
- pulses: z5.array(wallPulseSchema).default([]),
3375
+ pulses: z4.array(wallPulseSchema).default([]),
3355
3376
  /** Preview mode: render every tile as a flat labeled color box (see `testTiles`) instead of the
3356
3377
  * real assets, so you can dial in layout + motion instantly — no producers run. */
3357
- test: z5.boolean().default(false),
3378
+ test: z4.boolean().default(false),
3358
3379
  /** Per-tile faux appearance for `test` mode, keyed by tile name. Tiles not listed get an
3359
3380
  * auto-derived color and their name as the label. */
3360
- testTiles: z5.record(z5.string(), fauxTileSchema).default({})
3381
+ testTiles: z4.record(z4.string(), fauxTileSchema).default({})
3361
3382
  }).strict();
3362
- var reelItemSchema = z5.object({
3383
+ var reelItemSchema = z4.object({
3363
3384
  /** Color display name (already cased per `uppercase`). */
3364
- name: z5.string(),
3385
+ name: z4.string(),
3365
3386
  /** Swatch background hex (`#RRGGBB`). */
3366
- hex: z5.string(),
3387
+ hex: z4.string(),
3367
3388
  /** Contrast-picked text color for this band. */
3368
- textColor: z5.string(),
3389
+ textColor: z4.string(),
3369
3390
  /** Preformatted detail lines revealed on expand (hex / oklch / rgb …). */
3370
- details: z5.array(z5.string())
3391
+ details: z4.array(z4.string())
3371
3392
  }).strict();
3372
- var paletteReelSceneOptionsSchema = z5.object({
3373
- items: z5.array(reelItemSchema).min(1),
3374
- orientation: z5.enum(["rows", "columns"]).default("rows"),
3375
- holdSeconds: z5.number().positive().default(2),
3376
- transitionSeconds: z5.number().positive().default(0.7),
3377
- bounce: z5.boolean().default(true),
3378
- easing: z5.enum(["linear", "ease-in", "ease-out", "ease-in-out"]).default("ease-in-out"),
3379
- grownFlex: z5.number().min(1).default(12),
3380
- minCrossPx: z5.number().nonnegative().default(0),
3381
- nameAlwaysVisible: z5.boolean().default(true),
3382
- fontWeight: z5.number().int().min(1).max(1e3).default(700),
3383
- fontSize: z5.number().positive().optional(),
3384
- detailFontScale: z5.number().positive().default(0.62),
3385
- gap: z5.number().nonnegative().default(0),
3386
- cornerRadius: z5.number().nonnegative().default(0)
3393
+ var paletteReelSceneOptionsSchema = z4.object({
3394
+ items: z4.array(reelItemSchema).min(1),
3395
+ orientation: z4.enum(["rows", "columns"]).default("rows"),
3396
+ holdSeconds: z4.number().positive().default(2),
3397
+ transitionSeconds: z4.number().positive().default(0.7),
3398
+ bounce: z4.boolean().default(true),
3399
+ easing: z4.enum(["linear", "ease-in", "ease-out", "ease-in-out"]).default("ease-in-out"),
3400
+ grownFlex: z4.number().min(1).default(12),
3401
+ minCrossPx: z4.number().nonnegative().default(0),
3402
+ nameAlwaysVisible: z4.boolean().default(true),
3403
+ fontWeight: z4.number().int().min(1).max(1e3).default(700),
3404
+ fontSize: z4.number().positive().optional(),
3405
+ detailFontScale: z4.number().positive().default(0.62),
3406
+ gap: z4.number().nonnegative().default(0),
3407
+ cornerRadius: z4.number().nonnegative().default(0)
3387
3408
  }).strict();
3388
3409
  var SCENE_OPTION_SCHEMAS = {
3389
3410
  specimen: specimenSceneOptionsSchema,
@@ -3392,64 +3413,64 @@ var SCENE_OPTION_SCHEMAS = {
3392
3413
  };
3393
3414
 
3394
3415
  // src/generators/wall/options.ts
3395
- var wallOptionsSchema = z6.object({
3416
+ var wallOptionsSchema = z5.object({
3396
3417
  // --- output ---
3397
3418
  /** Output width (CSS px). */
3398
- width: z6.number().int().positive().default(1920).describe("Output width in CSS px. Default 1920."),
3419
+ width: z5.number().int().positive().default(1920).describe("Output width in CSS px. Default 1920."),
3399
3420
  /** Output height (CSS px). */
3400
- height: z6.number().int().positive().default(1080).describe("Output height in CSS px. Default 1080."),
3421
+ height: z5.number().int().positive().default(1080).describe("Output height in CSS px. Default 1080."),
3401
3422
  /** Render scale (2 = retina-crisp, downscaled into the video). */
3402
- deviceScaleFactor: z6.number().positive().max(4).default(2).describe("Render scale (2 = retina-crisp, downscaled into the video). Default 2."),
3423
+ deviceScaleFactor: z5.number().positive().max(4).default(2).describe("Render scale (2 = retina-crisp, downscaled into the video). Default 2."),
3403
3424
  /** Output frames per second. */
3404
- fps: z6.number().int().positive().max(120).default(30).describe("Output frames per second. Default 30."),
3405
- /** Clip length (seconds) — the whole loop. Tile videos should loop within a length dividing this. */
3406
- durationSeconds: z6.number().positive().default(16).describe("Clip length in seconds \u2014 the whole loop. Default 16."),
3425
+ fps: z5.number().int().positive().max(120).default(30).describe("Output frames per second. Default 30."),
3426
+ /** Clip length (ms) — the whole loop. Tile videos should loop within a length dividing this. */
3427
+ durationMs: z5.number().positive().default(16e3).describe("Clip length in ms \u2014 the whole loop. Default 16000."),
3407
3428
  /** x264 quality, 0–51 (lower = better/larger). */
3408
- crf: z6.number().int().min(0).max(51).default(18).describe("x264 quality, 0\u201351 (lower = better quality / larger file). Default 18."),
3429
+ crf: z5.number().int().min(0).max(51).default(18).describe("x264 quality, 0\u201351 (lower = better quality / larger file). Default 18."),
3409
3430
  /** Capture strategy. "frames" (default) is deterministic + parallelizable. */
3410
- capture: z6.enum(["frames", "realtime"]).default("frames").describe('Capture strategy. "frames" (default) is deterministic + parallelizable; "realtime" records the live session.'),
3431
+ capture: z5.enum(["frames", "realtime"]).default("frames").describe('Capture strategy. "frames" (default) is deterministic + parallelizable; "realtime" records the live session.'),
3411
3432
  /** Parallel frame-render workers. Video-heavy walls can cold-start black under many workers —
3412
3433
  * set 1 (or omit) for those. */
3413
- workers: z6.number().int().positive().optional().describe("Parallel frame-render workers. Video-heavy walls can cold-start to black under many workers \u2014 set 1 (or omit). Auto-picks from cores."),
3434
+ workers: z5.number().int().positive().optional().describe("Parallel frame-render workers. Video-heavy walls can cold-start to black under many workers \u2014 set 1 (or omit). Auto-picks from cores."),
3414
3435
  /** Intermediate frame format (frames capture only). "jpeg" (default) is fast; "png" is lossless. */
3415
- frameFormat: z6.enum(["jpeg", "png"]).default("jpeg").describe('Intermediate frame format (frames capture). "jpeg" (default) is fast; "png" is lossless.'),
3436
+ frameFormat: z5.enum(["jpeg", "png"]).default("jpeg").describe('Intermediate frame format (frames capture). "jpeg" (default) is fast; "png" is lossless.'),
3416
3437
  /** Backdrop shown in the gutters between tiles. */
3417
- background: z6.string().default("#0b0b0f").describe('Backdrop shown in the gutters between tiles. Default "#0b0b0f".'),
3438
+ background: z5.string().default("#0b0b0f").describe('Backdrop shown in the gutters between tiles. Default "#0b0b0f".'),
3418
3439
  /** Output filename; defaults to "<slug(asset name)>.mp4". */
3419
- fileName: z6.string().optional().describe('Output filename; defaults to "<slug(asset name)>.mp4".'),
3440
+ fileName: z5.string().optional().describe('Output filename; defaults to "<slug(asset name)>.mp4".'),
3420
3441
  // --- columns (System 2 + layout): each column = its tiles + its own motion ---
3421
3442
  /** The columns (≥3) — each its own tiles + motion. Count = array length (fewer = larger tiles). */
3422
- columns: z6.array(wallColumnSchema).min(3).describe("The columns (\u22653) \u2014 each lists its stacked `tiles` (by name) and may carry its own motion. Count = columns.length."),
3443
+ columns: z5.array(wallColumnSchema).min(3).describe("The columns (\u22653) \u2014 each lists its stacked `tiles` (by name) and may carry its own motion. Count = columns.length."),
3423
3444
  /** Gap between columns and between tiles (px). */
3424
- gap: z6.number().nonnegative().default(8).describe("Gap between columns and between tiles (px). Default 8."),
3445
+ gap: z5.number().nonnegative().default(8).describe("Gap between columns and between tiles (px). Default 8."),
3425
3446
  /** Default/fallback tile aspect (width / height). Tiles fit the column width and take their OWN
3426
3447
  * height from their media's aspect (16:9 → short, 9:16 → tall); this is only used for faux
3427
3448
  * (`test`) tiles that don't set their own `aspect`. 0.75 = 3:4 portrait. */
3428
- tileAspect: z6.number().positive().default(0.75).describe("Default/fallback tile aspect (w/h) \u2014 only for faux (test) tiles without their own `aspect`. 0.75 = 3:4 portrait. Default 0.75."),
3449
+ tileAspect: z5.number().positive().default(0.75).describe("Default/fallback tile aspect (w/h) \u2014 only for faux (test) tiles without their own `aspect`. 0.75 = 3:4 portrait. Default 0.75."),
3429
3450
  /** Tile corner radius (px). */
3430
- cornerRadius: z6.number().nonnegative().default(6).describe("Tile corner radius (px). Default 6."),
3451
+ cornerRadius: z5.number().nonnegative().default(6).describe("Tile corner radius (px). Default 6."),
3431
3452
  // --- motion (uniform pulse model) ---
3432
3453
  /** System 1 — the whole-wall X pan. */
3433
3454
  pan: wallPanSchema.default({}).describe("System 1 \u2014 the whole wall's horizontal pan (`direction` / `loops` / `pulses`). Default: no pan."),
3434
3455
  /** Default continuous whole-clip loops for columns that omit their own `loops` (0 = static unless
3435
3456
  * a pulse moves it; one pulse then rounds the total up to a single loop). */
3436
- loops: z6.number().nonnegative().default(0).describe("Default continuous whole-clip loops for columns that omit their own `loops`. Default 0 (static unless a pulse moves it)."),
3457
+ loops: z5.number().nonnegative().default(0).describe("Default continuous whole-clip loops for columns that omit their own `loops`. Default 0 (static unless a pulse moves it)."),
3437
3458
  /** Default pulses for columns that omit their own `pulses` (the uniform wall-level motion). */
3438
- pulses: z6.array(wallPulseSchema).default([]).describe("Default pulses for columns that omit their own `pulses` (the uniform wall-level motion). Default none."),
3459
+ pulses: z5.array(wallPulseSchema).default([]).describe("Default pulses for columns that omit their own `pulses` (the uniform wall-level motion). Default none."),
3439
3460
  // --- test / preview ---
3440
3461
  /** Preview mode: render every tile as a flat labeled color box (see `testTiles`) instead of the
3441
3462
  * real assets. No producer assets run, so the wall renders in seconds — use it to dial in
3442
3463
  * layout + motion, then turn it off for the real render. */
3443
- test: z6.boolean().default(false).describe("Preview mode: render every tile as a flat labeled color box instead of real assets, so the wall renders in seconds. Default false."),
3464
+ test: z5.boolean().default(false).describe("Preview mode: render every tile as a flat labeled color box instead of real assets, so the wall renders in seconds. Default false."),
3444
3465
  /** Per-tile faux appearance for `test` mode, keyed by tile name. Tiles not listed get an
3445
3466
  * auto-derived color and their name as the label. */
3446
- testTiles: z6.record(z6.string(), fauxTileSchema).default({}).describe("Per-tile faux appearance for `test` mode, keyed by tile name (color + caption). Default {} (auto colors + names).")
3467
+ testTiles: z5.record(z5.string(), fauxTileSchema).default({}).describe("Per-tile faux appearance for `test` mode, keyed by tile name (color + caption). Default {} (auto colors + names).")
3447
3468
  }).strict();
3448
3469
 
3449
3470
  // src/generators/scene/index.ts
3450
3471
  import path13 from "path";
3451
3472
  import { fileURLToPath } from "url";
3452
- import { copyFile as copyFile2, rename, stat as stat4 } from "fs/promises";
3473
+ import { copyFile as copyFile2, rename as rename2, stat as stat4 } from "fs/promises";
3453
3474
 
3454
3475
  // src/scene/serve.ts
3455
3476
  import http from "http";
@@ -3774,7 +3795,7 @@ async function renderScene(ctx, options, generatorId = SCENE_ID) {
3774
3795
  });
3775
3796
  }
3776
3797
  try {
3777
- await rename(composedTmp, outPath);
3798
+ await rename2(composedTmp, outPath);
3778
3799
  } catch {
3779
3800
  await copyFile2(composedTmp, outPath);
3780
3801
  }
@@ -3815,7 +3836,8 @@ async function run3(ctx, o) {
3815
3836
  background: o.background,
3816
3837
  deviceScaleFactor: o.deviceScaleFactor,
3817
3838
  fps: o.fps,
3818
- durationSeconds: o.durationSeconds,
3839
+ // The scene wire format keeps seconds internally; the authoring surface is milliseconds.
3840
+ durationSeconds: o.durationMs / 1e3,
3819
3841
  capture: o.capture,
3820
3842
  workers: o.workers,
3821
3843
  frameFormat: o.frameFormat,
@@ -3850,11 +3872,147 @@ var wallGenerator = {
3850
3872
  run: run3
3851
3873
  };
3852
3874
 
3875
+ // src/generators/specimen/options.ts
3876
+ import { z as z6 } from "zod";
3877
+ var pulseSchema = z6.object({
3878
+ /** Human label for the beat, e.g. "color sweep" — purely to keep the config readable. */
3879
+ name: z6.string().default("").describe('Human label for the beat, e.g. "color sweep" \u2014 purely to keep the config readable.'),
3880
+ /** Length of this beat (ms). */
3881
+ durationMs: z6.number().positive().describe("Length of this beat in ms."),
3882
+ /** Fraction of cells whose glyph changes during this beat (0..1; 1 = every cell once). */
3883
+ chars: z6.number().nonnegative().default(0).describe("Fraction of cells whose glyph changes during this beat (0..1; 1 = every cell once; 0 = a hold). Default 0."),
3884
+ /** Fraction of cells whose color changes during this beat (0..1; 1 = every cell once). */
3885
+ colors: z6.number().nonnegative().default(0).describe("Fraction of cells whose color changes during this beat (0..1; 1 = every cell once). Default 0."),
3886
+ /**
3887
+ * Target color for this beat's color changes. When set, every color change in the beat goes to
3888
+ * this exact token (a deliberate sweep) instead of a weighted-random pick. Set `colors: 1` with
3889
+ * `pacing: "even"` to wash the whole specimen to one color evenly. Omit for the default
3890
+ * scattered, weighted-random recoloring.
3891
+ */
3892
+ color: z6.enum(["foreground", "muted", "accent"]).optional().describe("Target color token for this beat's color changes (a deliberate sweep); omit for the default weighted-random recoloring."),
3893
+ /**
3894
+ * How the changes are distributed in time across the beat — like a CSS easing curve:
3895
+ * "linear"/"even" = uniform, "ease-in" = front-loaded, "ease-out" = back-loaded,
3896
+ * "ease-in-out" = bunched at both ends, "random" = scattered.
3897
+ */
3898
+ pacing: z6.enum(["even", "linear", "ease-in", "ease-out", "ease-in-out", "random"]).default("even").describe("How changes are distributed in time across the beat (CSS-easing-like): even/linear, ease-in, ease-out, ease-in-out, random. Default even.")
3899
+ }).strict();
3900
+ var P = (name, durationMs, chars = 0, colors = 0) => ({
3901
+ name,
3902
+ durationMs,
3903
+ chars,
3904
+ colors,
3905
+ pacing: "even"
3906
+ });
3907
+ var DEFAULT_PULSES = [
3908
+ P("intro hold", 800),
3909
+ P("first letters", 800, 0.15, 0),
3910
+ P("settle", 1500),
3911
+ P("ripple", 1e3, 0.08, 0.04),
3912
+ P("color sweep", 1200, 0, 0.13),
3913
+ P("rest", 1200),
3914
+ P("quick burst", 600, 0.18, 0),
3915
+ P("drift", 1200, 0, 0.08),
3916
+ P("finale", 1200, 0.18, 0.13),
3917
+ P("outro hold", 500)
3918
+ ];
3919
+ var DEMO_PULSES = [
3920
+ { name: "linear", durationMs: 5e3, chars: 0.5, pacing: "linear" },
3921
+ { name: "hold", durationMs: 2e3 },
3922
+ { name: "ease-in", durationMs: 5e3, chars: 0.5, pacing: "ease-in" },
3923
+ { name: "hold", durationMs: 2e3 },
3924
+ { name: "ease-out", durationMs: 5e3, chars: 0.5, pacing: "ease-out" },
3925
+ { name: "hold", durationMs: 2e3 },
3926
+ { name: "ease-in-out", durationMs: 5e3, chars: 0.5, pacing: "ease-in-out" },
3927
+ { name: "hold", durationMs: 2e3 },
3928
+ { name: "random", durationMs: 5e3, chars: 0.5, pacing: "random" },
3929
+ { name: "hold", durationMs: 2e3 },
3930
+ // Even, per-character color sweeps: each washes every glyph to one token, evenly across the beat.
3931
+ { name: "sweep \u2192 muted", durationMs: 4e3, colors: 1, color: "muted", pacing: "even" },
3932
+ { name: "sweep \u2192 accent", durationMs: 4e3, colors: 1, color: "accent", pacing: "even" },
3933
+ { name: "sweep \u2192 foreground", durationMs: 4e3, colors: 1, color: "foreground", pacing: "even" },
3934
+ { name: "hold", durationMs: 2e3 },
3935
+ { name: "weighted recolor", durationMs: 5e3, colors: 0.6 },
3936
+ { name: "hold", durationMs: 2e3 },
3937
+ { name: "mingle", durationMs: 5e3, chars: 0.3, colors: 0.3 }
3938
+ ];
3939
+ var SWEEP_COLORS = {
3940
+ background: "#0b0b0f",
3941
+ foreground: "#f4f4f5",
3942
+ muted: "#6b7280",
3943
+ accent: "#7c9cff"
3944
+ };
3945
+ var SWEEP_PULSES = [
3946
+ { name: "hold", durationMs: 800 },
3947
+ { name: "to muted", durationMs: 2200, colors: 1, color: "muted", pacing: "ease-in-out" },
3948
+ { name: "settle", durationMs: 600 },
3949
+ { name: "to accent", durationMs: 2200, colors: 1, color: "accent", pacing: "ease-in-out" },
3950
+ { name: "settle", durationMs: 600 },
3951
+ { name: "to foreground", durationMs: 2200, colors: 1, color: "foreground", pacing: "ease-in-out" },
3952
+ { name: "glyph drift", durationMs: 1400, chars: 0.5, pacing: "even" },
3953
+ { name: "hold", durationMs: 600 }
3954
+ ];
3955
+ var SPECIMEN_TEMPLATES = {
3956
+ demo: { demo: true, mirror: false, lines: 4, pulses: DEMO_PULSES },
3957
+ sweep: {
3958
+ mirror: true,
3959
+ lines: 4,
3960
+ colors: SWEEP_COLORS,
3961
+ pulses: SWEEP_PULSES
3962
+ }
3963
+ };
3964
+ function applyTemplate(raw) {
3965
+ if (!raw || typeof raw !== "object") return raw;
3966
+ const r = raw;
3967
+ const tmpl = typeof r.template === "string" ? SPECIMEN_TEMPLATES[r.template] : void 0;
3968
+ return tmpl ? { ...tmpl, ...r } : raw;
3969
+ }
3970
+ var specimenObjectSchema = z6.object({
3971
+ font: z6.string().min(1).describe("Font file to showcase (path relative to the working dir, or absolute). Required."),
3972
+ template: z6.enum(["demo", "sweep"]).optional().describe("Load a named option preset (demo or sweep); your explicit options still override what it sets."),
3973
+ name: z6.string().default("").describe('Display name shown bottom-left (e.g. "ABC Oracle"). Default none.'),
3974
+ demo: z6.boolean().default(false).describe("Demo mode: overlay the active pulse's name bottom-right, to see which beat is playing. Default false."),
3975
+ fps: z6.number().int().positive().max(120).default(30).describe("Output frames per second. Default 30."),
3976
+ durationMs: z6.number().positive().optional().describe("Clip length in ms. Defaults to the (mirrored) sum of the pulse durations; set to override."),
3977
+ width: z6.number().int().positive().default(1920).describe("Output frame width in px. Default 1920."),
3978
+ height: z6.number().int().positive().default(1080).describe("Output frame height in px. Default 1080."),
3979
+ deviceScaleFactor: z6.number().positive().max(4).default(1).describe("Render scale (1 = 1:1; higher = crisper capture, downscaled into the video). Default 1."),
3980
+ weight: z6.number().int().min(1).max(1e3).default(820).describe("Glyph weight on the variable-font axis, 1\u20131000. Default 820."),
3981
+ lines: z6.number().int().min(1).max(40).default(3).describe("Number of glyph rows; glyph size is derived so rows fill the top 80% of the frame. Default 3."),
3982
+ leading: z6.number().positive().default(0.78).describe("Line-height of the glyph block. Default 0.78 (tight, cap-height-hugging)."),
3983
+ blacklist: z6.string().default("").describe('Glyphs to exclude from the showcase, e.g. "QXZ" (case-insensitive). Default none.'),
3984
+ characterPool: z6.string().refine((s) => (/* @__PURE__ */ new Set([...s.trim()])).size >= 2, "characterPool needs \u22652 distinct characters").optional().describe("Override the glyph pool the specimen draws from (\u22652 distinct characters). Default A\u2013Z 0\u20139 + symbols."),
3985
+ seed: z6.number().int().default(1).describe("Schedule seed \u2014 same seed \u21D2 identical animation. Change for a different deterministic take. Default 1."),
3986
+ colors: z6.object({
3987
+ background: z6.string().default("#eceef1").describe("Backdrop behind the glyphs."),
3988
+ foreground: z6.string().default("#16181d").describe("Primary glyph color \u2014 the resting majority."),
3989
+ muted: z6.string().default("#a7adb6").describe("Muted/secondary glyph color."),
3990
+ accent: z6.string().optional().describe("Accent color for occasional pops; defaults to `background` (accent glyphs blend in) if unset."),
3991
+ // defaults to `background` at render (accent glyphs blend in)
3992
+ label: z6.string().optional().describe("Color of the font-name label (bottom corner); defaults to `foreground` if unset.")
3993
+ // defaults to `foreground` at render
3994
+ }).default({}).describe("Color tokens the glyphs cycle through (any CSS colors). Override any subset. Default: light-grey palette."),
3995
+ colorWeights: z6.object({
3996
+ foreground: z6.number().nonnegative().default(2).describe("Relative likelihood of the foreground token on a random color change. Default 2."),
3997
+ muted: z6.number().nonnegative().default(2).describe("Relative likelihood of the muted token on a random color change. Default 2."),
3998
+ accent: z6.number().nonnegative().default(1).describe("Relative likelihood of the accent token on a random color change. Default 1.")
3999
+ }).default({}).describe("Relative likelihood of each color token on a random (non-targeted) color change. Default 2 / 2 / 1."),
4000
+ pulses: z6.array(pulseSchema).min(1).default(DEFAULT_PULSES).describe("The animation storyboard: an ordered sequence of pulses (beats). Default: a lively built-in storyboard."),
4001
+ characterIntensity: z6.number().nonnegative().default(1).describe("Multiply every pulse's glyph-change fraction (1 = baseline, 2 = twice as busy, 0 = none). Default 1."),
4002
+ colorIntensity: z6.number().nonnegative().default(1).describe("Multiply every pulse's color-change fraction (1 = baseline, 2 = twice as busy, 0 = none). Default 1."),
4003
+ maxLineDrift: z6.number().positive().max(0.5).default(0.05).describe("Max fraction a line's total width may drift as its glyphs change; swaps are width-compensated. Default 0.05."),
4004
+ mirror: z6.boolean().default(true).describe("Mirror the pulses (play out and back) for a seamless loop; doubles the clip length. Default true."),
4005
+ crf: z6.number().int().min(0).max(51).default(18).describe("x264 quality, 0\u201351 (lower = better quality / larger file). Default 18."),
4006
+ fileName: z6.string().optional().describe('Output filename; defaults to "<slug(asset name)>.mp4".')
4007
+ }).strict();
4008
+ var specimenOptionsSchema = z6.preprocess(applyTemplate, specimenObjectSchema);
4009
+
3853
4010
  // src/generators/specimen/index.ts
3854
4011
  var SPECIMEN_ID = "specimen";
3855
4012
  async function run4(ctx, o) {
3856
- const pulsesTotal = o.pulses.reduce((sum, p) => sum + p.duration, 0);
3857
- const durationSeconds = o.durationSeconds ?? (o.mirror ? pulsesTotal * 2 : pulsesTotal);
4013
+ const pulsesTotalMs = o.pulses.reduce((sum, p) => sum + p.durationMs, 0);
4014
+ const durationSeconds = (o.durationMs ?? (o.mirror ? pulsesTotalMs * 2 : pulsesTotalMs)) / 1e3;
4015
+ const wirePulses = o.pulses.map(({ durationMs, ...p }) => ({ ...p, duration: durationMs / 1e3 }));
3858
4016
  const sceneOptions = {
3859
4017
  scene: "specimen",
3860
4018
  width: o.width,
@@ -3881,7 +4039,7 @@ async function run4(ctx, o) {
3881
4039
  characterPool: o.characterPool,
3882
4040
  colors: o.colors,
3883
4041
  colorWeights: o.colorWeights,
3884
- pulses: o.pulses,
4042
+ pulses: wirePulses,
3885
4043
  mirror: o.mirror,
3886
4044
  characterIntensity: o.characterIntensity,
3887
4045
  colorIntensity: o.colorIntensity,
@@ -4217,13 +4375,13 @@ var paletteReelObjectSchema = z8.object({
4217
4375
  details: z8.array(fieldEnum2).default(["hex", "oklch", "rgb"]).describe(
4218
4376
  "Fields revealed when a color expands (the name is always shown, so it's ignored here). Default hex + oklch + rgb."
4219
4377
  ),
4220
- holdSeconds: z8.number().positive().default(2).describe("How long each color stays fully open before handing off to the next (s). Default 2."),
4221
- transitionSeconds: z8.number().positive().default(0.7).describe("Crossfade length from one open color to the next (s). Default 0.7."),
4378
+ holdMs: z8.number().positive().default(2e3).describe("How long each color stays fully open before handing off to the next (ms). Default 2000."),
4379
+ transitionMs: z8.number().positive().default(700).describe("Crossfade length from one open color to the next (ms). Default 700."),
4222
4380
  bounce: z8.boolean().default(true).describe(
4223
4381
  "Ping-pong the sweep so each handoff is between neighbouring bands; off wraps directly (last to first). Default true."
4224
4382
  ),
4225
4383
  easing: z8.enum(["linear", "ease-in", "ease-out", "ease-in-out"]).default("ease-in-out").describe('Easing applied to the crossfade ramp. Default "ease-in-out".'),
4226
- durationSeconds: z8.number().positive().optional().describe("Clip length override (s). Omit to derive (count x (hold + transition)) for a clean loop."),
4384
+ durationMs: z8.number().positive().optional().describe("Clip length override (ms). Omit to derive (count x (hold + transition)) for a clean loop."),
4227
4385
  grownFlex: z8.number().min(1).default(12).describe(
4228
4386
  "How many times a sliver's share a fully-open band takes (a collapsed sliver is the baseline). Default 12."
4229
4387
  ),
@@ -4255,10 +4413,10 @@ var paletteReelOptionsSchema = paletteReelObjectSchema;
4255
4413
 
4256
4414
  // src/generators/palette-reel/index.ts
4257
4415
  var PALETTE_REEL_ID = "palette-reel";
4258
- function deriveDuration(o) {
4416
+ function deriveDurationMs(o) {
4259
4417
  const n = o.colors.length;
4260
4418
  const stops = n <= 1 ? n : o.bounce ? 2 * (n - 1) : n;
4261
- return stops * (o.holdSeconds + o.transitionSeconds);
4419
+ return stops * (o.holdMs + o.transitionMs);
4262
4420
  }
4263
4421
  async function run6(ctx, o) {
4264
4422
  const fmt = { uppercaseName: o.uppercase, rgbStyle: o.rgbStyle, oklchStyle: o.oklchStyle };
@@ -4276,7 +4434,7 @@ async function run6(ctx, o) {
4276
4434
  details: fields.map((f) => formatField(color, f, fmt))
4277
4435
  };
4278
4436
  });
4279
- const durationSeconds = o.durationSeconds ?? deriveDuration(o);
4437
+ const durationSeconds = (o.durationMs ?? deriveDurationMs(o)) / 1e3;
4280
4438
  const sceneOptions = {
4281
4439
  scene: PALETTE_REEL_ID,
4282
4440
  width: o.width,
@@ -4296,8 +4454,8 @@ async function run6(ctx, o) {
4296
4454
  sceneOptions: {
4297
4455
  items,
4298
4456
  orientation: o.orientation,
4299
- holdSeconds: o.holdSeconds,
4300
- transitionSeconds: o.transitionSeconds,
4457
+ holdSeconds: o.holdMs / 1e3,
4458
+ transitionSeconds: o.transitionMs / 1e3,
4301
4459
  bounce: o.bounce,
4302
4460
  easing: o.easing,
4303
4461
  grownFlex: o.grownFlex,
@@ -4402,6 +4560,9 @@ function getGenerator(id) {
4402
4560
  function listGenerators() {
4403
4561
  return [...registry.values()];
4404
4562
  }
4563
+ function generatorIds() {
4564
+ return [...registry.keys()];
4565
+ }
4405
4566
  register(scrollReelGenerator);
4406
4567
  register(screenshotsGenerator);
4407
4568
  register(wallGenerator);
@@ -4501,10 +4662,52 @@ var CONFIG_FILES = [
4501
4662
  ".pro-visurc",
4502
4663
  ".pro-visurc.json"
4503
4664
  ];
4504
- var CONFIG_TEMPLATE = `import { defineConfig } from "pro-visu";
4665
+ var FRAMEWORKS = [
4666
+ ["next", "Next.js", 3e3],
4667
+ ["nuxt", "Nuxt", 3e3],
4668
+ ["astro", "Astro", 4321],
4669
+ ["@sveltejs/kit", "SvelteKit", 5173],
4670
+ ["gatsby", "Gatsby", 8e3],
4671
+ ["@remix-run/dev", "Remix", 3e3],
4672
+ ["vite", "Vite", 5173]
4673
+ ];
4674
+ function detectProject(cwd) {
4675
+ let pkg = {};
4676
+ try {
4677
+ pkg = JSON.parse(readFileSync(path17.join(cwd, "package.json"), "utf8"));
4678
+ } catch {
4679
+ }
4680
+ const pmField = typeof pkg.packageManager === "string" ? pkg.packageManager.split("@")[0] : "";
4681
+ const pm = pmField === "pnpm" || pmField === "yarn" || pmField === "bun" || pmField === "npm" ? pmField : existsSync4(path17.join(cwd, "pnpm-lock.yaml")) ? "pnpm" : existsSync4(path17.join(cwd, "yarn.lock")) ? "yarn" : existsSync4(path17.join(cwd, "bun.lockb")) || existsSync4(path17.join(cwd, "bun.lock")) ? "bun" : "npm";
4682
+ const deps = {
4683
+ ...pkg.dependencies,
4684
+ ...pkg.devDependencies
4685
+ };
4686
+ const match = FRAMEWORKS.find(([dep]) => deps[dep]);
4687
+ const devScript = pkg.scripts?.dev ?? "";
4688
+ const flag = /(?:-p|--port)[ =](\d{2,5})/.exec(devScript);
4689
+ const devPort = flag ? Number(flag[1]) : match?.[2];
4690
+ let localDep = false;
4691
+ try {
4692
+ createRequire2(path17.join(cwd, "package.json")).resolve("pro-visu/package.json");
4693
+ localDep = true;
4694
+ } catch {
4695
+ localDep = false;
4696
+ }
4697
+ return { pm, framework: match?.[1], devPort, localDep };
4698
+ }
4699
+ function runCmd(pm, script) {
4700
+ return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
4701
+ }
4702
+ function tsConfigTemplate(info) {
4703
+ const port = info.devPort ?? 3101;
4704
+ const urlComment = info.devPort ? `// Your ${info.framework} dev server's URL \u2014 start it (\`${runCmd(info.pm, "dev")}\`) before generating,
4705
+ // point this at a deployed site, or enable the managed \`server\` block below instead.` : "// URL the capture assets point at \u2014 your running site, a deployed URL, or the managed server below.";
4706
+ const start = info.pm === "npm" ? "npm start" : `${info.pm} start`;
4707
+ return `import { defineConfig } from "pro-visu";
4505
4708
 
4506
- // URL the capture assets point at. The optional managed server (below) binds this port.
4507
- const URL = "http://localhost:3101";
4709
+ ${urlComment}
4710
+ const URL = "http://localhost:${port}";
4508
4711
 
4509
4712
  export default defineConfig({
4510
4713
  settings: {
@@ -4514,10 +4717,11 @@ export default defineConfig({
4514
4717
  // installed Chrome, or args: ["--no-sandbox"] on CI.
4515
4718
  browser: { headless: true },
4516
4719
  // Optional: let the tool build + start your site, wait for it, capture, then stop it.
4517
- // 'port' defaults to 3101 (off the common 3000 dev port); your command must bind it.
4720
+ // PORT is set in the command's env (default 3101), so PORT-honoring frameworks bind it
4721
+ // automatically. If you enable this, point URL above at the same port.
4518
4722
  // server: {
4519
- // build: "npm run build",
4520
- // command: "npm start -- -p 3101", // e.g. Next: "npx next start -p 3101"
4723
+ // build: "${runCmd(info.pm, "build")}",
4724
+ // command: "${start}",
4521
4725
  // port: 3101,
4522
4726
  // },
4523
4727
  defaults: {
@@ -4530,7 +4734,7 @@ export default defineConfig({
4530
4734
  name: "home-reel",
4531
4735
  url: URL,
4532
4736
  generator: "scroll-reel",
4533
- // options: { duration: 7000, waitForSelector: "main" },
4737
+ // options: { durationMs: 7000, waitForSelector: "main" },
4534
4738
  },
4535
4739
  // A looping type-specimen from a font file (no URL needed):
4536
4740
  // {
@@ -4541,7 +4745,10 @@ export default defineConfig({
4541
4745
  ],
4542
4746
  });
4543
4747
  `;
4544
- var JSON_CONFIG_TEMPLATE = `{
4748
+ }
4749
+ function jsonConfigTemplate(info) {
4750
+ const port = info.devPort ?? 3101;
4751
+ return `{
4545
4752
  "$schema": "./${DEFAULT_SCHEMA_FILE}",
4546
4753
  "settings": {
4547
4754
  "outDir": "pro-visu",
@@ -4552,26 +4759,36 @@ var JSON_CONFIG_TEMPLATE = `{
4552
4759
  }
4553
4760
  },
4554
4761
  "assets": [
4555
- { "name": "home-reel", "url": "http://localhost:3101", "generator": "scroll-reel" }
4762
+ { "name": "home-reel", "url": "http://localhost:${port}", "generator": "scroll-reel" }
4556
4763
  ]
4557
4764
  }
4558
4765
  `;
4766
+ }
4559
4767
  async function runInit(options = {}) {
4560
4768
  const cwd = resolveCwd(options.cwd);
4561
4769
  const logger = createLogger("info");
4562
4770
  let createdSomething = false;
4563
- const configFile = options.json ? "pro-visu.config.json" : "pro-visu.config.ts";
4771
+ const info = detectProject(cwd);
4772
+ if (info.framework) {
4773
+ logger.info(`detected ${info.framework} (${info.pm}${info.devPort ? `, dev port ${info.devPort}` : ""})`);
4774
+ }
4775
+ let useJson = Boolean(options.json);
4776
+ if (!useJson && !info.localDep) {
4777
+ useJson = true;
4778
+ logger.info("pro-visu isn't a local dependency \u2014 scaffolding a JSON config (works via npx/global)");
4779
+ }
4780
+ const configFile = useJson ? "pro-visu.config.json" : "pro-visu.config.ts";
4564
4781
  const existingConfig = findExistingConfig(cwd);
4565
4782
  if (existingConfig) {
4566
4783
  logger.info(`config exists (${existingConfig}) \u2014 leaving it untouched`);
4567
- } else if (options.json) {
4568
- await writeFile6(path17.join(cwd, configFile), JSON_CONFIG_TEMPLATE, "utf8");
4784
+ } else if (useJson) {
4785
+ await writeFile6(path17.join(cwd, configFile), jsonConfigTemplate(info), "utf8");
4569
4786
  logger.success(`created ${configFile}`);
4570
4787
  await writeFile6(path17.join(cwd, DEFAULT_SCHEMA_FILE), serializeConfigJsonSchema(), "utf8");
4571
4788
  logger.success(`created ${DEFAULT_SCHEMA_FILE}`);
4572
4789
  createdSomething = true;
4573
4790
  } else {
4574
- await writeFile6(path17.join(cwd, configFile), CONFIG_TEMPLATE, "utf8");
4791
+ await writeFile6(path17.join(cwd, configFile), tsConfigTemplate(info), "utf8");
4575
4792
  logger.success(`created ${configFile}`);
4576
4793
  createdSomething = true;
4577
4794
  }
@@ -4616,8 +4833,9 @@ async function runInit(options = {}) {
4616
4833
  }
4617
4834
  }
4618
4835
  logger.log("");
4836
+ const startHint = info.devPort ? `start your dev server (\`${runCmd(info.pm, "dev")}\`)` : "start your site (or point the config at a deployed URL)";
4619
4837
  logger.info(
4620
- createdSomething ? `Next: edit ${configFile}, start your site (or use a deployed URL), then run \`pro-visu generate\`.` : `Already initialized. Edit ${configFile}, then run \`pro-visu generate\`.`
4838
+ createdSomething ? `Next: edit ${configFile}, ${startHint}, then run \`pro-visu generate\`. (\`pro-visu doctor\` checks the setup.)` : `Already initialized. Edit ${configFile}, then run \`pro-visu generate\`.`
4621
4839
  );
4622
4840
  }
4623
4841
  function findExistingConfig(cwd) {
@@ -4916,7 +5134,7 @@ function killTreeByPid(pid) {
4916
5134
  // src/server/manage-server.ts
4917
5135
  import path20 from "path";
4918
5136
  import http2 from "http";
4919
- import https from "https";
5137
+ import https2 from "https";
4920
5138
  import { spawn as spawn6 } from "child_process";
4921
5139
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
4922
5140
  function resolveServerUrl(server) {
@@ -4924,7 +5142,7 @@ function resolveServerUrl(server) {
4924
5142
  }
4925
5143
  function probe(url) {
4926
5144
  return new Promise((resolve) => {
4927
- const lib = url.startsWith("https:") ? https : http2;
5145
+ const lib = url.startsWith("https:") ? https2 : http2;
4928
5146
  const req = lib.get(url, (res) => {
4929
5147
  res.resume();
4930
5148
  resolve((res.statusCode ?? 0) > 0);
@@ -5085,7 +5303,7 @@ async function startManagedServer(server, baseCwd, logger, tasks = {}, signal) {
5085
5303
  }
5086
5304
 
5087
5305
  // src/pipeline/runner.ts
5088
- import os2 from "os";
5306
+ import os3 from "os";
5089
5307
  import path23 from "path";
5090
5308
  import { existsSync as existsSync7 } from "fs";
5091
5309
  import { mkdtemp as mkdtemp4 } from "fs/promises";
@@ -5109,7 +5327,10 @@ async function createContext(args) {
5109
5327
  await ensureDir(genDir);
5110
5328
  return {
5111
5329
  browser: args.browser,
5112
- target: args.target,
5330
+ // Fold the capture query params into the target URL so every generator's `requireUrl(ctx)`
5331
+ // captures in capture mode without each one re-applying it.
5332
+ target: { ...args.target, url: withCaptureQuery(args.target.url, args.capture) },
5333
+ capture: args.capture,
5113
5334
  resolvedInputs: args.resolvedInputs,
5114
5335
  outDir: args.outDir,
5115
5336
  resolveOutPath: (filename) => path21.join(genDir, filename),
@@ -5196,7 +5417,7 @@ function computeCacheKey(parts) {
5196
5417
  // src/manifest/manifest.ts
5197
5418
  import path22 from "path";
5198
5419
  import { existsSync as existsSync6 } from "fs";
5199
- import { readFile as readFile7, rename as rename2, rm as rm5, writeFile as writeFile8 } from "fs/promises";
5420
+ import { readFile as readFile7, rename as rename3, rm as rm5, writeFile as writeFile8 } from "fs/promises";
5200
5421
 
5201
5422
  // src/manifest/schema.ts
5202
5423
  import { z as z10 } from "zod";
@@ -5263,7 +5484,7 @@ async function writeManifest(outDir, manifest) {
5263
5484
  const maxAttempts = 5;
5264
5485
  for (let attempt = 1; ; attempt++) {
5265
5486
  try {
5266
- await rename2(tmp, file);
5487
+ await rename3(tmp, file);
5267
5488
  return;
5268
5489
  } catch (err) {
5269
5490
  const code = err.code;
@@ -5309,6 +5530,57 @@ var ManifestStore = class _ManifestStore {
5309
5530
  };
5310
5531
 
5311
5532
  // src/pipeline/runner.ts
5533
+ import { ZodError } from "zod";
5534
+
5535
+ // src/generators/migration.ts
5536
+ var RENAMED_KEYS = {
5537
+ "scroll-reel": {
5538
+ duration: 'renamed to "durationMs" (same unit \u2014 milliseconds)'
5539
+ },
5540
+ screenshots: {
5541
+ breakpoints: 'renamed to "viewports" (same shape)'
5542
+ },
5543
+ wall: {
5544
+ durationSeconds: 'renamed to "durationMs" and now in milliseconds (16 \u2192 16000)',
5545
+ duration: 'pulse "duration" was renamed to "span" (same 0..1 clip fraction)'
5546
+ },
5547
+ specimen: {
5548
+ durationSeconds: 'renamed to "durationMs" and now in milliseconds (10 \u2192 10000)',
5549
+ duration: 'pulse "duration" was renamed to "durationMs" and is now in milliseconds (0.8 \u2192 800)'
5550
+ },
5551
+ "palette-reel": {
5552
+ holdSeconds: 'renamed to "holdMs" and now in milliseconds (2 \u2192 2000)',
5553
+ transitionSeconds: 'renamed to "transitionMs" and now in milliseconds (0.7 \u2192 700)',
5554
+ durationSeconds: 'renamed to "durationMs" and now in milliseconds (10 \u2192 10000)'
5555
+ }
5556
+ };
5557
+ function kebab(value) {
5558
+ return value.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
5559
+ }
5560
+ function legacyOptionHint(generatorId, issue) {
5561
+ if (issue.code === "unrecognized_keys") {
5562
+ const renames = RENAMED_KEYS[generatorId];
5563
+ if (!renames) return void 0;
5564
+ const hints = issue.keys.filter((key) => renames[key]).map((key) => `"${key}" ${renames[key]}`);
5565
+ return hints.length ? hints.join("; ") : void 0;
5566
+ }
5567
+ if (issue.code === "invalid_enum_value" && typeof issue.received === "string") {
5568
+ const kebabbed = kebab(issue.received);
5569
+ if (kebabbed !== issue.received && issue.options.includes(kebabbed)) {
5570
+ return `easing names are kebab-case now \u2014 use "${kebabbed}"`;
5571
+ }
5572
+ }
5573
+ return void 0;
5574
+ }
5575
+
5576
+ // src/pipeline/runner.ts
5577
+ function describeOptionIssues(generatorId, err) {
5578
+ return err.issues.map((issue) => {
5579
+ const where = ["options", ...issue.path].join(".");
5580
+ const hint = legacyOptionHint(generatorId, issue);
5581
+ return ` \u2022 ${where}: ${issue.message}${hint ? ` \u2014 ${hint}` : ""}`;
5582
+ }).join("\n");
5583
+ }
5312
5584
  function applyQuality(options, quality) {
5313
5585
  if (quality !== "draft") return options;
5314
5586
  const o = { ...options };
@@ -5324,7 +5596,7 @@ async function runPipeline(opts) {
5324
5596
  if (specs.length === 0) return [];
5325
5597
  await ensureDir(opts.outDir);
5326
5598
  const manifest = await ManifestStore.load(opts.outDir);
5327
- const tmpRoot = await mkdtemp4(path23.join(os2.tmpdir(), "pro-visu-"));
5599
+ const tmpRoot = await mkdtemp4(path23.join(os3.tmpdir(), "pro-visu-"));
5328
5600
  const browser = await launchBrowser(opts.config.settings.browser);
5329
5601
  opts.onResources?.({ tmpDir: tmpRoot });
5330
5602
  const concurrency = Math.max(1, opts.concurrency ?? opts.config.settings.concurrency);
@@ -5376,7 +5648,9 @@ async function runPipeline(opts) {
5376
5648
  inputs: inputHashes,
5377
5649
  files: fileHashes,
5378
5650
  quality,
5379
- toolVersion: opts.toolVersion
5651
+ toolVersion: opts.toolVersion,
5652
+ // Capture-mode toggles change the rendered output, so a change must bust the cache.
5653
+ capture: opts.config.settings.capture
5380
5654
  });
5381
5655
  if (cacheEnabled) {
5382
5656
  const existing = manifest.find(spec.name);
@@ -5405,6 +5679,7 @@ async function runPipeline(opts) {
5405
5679
  toolVersion: opts.toolVersion,
5406
5680
  quality,
5407
5681
  manifest,
5682
+ capture: opts.config.settings.capture,
5408
5683
  onProgress: reporter ? (v) => reporter.progress(spec.name, v) : void 0,
5409
5684
  signal: opts.signal
5410
5685
  });
@@ -5417,7 +5692,8 @@ async function runPipeline(opts) {
5417
5692
  reporter?.status(spec.name, "ok");
5418
5693
  return { name: spec.name, generator: spec.generator, status: "ok", records: result.assets };
5419
5694
  } catch (error) {
5420
- const err = error instanceof Error ? error : new Error(String(error));
5695
+ const err = error instanceof ZodError ? new Error(`Invalid ${spec.generator} options:
5696
+ ${describeOptionIssues(spec.generator, error)}`) : error instanceof Error ? error : new Error(String(error));
5421
5697
  if (opts.signal?.aborted) {
5422
5698
  return { name: spec.name, generator: spec.generator, status: "failed", records: [], error: err, cancelled: true };
5423
5699
  }
@@ -5494,7 +5770,18 @@ function applyDerivedInputs(config) {
5494
5770
  }
5495
5771
  function mergeGeneratorOptions(defaults, spec) {
5496
5772
  const generatorDefaults = defaults[spec.generator] ?? {};
5497
- return { ...generatorDefaults, ...spec.options };
5773
+ return deepMerge(generatorDefaults, spec.options);
5774
+ }
5775
+ function isPlainObject(value) {
5776
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5777
+ }
5778
+ function deepMerge(base, override) {
5779
+ const out = { ...base };
5780
+ for (const [key, value] of Object.entries(override)) {
5781
+ const existing = out[key];
5782
+ out[key] = isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value;
5783
+ }
5784
+ return out;
5498
5785
  }
5499
5786
 
5500
5787
  // src/cli/dashboard/index.tsx
@@ -5975,7 +6262,7 @@ var InkReporter = class {
5975
6262
  }
5976
6263
  };
5977
6264
  function createReporter(opts) {
5978
- const forced = process.env.SHOWCASE_LIVE;
6265
+ const forced = process.env.PRO_VISU_LIVE ?? process.env.SHOWCASE_LIVE;
5979
6266
  if (forced === "0") return new NoopReporter();
5980
6267
  if (forced === "1") return new InkReporter();
5981
6268
  return opts.tty && !opts.verbose ? new InkReporter() : new NoopReporter();
@@ -6064,6 +6351,13 @@ function renderSummary(outcomes, outDir, columns = 80) {
6064
6351
  }
6065
6352
 
6066
6353
  // src/cli/ui.ts
6354
+ function zodIssueLines(zodError, pathPrefix = "") {
6355
+ return zodError.issues.map((issue) => {
6356
+ const joined = issue.path.join(".");
6357
+ const where = [pathPrefix, joined].filter(Boolean).join(".") || "(root)";
6358
+ return ` \u2022 ${where}: ${issue.message}`;
6359
+ });
6360
+ }
6067
6361
  function reportConfigError(logger, err) {
6068
6362
  if (err instanceof ConfigNotFoundError) {
6069
6363
  logger.error(err.message);
@@ -6071,10 +6365,7 @@ function reportConfigError(logger, err) {
6071
6365
  }
6072
6366
  if (err instanceof ConfigValidationError) {
6073
6367
  logger.error(`Invalid config${err.file ? ` (${err.file})` : ""}:`);
6074
- for (const issue of err.zodError.issues) {
6075
- const where = issue.path.length ? issue.path.join(".") : "(root)";
6076
- logger.error(` \u2022 ${where}: ${issue.message}`);
6077
- }
6368
+ for (const line of zodIssueLines(err.zodError)) logger.error(line);
6078
6369
  return;
6079
6370
  }
6080
6371
  logger.error(err instanceof Error ? err.message : String(err));
@@ -6088,6 +6379,47 @@ function printSummary(logger, outcomes, outDir) {
6088
6379
  logger.log(renderSummary(outcomes, outDir, process.stdout.columns ?? 80));
6089
6380
  }
6090
6381
 
6382
+ // src/utils/suggest.ts
6383
+ function editDistance(a, b) {
6384
+ const cols = b.length + 1;
6385
+ const dist = Array.from({ length: cols }, (_, j) => j);
6386
+ for (let i = 1; i <= a.length; i++) {
6387
+ let prev = dist[0] ?? 0;
6388
+ dist[0] = i;
6389
+ for (let j = 1; j < cols; j++) {
6390
+ const tmp = dist[j] ?? 0;
6391
+ dist[j] = Math.min(
6392
+ (dist[j] ?? 0) + 1,
6393
+ // deletion
6394
+ (dist[j - 1] ?? 0) + 1,
6395
+ // insertion
6396
+ prev + (a[i - 1] === b[j - 1] ? 0 : 1)
6397
+ // substitution
6398
+ );
6399
+ prev = tmp;
6400
+ }
6401
+ }
6402
+ return dist[cols - 1] ?? 0;
6403
+ }
6404
+ function closestMatch(input, candidates) {
6405
+ const needle = input.toLowerCase();
6406
+ let best;
6407
+ let bestDist = Number.POSITIVE_INFINITY;
6408
+ for (const candidate of candidates) {
6409
+ const d = editDistance(needle, candidate.toLowerCase());
6410
+ if (d < bestDist) {
6411
+ bestDist = d;
6412
+ best = candidate;
6413
+ }
6414
+ }
6415
+ const threshold = Math.max(2, Math.floor(input.length / 3));
6416
+ return bestDist <= threshold ? best : void 0;
6417
+ }
6418
+ function didYouMean(input, candidates) {
6419
+ const match = closestMatch(input, candidates);
6420
+ return match ? ` (did you mean "${match}"?)` : "";
6421
+ }
6422
+
6091
6423
  // src/cli/commands/generate.ts
6092
6424
  async function runGenerate(options = {}) {
6093
6425
  const cwd = resolveCwd(options.cwd);
@@ -6105,35 +6437,76 @@ async function runGenerate(options = {}) {
6105
6437
  if (config.settings.maxMemoryMB) {
6106
6438
  bootstrapLog.info(`Node heap limit: ${currentHeapLimitMB()} MB (settings.maxMemoryMB=${config.settings.maxMemoryMB})`);
6107
6439
  }
6108
- const level = options.verbose ? "debug" : config.settings.logLevel;
6109
- const reporter = createReporter({ tty: Boolean(process.stdout.isTTY), verbose: !!options.verbose });
6110
- const logger = reporter.isLive ? createReportingLogger(level, reporter) : createLogger(level);
6111
6440
  const outDir = resolveOutDir(cwd, config.settings.outDir);
6441
+ let concurrencyOverride;
6442
+ if (options.concurrency != null) {
6443
+ const raw = options.concurrency;
6444
+ const n = typeof raw === "boolean" ? Number.NaN : Number(raw);
6445
+ if (!Number.isInteger(n) || n < 1) {
6446
+ bootstrapLog.error(`Invalid --concurrency "${options.concurrency}" \u2014 expected a positive integer.`);
6447
+ process.exitCode = 1;
6448
+ return;
6449
+ }
6450
+ concurrencyOverride = n;
6451
+ }
6112
6452
  const requested = normalizeAssetNames(options.asset);
6113
6453
  if (requested) {
6114
6454
  const known = new Set(config.assets.map((a) => a.name));
6115
6455
  const unknown = requested.filter((name) => !known.has(name));
6116
- if (unknown.length) logger.warn(`Unknown asset(s): ${unknown.join(", ")}`);
6456
+ for (const name of unknown) bootstrapLog.warn(`Unknown asset "${name}"${didYouMean(name, known)}`);
6117
6457
  if (requested.every((name) => !known.has(name))) {
6118
- logger.error("No matching assets to generate.");
6458
+ bootstrapLog.error("No matching assets to generate.");
6119
6459
  process.exitCode = 1;
6120
6460
  return;
6121
6461
  }
6122
6462
  }
6463
+ applyDerivedInputs(config);
6464
+ const selected = expandSelection(config.assets, requested);
6465
+ const quality = options.draft ? "draft" : config.settings.quality;
6466
+ if (!validatePlan(bootstrapLog, config, selected, quality)) {
6467
+ process.exitCode = 1;
6468
+ return;
6469
+ }
6470
+ const anyNeedsServer = selected.some((s) => Boolean(getGenerator(s.generator)?.requiresUrl));
6471
+ if (config.settings.server && !options.skipServer && !anyNeedsServer) {
6472
+ bootstrapLog.info("No selected asset needs a URL \u2014 skipping the managed server.");
6473
+ }
6474
+ const baseServerCfg = options.skipServer || !anyNeedsServer ? void 0 : config.settings.server;
6475
+ const serverCfg = baseServerCfg && options.skipBuild ? { ...baseServerCfg, build: void 0 } : baseServerCfg;
6476
+ const serverBase = serverCfg ? resolveServerUrl(serverCfg) : void 0;
6477
+ const resolvedConfig = {
6478
+ ...config,
6479
+ assets: resolveTargets(
6480
+ config.assets,
6481
+ serverBase,
6482
+ (id) => Boolean(getGenerator(id)?.requiresUrl)
6483
+ )
6484
+ };
6485
+ if (options.dryRun) {
6486
+ printPlan(bootstrapLog, resolvedConfig, selected, serverCfg, quality, concurrencyOverride);
6487
+ return;
6488
+ }
6489
+ if (!serverCfg && !await preflightUrls(bootstrapLog, resolvedConfig, selected)) {
6490
+ process.exitCode = 1;
6491
+ return;
6492
+ }
6123
6493
  const usingManaged = !config.settings.browser.channel && !config.settings.browser.executablePath;
6124
6494
  if (!options.skipBrowser && usingManaged) {
6125
- const ready = await ensureChromium({ logger });
6495
+ const ready = await ensureChromium({ logger: bootstrapLog });
6126
6496
  if (!ready) {
6127
- logger.error("Chromium is required. Run `pro-visu init` or install it manually.");
6497
+ bootstrapLog.error("Chromium is required. Run `pro-visu init` or install it manually.");
6128
6498
  process.exitCode = 1;
6129
6499
  return;
6130
6500
  }
6131
6501
  }
6132
- if (!await ensureFfmpeg({ logger })) {
6133
- logger.error("A working ffmpeg is required for video generators.");
6502
+ if (!await ensureFfmpeg({ logger: bootstrapLog })) {
6503
+ bootstrapLog.error("A working ffmpeg is required for video generators.");
6134
6504
  process.exitCode = 1;
6135
6505
  return;
6136
6506
  }
6507
+ const level = options.verbose ? "debug" : config.settings.logLevel;
6508
+ const reporter = createReporter({ tty: Boolean(process.stdout.isTTY), verbose: !!options.verbose });
6509
+ const logger = reporter.isLive ? createReportingLogger(level, reporter) : createLogger(level);
6137
6510
  await ensureDir(outDir);
6138
6511
  await startRunState(outDir);
6139
6512
  const abort = new AbortController();
@@ -6174,23 +6547,6 @@ async function runGenerate(options = {}) {
6174
6547
  }
6175
6548
  }, 1500);
6176
6549
  memTimer.unref?.();
6177
- applyDerivedInputs(config);
6178
- const selected = expandSelection(config.assets, requested);
6179
- const anyNeedsServer = selected.some((s) => Boolean(getGenerator(s.generator)?.requiresUrl));
6180
- if (config.settings.server && !options.skipServer && !anyNeedsServer) {
6181
- logger.info("No selected asset needs a URL \u2014 skipping the managed server.");
6182
- }
6183
- const baseServerCfg = options.skipServer || !anyNeedsServer ? void 0 : config.settings.server;
6184
- const serverCfg = baseServerCfg && options.skipBuild ? { ...baseServerCfg, build: void 0 } : baseServerCfg;
6185
- const serverBase = serverCfg ? resolveServerUrl(serverCfg) : void 0;
6186
- const resolvedConfig = {
6187
- ...config,
6188
- assets: resolveTargets(
6189
- config.assets,
6190
- serverBase,
6191
- (id) => Boolean(getGenerator(id)?.requiresUrl)
6192
- )
6193
- };
6194
6550
  const gates = [];
6195
6551
  const tasks = {};
6196
6552
  if (reporter.isLive && serverCfg) {
@@ -6222,16 +6578,14 @@ async function runGenerate(options = {}) {
6222
6578
  server = await startManagedServer(serverCfg, cwd, logger, tasks, abort.signal);
6223
6579
  await updateRunState(outDir, { serverPid: server?.pid });
6224
6580
  }
6225
- const concurrency = options.concurrency != null ? Number(options.concurrency) : void 0;
6226
- const count = requested ? requested.length : config.assets.length;
6227
- if (!reporter.isLive) logger.start(`Generating ${count} asset(s)\u2026`);
6581
+ if (!reporter.isLive) logger.start(`Generating ${selected.length} asset(s)\u2026`);
6228
6582
  outcomes = await runPipeline({
6229
6583
  config: resolvedConfig,
6230
6584
  outDir,
6231
6585
  logger,
6232
6586
  toolVersion: TOOL_VERSION,
6233
6587
  assetNames: requested,
6234
- concurrency: Number.isFinite(concurrency) ? concurrency : void 0,
6588
+ concurrency: concurrencyOverride,
6235
6589
  quality: options.draft ? "draft" : void 0,
6236
6590
  cache: options.cache,
6237
6591
  reporter,
@@ -6270,6 +6624,107 @@ async function runGenerate(options = {}) {
6270
6624
  process.exitCode = 1;
6271
6625
  }
6272
6626
  }
6627
+ function validatePlan(log, config, selected, quality) {
6628
+ let ok = true;
6629
+ const ids = generatorIds();
6630
+ const known = new Set(ids);
6631
+ for (const key of Object.keys(config.settings.defaults)) {
6632
+ if (!known.has(key)) {
6633
+ log.warn(`settings.defaults["${key}"] matches no generator${didYouMean(key, ids)} \u2014 it will never apply.`);
6634
+ }
6635
+ }
6636
+ for (const spec of selected) {
6637
+ const generator = getGenerator(spec.generator);
6638
+ if (!generator) {
6639
+ log.error(
6640
+ `Asset "${spec.name}": unknown generator "${spec.generator}"${didYouMean(spec.generator, ids)}. Available: ${ids.join(", ")}.`
6641
+ );
6642
+ ok = false;
6643
+ continue;
6644
+ }
6645
+ const merged = applyQuality(mergeGeneratorOptions(config.settings.defaults, spec), quality);
6646
+ const parsed2 = generator.optionsSchema.safeParse(merged);
6647
+ if (!parsed2.success) {
6648
+ log.error(`Asset "${spec.name}" has invalid ${spec.generator} options:`);
6649
+ for (const issue of parsed2.error.issues) {
6650
+ const where = ["options", ...issue.path].join(".");
6651
+ const hint = legacyOptionHint(spec.generator, issue);
6652
+ log.error(` \u2022 ${where}: ${issue.message}${hint ? ` \u2014 ${hint}` : ""}`);
6653
+ }
6654
+ ok = false;
6655
+ }
6656
+ }
6657
+ return ok;
6658
+ }
6659
+ function printPlan(log, resolvedConfig, selected, serverCfg, quality, concurrencyOverride) {
6660
+ const byName = new Map(resolvedConfig.assets.map((a) => [a.name, a]));
6661
+ const concurrency = concurrencyOverride ?? resolvedConfig.settings.concurrency;
6662
+ log.info(`Plan: ${selected.length} asset(s), quality "${quality}", concurrency ${concurrency}`);
6663
+ for (const s of selected) {
6664
+ const resolved = byName.get(s.name) ?? s;
6665
+ log.log(` \u2022 ${s.name} [${s.generator}]${resolved.url ? ` ${resolved.url}` : ""}`);
6666
+ }
6667
+ if (serverCfg) log.info(`Managed server: ${serverCfg.command}`);
6668
+ log.info("Dry run \u2014 nothing generated.");
6669
+ }
6670
+ async function preflightUrls(log, resolvedConfig, selected) {
6671
+ const byName = new Map(resolvedConfig.assets.map((a) => [a.name, a]));
6672
+ const missing = [];
6673
+ const relative = [];
6674
+ const urls = /* @__PURE__ */ new Set();
6675
+ for (const s of selected) {
6676
+ if (!getGenerator(s.generator)?.requiresUrl) continue;
6677
+ const url = byName.get(s.name)?.url;
6678
+ if (!url) missing.push(s.name);
6679
+ else if (url.startsWith("/")) relative.push(`"${s.name}" (${url})`);
6680
+ else urls.add(url);
6681
+ }
6682
+ let ok = true;
6683
+ if (missing.length) {
6684
+ log.error(
6685
+ `Asset(s) missing a url: ${missing.join(", ")} \u2014 set "url", or configure settings.server so they capture the managed server's root.`
6686
+ );
6687
+ ok = false;
6688
+ }
6689
+ if (relative.length) {
6690
+ log.error(
6691
+ `Relative url(s) require the managed server: ${relative.join(", ")} \u2014 configure settings.server, or use absolute URLs.`
6692
+ );
6693
+ ok = false;
6694
+ }
6695
+ const origins = /* @__PURE__ */ new Map();
6696
+ for (const url of urls) {
6697
+ try {
6698
+ const origin = new URL(url).origin;
6699
+ if (!origins.has(origin)) origins.set(origin, url);
6700
+ } catch {
6701
+ }
6702
+ }
6703
+ const probes = await Promise.all(
6704
+ [...origins.values()].map(async (url) => await urlResponds(url) ? null : url)
6705
+ );
6706
+ const unreachable = probes.filter((url) => url !== null);
6707
+ if (unreachable.length) {
6708
+ for (const url of unreachable) log.error(`Nothing is responding at ${url}.`);
6709
+ log.error(
6710
+ "Start your site (or point the asset urls at a deployed one), or configure settings.server so pro-visu builds and starts it for you."
6711
+ );
6712
+ ok = false;
6713
+ }
6714
+ return ok;
6715
+ }
6716
+ async function urlResponds(url) {
6717
+ const ctrl = new AbortController();
6718
+ const timer = setTimeout(() => ctrl.abort(), 1e4);
6719
+ try {
6720
+ await fetch(url, { signal: ctrl.signal, redirect: "manual" });
6721
+ return true;
6722
+ } catch {
6723
+ return false;
6724
+ } finally {
6725
+ clearTimeout(timer);
6726
+ }
6727
+ }
6273
6728
  function taskHandle(reporter, id) {
6274
6729
  return {
6275
6730
  start: () => reporter.status(id, "running"),
@@ -6284,6 +6739,83 @@ function normalizeAssetNames(value) {
6284
6739
  return arr.length ? arr : void 0;
6285
6740
  }
6286
6741
 
6742
+ // src/cli/commands/doctor.ts
6743
+ import { existsSync as existsSync8 } from "fs";
6744
+ async function runDoctor(options = {}) {
6745
+ const cwd = resolveCwd(options.cwd);
6746
+ const log = createLogger("info");
6747
+ let failed = false;
6748
+ const fail = (message) => {
6749
+ failed = true;
6750
+ log.error(message);
6751
+ };
6752
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
6753
+ if (major > 18 || major === 18 && minor >= 18) {
6754
+ log.success(`Node ${process.versions.node}`);
6755
+ } else {
6756
+ fail(`Node ${process.versions.node} \u2014 pro-visu requires >= 18.18.`);
6757
+ }
6758
+ let config;
6759
+ try {
6760
+ const loaded = await loadShowcaseConfig({ cwd, configFile: options.config });
6761
+ config = loaded.config;
6762
+ const where = loaded.configFile ?? 'package.json "pro-visu" key';
6763
+ log.success(`Config OK (${where}) \u2014 ${config.assets.length} asset(s).`);
6764
+ } catch (err) {
6765
+ failed = true;
6766
+ reportConfigError(log, err);
6767
+ }
6768
+ if (config) {
6769
+ if (validatePlan(log, config, config.assets, config.settings.quality)) {
6770
+ log.success("Asset options OK.");
6771
+ } else {
6772
+ failed = true;
6773
+ }
6774
+ try {
6775
+ applyDerivedInputs(config);
6776
+ buildGraph(config.assets);
6777
+ } catch (err) {
6778
+ fail(err.message);
6779
+ }
6780
+ }
6781
+ const browser = config?.settings.browser;
6782
+ if (browser?.executablePath) {
6783
+ if (existsSync8(browser.executablePath)) log.success(`Browser executable: ${browser.executablePath}`);
6784
+ else fail(`browser.executablePath not found: ${browser.executablePath}`);
6785
+ } else if (browser?.channel) {
6786
+ log.info(`Browser channel "${browser.channel}" \u2014 verified at launch.`);
6787
+ } else if (await ensureChromium({ logger: log, checkOnly: true })) {
6788
+ log.success("Chromium installed.");
6789
+ } else {
6790
+ fail("Chromium missing \u2014 run `pro-visu init` (or it installs on first `pro-visu generate`).");
6791
+ }
6792
+ if (await ensureFfmpeg({ logger: log, checkOnly: true })) {
6793
+ log.success(`ffmpeg OK${process.env.FFMPEG_BIN ? " (FFMPEG_BIN)" : ""}.`);
6794
+ } else if (ffmpegIsSupported()) {
6795
+ log.warn("ffmpeg not fetched yet \u2014 it downloads automatically on first `pro-visu generate`.");
6796
+ } else {
6797
+ fail(
6798
+ `No prebuilt ffmpeg for ${process.platform}/${process.arch} \u2014 set FFMPEG_BIN to a local ffmpeg binary.`
6799
+ );
6800
+ }
6801
+ if (config) {
6802
+ if (config.settings.server) {
6803
+ log.info(`Managed server configured: \`${config.settings.server.command}\` (started per run).`);
6804
+ } else if (await preflightUrls(log, config, config.assets)) {
6805
+ log.success("Asset URLs respond.");
6806
+ } else {
6807
+ failed = true;
6808
+ }
6809
+ }
6810
+ log.log("");
6811
+ if (failed) {
6812
+ log.error("Some checks failed \u2014 see above.");
6813
+ process.exitCode = 1;
6814
+ } else {
6815
+ log.success("All checks passed. Run `pro-visu generate` when ready.");
6816
+ }
6817
+ }
6818
+
6287
6819
  // src/cli/commands/list.ts
6288
6820
  import pc from "picocolors";
6289
6821
  async function runList(options = {}) {
@@ -6303,6 +6835,11 @@ async function runList(options = {}) {
6303
6835
  }
6304
6836
  }
6305
6837
  const manifest = await readManifest(outDir);
6838
+ if (options.json) {
6839
+ process.stdout.write(`${JSON.stringify({ outDir, assets: manifest.assets }, null, 2)}
6840
+ `);
6841
+ return;
6842
+ }
6306
6843
  if (manifest.assets.length === 0) {
6307
6844
  logger.info("Nothing generated yet. Run `pro-visu generate`.");
6308
6845
  return;
@@ -6323,7 +6860,7 @@ import path25 from "path";
6323
6860
  async function runReset(options = {}) {
6324
6861
  const cwd = resolveCwd(options.cwd);
6325
6862
  const log = createLogger("info");
6326
- let outDir = path25.join(cwd, "pro-visu");
6863
+ let outDir = path25.join(cwd, DEFAULT_OUTDIR);
6327
6864
  try {
6328
6865
  const { config } = await loadShowcaseConfig({ cwd, configFile: options.config });
6329
6866
  outDir = resolveOutDir(cwd, config.settings.outDir);
@@ -6358,9 +6895,10 @@ async function runReset(options = {}) {
6358
6895
 
6359
6896
  // src/cli/index.ts
6360
6897
  var cli = cac("pro-visu");
6361
- cli.command("init", "Scaffold config, gitignore the output dir, and ensure a browser").option("--cwd <dir>", "Working directory").option("--no-script", "Do not add a package.json script").option("--skip-browser", "Do not install Chromium").option("--json", "Scaffold a dependency-free JSON config + JSON Schema (for npx / global use)").action(runInit);
6362
- cli.command("generate", "Generate showcase assets defined in your config").alias("gen").option("--config <path>", "Path to a config file").option("--cwd <dir>", "Working directory").option("--asset <name>", "Only generate these assets (repeatable)").option("--concurrency <n>", "Override parallelism").option("--skip-browser", "Skip the Chromium check/install").option("--skip-server", "Skip the managed server (use an already-running site)").option("--skip-build", "Skip the server build step (fast iteration when the site is unchanged)").option("--draft", "Draft quality: faster, lower-fidelity renders for iteration").option("--cache", "Skip assets whose inputs+options are unchanged").option("--verbose", "Verbose (debug) logging").action(runGenerate);
6363
- cli.command("list", "List assets recorded in the manifest").alias("ls").option("--config <path>", "Path to a config file").option("--cwd <dir>", "Working directory").action(runList);
6898
+ cli.command("init", "Scaffold config, gitignore the output dir, and ensure a browser").option("--cwd <dir>", "Working directory").option("--no-script", 'Skip adding the "pro-visu" script to package.json').option("--skip-browser", "Do not install Chromium").option("--json", "Scaffold a dependency-free JSON config + JSON Schema (for npx / global use)").action(runInit);
6899
+ cli.command("generate", "Generate showcase assets defined in your config").alias("gen").option("--config <path>", "Path to a config file").option("--cwd <dir>", "Working directory").option("--asset <name>", "Only generate these assets (repeatable)").option("--concurrency <n>", "Override parallelism").option("--skip-browser", "Skip the Chromium check/install").option("--skip-server", "Skip the managed server (use an already-running site)").option("--skip-build", "Skip the server build step (fast iteration when the site is unchanged)").option("--draft", "Draft quality: faster, lower-fidelity renders for iteration").option("--cache", "Skip assets whose inputs+options are unchanged").option("--dry-run", "Validate the config and print the plan without generating").option("--verbose", "Verbose (debug) logging (plain logs instead of the live dashboard)").action(runGenerate);
6900
+ cli.command("doctor", "Check the environment + config (Node, config, Chromium, ffmpeg, URLs)").option("--config <path>", "Path to a config file").option("--cwd <dir>", "Working directory").action(runDoctor);
6901
+ cli.command("list", "List assets recorded in the manifest").alias("ls").option("--config <path>", "Path to a config file").option("--cwd <dir>", "Working directory").option("--json", "Print the manifest as JSON (for scripts/CI)").action(runList);
6364
6902
  cli.command("schema", "Write a JSON Schema for pro-visu.config.json (editor autocomplete)").option("--cwd <dir>", "Working directory").option("--out <path>", "Output path (default pro-visu.schema.json)").action(runSchema);
6365
6903
  cli.command("reset", "Clean up orphaned processes/temp from an interrupted run").option("--config <path>", "Path to a config file").option("--cwd <dir>", "Working directory").option("--force", "Clean up even if a run still looks active").action(runReset);
6366
6904
  cli.help();
@@ -6369,6 +6907,11 @@ var parsed = cli.parse(process.argv, { run: false });
6369
6907
  async function main() {
6370
6908
  checkForUpdates();
6371
6909
  if (!cli.matchedCommand) {
6910
+ if (parsed.args.length > 0) {
6911
+ console.error(`Unknown command "${parsed.args[0]}". Run \`pro-visu --help\` for the command list.`);
6912
+ process.exitCode = 1;
6913
+ return;
6914
+ }
6372
6915
  if (!parsed.options.help && !parsed.options.version) cli.outputHelp();
6373
6916
  return;
6374
6917
  }
@@ -6376,6 +6919,12 @@ async function main() {
6376
6919
  }
6377
6920
  main().catch((err) => {
6378
6921
  process.exitCode = 1;
6379
- console.error(err instanceof Error ? err.stack ?? err.message : String(err));
6922
+ const message = err instanceof Error ? err.message : String(err);
6923
+ console.error(message);
6924
+ if (!process.argv.includes("--verbose") && err instanceof Error && err.stack) {
6925
+ console.error("Re-run with --verbose for a stack trace.");
6926
+ } else if (err instanceof Error && err.stack) {
6927
+ console.error(err.stack);
6928
+ }
6380
6929
  });
6381
6930
  //# sourceMappingURL=index.js.map