@hyperframes/producer 0.9.20 → 0.9.22

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/index.js CHANGED
@@ -331,7 +331,7 @@ function buildVirtualTimeShim(options) {
331
331
  } catch (e) {}
332
332
  }
333
333
  ` : "";
334
- const seekToTimeReseedCall = seedRandomFromFrame ? "reseedRngFromTime(safeTimeMs);\n " : "";
334
+ const seekToTimeReseedCall = seedRandomFromFrame ? "reseedRngFromTime(safeTimeMs);\n " : "";
335
335
  return String.raw`(function() {
336
336
  if (window.__HF_VIRTUAL_TIME__) return;
337
337
 
@@ -410,6 +410,82 @@ ${seededRandomBlock}
410
410
  }
411
411
  };
412
412
 
413
+ // Timers set once rendering has started run on film time: each seek fires every timer due by then, in
414
+ // due order. Until a seek after the page reports ready, timers keep the wall clock: setup code waits on
415
+ // them while the renderer may already be seeking.
416
+ var rendering = false;
417
+ var timerQueue = [];
418
+ var nextTimerId = 1000000000;
419
+ var firingDepth = 0;
420
+ function addTimer(callback, delay, args, repeats) {
421
+ // A timer set from a firing timer waits at least 1 ms, so a zero-delay chain cannot stall a seek.
422
+ var ms = Math.max(firingDepth > 0 ? 1 : 0, Number(delay) || 0);
423
+ var entry = { id: nextTimerId++, callback: callback, args: args, due: virtualNowMs + ms, every: repeats ? Math.max(1, ms) : 0, cancelled: false };
424
+ timerQueue.push(entry);
425
+ return entry.id;
426
+ }
427
+ function cancelTimer(id) {
428
+ for (var i = 0; i < timerQueue.length; i++) {
429
+ if (timerQueue[i].id === id) {
430
+ timerQueue[i].cancelled = true;
431
+ return true;
432
+ }
433
+ }
434
+ return false;
435
+ }
436
+ function fireDueTimers(untilMs) {
437
+ // Frame times are fractional, so a timer due on a frame lands a rounding error past it; it still fires on it.
438
+ untilMs += 0.000001;
439
+ while (true) {
440
+ var next = null;
441
+ for (var i = 0; i < timerQueue.length; i++) {
442
+ var candidate = timerQueue[i];
443
+ if (!candidate.cancelled && candidate.due <= untilMs && (next === null || candidate.due < next.due)) next = candidate;
444
+ }
445
+ if (next === null) break;
446
+ var dueMs = next.due;
447
+ if (next.every) next.due += next.every;
448
+ else next.cancelled = true;
449
+ virtualNowMs = dueMs;
450
+ firingDepth++;
451
+ try {
452
+ next.callback.apply(window, next.args);
453
+ } catch (err) {
454
+ originalSetTimeout(function() { throw err; }, 0);
455
+ }
456
+ firingDepth--;
457
+ }
458
+ timerQueue = timerQueue.filter(function(entry) { return !entry.cancelled; });
459
+ }
460
+ window.setTimeout = function(callback, delay) {
461
+ if (!rendering || typeof callback !== "function") return originalSetTimeout.apply(null, arguments);
462
+ return addTimer(callback, delay, Array.prototype.slice.call(arguments, 2), false);
463
+ };
464
+ window.setInterval = function(callback, delay) {
465
+ if (!rendering || typeof callback !== "function") return originalSetInterval.apply(null, arguments);
466
+ return addTimer(callback, delay, Array.prototype.slice.call(arguments, 2), true);
467
+ };
468
+ window.clearTimeout = function(id) {
469
+ if (!cancelTimer(id)) originalClearTimeout(id);
470
+ };
471
+ window.clearInterval = function(id) {
472
+ if (!cancelTimer(id)) originalClearInterval(id);
473
+ };
474
+
475
+ function seekToTime(nextTimeMs) {
476
+ var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
477
+ if (!rendering && window.__renderReady === true) {
478
+ rendering = true;
479
+ // A worker whose first frame is past the start plays the film's first frame first, so timers the
480
+ // page starts there keep the schedule a one-worker render gives them.
481
+ if (safeTimeMs > 0) seekToTime(0);
482
+ }
483
+ ${seekToTimeReseedCall}fireDueTimers(safeTimeMs);
484
+ virtualNowMs = safeTimeMs;
485
+ flushAnimationFrame();
486
+ return virtualNowMs;
487
+ }
488
+
413
489
  window.__HF_VIRTUAL_TIME__ = {
414
490
  originalSetTimeout: originalSetTimeout,
415
491
  originalClearTimeout: originalClearTimeout,
@@ -417,12 +493,7 @@ ${seededRandomBlock}
417
493
  originalClearInterval: originalClearInterval,
418
494
  originalRequestAnimationFrame: originalRequestAnimationFrame,
419
495
  originalCancelAnimationFrame: originalCancelAnimationFrame,
420
- seekToTime: function(nextTimeMs) {
421
- var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
422
- virtualNowMs = safeTimeMs;
423
- ${seekToTimeReseedCall}flushAnimationFrame();
424
- return virtualNowMs;
425
- },
496
+ seekToTime: seekToTime,
426
497
  getTime: function() {
427
498
  return virtualNowMs;
428
499
  },
@@ -16828,13 +16899,14 @@ var RenderChunkValidationError = class extends Error {
16828
16899
  this.code = code;
16829
16900
  }
16830
16901
  };
