pro-visu 0.4.0 → 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.4.0" : "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.");
@@ -224,7 +229,17 @@ function getFollowing(url, redirects = 5) {
224
229
  req.on("error", reject);
225
230
  });
226
231
  }
227
- async function downloadFfmpeg() {
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) {
228
243
  const url = ffmpegDownloadUrl();
229
244
  if (!url) {
230
245
  throw new Error(
@@ -235,8 +250,21 @@ async function downloadFfmpeg() {
235
250
  const tmp = `${dest}.download`;
236
251
  await mkdir2(path4.dirname(dest), { recursive: true });
237
252
  await rm2(tmp, { force: true });
238
- const res = await getFollowing(url);
239
- await pipeline(res, createGunzip(), createWriteStream(tmp));
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
+ }
240
268
  await chmod(tmp, 493).catch(() => {
241
269
  });
242
270
  await rm2(dest, { force: true });
@@ -569,7 +597,16 @@ async function ensureFfmpeg(opts) {
569
597
  }
570
598
  opts.logger.info("Fetching ffmpeg (one-time, ~80 MB)\u2026");
571
599
  try {
572
- await downloadFfmpeg();
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
+ }
609
+ });
573
610
  } catch (err) {
574
611
  opts.logger.error(`ffmpeg download failed: ${err.message}`);
575
612
  return false;
@@ -665,13 +702,13 @@ var settingsSchema = z.object({
665
702
  quality: z.enum(["draft", "final"]).default("final").describe('Render quality; "draft" lowers fps/scale and speeds the encoder for fast iteration (default "final").'),
666
703
  /** Skip assets whose inputs+options+tool fingerprint is unchanged (opt-in; can be stale). */
667
704
  cache: z.boolean().default(false).describe("Skip assets whose inputs+options+tool fingerprint is unchanged (opt-in; can be stale). Default false.")
668
- });
705
+ }).strict();
669
706
  var assetSpecSchema = z.object({
670
707
  name: z.string().min(1),
671
708
  /**
672
709
  * Page to capture: an absolute `https://…` URL, or a path like `/shop` resolved against the
673
710
  * managed server's URL. Optional — with a managed server, a url-based asset that omits it
674
- * captures the server root; local generators (`scene`, `palette`) need no url.
711
+ * captures the server root; local generators (`specimen`, `palette`, `wall`, …) need no url.
675
712
  */
676
713
  url: z.string().min(1).refine((s) => /^https?:\/\//i.test(s) || s.startsWith("/"), {
677
714
  message: 'url must be an absolute http(s) URL or a path starting with "/" (resolved against the managed server)'
@@ -686,9 +723,11 @@ var assetSpecSchema = z.object({
686
723
  inputs: z.record(z.string(), z.string()).default({})
687
724
  }).strict();
688
725
  var showcaseConfigSchema = z.object({
726
+ /** JSON configs may carry an editor `$schema` pointer; accepted and ignored at runtime. */
727
+ $schema: z.string().optional(),
689
728
  settings: settingsSchema.default({}),
690
729
  assets: z.array(assetSpecSchema).min(1, "Define at least one asset in `assets`.")
691
- }).superRefine((cfg, ctx) => {
730
+ }).strict().superRefine((cfg, ctx) => {
692
731
  const seen = /* @__PURE__ */ new Set();
693
732
  for (const [i, asset] of cfg.assets.entries()) {
694
733
  if (seen.has(asset.name)) {
@@ -710,12 +749,12 @@ import { stat as stat2 } from "fs/promises";
710
749
  import { z as z2 } from "zod";
711
750
  var easingSchema = z2.enum([
712
751
  "linear",
713
- "easeInOutCubic",
714
- "easeInOutQuad",
715
- "easeOutCubic",
716
- "easeInOutSine",
717
- "easeInOutExpo",
718
- "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"
719
758
  ]);
720
759
  var choreographyStepSchema = z2.object({
721
760
  /** Target: a 0..1 number, an "NN%" string, or a CSS selector to bring into view. */
@@ -724,7 +763,7 @@ var choreographyStepSchema = z2.object({
724
763
  durationMs: z2.number().int().nonnegative().optional().describe("Travel time to this target (ms). Default 1200."),
725
764
  /** Hold time at this target after arriving (ms). Default 800. */
726
765
  holdMs: z2.number().int().nonnegative().optional().describe("Hold time at this target after arriving (ms). Default 800."),
727
- 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".')
728
767
  }).strict();
729
768
  var interactionActionSchema = z2.object({
730
769
  do: z2.enum(["move", "click", "hover", "type", "scrollTo", "wait"]).describe("What this step does."),
@@ -790,8 +829,8 @@ var scrollReelOptionsSchema = z2.object({
790
829
  /** Output frames per second (re-encoded from the recording). */
791
830
  fps: z2.number().int().positive().max(120).default(30).describe("Output frames per second (re-encoded from the recording). Default 30."),
792
831
  /** Time to scroll from top to bottom (ms). */
793
- duration: z2.number().int().positive().default(6e3).describe("Time to scroll from top to bottom (ms). Default 6000."),
794
- 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".'),
795
834
  /** Dwell at the top before scrolling (ms). */
796
835
  startDelayMs: z2.number().int().nonnegative().default(500).describe("Dwell at the top before scrolling (ms). Default 500."),
797
836
  /** Dwell at the bottom after scrolling (ms). */
@@ -850,7 +889,7 @@ var scrollReelOptionsSchema = z2.object({
850
889
  scaleFrom: z2.number().positive().optional().describe("Start scale (1 = no zoom). Default 1."),
851
890
  /** End scale. Default 1.08. */
852
891
  scaleTo: z2.number().positive().optional().describe("End scale. Default 1.08."),
853
- 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".'),
854
893
  /** Zoom origin X within the viewport (0 = left, 1 = right). Default 0.5. */
855
894
  originX: z2.number().min(0).max(1).optional().describe("Zoom origin X within the viewport (0 = left, 1 = right). Default 0.5."),
856
895
  /** Zoom origin Y within the viewport (0 = top, 1 = bottom). Default 0.5. */
@@ -1040,12 +1079,12 @@ async function applyCapture(context, capture, url) {
1040
1079
  // src/generators/scroll-reel/scroll.ts
1041
1080
  var EASINGS = {
1042
1081
  linear: (t) => t,
1043
- easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
1044
- easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
1045
- easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
1046
- easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
1047
- 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,
1048
- 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)
1049
1088
  };
1050
1089
  async function prepareScroll(args) {
1051
1090
  const target = findScrollTarget(document, getComputedStyle);
@@ -1410,17 +1449,17 @@ async function pageScroll(args) {
1410
1449
  const { durationMs, easing, startDelayMs, endDwellMs } = args;
1411
1450
  const ease = (t) => {
1412
1451
  switch (easing) {
1413
- case "easeInOutCubic":
1452
+ case "ease-in-out-cubic":
1414
1453
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1415
- case "easeInOutQuad":
1454
+ case "ease-in-out-quad":
1416
1455
  return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
1417
- case "easeOutCubic":
1456
+ case "ease-out-cubic":
1418
1457
  return 1 - Math.pow(1 - t, 3);
1419
- case "easeInOutSine":
1458
+ case "ease-in-out-sine":
1420
1459
  return -(Math.cos(Math.PI * t) - 1) / 2;
1421
- case "easeInOutExpo":
1460
+ case "ease-in-out-expo":
1422
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;
1423
- case "easeOutQuint":
1462
+ case "ease-out-quint":
1424
1463
  return 1 - Math.pow(1 - t, 5);
1425
1464
  default:
1426
1465
  return t;
@@ -1524,7 +1563,7 @@ async function captureScrollWebm(args) {
1524
1563
  await page.evaluate(prepareScroll, { settleMs: PREWARM_SETTLE_MS });
1525
1564
  leadSeconds = (Date.now() - recStart) / 1e3;
1526
1565
  const distance = await page.evaluate(pageScroll, {
1527
- durationMs: options.duration,
1566
+ durationMs: options.durationMs,
1528
1567
  easing: options.easing,
1529
1568
  startDelayMs: options.startDelayMs,
1530
1569
  endDwellMs: options.endDwellMs
@@ -1995,7 +2034,7 @@ function scrollTimelineTotalMs(o) {
1995
2034
  if (o.autoSections) {
1996
2035
  return autoSectionsBudgetMs(o.autoSections);
1997
2036
  }
1998
- return o.startDelayMs + o.duration + o.endDwellMs;
2037
+ return o.startDelayMs + o.durationMs + o.endDwellMs;
1999
2038
  }
2000
2039
  function boomerangSpec(spec) {
2001
2040
  const forward = spec.segments.map((s) => ({ ...s, durationFraction: s.durationFraction / 2 }));
@@ -2091,7 +2130,7 @@ async function buildScrollTimeline(page, options, totalSeconds, logger) {
2091
2130
  return finalize(
2092
2131
  defaultTimelineSpec({
2093
2132
  startDelayMs: options.startDelayMs,
2094
- durationMs: options.duration,
2133
+ durationMs: options.durationMs,
2095
2134
  endDwellMs: options.endDwellMs,
2096
2135
  easing: options.easing
2097
2136
  })
@@ -2852,7 +2891,7 @@ async function run(ctx, options) {
2852
2891
  ...options,
2853
2892
  choreography: r.choreography,
2854
2893
  autoSections: r.autoSections,
2855
- duration: r.durationMs ?? options.duration
2894
+ durationMs: r.durationMs ?? options.durationMs
2856
2895
  };
2857
2896
  const segPath = path10.join(ctx.tmpDir, `${slugify(ctx.target.name)}-route-${i}.mp4`);
2858
2897
  ctx.logger.info(
@@ -2912,7 +2951,7 @@ async function run(ctx, options) {
2912
2951
  }
2913
2952
  const fileName = options.fileName ?? `${slugify(ctx.target.name)}.mp4`;
2914
2953
  const outPath = ctx.resolveOutPath(fileName);
2915
- const durationSeconds = (options.startDelayMs + options.duration + options.endDwellMs) / 1e3;
2954
+ const durationSeconds = (options.startDelayMs + options.durationMs + options.endDwellMs) / 1e3;
2916
2955
  ctx.logger.info(`recording ${url} (realtime)`);
2917
2956
  const { webmPath, leadSeconds } = await captureScrollWebm({
2918
2957
  browser: ctx.browser,
@@ -2946,7 +2985,7 @@ async function run(ctx, options) {
2946
2985
  format: "mp4",
2947
2986
  width: options.width,
2948
2987
  height: options.height,
2949
- durationMs: options.startDelayMs + options.duration + options.endDwellMs,
2988
+ durationMs: options.startDelayMs + options.durationMs + options.endDwellMs,
2950
2989
  bytes: stats.size,
2951
2990
  contentHash,
2952
2991
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3030,7 +3069,7 @@ import { imageSize } from "image-size";
3030
3069
 
3031
3070
  // src/generators/screenshots/options.ts
3032
3071
  import { z as z3 } from "zod";
3033
- var breakpointSchema = z3.object({
3072
+ var viewportSchema = z3.object({
3034
3073
  name: z3.string().min(1).describe('Label for this viewport \u2014 used in the filename + manifest id (e.g. "desktop").'),
3035
3074
  width: z3.number().int().positive().describe("Viewport width in CSS px."),
3036
3075
  /** Viewport height. Note: ignored for `fullPage` shots (Playwright resizes to the page height);
@@ -3038,8 +3077,8 @@ var breakpointSchema = z3.object({
3038
3077
  height: z3.number().int().positive().default(900).describe(
3039
3078
  "Viewport height in CSS px. Default 900. Ignored for fullPage shots; only affects viewport/element captures."
3040
3079
  ),
3041
- /** Override the generator-level deviceScaleFactor for this breakpoint. */
3042
- 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.")
3043
3082
  }).strict();
3044
3083
  var elementShotSchema = z3.object({
3045
3084
  selector: z3.string().min(1).describe("CSS selector of the element to shoot."),
@@ -3047,7 +3086,7 @@ var elementShotSchema = z3.object({
3047
3086
  name: z3.string().min(1).describe("Name used in the filename + manifest id for this element shot.")
3048
3087
  }).strict();
3049
3088
  var screenshotsOptionsSchema = z3.object({
3050
- breakpoints: z3.array(breakpointSchema).min(1).default([
3089
+ viewports: z3.array(viewportSchema).min(1).default([
3051
3090
  { name: "desktop", width: 1440, height: 900 },
3052
3091
  { name: "mobile", width: 390, height: 844 }
3053
3092
  ]).describe(
@@ -3061,8 +3100,8 @@ var screenshotsOptionsSchema = z3.object({
3061
3100
  deviceScaleFactor: z3.number().positive().max(4).default(2).describe("Render scale (2 = retina-crisp). Default 2."),
3062
3101
  waitUntil: z3.enum(["load", "domcontentloaded", "networkidle", "commit"]).default("networkidle").describe('Page-load milestone to wait for before capturing. Default "networkidle".'),
3063
3102
  waitForSelector: z3.string().optional().describe("Optional element to wait for before capturing (e.g. a hero image). Omit to skip."),
3064
- /** Element captures taken at every breakpoint. */
3065
- 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."),
3066
3105
  /** png only: capture with a transparent background. */
3067
3106
  omitBackground: z3.boolean().default(false).describe("Capture with a transparent background (png only). Default false."),
3068
3107
  /** Extra settle time after load before capturing (ms). */
@@ -3092,14 +3131,14 @@ async function mapLimit(items, limit, fn) {
3092
3131
  var MIN_PREPARE_SETTLE_MS = 600;
3093
3132
  async function captureScreenshots(args) {
3094
3133
  const { options } = args;
3095
- const perBreakpoint = await mapLimit(
3096
- options.breakpoints,
3097
- Math.min(3, options.breakpoints.length),
3098
- (bp) => captureBreakpoint(args, bp)
3134
+ const perViewport = await mapLimit(
3135
+ options.viewports,
3136
+ Math.min(3, options.viewports.length),
3137
+ (bp) => captureViewport(args, bp)
3099
3138
  );
3100
- return perBreakpoint.flat();
3139
+ return perViewport.flat();
3101
3140
  }
3102
- async function captureBreakpoint(args, bp) {
3141
+ async function captureViewport(args, bp) {
3103
3142
  const { browser, url, options, logger } = args;
3104
3143
  const shots = [];
3105
3144
  {
@@ -3224,276 +3263,148 @@ var screenshotsGenerator = {
3224
3263
  };
3225
3264
 
3226
3265
  // src/generators/wall/options.ts
3227
- import { z as z6 } from "zod";
3228
-
3229
- // src/generators/scene/scene-options.ts
3230
3266
  import { z as z5 } from "zod";
3231
3267
 
3232
- // src/generators/specimen/options.ts
3268
+ // src/generators/scene/scene-options.ts
3233
3269
  import { z as z4 } from "zod";
3234
- var pulseSchema = z4.object({
3235
- /** Human label for the beat, e.g. "color sweep" — purely to keep the config readable. */
3236
- name: z4.string().default("").describe('Human label for the beat, e.g. "color sweep" \u2014 purely to keep the config readable.'),
3237
- /** Length of this beat, in seconds. */
3238
- duration: z4.number().positive().describe("Length of this beat, in seconds."),
3239
- /** Fraction of cells whose glyph changes during this beat (0..1; 1 = every cell once). */
3240
- 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."),
3241
- /** Fraction of cells whose color changes during this beat (0..1; 1 = every cell once). */
3242
- colors: z4.number().nonnegative().default(0).describe("Fraction of cells whose color changes during this beat (0..1; 1 = every cell once). Default 0."),
3243
- /**
3244
- * Target color for this beat's color changes. When set, every color change in the beat goes to
3245
- * this exact token (a deliberate sweep) instead of a weighted-random pick. Set `colors: 1` with
3246
- * `pacing: "even"` to wash the whole specimen to one color evenly. Omit for the default
3247
- * scattered, weighted-random recoloring.
3248
- */
3249
- 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."),
3250
- /**
3251
- * How the changes are distributed in time across the beat — like a CSS easing curve:
3252
- * "linear"/"even" = uniform, "ease-in" = front-loaded, "ease-out" = back-loaded,
3253
- * "ease-in-out" = bunched at both ends, "random" = scattered.
3254
- */
3255
- 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")
3256
3278
  }).strict();
3257
- var P = (name, duration, chars = 0, colors = 0) => ({
3258
- name,
3259
- duration,
3260
- chars,
3261
- colors,
3262
- pacing: "even"
3263
- });
3264
- var DEFAULT_PULSES = [
3265
- P("intro hold", 0.8),
3266
- P("first letters", 0.8, 0.15, 0),
3267
- P("settle", 1.5),
3268
- P("ripple", 1, 0.08, 0.04),
3269
- P("color sweep", 1.2, 0, 0.13),
3270
- P("rest", 1.2),
3271
- P("quick burst", 0.6, 0.18, 0),
3272
- P("drift", 1.2, 0, 0.08),
3273
- P("finale", 1.2, 0.18, 0.13),
3274
- P("outro hold", 0.5)
3275
- ];
3276
- var DEMO_PULSES = [
3277
- { name: "linear", duration: 5, chars: 0.5, pacing: "linear" },
3278
- { name: "hold", duration: 2 },
3279
- { name: "ease-in", duration: 5, chars: 0.5, pacing: "ease-in" },
3280
- { name: "hold", duration: 2 },
3281
- { name: "ease-out", duration: 5, chars: 0.5, pacing: "ease-out" },
3282
- { name: "hold", duration: 2 },
3283
- { name: "ease-in-out", duration: 5, chars: 0.5, pacing: "ease-in-out" },
3284
- { name: "hold", duration: 2 },
3285
- { name: "random", duration: 5, chars: 0.5, pacing: "random" },
3286
- { name: "hold", duration: 2 },
3287
- // Even, per-character color sweeps: each washes every glyph to one token, evenly across the beat.
3288
- { name: "sweep \u2192 muted", duration: 4, colors: 1, color: "muted", pacing: "even" },
3289
- { name: "sweep \u2192 accent", duration: 4, colors: 1, color: "accent", pacing: "even" },
3290
- { name: "sweep \u2192 foreground", duration: 4, colors: 1, color: "foreground", pacing: "even" },
3291
- { name: "hold", duration: 2 },
3292
- { name: "weighted recolor", duration: 5, colors: 0.6 },
3293
- { name: "hold", duration: 2 },
3294
- { name: "mingle", duration: 5, chars: 0.3, colors: 0.3 }
3295
- ];
3296
- var SWEEP_COLORS = {
3297
- background: "#0b0b0f",
3298
- foreground: "#f4f4f5",
3299
- muted: "#6b7280",
3300
- accent: "#7c9cff"
3301
- };
3302
- var SWEEP_PULSES = [
3303
- { name: "hold", duration: 0.8 },
3304
- { name: "to muted", duration: 2.2, colors: 1, color: "muted", pacing: "ease-in-out" },
3305
- { name: "settle", duration: 0.6 },
3306
- { name: "to accent", duration: 2.2, colors: 1, color: "accent", pacing: "ease-in-out" },
3307
- { name: "settle", duration: 0.6 },
3308
- { name: "to foreground", duration: 2.2, colors: 1, color: "foreground", pacing: "ease-in-out" },
3309
- { name: "glyph drift", duration: 1.4, chars: 0.5, pacing: "even" },
3310
- { name: "hold", duration: 0.6 }
3311
- ];
3312
- var SPECIMEN_TEMPLATES = {
3313
- demo: { demo: true, mirror: false, lines: 4, pulses: DEMO_PULSES },
3314
- sweep: {
3315
- mirror: true,
3316
- lines: 4,
3317
- colors: SWEEP_COLORS,
3318
- pulses: SWEEP_PULSES
3319
- }
3320
- };
3321
- function applyTemplate(raw) {
3322
- if (!raw || typeof raw !== "object") return raw;
3323
- const r = raw;
3324
- const tmpl = typeof r.template === "string" ? SPECIMEN_TEMPLATES[r.template] : void 0;
3325
- return tmpl ? { ...tmpl, ...r } : raw;
3326
- }
3327
- var specimenObjectSchema = z4.object({
3328
- font: z4.string().min(1).describe("Font file to showcase (path relative to the working dir, or absolute). Required."),
3329
- template: z4.enum(["demo", "sweep"]).optional().describe("Load a named option preset (demo or sweep); your explicit options still override what it sets."),
3330
- name: z4.string().default("").describe('Display name shown bottom-left (e.g. "ABC Oracle"). Default none.'),
3331
- demo: z4.boolean().default(false).describe("Demo mode: overlay the active pulse's name bottom-right, to see which beat is playing. Default false."),
3332
- fps: z4.number().int().positive().max(120).default(30).describe("Output frames per second. Default 30."),
3333
- durationSeconds: z4.number().positive().optional().describe("Clip length in seconds. Defaults to the (mirrored) sum of the pulse durations; set to override."),
3334
- width: z4.number().int().positive().default(1920).describe("Output frame width in px. Default 1920."),
3335
- height: z4.number().int().positive().default(1080).describe("Output frame height in px. Default 1080."),
3336
- deviceScaleFactor: z4.number().positive().max(4).default(1).describe("Render scale (1 = 1:1; higher = crisper capture, downscaled into the video). Default 1."),
3337
- weight: z4.number().int().min(1).max(1e3).default(820).describe("Glyph weight on the variable-font axis, 1\u20131000. Default 820."),
3338
- 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."),
3339
- leading: z4.number().positive().default(0.78).describe("Line-height of the glyph block. Default 0.78 (tight, cap-height-hugging)."),
3340
- blacklist: z4.string().default("").describe('Glyphs to exclude from the showcase, e.g. "QXZ" (case-insensitive). Default none.'),
3341
- 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."),
3342
- 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(""),
3343
3285
  colors: z4.object({
3344
- background: z4.string().default("#eceef1").describe("Backdrop behind the glyphs."),
3345
- foreground: z4.string().default("#16181d").describe("Primary glyph color \u2014 the resting majority."),
3346
- muted: z4.string().default("#a7adb6").describe("Muted/secondary glyph color."),
3347
- accent: z4.string().optional().describe("Accent color for occasional pops; defaults to `background` (accent glyphs blend in) if unset."),
3348
- // defaults to `background` at render (accent glyphs blend in)
3349
- label: z4.string().optional().describe("Color of the font-name label (bottom corner); defaults to `foreground` if unset.")
3350
- // defaults to `foreground` at render
3351
- }).default({}).describe("Color tokens the glyphs cycle through (any CSS colors). Override any subset. Default: light-grey palette."),
3352
- colorWeights: z4.object({
3353
- foreground: z4.number().nonnegative().default(2).describe("Relative likelihood of the foreground token on a random color change. Default 2."),
3354
- muted: z4.number().nonnegative().default(2).describe("Relative likelihood of the muted token on a random color change. Default 2."),
3355
- accent: z4.number().nonnegative().default(1).describe("Relative likelihood of the accent token on a random color change. Default 1.")
3356
- }).default({}).describe("Relative likelihood of each color token on a random (non-targeted) color change. Default 2 / 2 / 1."),
3357
- 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."),
3358
- characterIntensity: z4.number().nonnegative().default(1).describe("Multiply every pulse's glyph-change fraction (1 = baseline, 2 = twice as busy, 0 = none). Default 1."),
3359
- colorIntensity: z4.number().nonnegative().default(1).describe("Multiply every pulse's color-change fraction (1 = baseline, 2 = twice as busy, 0 = none). Default 1."),
3360
- 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."),
3361
- mirror: z4.boolean().default(true).describe("Mirror the pulses (play out and back) for a seamless loop; doubles the clip length. Default true."),
3362
- crf: z4.number().int().min(0).max(51).default(18).describe("x264 quality, 0\u201351 (lower = better quality / larger file). Default 18."),
3363
- fileName: z4.string().optional().describe('Output filename; defaults to "<slug(asset name)>.mp4".')
3364
- }).strict();
3365
- var specimenOptionsSchema = z4.preprocess(applyTemplate, specimenObjectSchema);
3366
-
3367
- // src/generators/scene/scene-options.ts
3368
- var specimenSceneOptionsSchema = z5.object({
3369
- label: z5.string().default(""),
3370
- demo: z5.boolean().default(false),
3371
- weight: z5.number().min(1).max(1e3).default(820),
3372
- lines: z5.number().int().min(1).max(40).default(3),
3373
- blacklist: z5.string().default(""),
3374
- colors: z5.object({
3375
- background: z5.string(),
3376
- foreground: z5.string(),
3377
- muted: z5.string(),
3378
- accent: z5.string().optional(),
3379
- 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()
3380
3291
  }).partial().default({}),
3381
- colorWeights: z5.object({
3382
- foreground: z5.number().nonnegative(),
3383
- muted: z5.number().nonnegative(),
3384
- accent: z5.number().nonnegative()
3292
+ colorWeights: z4.object({
3293
+ foreground: z4.number().nonnegative(),
3294
+ muted: z4.number().nonnegative(),
3295
+ accent: z4.number().nonnegative()
3385
3296
  }).partial().default({}),
3386
- pulses: z5.array(pulseSchema).default([]),
3387
- mirror: z5.boolean().default(true),
3388
- characterIntensity: z5.number().nonnegative().default(1),
3389
- 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),
3390
3301
  /** Max fraction a line's width may drift as glyphs change (right-edge stability). */
3391
- maxLineDrift: z5.number().positive().max(0.5).default(0.05),
3302
+ maxLineDrift: z4.number().positive().max(0.5).default(0.05),
3392
3303
  /** Schedule seed — same seed ⇒ identical animation (parallel workers must agree). */
3393
- seed: z5.number().int().default(1),
3304
+ seed: z4.number().int().default(1),
3394
3305
  /** Line-height of the glyph block. */
3395
- leading: z5.number().positive().default(0.78),
3306
+ leading: z4.number().positive().default(0.78),
3396
3307
  /** Glyph pool override (≥2 distinct characters after the blacklist). */
3397
- characterPool: z5.string().min(2).optional()
3308
+ characterPool: z4.string().min(2).optional()
3398
3309
  }).strict();
3399
- var wallEasingEnum = z5.enum([
3310
+ var wallEasingEnum = z4.enum([
3400
3311
  "linear",
3401
3312
  "ease-in",
3402
3313
  "ease-out",
3403
3314
  "ease-in-out",
3404
3315
  "ease-in-out-strong"
3405
3316
  ]);
3406
- var wallPulseSchema = z5.object({
3317
+ var wallPulseSchema = z4.object({
3407
3318
  /** When the pulse starts, as a fraction of the clip (0..1). */
3408
- 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)."),
3409
3320
  /** How long the move takes, as a fraction of the clip (0..1). */
3410
- 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)."),
3411
3322
  /** How far it travels, in periods (1 = one full tile-set / one wrap). Usually 0..1. */
3412
- 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."),
3413
3324
  /** Easing of the move's ramp. */
3414
3325
  easing: wallEasingEnum.default("ease-in-out").describe("Easing of the move's ramp. Default 'ease-in-out'.")
3415
3326
  }).strict();
3416
- var wallPanSchema = z5.object({
3327
+ var wallPanSchema = z4.object({
3417
3328
  /** Pan direction. */
3418
- 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'."),
3419
3330
  /** Continuous whole-clip horizontal loops (0 = no pan unless `pulses` move it). */
3420
- 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."),
3421
3332
  /** Pulses added on top of the base loops. */
3422
- 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.")
3423
3334
  }).strict();
3424
- var wallColumnSchema = z5.object({
3335
+ var wallColumnSchema = z4.object({
3425
3336
  /** Assets stacked in this column, by name (cycled to fill the height). At least one. */
3426
- 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."),
3427
3338
  /** Constant start-position shift, 0..1 of a tile-set — de-aligns columns with similar content. */
3428
- 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."),
3429
3340
  /** Scroll direction. Defaults to "down". */
3430
- direction: z5.enum(["up", "down"]).optional().describe("Scroll direction. Default 'down'."),
3341
+ direction: z4.enum(["up", "down"]).optional().describe("Scroll direction. Default 'down'."),
3431
3342
  /** Continuous whole-clip loops for this column. Omit to inherit the wall-level `loops`. */
3432
- 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."),
3433
3344
  /** This column's pulses. Omit to inherit the wall-level `pulses`. */
3434
- 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.")
3435
3346
  }).strict();
3436
- var fauxTileSchema = z5.object({
3347
+ var fauxTileSchema = z4.object({
3437
3348
  /** Box fill (any CSS color). Omit to auto-derive a distinct color from the tile name. */
3438
- 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."),
3439
3350
  /** Optional caption shown under the name (e.g. "16:9") — purely cosmetic. */
3440
- 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."),
3441
3352
  /** This faux tile's aspect ratio (width / height): 1.78 = 16:9 (short), 0.56 = 9:16 (tall), 1 =
3442
3353
  * square. Omit to use the wall's `tileAspect` default. Real tiles use their media's own aspect. */
3443
- 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.")
3444
3355
  }).strict();
3445
- var wallSceneOptionsSchema = z5.object({
3356
+ var wallSceneOptionsSchema = z4.object({
3446
3357
  /** The columns (≥3) — each its own tiles + motion. Count = array length (fewer = bigger tiles). */
3447
- columns: z5.array(wallColumnSchema).min(3),
3358
+ columns: z4.array(wallColumnSchema).min(3),
3448
3359
  /** Gap between columns and between their tile contents (px). */
3449
- gap: z5.number().nonnegative().default(16),
3360
+ gap: z4.number().nonnegative().default(16),
3450
3361
  /** Default/fallback tile aspect (width / height). Tiles fit the column width and take their OWN
3451
3362
  * height from their media's aspect (16:9 → short, 9:16 → tall); this is only used for faux
3452
3363
  * (`test`) tiles that don't set their own `aspect`. 1.6 = 16:10 landscape, <1 = portrait. */
3453
- tileAspect: z5.number().positive().default(1.6),
3364
+ tileAspect: z4.number().positive().default(1.6),
3454
3365
  /** Tile corner radius (px). */
3455
- cornerRadius: z5.number().nonnegative().default(12),
3366
+ cornerRadius: z4.number().nonnegative().default(12),
3456
3367
  /** Backdrop shown in the gap gutters and behind tiles. Defaults to the scene's `background`. */
3457
- background: z5.string().optional(),
3368
+ background: z4.string().optional(),
3458
3369
  /** System 1 — the X pan. */
3459
3370
  pan: wallPanSchema.default({}),
3460
3371
  /** Default continuous whole-clip loops for columns that omit their own `loops` (0 = static unless
3461
3372
  * a pulse moves it; one pulse then rounds the total up to a single loop). */
3462
- loops: z5.number().nonnegative().default(0),
3373
+ loops: z4.number().nonnegative().default(0),
3463
3374
  /** Default pulses for columns that omit their own `pulses` (the uniform wall-level motion). */
3464
- pulses: z5.array(wallPulseSchema).default([]),
3375
+ pulses: z4.array(wallPulseSchema).default([]),
3465
3376
  /** Preview mode: render every tile as a flat labeled color box (see `testTiles`) instead of the
3466
3377
  * real assets, so you can dial in layout + motion instantly — no producers run. */
3467
- test: z5.boolean().default(false),
3378
+ test: z4.boolean().default(false),
3468
3379
  /** Per-tile faux appearance for `test` mode, keyed by tile name. Tiles not listed get an
3469
3380
  * auto-derived color and their name as the label. */
3470
- testTiles: z5.record(z5.string(), fauxTileSchema).default({})
3381
+ testTiles: z4.record(z4.string(), fauxTileSchema).default({})
3471
3382
  }).strict();
3472
- var reelItemSchema = z5.object({
3383
+ var reelItemSchema = z4.object({
3473
3384
  /** Color display name (already cased per `uppercase`). */
3474
- name: z5.string(),
3385
+ name: z4.string(),
3475
3386
  /** Swatch background hex (`#RRGGBB`). */
3476
- hex: z5.string(),
3387
+ hex: z4.string(),
3477
3388
  /** Contrast-picked text color for this band. */
3478
- textColor: z5.string(),
3389
+ textColor: z4.string(),
3479
3390
  /** Preformatted detail lines revealed on expand (hex / oklch / rgb …). */
3480
- details: z5.array(z5.string())
3391
+ details: z4.array(z4.string())
3481
3392
  }).strict();
3482
- var paletteReelSceneOptionsSchema = z5.object({
3483
- items: z5.array(reelItemSchema).min(1),
3484
- orientation: z5.enum(["rows", "columns"]).default("rows"),
3485
- holdSeconds: z5.number().positive().default(2),
3486
- transitionSeconds: z5.number().positive().default(0.7),
3487
- bounce: z5.boolean().default(true),
3488
- easing: z5.enum(["linear", "ease-in", "ease-out", "ease-in-out"]).default("ease-in-out"),
3489
- grownFlex: z5.number().min(1).default(12),
3490
- minCrossPx: z5.number().nonnegative().default(0),
3491
- nameAlwaysVisible: z5.boolean().default(true),
3492
- fontWeight: z5.number().int().min(1).max(1e3).default(700),
3493
- fontSize: z5.number().positive().optional(),
3494
- detailFontScale: z5.number().positive().default(0.62),
3495
- gap: z5.number().nonnegative().default(0),
3496
- 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)
3497
3408
  }).strict();
3498
3409
  var SCENE_OPTION_SCHEMAS = {
3499
3410
  specimen: specimenSceneOptionsSchema,
@@ -3502,58 +3413,58 @@ var SCENE_OPTION_SCHEMAS = {
3502
3413
  };
3503
3414
 
3504
3415
  // src/generators/wall/options.ts
3505
- var wallOptionsSchema = z6.object({
3416
+ var wallOptionsSchema = z5.object({
3506
3417
  // --- output ---
3507
3418
  /** Output width (CSS px). */
3508
- 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."),
3509
3420
  /** Output height (CSS px). */
3510
- 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."),
3511
3422
  /** Render scale (2 = retina-crisp, downscaled into the video). */
3512
- 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."),
3513
3424
  /** Output frames per second. */
3514
- fps: z6.number().int().positive().max(120).default(30).describe("Output frames per second. Default 30."),
3515
- /** Clip length (seconds) — the whole loop. Tile videos should loop within a length dividing this. */
3516
- 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."),
3517
3428
  /** x264 quality, 0–51 (lower = better/larger). */
3518
- 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."),
3519
3430
  /** Capture strategy. "frames" (default) is deterministic + parallelizable. */
3520
- 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.'),
3521
3432
  /** Parallel frame-render workers. Video-heavy walls can cold-start black under many workers —
3522
3433
  * set 1 (or omit) for those. */
3523
- 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."),
3524
3435
  /** Intermediate frame format (frames capture only). "jpeg" (default) is fast; "png" is lossless. */
3525
- 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.'),
3526
3437
  /** Backdrop shown in the gutters between tiles. */
3527
- 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".'),
3528
3439
  /** Output filename; defaults to "<slug(asset name)>.mp4". */
3529
- 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".'),
3530
3441
  // --- columns (System 2 + layout): each column = its tiles + its own motion ---
3531
3442
  /** The columns (≥3) — each its own tiles + motion. Count = array length (fewer = larger tiles). */
3532
- 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."),
3533
3444
  /** Gap between columns and between tiles (px). */
3534
- 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."),
3535
3446
  /** Default/fallback tile aspect (width / height). Tiles fit the column width and take their OWN
3536
3447
  * height from their media's aspect (16:9 → short, 9:16 → tall); this is only used for faux
3537
3448
  * (`test`) tiles that don't set their own `aspect`. 0.75 = 3:4 portrait. */
3538
- 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."),
3539
3450
  /** Tile corner radius (px). */
3540
- 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."),
3541
3452
  // --- motion (uniform pulse model) ---
3542
3453
  /** System 1 — the whole-wall X pan. */
3543
3454
  pan: wallPanSchema.default({}).describe("System 1 \u2014 the whole wall's horizontal pan (`direction` / `loops` / `pulses`). Default: no pan."),
3544
3455
  /** Default continuous whole-clip loops for columns that omit their own `loops` (0 = static unless
3545
3456
  * a pulse moves it; one pulse then rounds the total up to a single loop). */
3546
- 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)."),
3547
3458
  /** Default pulses for columns that omit their own `pulses` (the uniform wall-level motion). */
3548
- 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."),
3549
3460
  // --- test / preview ---
3550
3461
  /** Preview mode: render every tile as a flat labeled color box (see `testTiles`) instead of the
3551
3462
  * real assets. No producer assets run, so the wall renders in seconds — use it to dial in
3552
3463
  * layout + motion, then turn it off for the real render. */
3553
- 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."),
3554
3465
  /** Per-tile faux appearance for `test` mode, keyed by tile name. Tiles not listed get an
3555
3466
  * auto-derived color and their name as the label. */
3556
- 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).")
3557
3468
  }).strict();
3558
3469
 
3559
3470
  // src/generators/scene/index.ts
@@ -3925,7 +3836,8 @@ async function run3(ctx, o) {
3925
3836
  background: o.background,
3926
3837
  deviceScaleFactor: o.deviceScaleFactor,
3927
3838
  fps: o.fps,
3928
- durationSeconds: o.durationSeconds,
3839
+ // The scene wire format keeps seconds internally; the authoring surface is milliseconds.
3840
+ durationSeconds: o.durationMs / 1e3,
3929
3841
  capture: o.capture,
3930
3842
  workers: o.workers,
3931
3843
  frameFormat: o.frameFormat,
@@ -3960,11 +3872,147 @@ var wallGenerator = {
3960
3872
  run: run3
3961
3873
  };
3962
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
+
3963
4010
  // src/generators/specimen/index.ts
3964
4011
  var SPECIMEN_ID = "specimen";
3965
4012
  async function run4(ctx, o) {
3966
- const pulsesTotal = o.pulses.reduce((sum, p) => sum + p.duration, 0);
3967
- 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 }));
3968
4016
  const sceneOptions = {
3969
4017
  scene: "specimen",
3970
4018
  width: o.width,
@@ -3991,7 +4039,7 @@ async function run4(ctx, o) {
3991
4039
  characterPool: o.characterPool,
3992
4040
  colors: o.colors,
3993
4041
  colorWeights: o.colorWeights,
3994
- pulses: o.pulses,
4042
+ pulses: wirePulses,
3995
4043
  mirror: o.mirror,
3996
4044
  characterIntensity: o.characterIntensity,
3997
4045
  colorIntensity: o.colorIntensity,
@@ -4327,13 +4375,13 @@ var paletteReelObjectSchema = z8.object({
4327
4375
  details: z8.array(fieldEnum2).default(["hex", "oklch", "rgb"]).describe(
4328
4376
  "Fields revealed when a color expands (the name is always shown, so it's ignored here). Default hex + oklch + rgb."
4329
4377
  ),
4330
- holdSeconds: z8.number().positive().default(2).describe("How long each color stays fully open before handing off to the next (s). Default 2."),
4331
- 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."),
4332
4380
  bounce: z8.boolean().default(true).describe(
4333
4381
  "Ping-pong the sweep so each handoff is between neighbouring bands; off wraps directly (last to first). Default true."
4334
4382
  ),
4335
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".'),
4336
- 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."),
4337
4385
  grownFlex: z8.number().min(1).default(12).describe(
4338
4386
  "How many times a sliver's share a fully-open band takes (a collapsed sliver is the baseline). Default 12."
4339
4387
  ),
@@ -4365,10 +4413,10 @@ var paletteReelOptionsSchema = paletteReelObjectSchema;
4365
4413
 
4366
4414
  // src/generators/palette-reel/index.ts
4367
4415
  var PALETTE_REEL_ID = "palette-reel";
4368
- function deriveDuration(o) {
4416
+ function deriveDurationMs(o) {
4369
4417
  const n = o.colors.length;
4370
4418
  const stops = n <= 1 ? n : o.bounce ? 2 * (n - 1) : n;
4371
- return stops * (o.holdSeconds + o.transitionSeconds);
4419
+ return stops * (o.holdMs + o.transitionMs);
4372
4420
  }
4373
4421
  async function run6(ctx, o) {
4374
4422
  const fmt = { uppercaseName: o.uppercase, rgbStyle: o.rgbStyle, oklchStyle: o.oklchStyle };
@@ -4386,7 +4434,7 @@ async function run6(ctx, o) {
4386
4434
  details: fields.map((f) => formatField(color, f, fmt))
4387
4435
  };
4388
4436
  });
4389
- const durationSeconds = o.durationSeconds ?? deriveDuration(o);
4437
+ const durationSeconds = (o.durationMs ?? deriveDurationMs(o)) / 1e3;
4390
4438
  const sceneOptions = {
4391
4439
  scene: PALETTE_REEL_ID,
4392
4440
  width: o.width,
@@ -4406,8 +4454,8 @@ async function run6(ctx, o) {
4406
4454
  sceneOptions: {
4407
4455
  items,
4408
4456
  orientation: o.orientation,
4409
- holdSeconds: o.holdSeconds,
4410
- transitionSeconds: o.transitionSeconds,
4457
+ holdSeconds: o.holdMs / 1e3,
4458
+ transitionSeconds: o.transitionMs / 1e3,
4411
4459
  bounce: o.bounce,
4412
4460
  easing: o.easing,
4413
4461
  grownFlex: o.grownFlex,
@@ -4512,6 +4560,9 @@ function getGenerator(id) {
4512
4560
  function listGenerators() {
4513
4561
  return [...registry.values()];
4514
4562
  }
4563
+ function generatorIds() {
4564
+ return [...registry.keys()];
4565
+ }
4515
4566
  register(scrollReelGenerator);
4516
4567
  register(screenshotsGenerator);
4517
4568
  register(wallGenerator);
@@ -4611,10 +4662,52 @@ var CONFIG_FILES = [
4611
4662
  ".pro-visurc",
4612
4663
  ".pro-visurc.json"
4613
4664
  ];
4614
- 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";
4615
4708
 
4616
- // URL the capture assets point at. The optional managed server (below) binds this port.
4617
- const URL = "http://localhost:3101";
4709
+ ${urlComment}
4710
+ const URL = "http://localhost:${port}";
4618
4711
 
4619
4712
  export default defineConfig({
4620
4713
  settings: {
@@ -4624,10 +4717,11 @@ export default defineConfig({
4624
4717
  // installed Chrome, or args: ["--no-sandbox"] on CI.
4625
4718
  browser: { headless: true },
4626
4719
  // Optional: let the tool build + start your site, wait for it, capture, then stop it.
4627
- // '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.
4628
4722
  // server: {
4629
- // build: "npm run build",
4630
- // command: "npm start -- -p 3101", // e.g. Next: "npx next start -p 3101"
4723
+ // build: "${runCmd(info.pm, "build")}",
4724
+ // command: "${start}",
4631
4725
  // port: 3101,
4632
4726
  // },
4633
4727
  defaults: {
@@ -4640,7 +4734,7 @@ export default defineConfig({
4640
4734
  name: "home-reel",
4641
4735
  url: URL,
4642
4736
  generator: "scroll-reel",
4643
- // options: { duration: 7000, waitForSelector: "main" },
4737
+ // options: { durationMs: 7000, waitForSelector: "main" },
4644
4738
  },
4645
4739
  // A looping type-specimen from a font file (no URL needed):
4646
4740
  // {
@@ -4651,7 +4745,10 @@ export default defineConfig({
4651
4745
  ],
4652
4746
  });
4653
4747
  `;
4654
- var JSON_CONFIG_TEMPLATE = `{
4748
+ }
4749
+ function jsonConfigTemplate(info) {
4750
+ const port = info.devPort ?? 3101;
4751
+ return `{
4655
4752
  "$schema": "./${DEFAULT_SCHEMA_FILE}",
4656
4753
  "settings": {
4657
4754
  "outDir": "pro-visu",
@@ -4662,26 +4759,36 @@ var JSON_CONFIG_TEMPLATE = `{
4662
4759
  }
4663
4760
  },
4664
4761
  "assets": [
4665
- { "name": "home-reel", "url": "http://localhost:3101", "generator": "scroll-reel" }
4762
+ { "name": "home-reel", "url": "http://localhost:${port}", "generator": "scroll-reel" }
4666
4763
  ]
4667
4764
  }
4668
4765
  `;
4766
+ }
4669
4767
  async function runInit(options = {}) {
4670
4768
  const cwd = resolveCwd(options.cwd);
4671
4769
  const logger = createLogger("info");
4672
4770
  let createdSomething = false;
4673
- 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";
4674
4781
  const existingConfig = findExistingConfig(cwd);
4675
4782
  if (existingConfig) {
4676
4783
  logger.info(`config exists (${existingConfig}) \u2014 leaving it untouched`);
4677
- } else if (options.json) {
4678
- await writeFile6(path17.join(cwd, configFile), JSON_CONFIG_TEMPLATE, "utf8");
4784
+ } else if (useJson) {
4785
+ await writeFile6(path17.join(cwd, configFile), jsonConfigTemplate(info), "utf8");
4679
4786
  logger.success(`created ${configFile}`);
4680
4787
  await writeFile6(path17.join(cwd, DEFAULT_SCHEMA_FILE), serializeConfigJsonSchema(), "utf8");
4681
4788
  logger.success(`created ${DEFAULT_SCHEMA_FILE}`);
4682
4789
  createdSomething = true;
4683
4790
  } else {
4684
- await writeFile6(path17.join(cwd, configFile), CONFIG_TEMPLATE, "utf8");
4791
+ await writeFile6(path17.join(cwd, configFile), tsConfigTemplate(info), "utf8");
4685
4792
  logger.success(`created ${configFile}`);
4686
4793
  createdSomething = true;
4687
4794
  }
@@ -4726,8 +4833,9 @@ async function runInit(options = {}) {
4726
4833
  }
4727
4834
  }
4728
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)";
4729
4837
  logger.info(
4730
- 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\`.`
4731
4839
  );
4732
4840
  }
4733
4841
  function findExistingConfig(cwd) {
@@ -5422,6 +5530,57 @@ var ManifestStore = class _ManifestStore {
5422
5530
  };
5423
5531
 
5424
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
+ }
5425
5584
  function applyQuality(options, quality) {
5426
5585
  if (quality !== "draft") return options;
5427
5586
  const o = { ...options };
@@ -5533,7 +5692,8 @@ async function runPipeline(opts) {
5533
5692
  reporter?.status(spec.name, "ok");
5534
5693
  return { name: spec.name, generator: spec.generator, status: "ok", records: result.assets };
5535
5694
  } catch (error) {
5536
- 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));
5537
5697
  if (opts.signal?.aborted) {
5538
5698
  return { name: spec.name, generator: spec.generator, status: "failed", records: [], error: err, cancelled: true };
5539
5699
  }
@@ -5610,7 +5770,18 @@ function applyDerivedInputs(config) {
5610
5770
  }
5611
5771
  function mergeGeneratorOptions(defaults, spec) {
5612
5772
  const generatorDefaults = defaults[spec.generator] ?? {};
5613
- 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;
5614
5785
  }
5615
5786
 
5616
5787
  // src/cli/dashboard/index.tsx
@@ -6091,7 +6262,7 @@ var InkReporter = class {
6091
6262
  }
6092
6263
  };
6093
6264
  function createReporter(opts) {
6094
- const forced = process.env.SHOWCASE_LIVE;
6265
+ const forced = process.env.PRO_VISU_LIVE ?? process.env.SHOWCASE_LIVE;
6095
6266
  if (forced === "0") return new NoopReporter();
6096
6267
  if (forced === "1") return new InkReporter();
6097
6268
  return opts.tty && !opts.verbose ? new InkReporter() : new NoopReporter();
@@ -6180,6 +6351,13 @@ function renderSummary(outcomes, outDir, columns = 80) {
6180
6351
  }
6181
6352
 
6182
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
+ }
6183
6361
  function reportConfigError(logger, err) {
6184
6362
  if (err instanceof ConfigNotFoundError) {
6185
6363
  logger.error(err.message);
@@ -6187,10 +6365,7 @@ function reportConfigError(logger, err) {
6187
6365
  }
6188
6366
  if (err instanceof ConfigValidationError) {
6189
6367
  logger.error(`Invalid config${err.file ? ` (${err.file})` : ""}:`);
6190
- for (const issue of err.zodError.issues) {
6191
- const where = issue.path.length ? issue.path.join(".") : "(root)";
6192
- logger.error(` \u2022 ${where}: ${issue.message}`);
6193
- }
6368
+ for (const line of zodIssueLines(err.zodError)) logger.error(line);
6194
6369
  return;
6195
6370
  }
6196
6371
  logger.error(err instanceof Error ? err.message : String(err));
@@ -6204,6 +6379,47 @@ function printSummary(logger, outcomes, outDir) {
6204
6379
  logger.log(renderSummary(outcomes, outDir, process.stdout.columns ?? 80));
6205
6380
  }
6206
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
+
6207
6423
  // src/cli/commands/generate.ts
6208
6424
  async function runGenerate(options = {}) {
6209
6425
  const cwd = resolveCwd(options.cwd);
@@ -6221,35 +6437,76 @@ async function runGenerate(options = {}) {
6221
6437
  if (config.settings.maxMemoryMB) {
6222
6438
  bootstrapLog.info(`Node heap limit: ${currentHeapLimitMB()} MB (settings.maxMemoryMB=${config.settings.maxMemoryMB})`);
6223
6439
  }
6224
- const level = options.verbose ? "debug" : config.settings.logLevel;
6225
- const reporter = createReporter({ tty: Boolean(process.stdout.isTTY), verbose: !!options.verbose });
6226
- const logger = reporter.isLive ? createReportingLogger(level, reporter) : createLogger(level);
6227
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
+ }
6228
6452
  const requested = normalizeAssetNames(options.asset);
6229
6453
  if (requested) {
6230
6454
  const known = new Set(config.assets.map((a) => a.name));
6231
6455
  const unknown = requested.filter((name) => !known.has(name));
6232
- if (unknown.length) logger.warn(`Unknown asset(s): ${unknown.join(", ")}`);
6456
+ for (const name of unknown) bootstrapLog.warn(`Unknown asset "${name}"${didYouMean(name, known)}`);
6233
6457
  if (requested.every((name) => !known.has(name))) {
6234
- logger.error("No matching assets to generate.");
6458
+ bootstrapLog.error("No matching assets to generate.");
6235
6459
  process.exitCode = 1;
6236
6460
  return;
6237
6461
  }
6238
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
+ }
6239
6493
  const usingManaged = !config.settings.browser.channel && !config.settings.browser.executablePath;
6240
6494
  if (!options.skipBrowser && usingManaged) {
6241
- const ready = await ensureChromium({ logger });
6495
+ const ready = await ensureChromium({ logger: bootstrapLog });
6242
6496
  if (!ready) {
6243
- 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.");
6244
6498
  process.exitCode = 1;
6245
6499
  return;
6246
6500
  }
6247
6501
  }
6248
- if (!await ensureFfmpeg({ logger })) {
6249
- 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.");
6250
6504
  process.exitCode = 1;
6251
6505
  return;
6252
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);
6253
6510
  await ensureDir(outDir);
6254
6511
  await startRunState(outDir);
6255
6512
  const abort = new AbortController();
@@ -6290,23 +6547,6 @@ async function runGenerate(options = {}) {
6290
6547
  }
6291
6548
  }, 1500);
6292
6549
  memTimer.unref?.();
6293
- applyDerivedInputs(config);
6294
- const selected = expandSelection(config.assets, requested);
6295
- const anyNeedsServer = selected.some((s) => Boolean(getGenerator(s.generator)?.requiresUrl));
6296
- if (config.settings.server && !options.skipServer && !anyNeedsServer) {
6297
- logger.info("No selected asset needs a URL \u2014 skipping the managed server.");
6298
- }
6299
- const baseServerCfg = options.skipServer || !anyNeedsServer ? void 0 : config.settings.server;
6300
- const serverCfg = baseServerCfg && options.skipBuild ? { ...baseServerCfg, build: void 0 } : baseServerCfg;
6301
- const serverBase = serverCfg ? resolveServerUrl(serverCfg) : void 0;
6302
- const resolvedConfig = {
6303
- ...config,
6304
- assets: resolveTargets(
6305
- config.assets,
6306
- serverBase,
6307
- (id) => Boolean(getGenerator(id)?.requiresUrl)
6308
- )
6309
- };
6310
6550
  const gates = [];
6311
6551
  const tasks = {};
6312
6552
  if (reporter.isLive && serverCfg) {
@@ -6338,16 +6578,14 @@ async function runGenerate(options = {}) {
6338
6578
  server = await startManagedServer(serverCfg, cwd, logger, tasks, abort.signal);
6339
6579
  await updateRunState(outDir, { serverPid: server?.pid });
6340
6580
  }
6341
- const concurrency = options.concurrency != null ? Number(options.concurrency) : void 0;
6342
- const count = requested ? requested.length : config.assets.length;
6343
- if (!reporter.isLive) logger.start(`Generating ${count} asset(s)\u2026`);
6581
+ if (!reporter.isLive) logger.start(`Generating ${selected.length} asset(s)\u2026`);
6344
6582
  outcomes = await runPipeline({
6345
6583
  config: resolvedConfig,
6346
6584
  outDir,
6347
6585
  logger,
6348
6586
  toolVersion: TOOL_VERSION,
6349
6587
  assetNames: requested,
6350
- concurrency: Number.isFinite(concurrency) ? concurrency : void 0,
6588
+ concurrency: concurrencyOverride,
6351
6589
  quality: options.draft ? "draft" : void 0,
6352
6590
  cache: options.cache,
6353
6591
  reporter,
@@ -6386,6 +6624,107 @@ async function runGenerate(options = {}) {
6386
6624
  process.exitCode = 1;
6387
6625
  }
6388
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
+ }
6389
6728
  function taskHandle(reporter, id) {
6390
6729
  return {
6391
6730
  start: () => reporter.status(id, "running"),
@@ -6400,6 +6739,83 @@ function normalizeAssetNames(value) {
6400
6739
  return arr.length ? arr : void 0;
6401
6740
  }
6402
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
+
6403
6819
  // src/cli/commands/list.ts
6404
6820
  import pc from "picocolors";
6405
6821
  async function runList(options = {}) {
@@ -6419,6 +6835,11 @@ async function runList(options = {}) {
6419
6835
  }
6420
6836
  }
6421
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
+ }
6422
6843
  if (manifest.assets.length === 0) {
6423
6844
  logger.info("Nothing generated yet. Run `pro-visu generate`.");
6424
6845
  return;
@@ -6439,7 +6860,7 @@ import path25 from "path";
6439
6860
  async function runReset(options = {}) {
6440
6861
  const cwd = resolveCwd(options.cwd);
6441
6862
  const log = createLogger("info");
6442
- let outDir = path25.join(cwd, "pro-visu");
6863
+ let outDir = path25.join(cwd, DEFAULT_OUTDIR);
6443
6864
  try {
6444
6865
  const { config } = await loadShowcaseConfig({ cwd, configFile: options.config });
6445
6866
  outDir = resolveOutDir(cwd, config.settings.outDir);
@@ -6474,9 +6895,10 @@ async function runReset(options = {}) {
6474
6895
 
6475
6896
  // src/cli/index.ts
6476
6897
  var cli = cac("pro-visu");
6477
- 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);
6478
- 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);
6479
- 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);
6480
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);
6481
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);
6482
6904
  cli.help();
@@ -6485,6 +6907,11 @@ var parsed = cli.parse(process.argv, { run: false });
6485
6907
  async function main() {
6486
6908
  checkForUpdates();
6487
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
+ }
6488
6915
  if (!parsed.options.help && !parsed.options.version) cli.outputHelp();
6489
6916
  return;
6490
6917
  }
@@ -6492,6 +6919,12 @@ async function main() {
6492
6919
  }
6493
6920
  main().catch((err) => {
6494
6921
  process.exitCode = 1;
6495
- 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
+ }
6496
6929
  });
6497
6930
  //# sourceMappingURL=index.js.map