pro-visu 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { cac } from "cac";
5
5
 
6
6
  // src/version.ts
7
- var TOOL_VERSION = true ? "0.3.0" : "0.0.0-dev";
7
+ var TOOL_VERSION = true ? "0.4.0" : "0.0.0-dev";
8
8
 
9
9
  // src/cli/update-check.ts
10
10
  import updateNotifier from "update-notifier";
@@ -163,17 +163,88 @@ async function ensureChromium(opts) {
163
163
  }
164
164
 
165
165
  // src/media/ensure-ffmpeg.ts
166
- import path5 from "path";
167
166
  import { existsSync as existsSync2 } from "fs";
168
- import { rm as rm3 } from "fs/promises";
169
167
  import { spawn as spawn3 } from "child_process";
170
- import { createRequire as createRequire2 } from "module";
171
168
 
172
169
  // src/media/ffmpeg.ts
173
- import path4 from "path";
170
+ import path5 from "path";
174
171
  import { spawn as spawn2 } from "child_process";
175
- import { rm as rm2, writeFile as writeFile2 } from "fs/promises";
176
- import ffmpegStatic from "ffmpeg-static";
172
+ import { rm as rm3, writeFile as writeFile2 } from "fs/promises";
173
+
174
+ // src/media/ffmpeg-binary.ts
175
+ import os from "os";
176
+ import path4 from "path";
177
+ import https from "https";
178
+ import { createWriteStream } from "fs";
179
+ import { mkdir as mkdir2, rename, rm as rm2, chmod } from "fs/promises";
180
+ import { createGunzip } from "zlib";
181
+ import { pipeline } from "stream/promises";
182
+ var FFMPEG_RELEASE = process.env.FFMPEG_BINARY_RELEASE || "b6.1.1";
183
+ var BINARIES_URL = process.env.FFMPEG_BINARIES_URL || "https://github.com/eugeneware/ffmpeg-static/releases/download";
184
+ var SUPPORTED = {
185
+ darwin: ["x64", "arm64"],
186
+ linux: ["x64", "ia32", "arm64", "arm"],
187
+ win32: ["x64", "ia32"],
188
+ freebsd: ["x64"]
189
+ };
190
+ function ffmpegIsSupported() {
191
+ return (SUPPORTED[process.platform] ?? []).includes(process.arch);
192
+ }
193
+ function ffmpegCacheDir() {
194
+ const base = process.env.PROVISU_FFMPEG_DIR || path4.join(os.homedir(), ".cache", "pro-visu", "ffmpeg");
195
+ return path4.join(base, FFMPEG_RELEASE);
196
+ }
197
+ function ffmpegCachedBinary() {
198
+ return path4.join(ffmpegCacheDir(), process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
199
+ }
200
+ function ffmpegBinaryPath() {
201
+ return process.env.FFMPEG_BIN || ffmpegCachedBinary();
202
+ }
203
+ function ffmpegDownloadUrl() {
204
+ if (!ffmpegIsSupported()) return null;
205
+ return `${BINARIES_URL}/${FFMPEG_RELEASE}/ffmpeg-${process.platform}-${process.arch}.gz`;
206
+ }
207
+ function getFollowing(url, redirects = 5) {
208
+ return new Promise((resolve, reject) => {
209
+ const req = https.get(url, { headers: { "User-Agent": "pro-visu" } }, (res) => {
210
+ const status = res.statusCode ?? 0;
211
+ if (status >= 300 && status < 400 && res.headers.location) {
212
+ res.resume();
213
+ if (redirects <= 0) return reject(new Error("Too many redirects fetching ffmpeg."));
214
+ resolve(getFollowing(new URL(res.headers.location, url).toString(), redirects - 1));
215
+ return;
216
+ }
217
+ if (status !== 200) {
218
+ res.resume();
219
+ reject(new Error(`ffmpeg download failed: HTTP ${status} for ${url}`));
220
+ return;
221
+ }
222
+ resolve(res);
223
+ });
224
+ req.on("error", reject);
225
+ });
226
+ }
227
+ async function downloadFfmpeg() {
228
+ const url = ffmpegDownloadUrl();
229
+ if (!url) {
230
+ throw new Error(
231
+ `No prebuilt ffmpeg for ${process.platform}/${process.arch}. Set FFMPEG_BIN to a local ffmpeg binary.`
232
+ );
233
+ }
234
+ const dest = ffmpegCachedBinary();
235
+ const tmp = `${dest}.download`;
236
+ await mkdir2(path4.dirname(dest), { recursive: true });
237
+ await rm2(tmp, { force: true });
238
+ const res = await getFollowing(url);
239
+ await pipeline(res, createGunzip(), createWriteStream(tmp));
240
+ await chmod(tmp, 493).catch(() => {
241
+ });
242
+ await rm2(dest, { force: true });
243
+ await rename(tmp, dest);
244
+ return dest;
245
+ }
246
+
247
+ // src/media/ffmpeg.ts
177
248
  var SCALE_COLOR = "out_color_matrix=bt709:out_range=tv";
178
249
  var COLOR_TAGS = [
179
250
  "-colorspace",
@@ -186,11 +257,7 @@ var COLOR_TAGS = [
186
257
  "tv"
187
258
  ];
188
259
  function ffmpegPath() {
189
- const p = ffmpegStatic;
190
- if (!p) {
191
- throw new Error("ffmpeg-static did not provide a binary for this platform.");
192
- }
193
- return p;
260
+ return ffmpegBinaryPath();
194
261
  }
195
262
  function buildTranscodeArgs(args) {
196
263
  const seek = args.startOffsetSeconds && args.startOffsetSeconds > 0 ? ["-ss", args.startOffsetSeconds.toFixed(3)] : [];
@@ -332,7 +399,7 @@ function buildConcatArgs(listFile, outPath) {
332
399
  ];
333
400
  }
334
401
  async function concatMp4(segments, outPath, logger, signal) {
335
- await ensureDir(path4.dirname(outPath));
402
+ await ensureDir(path5.dirname(outPath));
336
403
  const listFile = `${outPath}.concat.txt`;
337
404
  const list = segments.map((s) => `file '${s.replace(/\\/g, "/")}'`).join("\n");
338
405
  await writeFile2(listFile, `${list}
@@ -340,7 +407,7 @@ async function concatMp4(segments, outPath, logger, signal) {
340
407
  try {
341
408
  await runFfmpeg(buildConcatArgs(listFile, outPath), logger, signal);
342
409
  } finally {
343
- await rm2(listFile, { force: true });
410
+ await rm3(listFile, { force: true });
344
411
  }
345
412
  }
346
413
  async function runFfmpeg(argv, logger, signal) {
@@ -362,7 +429,7 @@ ${stderr.slice(-2e3)}`));
362
429
  });