16902
+ var chunkFrameInjectorDependencies = { createVideoFrameInjector: createVideoFrameInjector3 };
16831
16903
  function createChunkVideoFrameInjectorFactory(frameLookup, options) {
16832
16904
  if (!(frameLookup instanceof Promise))
16833
- return () => createVideoFrameInjector3(frameLookup, options);
16905
+ return () => chunkFrameInjectorDependencies.createVideoFrameInjector(frameLookup, options);
16834
16906
  return () => {
16835
16907
  let hook;
16836
16908
  return async (page, time, heldVideoTime) => {
16837
- hook ??= createVideoFrameInjector3(await frameLookup, options);
16909
+ hook ??= chunkFrameInjectorDependencies.createVideoFrameInjector(await frameLookup, options);
16838
16910
  await hook?.(page, time, heldVideoTime);
16839
16911
  };
16840
16912
  };
@@ -18006,7 +18078,7 @@ function assertPlanSizeWithinLimit(input) {
18006
18078
  });
18007
18079
  throw new PlanTooLargeError(breakdown.totalBytes, input.limitBytes, breakdown, input.observedAt);
18008
18080
  }
18009
- function resolveChunkPlan(totalFrames, configChunkSize, maxParallelChunks, targetChunkFrames) {
18081
+ function resolveChunkPlan(totalFrames, configChunkSize, maxParallelChunks, targetChunkFrames, minParallelChunks = 1) {
18010
18082
  assertPositiveInteger2("totalFrames", totalFrames);
18011
18083
  assertPositiveInteger2("maxParallelChunks", maxParallelChunks);
18012
18084
  if (configChunkSize !== void 0) {
@@ -18015,7 +18087,11 @@ function resolveChunkPlan(totalFrames, configChunkSize, maxParallelChunks, targe
18015
18087
  if (targetChunkFrames !== void 0) {
18016
18088
  assertPositiveInteger2("targetChunkFrames", targetChunkFrames);
18017
18089
  }
18018
- const autoSizeParallel = targetChunkFrames === void 0 ? maxParallelChunks : Math.min(maxParallelChunks, Math.max(1, Math.ceil(totalFrames / targetChunkFrames)));
18090
+ assertPositiveInteger2("minParallelChunks", minParallelChunks);
18091
+ const autoSizeParallel = targetChunkFrames === void 0 ? maxParallelChunks : Math.min(
18092
+ maxParallelChunks,
18093
+ Math.max(minParallelChunks, Math.ceil(totalFrames / targetChunkFrames))
18094
+ );
18019
18095
  const resolvedChunkSize = configChunkSize ?? Math.max(MIN_CHUNK_SIZE, Math.ceil(totalFrames / autoSizeParallel));
18020
18096
  const naiveCount = Math.ceil(totalFrames / resolvedChunkSize);
18021
18097
  const chunkCount = Math.min(maxParallelChunks, Math.max(1, naiveCount));
@@ -18594,7 +18670,8 @@ async function buildLocalExecutionPlan(projectDir, config, executionPlanDir, opt
18594
18670
  totalFrames,
18595
18671
  config.chunkSize,
18596
18672
  maxParallel,
18597
- config.targetChunkFrames
18673
+ config.targetChunkFrames,
18674
+ config.minParallelChunks
18598
18675
  );
18599
18676
  const chunks = buildPlanChunkSlices(
18600
18677
  frameWeights,
@@ -20598,6 +20675,20 @@ function validateDistributedRenderConfig(config) {
20598
20675
  );
20599
20676
  }
20600
20677
  }
20678
+ if (config.minParallelChunks !== void 0) {
20679
+ if (!Number.isInteger(config.minParallelChunks) || config.minParallelChunks < 1) {
20680
+ throw new InvalidConfigError(
20681
+ "config.minParallelChunks",
20682
+ `must be a positive integer; got ${config.minParallelChunks}`
20683
+ );
20684
+ }
20685
+ if (config.minParallelChunks > MAX_PARALLEL_CHUNKS_CEILING) {
20686
+ throw new InvalidConfigError(
20687
+ "config.minParallelChunks",
20688
+ `must be <= ${MAX_PARALLEL_CHUNKS_CEILING}; got ${config.minParallelChunks}`
20689
+ );
20690
+ }
20691
+ }
20601
20692
  for (const field of ["workerTimeoutSeconds", "workerConcurrency"]) {
20602
20693
  const value = config[field];
20603
20694
  if (value !== void 0 && (!Number.isInteger(value) || value < 1)) {
@@ -20767,6 +20858,7 @@ function assertDistributedOptions(value) {
20767
20858
  "chunkSize",
20768
20859
  "maxParallelChunks",
20769
20860
  "targetChunkFrames",
20861
+ "minParallelChunks",
20770
20862
  "planDirSizeLimitBytes"
20771
20863
  ]) {
20772
20864
  assertOptionalInteger(value, field, 1);
@@ -20906,6 +20998,7 @@ function distributedConfigFromRequest(request, runtime = {}) {
20906
20998
  chunkSize: distributed.chunkSize,
20907
20999
  maxParallelChunks: distributed.maxParallelChunks,
20908
21000
  targetChunkFrames: distributed.targetChunkFrames,
21001
+ minParallelChunks: distributed.minParallelChunks,
20909
21002
  runtimeCap: distributed.runtimeCap,
20910
21003
  rejectOnSystemFonts: distributed.rejectOnSystemFonts,
20911
21004
  failClosedFontFetch: distributed.failClosedFontFetch,
@@ -20933,6 +21026,7 @@ function renderRequestFromDistributedConfig(input) {
20933
21026
  ...optionalProperty("chunkSize", config.chunkSize),
20934
21027
  ...optionalProperty("maxParallelChunks", config.maxParallelChunks),
20935
21028
  ...optionalProperty("targetChunkFrames", config.targetChunkFrames),
21029
+ ...optionalProperty("minParallelChunks", config.minParallelChunks),
20936
21030
  ...optionalProperty("runtimeCap", config.runtimeCap),
20937
21031
  ...optionalProperty("rejectOnSystemFonts", config.rejectOnSystemFonts),
20938
21032
  ...optionalProperty("failClosedFontFetch", config.failClosedFontFetch),
@@ -348,7 +348,7 @@ function buildVirtualTimeShim(options) {
348
348
  } catch (e) {}
349
349
  }
350
350
  ` : "";
351
- const seekToTimeReseedCall = seedRandomFromFrame ? "reseedRngFromTime(safeTimeMs);\n " : "";
351
+ const seekToTimeReseedCall = seedRandomFromFrame ? "reseedRngFromTime(safeTimeMs);\n " : "";
352
352
  return String.raw`(function() {
353
353
  if (window.__HF_VIRTUAL_TIME__) return;
354
354
 
@@ -427,6 +427,82 @@ ${seededRandomBlock}
427
427
  }
428
428
  };
429
429
 
430
+ // Timers set once rendering has started run on film time: each seek fires every timer due by then, in
431
+ // due order. Until a seek after the page reports ready, timers keep the wall clock: setup code waits on
432
+ // them while the renderer may already be seeking.
433
+ var rendering = false;
434
+ var timerQueue = [];
435
+ var nextTimerId = 1000000000;
436
+ var firingDepth = 0;
437
+ function addTimer(callback, delay, args, repeats) {
438
+ // A timer set from a firing timer waits at least 1 ms, so a zero-delay chain cannot stall a seek.
439
+ var ms = Math.max(firingDepth > 0 ? 1 : 0, Number(delay) || 0);
440
+ var entry = { id: nextTimerId++, callback: callback, args: args, due: virtualNowMs + ms, every: repeats ? Math.max(1, ms) : 0, cancelled: false };
441
+ timerQueue.push(entry);
442
+ return entry.id;
443
+ }
444
+ function cancelTimer(id) {
445
+ for (var i = 0; i < timerQueue.length; i++) {
446
+ if (timerQueue[i].id === id) {
447
+ timerQueue[i].cancelled = true;
448
+ return true;
449
+ }
450
+ }
451
+ return false;
452
+ }
453
+ function fireDueTimers(untilMs) {
454
+ // Frame times are fractional, so a timer due on a frame lands a rounding error past it; it still fires on it.
455
+ untilMs += 0.000001;
456
+ while (true) {
457
+ var next = null;
458
+ for (var i = 0; i < timerQueue.length; i++) {
459
+ var candidate = timerQueue[i];
460
+ if (!candidate.cancelled && candidate.due <= untilMs && (next === null || candidate.due < next.due)) next = candidate;
461
+ }
462
+ if (next === null) break;
463
+ var dueMs = next.due;
464
+ if (next.every) next.due += next.every;
465
+ else next.cancelled = true;
466
+ virtualNowMs = dueMs;
467
+ firingDepth++;
468
+ try {
469
+ next.callback.apply(window, next.args);
470
+ } catch (err) {
471
+ originalSetTimeout(function() { throw err; }, 0);
472
+ }
473
+ firingDepth--;
474
+ }
475
+ timerQueue = timerQueue.filter(function(entry) { return !entry.cancelled; });
476
+ }
477
+ window.setTimeout = function(callback, delay) {
478
+ if (!rendering || typeof callback !== "function") return originalSetTimeout.apply(null, arguments);
479
+ return addTimer(callback, delay, Array.prototype.slice.call(arguments, 2), false);
480
+ };
481
+ window.setInterval = function(callback, delay) {
482
+ if (!rendering || typeof callback !== "function") return originalSetInterval.apply(null, arguments);
483
+ return addTimer(callback, delay, Array.prototype.slice.call(arguments, 2), true);
484
+ };
485
+ window.clearTimeout = function(id) {
486
+ if (!cancelTimer(id)) originalClearTimeout(id);
487
+ };
488
+ window.clearInterval = function(id) {
489
+ if (!cancelTimer(id)) originalClearInterval(id);
490
+ };
491
+
492
+ function seekToTime(nextTimeMs) {
493
+ var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
494
+ if (!rendering && window.__renderReady === true) {
495
+ rendering = true;
496
+ // A worker whose first frame is past the start plays the film's first frame first, so timers the
497
+ // page starts there keep the schedule a one-worker render gives them.
498
+ if (safeTimeMs > 0) seekToTime(0);
499
+ }
500
+ ${seekToTimeReseedCall}fireDueTimers(safeTimeMs);
501
+ virtualNowMs = safeTimeMs;
502
+ flushAnimationFrame();
503
+ return virtualNowMs;
504
+ }
505
+
430
506
  window.__HF_VIRTUAL_TIME__ = {
431
507
  originalSetTimeout: originalSetTimeout,
432
508
  originalClearTimeout: originalClearTimeout,
@@ -434,12 +510,7 @@ ${seededRandomBlock}
434
510
  originalClearInterval: originalClearInterval,
435
511
  originalRequestAnimationFrame: originalRequestAnimationFrame,
436
512
  originalCancelAnimationFrame: originalCancelAnimationFrame,
437
- seekToTime: function(nextTimeMs) {
438
- var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
439
- virtualNowMs = safeTimeMs;
440
- ${seekToTimeReseedCall}flushAnimationFrame();
441
- return virtualNowMs;
442
- },
513
+ seekToTime: seekToTime,
443
514
  getTime: function() {
444
515
  return virtualNowMs;
445
516
  },
@@ -16537,6 +16608,7 @@ function assertDistributedOptions(value) {
16537
16608
  "chunkSize",
16538
16609
  "maxParallelChunks",
16539
16610
  "targetChunkFrames",
16611
+ "minParallelChunks",
16540
16612
  "planDirSizeLimitBytes"
16541
16613
  ]) {
16542
16614
  assertOptionalInteger(value, field, 1);
@@ -12,6 +12,7 @@ export interface DistributedRenderOptions {
12
12
  chunkSize?: number;
13
13
  maxParallelChunks?: number;
14
14
  targetChunkFrames?: number;
15
+ minParallelChunks?: number;
15
16
  runtimeCap?: DistributedRenderConfig["runtimeCap"];
16
17
  rejectOnSystemFonts?: boolean;
17
18
  failClosedFontFetch?: boolean;
@@ -16,6 +16,9 @@ export declare class RenderChunkValidationError extends Error {
16
16
  readonly code: RenderChunkValidationCode;
17
17
  constructor(code: RenderChunkValidationCode, message: string);
18
18
  }
19
+ export declare const chunkFrameInjectorDependencies: {
20
+ createVideoFrameInjector: typeof createVideoFrameInjector;
21
+ };
19
22
  /** One injector per browser session: a hook reused on a fresh page can skip its first injection. */
20
23
  export declare function createChunkVideoFrameInjectorFactory(frameLookup: FrameLookupTable | Promise<FrameLookupTable> | null, options?: Parameters<typeof createVideoFrameInjector>[1]): () => BeforeCaptureHook | null;
21
24
  /**
@@ -104,17 +104,19 @@ export interface DistributedRenderConfig extends Pick<RenderRequestOptions, "pag
104
104
  * chunks whose per-chunk frame count stays at or below this bound, still
105
105
  * capped by `maxParallelChunks`:
106
106
  *
107
- * chunkCount = clamp(ceil(totalFrames / targetChunkFrames), 1, maxParallelChunks)
107
+ * chunkCount = clamp(ceil(totalFrames / targetChunkFrames), minParallelChunks, maxParallelChunks)
108
108
  *
109
109
  * This bounds per-chunk render *time* (which scales with frames-per-chunk) so
110
110
  * a single chunk can't exceed a downstream per-chunk timeout on a long video,
111
- * while short videos still collapse to few chunks. It is a ceiling, not a
111
+ * while short videos drop to `minParallelChunks` (default 1). It is a ceiling, not a
112
112
  * fixed size: a video short enough to fit in fewer chunks gets fewer. Ignored
113
113
  * when `chunkSize` is set (an explicit fixed size already pins per-chunk
114
114
  * frames). Mutually exclusive with `chunkSize` in intent; if both are passed,
115
115
  * `chunkSize` wins and `targetChunkFrames` is a no-op.
116
116
  */
117
117
  targetChunkFrames?: number;
118
+ /** Fewest chunks a `targetChunkFrames`-sized plan uses, so a short video keeps this fan-out. */
119
+ minParallelChunks?: number;
118
120
  /** Runtime hint; consumed by future per-runtime budget checks. The current implementation records the value but does not enforce. */
119
121
  runtimeCap?: "lambda" | "temporal" | "cloud-run-job" | "k8s-job" | "none";
120
122
  /**
@@ -397,12 +399,12 @@ export declare function measurePlanDirBytes(planDir: string): number;
397
399
  * on tiny renders). Explicit numbers, including `240`, take precedence.
398
400
  *
399
401
  * Optional `targetChunkFrames` caps per-chunk frames in the auto-sized path:
400
- * the auto-sizer then targets `clamp(ceil(totalFrames / targetChunkFrames), 1,
401
- * maxParallelChunks)` chunks, so short videos collapse to fewer chunks and long
402
+ * the auto-sizer then targets `clamp(ceil(totalFrames / targetChunkFrames), minParallelChunks,
403
+ * maxParallelChunks)` chunks, so short videos drop to `minParallelChunks` and long
402
404
  * videos add chunks (up to the cap) to keep each one under the bound. It is a
403
405
  * no-op when omitted, and ignored when `configChunkSize` is set.
404
406
  */
405
- export declare function resolveChunkPlan(totalFrames: number, configChunkSize: number | undefined, maxParallelChunks: number, targetChunkFrames?: number): {
407
+ export declare function resolveChunkPlan(totalFrames: number, configChunkSize: number | undefined, maxParallelChunks: number, targetChunkFrames?: number, minParallelChunks?: number): {
406
408
  chunkCount: number;
407
409
  effectiveChunkSize: number;
408
410
  };
@@ -95,9 +95,9 @@ export interface VirtualTimeShimOptions {
95
95
  /**
96
96
  * Build the page-side virtual-time shim script.
97
97
  *
98
- * The shim freezes `Date.now`, `performance.now`, and the rAF/setTimeout
99
- * pipeline so a render seek can deterministically advance the page's
100
- * notion of "now". The renderer issues `__HF_VIRTUAL_TIME__.seekToTime(ms)`
98
+ * The shim freezes `Date.now`, `performance.now`, rAF, and the timers set
99
+ * after the first seek, so a render seek can deterministically advance the
100
+ * page's notion of "now". The renderer issues `__HF_VIRTUAL_TIME__.seekToTime(ms)`
101
101
  * before every frame capture; everything timing-related on the page sees
102
102
  * exactly `ms` until the next seek.
103
103
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/producer",
3
- "version": "0.9.20",
3
+ "version": "0.9.22",
4
4
  "description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,11 +35,11 @@
35
35
  "postcss": "^8.4.0",
36
36
  "puppeteer": "~25.11.0",
37
37
  "puppeteer-core": "^25.11.0",
38
- "@hyperframes/lint": "0.9.20",
39
- "@hyperframes/studio-server": "0.9.20",
40
- "@hyperframes/core": "0.9.20",
41
- "@hyperframes/parsers": "0.9.20",
42
- "@hyperframes/engine": "0.9.20"
38
+ "@hyperframes/core": "0.9.22",
39
+ "@hyperframes/parsers": "0.9.22",
40
+ "@hyperframes/lint": "0.9.22",
41
+ "@hyperframes/engine": "0.9.22",
42
+ "@hyperframes/studio-server": "0.9.22"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^25.0.10",
@@ -48,7 +48,7 @@
48
48
  "tsx": "^4.21.0",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^4.1.11",
51
- "@hyperframes/aws-lambda": "0.9.20"
51
+ "@hyperframes/aws-lambda": "0.9.22"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=22"