363
430
  }
364
431
  async function transcodeToMp4(args) {
365
- await ensureDir(path4.dirname(args.outputPath));
432
+ await ensureDir(path5.dirname(args.outputPath));
366
433
  await runFfmpeg(buildTranscodeArgs(args), args.logger, args.signal);
367
434
  }
368
435
  function aspectTarget(aspect) {
@@ -467,7 +534,6 @@ function buildStillSegmentArgs(a) {
467
534
  }
468
535
 
469
536
  // src/media/ensure-ffmpeg.ts
470
- var require3 = createRequire2(import.meta.url);
471
537
  async function ffmpegWorks() {
472
538
  let bin;
473
539
  try {
@@ -488,42 +554,26 @@ async function ffmpegWorks() {
488
554
  child.on("close", (code) => resolve(code === 0));
489
555
  });
490
556
  }
491
- function resolveInstallScript() {
492
- try {
493
- return require3.resolve("ffmpeg-static/install.js");
494
- } catch {
495
- try {
496
- const pkg = require3.resolve("ffmpeg-static/package.json");
497
- return path5.join(path5.dirname(pkg), "install.js");
498
- } catch {
499
- return null;
500
- }
501
- }
502
- }
503
557
  async function ensureFfmpeg(opts) {
504
558
  if (await ffmpegWorks()) return true;
505
559
  if (opts.checkOnly) return false;
506
- const installScript = resolveInstallScript();
507
- if (!installScript) {
508
- opts.logger.error("ffmpeg-static is not installed; cannot fetch an ffmpeg binary.");
560
+ if (process.env.FFMPEG_BIN) {
561
+ opts.logger.error(`FFMPEG_BIN is set to "${process.env.FFMPEG_BIN}" but that binary won't run.`);
562
+ return false;
563
+ }
564
+ if (!ffmpegIsSupported()) {
565
+ opts.logger.error(
566
+ `No prebuilt ffmpeg for ${process.platform}/${process.arch}. Set FFMPEG_BIN to a local ffmpeg binary.`
567
+ );
509
568
  return false;
510
569
  }
511
570
  opts.logger.info("Fetching ffmpeg (one-time, ~80 MB)\u2026");
512
571
  try {
513
- await rm3(ffmpegPath(), { force: true });
514
- } catch {
572
+ await downloadFfmpeg();
573
+ } catch (err) {
574
+ opts.logger.error(`ffmpeg download failed: ${err.message}`);
575
+ return false;
515
576
  }
516
- await new Promise((resolve, reject) => {
517
- const child = spawn3(process.execPath, [installScript], {
518
- cwd: path5.dirname(installScript),
519
- stdio: "inherit"
520
- });
521
- child.on("error", reject);
522
- child.on(
523
- "close",
524
- (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg download failed (exit code ${code}).`))
525
- );
526
- });
527
577
  if (!await ffmpegWorks()) {
528
578
  opts.logger.error("ffmpeg was downloaded but still won't run on this platform.");
529
579
  return false;
@@ -578,6 +628,16 @@ var serverSettingsSchema = z.object({
578
628
  /** If a server is already reachable at the URL, use it as-is (don't start or stop one). */
579
629
  reuseExisting: z.boolean().default(true).describe("If a server is already reachable at the URL, use it as-is (don't start or stop one). Default true.")
580
630
  }).strict();
631
+ var captureSettingsSchema = z.object({
632
+ /** Query params appended to every URL-based asset (e.g. `{ capture: "1" }` → `?capture=1`). */
633
+ query: z.record(z.string(), z.string()).optional().describe('Query params appended to every URL-based asset, e.g. { capture: "1" }.'),
634
+ /** Cookies set on every capture context before navigation, scoped to the asset's origin. */
635
+ cookies: z.array(z.object({ name: z.string().min(1), value: z.string() }).strict()).optional().describe("Cookies set on every capture context before navigation (scoped to the asset's origin)."),
636
+ /** localStorage entries seeded before the page's own scripts run. */
637
+ localStorage: z.record(z.string(), z.string()).optional().describe("localStorage entries seeded (per origin) before the page's own scripts run."),
638
+ /** JS source run in every page before its own scripts — e.g. set a global capture flag. */
639
+ initScript: z.string().optional().describe("JS run in every page before its own scripts (e.g. `window.__PV_CAPTURE__ = true`).")
640
+ }).strict();
581
641
  var settingsSchema = z.object({
582
642
  /** Output directory, relative to the repo root. */
583
643
  outDir: z.string().min(1).default("pro-visu").describe('Output directory for generated assets, relative to the repo root (default "pro-visu").'),
@@ -599,6 +659,8 @@ var settingsSchema = z.object({
599
659
  defaults: z.record(z.string(), z.record(z.string(), z.unknown())).default({}).describe("Per-generator option defaults, keyed by generator id, merged underneath each asset's own options."),
600
660
  /** Optional managed dev/prod server lifecycle (build → start → wait → … → stop). */
601
661
  server: serverSettingsSchema.optional().describe("Build \u2192 start \u2192 wait \u2192 capture \u2192 stop a server automatically."),
662
+ /** "Capture mode" toggles (query/cookies/localStorage/init script) applied to every URL capture. */
663
+ capture: captureSettingsSchema.optional().describe("Capture-mode toggles applied to every URL-based asset (e.g. disable animations / hide the cookie banner)."),
602
664
  /** Render quality. "draft" lowers fps/scale and speeds the encoder for fast iteration. */
603
665
  quality: z.enum(["draft", "final"]).default("final").describe('Render quality; "draft" lowers fps/scale and speeds the encoder for fast iteration (default "final").'),
604
666
  /** Skip assets whose inputs+options+tool fingerprint is unchanged (opt-in; can be stale). */
@@ -943,6 +1005,38 @@ var scrollReelOptionsSchema = z2.object({
943
1005
  import path6 from "path";
944
1006
  import { mkdtemp } from "fs/promises";
945
1007
 
1008
+ // src/pipeline/capture.ts
1009
+ function withCaptureQuery(url, capture) {
1010
+ if (!url || !capture?.query) return url;
1011
+ try {
1012
+ const u = new URL(url);
1013
+ for (const [k, v] of Object.entries(capture.query)) u.searchParams.set(k, v);
1014
+ return u.toString();
1015
+ } catch {
1016
+ return url;
1017
+ }
1018
+ }
1019
+ async function applyCapture(context, capture, url) {
1020
+ if (!capture) return;
1021
+ if (capture.cookies?.length && url) {
1022
+ try {
1023
+ const origin = new URL(url).origin;
1024
+ await context.addCookies(
1025
+ capture.cookies.map((c) => ({ name: c.name, value: c.value, url: origin }))
1026
+ );
1027
+ } catch {
1028
+ }
1029
+ }
1030
+ const scripts = [];
1031
+ if (capture.localStorage) {
1032
+ scripts.push(
1033
+ `try{var e=${JSON.stringify(capture.localStorage)};for(var k in e)localStorage.setItem(k,e[k]);}catch(_){}`
1034
+ );
1035
+ }
1036
+ if (capture.initScript) scripts.push(capture.initScript);
1037
+ if (scripts.length) await context.addInitScript(scripts.join("\n"));
1038
+ }
1039
+
946
1040
  // src/generators/scroll-reel/scroll.ts
947
1041
  var EASINGS = {
948
1042
  linear: (t) => t,
@@ -957,16 +1051,21 @@ async function prepareScroll(args) {
957
1051
  const target = findScrollTarget(document, getComputedStyle);
958
1052
  forceInstant(target);
959
1053
  const sleep2 = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
1054
+ const withCap = (p, ms) => Promise.race([p ?? Promise.resolve(), sleep2(ms)]);
960
1055
  scrollTargetTo(target, maxScrollOf(target));
961
1056
  await sleep2(Math.max(0, args.settleMs));
962
1057
  try {
963
- await (document.fonts?.ready ?? Promise.resolve());
1058
+ await withCap(document.fonts?.ready ?? null, 5e3);
964
1059
  } catch {
965
1060
  }
966
1061
  try {
967
1062
  const imgs = Array.from(document.images ?? []);
968
- await Promise.all(imgs.map((im) => im.decode ? im.decode().catch(() => {
969
- }) : null));
1063
+ await withCap(
1064
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1065
+ Promise.all(imgs.map((im) => im.decode ? im.decode().catch(() => {
1066
+ }) : null)),
1067
+ 4e3
1068
+ );
970
1069
  } catch {
971
1070
  }
972
1071
  scrollTargetTo(target, 0);
@@ -1044,7 +1143,8 @@ async function detectSectionOffsets(args) {
1044
1143
  continue;
1045
1144
  }
1046
1145
  if (!args.selector && rect.height < minH) continue;
1047
- const top = docTarget ? rect.top + curScroll : rect.top - containerTop + curScroll;
1146
+ const rawTop = docTarget ? rect.top + curScroll : rect.top - containerTop + curScroll;
1147
+ const top = rawTop - (args.headerInsetPx ?? 0);
1048
1148
  const y = Math.max(0, Math.min(distance, top));
1049
1149
  offsets.push(y / distance);
1050
1150
  }
@@ -1102,7 +1202,8 @@ async function measureNormalizedOffsets(args) {
1102
1202
  }
1103
1203
  if (!el) return null;
1104
1204
  const rect = el.getBoundingClientRect();
1105
- const offsetTop = docTarget ? rect.top + curScroll : rect.top - containerTop + curScroll;
1205
+ const rawTop = docTarget ? rect.top + curScroll : rect.top - containerTop + curScroll;
1206
+ const offsetTop = rawTop - (args.headerInsetPx ?? 0);
1106
1207
  const y = Math.max(0, Math.min(distance, offsetTop));
1107
1208
  return distance > 0 ? y / distance : 0;
1108
1209
  });
@@ -1138,6 +1239,79 @@ async function measureNormalizedOffsets(args) {
1138
1239
  return Math.max(0, t.scrollHeight - t.clientHeight);
1139
1240
  }
1140
1241
  }
1242
+ async function measureTopInset(args) {
1243
+ const target = findScrollTarget(document, getComputedStyle);
1244
+ const vh = window.innerHeight || document.documentElement.clientHeight || 0;
1245
+ const vw = window.innerWidth || document.documentElement.clientWidth || 0;
1246
+ const maxFraction = args.maxFraction ?? 0.4;
1247
+ const distance = maxScrollOf(target);
1248
+ const cur = isDocTarget(target) ? window.pageYOffset || document.documentElement.scrollTop || 0 : target.scrollTop;
1249
+ scrollTargetTo(target, Math.min(cur + vh, distance));
1250
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
1251
+ let inset = 0;
1252
+ try {
1253
+ const all = document.querySelectorAll("body *");
1254
+ for (let i = 0; i < all.length; i++) {
1255
+ const el = all[i];
1256
+ let position = "";
1257
+ try {
1258
+ position = getComputedStyle(el).position;
1259
+ } catch {
1260
+ continue;
1261
+ }
1262
+ if (position !== "fixed" && position !== "sticky") continue;
1263
+ let r;
1264
+ try {
1265
+ r = el.getBoundingClientRect();
1266
+ } catch {
1267
+ continue;
1268
+ }
1269
+ if (r.top <= 2 && r.width >= vw * 0.6 && r.height > 0 && r.bottom > inset && r.bottom <= vh * maxFraction) {
1270
+ inset = r.bottom;
1271
+ }
1272
+ }
1273
+ } catch {
1274
+ }
1275
+ scrollTargetTo(target, cur);
1276
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
1277
+ return inset;
1278
+ function findScrollTarget(doc, gcs) {
1279
+ const se = doc.scrollingElement || doc.documentElement;
1280
+ if (se && se.scrollHeight - se.clientHeight > 1) return se;
1281
+ let best = null;
1282
+ let bestArea = 0;
1283
+ const all = doc.querySelectorAll("*");
1284
+ for (let i = 0; i < all.length; i++) {
1285
+ const el = all[i];
1286
+ let oy = "";
1287
+ try {
1288
+ oy = gcs(el).overflowY;
1289
+ } catch {
1290
+ oy = "";
1291
+ }
1292
+ const scrollable = oy === "auto" || oy === "scroll" || oy === "overlay";
1293
+ if (scrollable && el.scrollHeight - el.clientHeight > 1) {
1294
+ const area = el.clientWidth * el.clientHeight;
1295
+ if (area > bestArea) {
1296
+ bestArea = area;
1297
+ best = el;
1298
+ }
1299
+ }
1300
+ }
1301
+ return best || se;
1302
+ }
1303
+ function isDocTarget(t) {
1304
+ return t === (document.scrollingElement || document.documentElement) || t === document.documentElement || t === document.body;
1305
+ }
1306
+ function scrollTargetTo(t, y) {
1307
+ if (isDocTarget(t)) window.scrollTo({ top: y, left: 0, behavior: "instant" });
1308
+ else if (t.scrollTo) t.scrollTo({ top: y, left: 0, behavior: "instant" });
1309
+ else t.scrollTop = y;
1310
+ }
1311
+ function maxScrollOf(t) {
1312
+ return Math.max(0, t.scrollHeight - t.clientHeight);
1313
+ }
1314
+ }
1141
1315
  async function settleInView() {
1142
1316
  try {
1143
1317
  await (document.fonts?.ready ?? Promise.resolve());
@@ -1335,6 +1509,7 @@ async function captureScrollWebm(args) {
1335
1509
  size: { width: options.width, height: options.height }
1336
1510
  }
1337
1511
  });
1512
+ await applyCapture(context, args.capture, url);
1338
1513
  const page = await context.newPage();
1339
1514
  const video = page.video();
1340
1515
  const recStart = Date.now();
@@ -1369,10 +1544,10 @@ async function captureScrollWebm(args) {
1369
1544
  }
1370
1545
 
1371
1546
  // src/media/frame-capture.ts
1372
- import os from "os";
1547
+ import os2 from "os";
1373
1548
  import path7 from "path";
1374
1549
  function autoWorkers() {
1375
- const cores = os.cpus()?.length ?? 2;
1550
+ const cores = os2.cpus()?.length ?? 2;
1376
1551
  return Math.max(1, Math.min(6, Math.floor(cores / 2)));
1377
1552
  }
1378
1553
  function planFrames(totalFrames, workers) {
@@ -1848,7 +2023,8 @@ async function buildScrollTimeline(page, options, totalSeconds, logger) {
1848
2023
  const finalize = (spec) => resolveTimeline(options.loop === "boomerang" ? boomerangSpec(spec) : spec, totalSeconds);
1849
2024
  if (steps && steps.length > 0) {
1850
2025
  const selectors = steps.map((s) => s.to).filter((to) => typeof to === "string" && !to.trim().endsWith("%"));
1851
- const measured = selectors.length > 0 ? await page.evaluate(measureNormalizedOffsets, { selectors }) : [];
2026
+ const headerInsetPx = selectors.length > 0 ? await page.evaluate(measureTopInset, {}) : 0;
2027
+ const measured = selectors.length > 0 ? await page.evaluate(measureNormalizedOffsets, { selectors, headerInsetPx }) : [];
1852
2028
  let mi = 0;
1853
2029
  let prevY = 0;
1854
2030
  const resolved = steps.map((s) => {
@@ -1884,10 +2060,12 @@ async function buildScrollTimeline(page, options, totalSeconds, logger) {
1884
2060
  }
1885
2061
  if (options.autoSections) {
1886
2062
  const cfg = options.autoSections === true ? {} : options.autoSections;
2063
+ const headerInsetPx = await page.evaluate(measureTopInset, {});
1887
2064
  const offsets = await page.evaluate(detectSectionOffsets, {
1888
2065
  minHeightFraction: cfg.minHeightFraction ?? DEFAULT_AUTO_MIN_HEIGHT_FRACTION,
1889
2066
  selector: cfg.selector ?? null,
1890
- maxSections: cfg.maxSections ?? DEFAULT_AUTO_MAX_SECTIONS
2067
+ maxSections: cfg.maxSections ?? DEFAULT_AUTO_MAX_SECTIONS,
2068
+ headerInsetPx
1891
2069
  });
1892
2070
  const autoSteps = autoSectionSteps({
1893
2071
  offsets,
@@ -1941,6 +2119,7 @@ async function captureScrollFrames(a) {
1941
2119
  signal: a.signal,
1942
2120
  prepare: async (page, { logger }) => {
1943
2121
  if (a.colorScheme) await page.emulateMedia({ colorScheme: a.colorScheme });
2122
+ await applyCapture(page.context(), a.capture, a.url);
1944
2123
  await installNetworkHygiene(page, options);
1945
2124
  await installPreNav(page, options);
1946
2125
  logger.debug(`navigating to ${a.url} (waitUntil=${options.waitUntil})`);
@@ -2405,6 +2584,7 @@ async function captureInteractionWebm(args) {
2405
2584
  deviceScaleFactor: options.deviceScaleFactor,
2406
2585
  recordVideo: { dir: recordDir, size: { width: options.width, height: options.height } }
2407
2586
  });
2587
+ await applyCapture(context, args.capture, url);
2408
2588
  const page = await context.newPage();
2409
2589
  const video = page.video();
2410
2590
  const actions = options.actions ?? [];
@@ -2467,6 +2647,7 @@ async function captureFocusWebm(args) {
2467
2647
  deviceScaleFactor: options.deviceScaleFactor,
2468
2648
  recordVideo: { dir: recordDir, size: { width: options.width, height: options.height } }
2469
2649
  });
2650
+ await applyCapture(context, args.capture, url);
2470
2651
  const page = await context.newPage();
2471
2652
  const video = page.video();
2472
2653
  const actions = focus.actions ?? [];
@@ -2561,6 +2742,7 @@ async function run(ctx, options) {
2561
2742
  ctx.logger.info(`recording ${url} (focus: ${options.focus.selector})`);
2562
2743
  const { webmPath, leadSeconds, durationSeconds, cropBox } = await captureFocusWebm({
2563
2744
  browser: ctx.browser,
2745
+ capture: ctx.capture,
2564
2746
  url,
2565
2747
  options,
2566
2748
  colorScheme: scheme,
@@ -2614,6 +2796,7 @@ async function run(ctx, options) {
2614
2796
  ctx.logger.info(`recording ${url} (interaction, ${options.actions.length} action(s))`);
2615
2797
  const { webmPath, leadSeconds, durationSeconds } = await captureInteractionWebm({
2616
2798
  browser: ctx.browser,
2799
+ capture: ctx.capture,
2617
2800
  url,
2618
2801
  options,
2619
2802
  colorScheme: scheme,
@@ -2677,6 +2860,7 @@ async function run(ctx, options) {
2677
2860
  );
2678
2861
  await captureScrollFrames({
2679
2862
  browser: ctx.browser,
2863
+ capture: ctx.capture,
2680
2864
  url: routeUrl,
2681
2865
  options: routeOpts,
2682
2866
  outPath: segPath,
@@ -2732,6 +2916,7 @@ async function run(ctx, options) {
2732
2916
  ctx.logger.info(`recording ${url} (realtime)`);
2733
2917
  const { webmPath, leadSeconds } = await captureScrollWebm({
2734
2918
  browser: ctx.browser,
2919
+ capture: ctx.capture,
2735
2920
  url,
2736
2921
  options,
2737
2922
  tmpDir: ctx.tmpDir,
@@ -2795,6 +2980,7 @@ async function run(ctx, options) {
2795
2980
  ctx.logger.info(`recording ${url}${label} (frame-stepped, ${workers} worker(s))`);
2796
2981
  await captureScrollFrames({
2797
2982
  browser: ctx.browser,
2983
+ capture: ctx.capture,
2798
2984
  url,
2799
2985
  options: vopts,
2800
2986
  outPath: captureMp4,
@@ -2921,6 +3107,7 @@ async function captureBreakpoint(args, bp) {
2921
3107
  viewport: { width: bp.width, height: bp.height },
2922
3108
  deviceScaleFactor: bp.deviceScaleFactor ?? options.deviceScaleFactor
2923
3109
  });
3110
+ await applyCapture(context, args.capture, url);
2924
3111
  const page = await context.newPage();
2925
3112
  try {
2926
3113
  logger.debug(`[${bp.name}] navigating to ${url}`);
@@ -2991,7 +3178,8 @@ async function run2(ctx, options) {
2991
3178
  browser: ctx.browser,
2992
3179
  url,
2993
3180
  options,
2994
- logger: ctx.logger
3181
+ logger: ctx.logger,
3182
+ capture: ctx.capture
2995
3183
  });
2996
3184
  const records = [];
2997
3185
  for (const shot of shots) {
@@ -3371,7 +3559,7 @@ var wallOptionsSchema = z6.object({
3371
3559
  // src/generators/scene/index.ts
3372
3560
  import path13 from "path";
3373
3561
  import { fileURLToPath } from "url";
3374
- import { copyFile as copyFile2, rename, stat as stat4 } from "fs/promises";
3562
+ import { copyFile as copyFile2, rename as rename2, stat as stat4 } from "fs/promises";
3375
3563
 
3376
3564
  // src/scene/serve.ts
3377
3565
  import http from "http";
@@ -3696,7 +3884,7 @@ async function renderScene(ctx, options, generatorId = SCENE_ID) {
3696
3884
  });
3697
3885
  }
3698
3886
  try {
3699
- await rename(composedTmp, outPath);
3887
+ await rename2(composedTmp, outPath);
3700
3888
  } catch {
3701
3889
  await copyFile2(composedTmp, outPath);
3702
3890
  }
@@ -4838,7 +5026,7 @@ function killTreeByPid(pid) {
4838
5026
  // src/server/manage-server.ts
4839
5027
  import path20 from "path";
4840
5028
  import http2 from "http";
4841
- import https from "https";
5029
+ import https2 from "https";
4842
5030
  import { spawn as spawn6 } from "child_process";
4843
5031
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
4844
5032
  function resolveServerUrl(server) {
@@ -4846,7 +5034,7 @@ function resolveServerUrl(server) {
4846
5034
  }
4847
5035
  function probe(url) {
4848
5036
  return new Promise((resolve) => {
4849
- const lib = url.startsWith("https:") ? https : http2;
5037
+ const lib = url.startsWith("https:") ? https2 : http2;
4850
5038
  const req = lib.get(url, (res) => {
4851
5039
  res.resume();
4852
5040
  resolve((res.statusCode ?? 0) > 0);
@@ -5007,7 +5195,7 @@ async function startManagedServer(server, baseCwd, logger, tasks = {}, signal) {
5007
5195
  }
5008
5196
 
5009
5197
  // src/pipeline/runner.ts
5010
- import os2 from "os";
5198
+ import os3 from "os";
5011
5199
  import path23 from "path";
5012
5200
  import { existsSync as existsSync7 } from "fs";
5013
5201
  import { mkdtemp as mkdtemp4 } from "fs/promises";
@@ -5031,7 +5219,10 @@ async function createContext(args) {
5031
5219
  await ensureDir(genDir);
5032
5220
  return {
5033
5221
  browser: args.browser,
5034
- target: args.target,
5222
+ // Fold the capture query params into the target URL so every generator's `requireUrl(ctx)`
5223
+ // captures in capture mode without each one re-applying it.
5224
+ target: { ...args.target, url: withCaptureQuery(args.target.url, args.capture) },
5225
+ capture: args.capture,
5035
5226
  resolvedInputs: args.resolvedInputs,
5036
5227
  outDir: args.outDir,
5037
5228
  resolveOutPath: (filename) => path21.join(genDir, filename),
@@ -5118,7 +5309,7 @@ function computeCacheKey(parts) {
5118
5309
  // src/manifest/manifest.ts
5119
5310
  import path22 from "path";
5120
5311
  import { existsSync as existsSync6 } from "fs";
5121
- import { readFile as readFile7, rename as rename2, rm as rm5, writeFile as writeFile8 } from "fs/promises";
5312
+ import { readFile as readFile7, rename as rename3, rm as rm5, writeFile as writeFile8 } from "fs/promises";
5122
5313
 
5123
5314
  // src/manifest/schema.ts
5124
5315
  import { z as z10 } from "zod";
@@ -5185,7 +5376,7 @@ async function writeManifest(outDir, manifest) {
5185
5376
  const maxAttempts = 5;
5186
5377
  for (let attempt = 1; ; attempt++) {
5187
5378
  try {
5188
- await rename2(tmp, file);
5379
+ await rename3(tmp, file);
5189
5380
  return;
5190
5381
  } catch (err) {
5191
5382
  const code = err.code;
@@ -5246,7 +5437,7 @@ async function runPipeline(opts) {
5246
5437
  if (specs.length === 0) return [];
5247
5438
  await ensureDir(opts.outDir);
5248
5439
  const manifest = await ManifestStore.load(opts.outDir);
5249
- const tmpRoot = await mkdtemp4(path23.join(os2.tmpdir(), "pro-visu-"));
5440
+ const tmpRoot = await mkdtemp4(path23.join(os3.tmpdir(), "pro-visu-"));
5250
5441
  const browser = await launchBrowser(opts.config.settings.browser);
5251
5442
  opts.onResources?.({ tmpDir: tmpRoot });
5252
5443
  const concurrency = Math.max(1, opts.concurrency ?? opts.config.settings.concurrency);
@@ -5298,7 +5489,9 @@ async function runPipeline(opts) {
5298
5489
  inputs: inputHashes,
5299
5490
  files: fileHashes,
5300
5491
  quality,
5301
- toolVersion: opts.toolVersion
5492
+ toolVersion: opts.toolVersion,
5493
+ // Capture-mode toggles change the rendered output, so a change must bust the cache.
5494
+ capture: opts.config.settings.capture
5302
5495
  });
5303
5496
  if (cacheEnabled) {
5304
5497
  const existing = manifest.find(spec.name);
@@ -5327,6 +5520,7 @@ async function runPipeline(opts) {
5327
5520
  toolVersion: opts.toolVersion,
5328
5521
  quality,
5329
5522
  manifest,
5523
+ capture: opts.config.settings.capture,
5330
5524
  onProgress: reporter ? (v) => reporter.progress(spec.name, v) : void 0,
5331
5525
  signal: opts.signal
5332
5526
  });