@hyperframes/producer 0.8.167 → 0.8.168

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.
@@ -5,9 +5,10 @@
5
5
  * different artifact layouts and hash schemas, so their plan hashes are not
6
6
  * expected to match even when they render identical output.
7
7
  */
8
+ import type { DistributedFps } from "@hyperframes/core";
8
9
  export type PlanParityProtocol = "v1" | "v2";
9
10
  export interface PlanParityRenderConfig {
10
- fps: 24 | 30 | 60;
11
+ fps: DistributedFps;
11
12
  width: number;
12
13
  height: number;
13
14
  format: "mp4";
@@ -10,7 +10,7 @@ import {
10
10
  rmSync as rmSync10,
11
11
  createReadStream as createReadStream3
12
12
  } from "node:fs";
13
- import { resolve as resolve9, dirname as dirname12, join as join29 } from "node:path";
13
+ import { resolve as resolve9, dirname as dirname12, join as join30 } from "node:path";
14
14
  import { tmpdir as tmpdir3 } from "node:os";
15
15
  import { parseArgs } from "node:util";
16
16
  import crypto from "node:crypto";
@@ -939,7 +939,7 @@ function resolveDeviceScaleFactor(input) {
939
939
  });
940
940
  if (!compat.ok) throw new Error(compat.message);
941
941
  const target = CANVAS_DIMENSIONS[input.outputResolution];
942
- return target.width / input.compositionWidth;
942
+ return Math.max(target.width / input.compositionWidth, target.height / input.compositionHeight);
943
943
  }
944
944
  function writeCompiledArtifacts(compiled, workDir, includeSummary) {
945
945
  const compileDir = join3(workDir, "compiled");
@@ -7593,10 +7593,10 @@ async function runCompileStage(input) {
7593
7593
  hdrRequested: job.config.hdrMode === "force-hdr",
7594
7594
  alphaRequested: needsAlpha
7595
7595
  });
7596
- const outputWidth = width * deviceScaleFactor;
7597
- const outputHeight = height * deviceScaleFactor;
7598
- if (deviceScaleFactor > 1) {
7599
- log.info("Supersampling composition via deviceScaleFactor", {
7596
+ const outputWidth = Math.round(width * deviceScaleFactor);
7597
+ const outputHeight = Math.round(height * deviceScaleFactor);
7598
+ if (deviceScaleFactor !== 1) {
7599
+ log.info("Scaling composition via deviceScaleFactor", {
7600
7600
  compositionWidth: width,
7601
7601
  compositionHeight: height,
7602
7602
  outputResolution: effectiveResolution,
@@ -10876,6 +10876,22 @@ function assertMotionBlurSupported(motionBlur, route) {
10876
10876
  );
10877
10877
  }
10878
10878
 
10879
+ // src/services/render/outputScaleRoute.ts
10880
+ var UNSUPPORTED_REASON2 = {
10881
+ sdr_streaming: null,
10882
+ sdr_disk: null,
10883
+ sdr_segmented: null,
10884
+ hdr_layered: "the layered compositor, used for HDR content and for shader transitions in webm and mov, sizes its layers and its encoder from the composition"
10885
+ };
10886
+ function assertOutputScaleSupported(deviceScaleFactor, route) {
10887
+ if (deviceScaleFactor === 1) return;
10888
+ const reason = UNSUPPORTED_REASON2[route];
10889
+ if (reason === null) return;
10890
+ throw new Error(
10891
+ `[Render] --resolution other than the composition's size is not supported on the "${route}" capture route: ${reason}. Render at the composition's size, or as mp4 with page-side compositing.`
10892
+ );
10893
+ }
10894
+
10879
10895
  // src/services/renderOrchestrator.ts
10880
10896
  function sampleDirectoryBytes(dir) {
10881
10897
  let total = 0;
@@ -11663,7 +11679,7 @@ async function closeOrphanedProbeForRetry(probe, closer, log, retryContext) {
11663
11679
  }
11664
11680
  }
11665
11681
  function resolveParallelCaptureMode(args) {
11666
- const supersampling = (args.deviceScaleFactor ?? 1) > 1;
11682
+ const supersampling = (args.deviceScaleFactor ?? 1) !== 1;
11667
11683
  return args.headlessShell && args.platform === "linux" && !args.forceScreenshot && !supersampling ? "beginframe" : "screenshot";
11668
11684
  }
11669
11685
  function isCaptureParallelStreamRouterEnabled(env, resolvedMode) {
@@ -12311,7 +12327,7 @@ async function executeRenderPipeline(input) {
12311
12327
  minFrames: deSingleMinFrames,
12312
12328
  singleWorkerStreamingOk: shouldUseStreamingEncode(cfg, outputFormat, 1, job.duration),
12313
12329
  layeredOrEffectRoute: hasHdrContent || compiled.hasShaderTransitions,
12314
- supersampling: deviceScaleFactor > 1,
12330
+ supersampling: deviceScaleFactor !== 1,
12315
12331
  probeDeGated: probeSession !== null && probeSession.captureMode !== "drawelement" && !probeSession.deInitDeferred,
12316
12332
  experimentalParallelDeOptIn: process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true" || // Verified parallel DE streaming (opt-in) wants its parallelism kept.
12317
12333
  process.env.HF_DE_PARALLEL_STREAM === "true"
@@ -12347,7 +12363,7 @@ async function executeRenderPipeline(input) {
12347
12363
  totalFrames,
12348
12364
  minFrames: deParallelMinFrames,
12349
12365
  layeredOrEffectRoute: hasHdrContent || compiled.hasShaderTransitions,
12350
- supersampling: deviceScaleFactor > 1,
12366
+ supersampling: deviceScaleFactor !== 1,
12351
12367
  probeDeGated: probeSession !== null && probeSession.captureMode !== "drawelement" && !probeSession.deInitDeferred,
12352
12368
  experimentalParallelDeOptIn: process.env.PRODUCER_EXPERIMENTAL_FAST_CAPTURE === "true" || process.env.HF_DE_PARALLEL_STREAM === "true",
12353
12369
  routerEnabled: deParallelRouterEnabled,
@@ -12731,6 +12747,7 @@ async function executeRenderPipeline(input) {
12731
12747
  });
12732
12748
  const syncCapturePlan = () => {
12733
12749
  assertMotionBlurSupported(job.config.motionBlur, capturePlan.kind);
12750
+ assertOutputScaleSupported(deviceScaleFactor, capturePlan.kind);
12734
12751
  workerCount = capturePlan.workerCount;
12735
12752
  captureForceScreenshot = capturePlan.forceScreenshot;
12736
12753
  useStreamingEncode = capturePlan.kind === "sdr_streaming";
@@ -12904,8 +12921,8 @@ async function executeRenderPipeline(input) {
12904
12921
  outputFormat,
12905
12922
  streamingEncoderOptions: {
12906
12923
  fps: job.config.fps,
12907
- width,
12908
- height,
12924
+ width: captureCompositionWidth ?? width,
12925
+ height: captureCompositionHeight ?? height,
12909
12926
  codec: preset.codec,
12910
12927
  preset: preset.preset,
12911
12928
  quality: effectiveQuality,
@@ -12996,8 +13013,8 @@ async function executeRenderPipeline(input) {
12996
13013
  outputFormat,
12997
13014
  streamingEncoderOptions: {
12998
13015
  fps: job.config.fps,
12999
- width,
13000
- height,
13016
+ width: captureCompositionWidth ?? width,
13017
+ height: captureCompositionHeight ?? height,
13001
13018
  codec: preset.codec,
13002
13019
  preset: preset.preset,
13003
13020
  quality: effectiveQuality,
@@ -13727,10 +13744,13 @@ import { parseEncoderOptions } from "@hyperframes/studio-server/encoder-options"
13727
13744
  // src/renderRequest.ts
13728
13745
  import {
13729
13746
  isVideoFrameFormat as isVideoFrameFormat2,
13730
- resolveConfig as resolveConfig3,
13747
+ resolveConfig as resolveConfig4,
13731
13748
  validateEngineConfigSnapshot as validateEngineConfigSnapshot2
13732
13749
  } from "@hyperframes/engine";
13733
- import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/core";
13750
+ import {
13751
+ VALID_CANVAS_RESOLUTIONS,
13752
+ isDistributedFps as isDistributedFps2
13753
+ } from "@hyperframes/core";
13734
13754
 
13735
13755
  // src/services/distributed/renderConfigValidation.ts
13736
13756
  import {
@@ -13738,6 +13758,82 @@ import {
13738
13758
  isVideoFrameFormat,
13739
13759
  validateEngineConfigSnapshot
13740
13760
  } from "@hyperframes/engine";
13761
+ import { DISTRIBUTED_FPS, isDistributedFps } from "@hyperframes/core";
13762
+
13763
+ // src/services/distributed/plan.ts
13764
+ import { audioFailureDiagnostics as audioFailureDiagnostics3 } from "@hyperframes/engine";
13765
+ import { extname as extname4, isAbsolute as isAbsolute4, join as join29, relative as relative4, sep as sep3 } from "node:path";
13766
+ import { fpsToNumber as fpsToNumber10 } from "@hyperframes/core";
13767
+ import {
13768
+ getEncoderPreset as getEncoderPreset4,
13769
+ normalizeVp9CpuUsed,
13770
+ resolveConfig as resolveConfig3
13771
+ } from "@hyperframes/engine";
13772
+
13773
+ // src/services/distributed/planProtocol.ts
13774
+ var PLAN_SCHEMA_VERSION = 1;
13775
+ var PLAN_ARTIFACT_LAYOUT = "plan-dir-v1";
13776
+ var PLAN_HASH_SCHEMA = "hyperframes-plan-hash-v1";
13777
+ var PLAN_V2_SCHEMA_VERSION = 2;
13778
+ var PLAN_V2_ARTIFACT_LAYOUT = "content-addressed-plan-v2";
13779
+ var PLAN_V2_HASH_SCHEMA = "hyperframes-plan-manifest-hash-v2";
13780
+ var PLAN_PROTOCOL_V1 = Object.freeze({
13781
+ schemaVersion: PLAN_SCHEMA_VERSION,
13782
+ artifactLayout: PLAN_ARTIFACT_LAYOUT,
13783
+ hashSchema: PLAN_HASH_SCHEMA
13784
+ });
13785
+ var PLAN_PROTOCOL_V2 = Object.freeze({
13786
+ schemaVersion: PLAN_V2_SCHEMA_VERSION,
13787
+ artifactLayout: PLAN_V2_ARTIFACT_LAYOUT,
13788
+ hashSchema: PLAN_V2_HASH_SCHEMA
13789
+ });
13790
+ var DISTRIBUTED_RENDER_CAPABILITIES = Object.freeze({
13791
+ roles: Object.freeze({
13792
+ planner: Object.freeze({
13793
+ produces: Object.freeze([PLAN_PROTOCOL_V1, PLAN_PROTOCOL_V2])
13794
+ }),
13795
+ chunk: Object.freeze({
13796
+ accepts: Object.freeze([PLAN_PROTOCOL_V1, PLAN_PROTOCOL_V2]),
13797
+ acceptsLegacyV1WithoutDescriptor: true
13798
+ }),
13799
+ assembler: Object.freeze({
13800
+ accepts: Object.freeze([PLAN_PROTOCOL_V1, PLAN_PROTOCOL_V2]),
13801
+ acceptsLegacyV1WithoutDescriptor: true
13802
+ })
13803
+ })
13804
+ });
13805
+
13806
+ // src/services/render/stages/planHash.ts
13807
+ var FIELD_DELIMITER = Buffer.from([0]);
13808
+
13809
+ // src/services/distributed/shared.ts
13810
+ import { execFile as execFileCallback } from "node:child_process";
13811
+ import { promisify as promisify2 } from "node:util";
13812
+ import {
13813
+ getFfmpegBinary,
13814
+ MIXED_AUDIO_FILENAME as MIXED_AUDIO_FILENAME3
13815
+ } from "@hyperframes/engine";
13816
+ var PLAN_AUDIO_RELATIVE_PATH = MIXED_AUDIO_FILENAME3;
13817
+ var execFile = promisify2(execFileCallback);
13818
+
13819
+ // src/services/distributed/planSize.ts
13820
+ var PLAN_ROOT_CATEGORY = {
13821
+ compiled: "compiled",
13822
+ [PLAN_AUDIO_RELATIVE_PATH]: "audio",
13823
+ meta: "metadata",
13824
+ "plan.json": "metadata"
13825
+ };
13826
+
13827
+ // src/services/distributed/plan.ts
13828
+ var PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
13829
+ var FREEZE_OWNED_PLAN_FILES = [
13830
+ "plan.json",
13831
+ join29("meta", "composition.json"),
13832
+ join29("meta", "encoder.json"),
13833
+ join29("meta", "chunks.json")
13834
+ ];
13835
+
13836
+ // src/services/distributed/renderConfigValidation.ts
13741
13837
  var InvalidConfigError = class extends Error {
13742
13838
  // Read via Error.prototype.toString; fallow can't see it.
13743
13839
  // fallow-ignore-next-line unused-class-member
@@ -13944,7 +14040,7 @@ function createRenderRequest(input) {
13944
14040
  outputPath: input.outputPath,
13945
14041
  options: {
13946
14042
  ...omitUndefinedProperties(input.options),
13947
- engineConfig: input.engineConfig ?? resolveConfig3(input.engineOverrides)
14043
+ engineConfig: input.engineConfig ?? resolveConfig4(input.engineOverrides)
13948
14044
  }
13949
14045
  };
13950
14046
  return parseRenderRequest(serializeRenderRequest(request));
@@ -14161,8 +14257,8 @@ async function resolveInlineRenderHtml(body) {
14161
14257
  }
14162
14258
  function materializeInlineProject(html, options) {
14163
14259
  const tempRoot = process.env.PRODUCER_TMP_PROJECT_DIR || tmpdir3();
14164
- const tempProjectDir = mkdtempSync4(join29(tempRoot, "producer-project-"));
14165
- writeFileSync6(join29(tempProjectDir, "index.html"), html, "utf-8");
14260
+ const tempProjectDir = mkdtempSync4(join30(tempRoot, "producer-project-"));
14261
+ writeFileSync6(join30(tempProjectDir, "index.html"), html, "utf-8");
14166
14262
  return {
14167
14263
  prepared: {
14168
14264
  input: { projectDir: tempProjectDir, ...options },
@@ -25,12 +25,12 @@
25
25
  * capture jitter, so the harness can't use it as a per-test gate.
26
26
  *
27
27
  * Not every fixture can run in distributed-simulated mode. Distributed mode
28
- * refuses HDR mp4, NTSC framerates, and non-{24,30,60} fps at plan time.
28
+ * refuses HDR mp4, NTSC framerates, and fps outside DISTRIBUTED_FPS at plan time.
29
29
  * Fixtures that don't meet the constraints are skipped — the harness logs
30
30
  * the reason and the fixture is treated as "passed (skipped)" in
31
31
  * distributed-simulated mode.
32
32
  */
33
- import type { Fps } from "@hyperframes/core";
33
+ import { type DistributedFps, type Fps } from "@hyperframes/core";
34
34
  import { type DistributedFormat } from "./services/distributed/shared.js";
35
35
  /**
36
36
  * Three-mode contract that backs `--mode=<value>` on the regression
@@ -67,8 +67,8 @@ export type DistributedSupportResult = {
67
67
  * Decide whether a fixture's `renderConfig` is one the distributed pipeline
68
68
  * can actually run. Two hard gates:
69
69
  *
70
- * - fps must be `{ num: 24|30|60, den: 1 }`. `DistributedRenderConfig.fps`
71
- * accepts only the three integer values, and rationals like
70
+ * - fps must be `{ num: <DISTRIBUTED_FPS member>, den: 1 }`. `DistributedRenderConfig.fps`
71
+ * accepts only those integer values, and rationals like
72
72
  * `{ num: 30000, den: 1001 }` (NTSC) trip the type system at the call
73
73
  * site. We surface this gate in code rather than only in TS so the
74
74
  * harness can skip the fixture cleanly instead of throwing.
@@ -95,7 +95,7 @@ export interface RunDistributedSimulatedInput {
95
95
  /** Where to write the assembled final mp4 / mov / png-sequence directory. */
96
96
  renderedOutputPath: string;
97
97
  /** From the fixture's renderConfig — must pass `checkDistributedSupport`. */
98
- fps: 24 | 30 | 60;
98
+ fps: DistributedFps;
99
99
  format: DistributedFormat;
100
100
  /**
101
101
  * Codec for `format: "mp4"`. Defaults to `"h264"`; pass `"h265"` to
@@ -8,6 +8,7 @@
8
8
  * adapter's shape without pulling the aws-lambda graph into producer's
9
9
  * type-check pass.
10
10
  */
11
+ import type { DistributedFps } from "@hyperframes/core";
11
12
  import type { DistributedFormat } from "./services/distributed/shared.js";
12
13
  /** Inputs for {@link runLambdaLocalRender}. Same contract as `runDistributedSimulatedRender`. */
13
14
  export interface RunLambdaLocalInput {
@@ -16,7 +17,7 @@ export interface RunLambdaLocalInput {
16
17
  projectDir: string;
17
18
  tempRoot: string;
18
19
  renderedOutputPath: string;
19
- fps: 24 | 30 | 60;
20
+ fps: DistributedFps;
20
21
  /**
21
22
  * Width/height from the fixture's renderConfig. Forwarded directly to
22
23
  * the Lambda event so this mode catches drift if the handler ever
@@ -10,14 +10,10 @@
10
10
  * decodable). For png-sequence chunks (each chunk is a directory of
11
11
  * frames) this is a straight directory merge with global re-numbering.
12
12
  *
13
- * Mux + faststart for mp4 / mov / webm go through the engine's
14
- * `muxVideoWithAudio` + `applyFaststart` helpers — same path the
15
- * in-process renderer uses; we just feed concat output rather than
16
- * streaming-encoder output. (Faststart is a no-op for webm and mov —
17
- * applyFaststart copies the input verbatim.) Audio length is
18
- * pad-or-trimmed to `frameCount / fps` via
19
- * `padOrTrimAudioToVideoFrameCount` so the mux step doesn't introduce
20
- * sub-millisecond drift at the end of long renders.
13
+ * Join, audio mux and mp4 faststart run as one ffmpeg pass over the concat
14
+ * demuxer, so disk holds the chunks plus one output copy. Audio is first
15
+ * pad-or-trimmed to `frameCount / fps` (frames summed over the chunks) so
16
+ * the mux adds no drift at the end of long renders.
21
17
  *
22
18
  * Pure function over local paths. No networking. The caller is responsible
23
19
  * for moving `outputPath` to its orchestration-level storage.
@@ -1,8 +1,8 @@
1
- import { type CanvasResolution } from "@hyperframes/core";
1
+ import { type CanvasResolution, type DistributedFps } from "@hyperframes/core";
2
2
  import { type EngineConfig, type VideoFrameFormat, type AudioProcessingFailure } from "@hyperframes/engine";
3
3
  import { type ProducerLogger } from "../../logger.js";
4
4
  import { type RenderJob, type RenderStrictness } from "../renderOrchestrator.js";
5
- import { type ChunkSliceJson } from "../render/stages/freezePlan.js";
5
+ import { type ChunkSliceJson, type PlanFileDigests } from "../render/stages/freezePlan.js";
6
6
  import { type DistributedFormat } from "./shared.js";
7
7
  import { type PlanProtocolV1Descriptor } from "./planProtocol.js";
8
8
  import { type PlanSizeBreakdown } from "./planSize.js";
@@ -13,7 +13,7 @@ import { type PlanSizeBreakdown } from "./planSize.js";
13
13
  */
14
14
  export interface DistributedRenderConfig {
15
15
  /** Integer frame rate. Distributed renders only accept integer fps; the in-process renderer's `Fps` rational handles NTSC. */
16
- fps: 24 | 30 | 60;
16
+ fps: DistributedFps;
17
17
  width: number;
18
18
  height: number;
19
19
  /**
@@ -79,8 +79,15 @@ export interface DistributedRenderConfig {
79
79
  * old 8-second-GOP behavior.
80
80
  */
81
81
  chunkSize?: number;
82
- /** Default `16`. Caps long renders to fewer-but-longer chunks for operational fairness. */
82
+ /**
83
+ * Caps the chunk count. When omitted, chunks are sized from `workerConcurrency` and
84
+ * `workerTimeoutSeconds` (see {@link resolveMaxParallelChunks}); without those, `16`.
85
+ */
83
86
  maxParallelChunks?: number;
87
+ /** Seconds a chunk worker may run before the platform kills it (Lambda: the function timeout). */
88
+ workerTimeoutSeconds?: number;
89
+ /** How many chunk workers can run at once (Lambda: reserved concurrency). */
90
+ workerConcurrency?: number;
84
91
  /**
85
92
  * Upper bound on frames-per-chunk, in frames. Optional; when omitted (the
86
93
  * default) chunk sizing is unchanged. When set, chunking targets the fewest
@@ -174,7 +181,7 @@ export interface LocalExecutionPlan {
174
181
  executionPlanHash: string;
175
182
  chunkCount: number;
176
183
  totalFrames: number;
177
- fps: 24 | 30 | 60;
184
+ fps: DistributedFps;
178
185
  width: number;
179
186
  height: number;
180
187
  format: DistributedFormat;
@@ -194,7 +201,12 @@ export interface BuildLocalExecutionPlanOptions {
194
201
  * chunks write those frames into their own work directory.
195
202
  */
196
203
  readonly deferVideoExtraction?: boolean;
204
+ /** Shared with the publisher so each plan file is hashed once. */
205
+ readonly digests?: PlanFileDigests;
206
+ /** Runs while the audio mixes, once `compiled/` and the video trees are final. */
207
+ readonly whileAudioMixes?: () => Promise<void>;
197
208
  }
209
+ export declare function logPlanStage(log: ProducerLogger, stage: string, startedAt: number): void;
198
210
  /**
199
211
  * Result of the legacy v1 {@link plan} wrapper. The `planHash` is the
200
212
  * content-addressed identifier that adapters key replay short-circuits off of.
@@ -208,7 +220,7 @@ export interface PlanResult {
208
220
  planHash: string;
209
221
  chunkCount: number;
210
222
  totalFrames: number;
211
- fps: 24 | 30 | 60;
223
+ fps: DistributedFps;
212
224
  width: number;
213
225
  height: number;
214
226
  format: DistributedFormat;
@@ -228,9 +240,8 @@ export declare function applyDistributedAudioWarningPolicy(job: RenderJob, audio
228
240
  */
229
241
  export declare const PLAN_PROJECT_DIR_SKIP_SEGMENTS: ReadonlySet<string>;
230
242
  /**
231
- * Default chunk size in frames (~8s @ 30fps; fits Lambda's 15-min cap).
232
- * Used when the caller explicitly passes this value. When `chunkSize` is
233
- * `undefined`, `plan()` auto-sizes from `maxParallelChunks` instead.
243
+ * Chunk size in frames (~8s @ 30fps) for callers that want a fixed GOP.
244
+ * `plan()` never applies it on its own; an undefined `chunkSize` auto-sizes.
234
245
  */
235
246
  export declare const DEFAULT_CHUNK_SIZE = 240;
236
247
  /** Default cap on parallel chunks for operational fairness across renders. */
@@ -242,6 +253,23 @@ export declare const DEFAULT_MAX_PARALLEL_CHUNKS = 16;
242
253
  * outweighs the parallelism gain on tiny renders.
243
254
  */
244
255
  export declare const MIN_CHUNK_SIZE = 10;
256
+ /** Most chunks one render may plan; `validateDistributedRenderConfig` enforces it on `maxParallelChunks`. */
257
+ export declare const MAX_PARALLEL_CHUNKS_CEILING = 256;
258
+ /** Predicted seconds for one chunk of `frames` frames at `megapixels` output pixels per frame. */
259
+ export declare function estimateChunkSeconds(frames: number, megapixels: number): number;
260
+ /** Non-retryable: even {@link MAX_PARALLEL_CHUNKS_CEILING} chunks cannot fit the worker time limit. */
261
+ export declare const CHUNK_EXCEEDS_WORKER_TIMEOUT = "CHUNK_EXCEEDS_WORKER_TIMEOUT";
262
+ export declare class ChunkExceedsWorkerTimeoutError extends Error {
263
+ readonly code: typeof CHUNK_EXCEEDS_WORKER_TIMEOUT;
264
+ constructor(predictedChunkSeconds: number, workerTimeoutSeconds: number);
265
+ }
266
+ /**
267
+ * The `maxParallelChunks` a plan uses: explicit chunking flags win; otherwise one wave across
268
+ * `workerConcurrency`, widened so each chunk fits `workerTimeoutSeconds`; with neither, 16.
269
+ */
270
+ export declare function resolveMaxParallelChunks(config: Pick<DistributedRenderConfig, "maxParallelChunks" | "chunkSize" | "targetChunkFrames" | "workerTimeoutSeconds" | "workerConcurrency">, totalFrames: number,
271
+ /** Output pixels per frame, after supersampling. */
272
+ megapixels: number, log?: ProducerLogger): number;
245
273
  /**
246
274
  * Default hard ceiling on `<planDir>/` size in bytes. 2 GB fits inside
247
275
  * AWS Lambda's 10 GB `/tmp` alongside the chunk worker's captured frames
@@ -10,6 +10,7 @@
10
10
  * FrameLookupTable at every captured global frame. If legacy video metadata
11
11
  * is absent, v2 explicitly falls back to the full-source pack.
12
12
  */
13
+ import { type DistributedFps } from "@hyperframes/core";
13
14
  import { type DistributedRenderConfig } from "./plan.js";
14
15
  import { type PlanProtocolV2Descriptor } from "./planProtocol.js";
15
16
  import { type PlanV2ArtifactPublisher } from "./planV2Publisher.js";
@@ -45,7 +46,7 @@ export interface PlanV2Manifest {
45
46
  readonly sourcePlanV1Hash: string;
46
47
  readonly chunkCount: number;
47
48
  readonly totalFrames: number;
48
- readonly fps: 24 | 30 | 60;
49
+ readonly fps: DistributedFps;
49
50
  readonly width: number;
50
51
  readonly height: number;
51
52
  readonly format: DistributedFormat;
@@ -71,7 +72,7 @@ export interface PlanV2Result {
71
72
  readonly sourcePlanV1Hash: string;
72
73
  readonly chunkCount: number;
73
74
  readonly totalFrames: number;
74
- readonly fps: 24 | 30 | 60;
75
+ readonly fps: DistributedFps;
75
76
  readonly width: number;
76
77
  readonly height: number;
77
78
  readonly format: DistributedFormat;
@@ -48,8 +48,8 @@ export interface PadTrimAudioInput {
48
48
  /** Deadline for each ffmpeg process; defaults to the engine's `ffmpegProcessTimeout`. */
49
49
  timeoutMs?: number;
50
50
  /**
51
- * Optional injectables for unit tests. Production callers omit them and
52
- * get the real `ffprobe`/`ffmpeg`-backed implementations.
51
+ * Optional injectables. Tests stub them; the distributed assembler supplies
52
+ * the frame probe because its video is still separate chunks.
53
53
  */
54
54
  probeVideoFrameInfo?: (videoPath: string) => Promise<ProbeVideoFrameInfo>;
55
55
  probeAudioInfo?: (audioPath: string, signal?: AbortSignal) => Promise<AudioProbeInfo>;
@@ -111,3 +111,4 @@ export declare function buildPadTrimAudioArgs(audioPath: string, outputPath: str
111
111
  * for the assembled video.
112
112
  */
113
113
  export declare function padOrTrimAudioToVideoFrameCount(input: PadTrimAudioInput): Promise<PadTrimAudioResult>;
114
+ export declare function defaultProbeVideoFrameInfo(videoPath: string, signal?: AbortSignal): Promise<ProbeVideoFrameInfo>;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * One owner for "does this capture route honour an output resolution other than the composition's".
3
+ * The scale is Chrome's deviceScaleFactor; a route that sizes its own buffers and encoder from the
4
+ * composition would encode at native size while the render reports the requested one.
5
+ */
6
+ import type { CapturePlan } from "./capturePlan.js";
7
+ /** Throw a named error when an output resolution other than the composition's meets a route that ignores it. */
8
+ export declare function assertOutputScaleSupported(deviceScaleFactor: number, route: CapturePlan["kind"]): void;
@@ -55,18 +55,16 @@ export declare function projectBrowserEndToCompositionTimeline(existingStart: nu
55
55
  * `deviceScaleFactor`. The composition's intrinsic dimensions stay the
56
56
  * page-layout viewport; the screenshot lands at output dims via DPR.
57
57
  *
58
- * The scale must be a positive integer ≥ 1 — fractional DPRs introduce
58
+ * Upsampling must be by a whole number — fractional DPRs above 1 introduce
59
59
  * visible aliasing and we'd rather fail loudly than produce a blurry
60
- * 4K render. Downsampling (output < composition) is rejected because
61
- * the user is unlikely to have intended it; if the use case appears
62
- * we can plumb a separate flag.
60
+ * 4K render. A smaller preset (720p, 480p) takes a DPR under 1, so Chrome
61
+ * rasterizes straight at the smaller size.
63
62
  *
64
63
  * Throws on:
65
64
  * - HDR + outputResolution (HDR compositor processes raw pixel buffers
66
65
  * at composition dimensions and would need parallel scaling).
67
66
  * - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k).
68
- * - Non-integer scale ratio.
69
- * - Downsampling (output dimensions smaller than composition).
67
+ * - Non-integer upsampling ratio.
70
68
  */
71
69
  export declare function resolveDeviceScaleFactor(input: {
72
70
  compositionWidth: number;
@@ -107,6 +107,7 @@ export interface FreezePlanInput {
107
107
  totalFrames: number;
108
108
  /** Whether the plan's mixed-audio artifact was produced. */
109
109
  hasAudio: boolean;
110
+ digests?: PlanFileDigests;
110
111
  }
111
112
  export interface FreezePlanResult {
112
113
  /** Absolute path to `plan.json`. */
@@ -114,6 +115,11 @@ export interface FreezePlanResult {
114
115
  /** Content-addressed planHash; see {@link computePlanHash}. */
115
116
  planHash: string;
116
117
  }
118
+ /** sha256 by absolute path. A staged plan file never changes, so one plan hashes it once. */
119
+ export type PlanFileDigests = Map<string, string>;
120
+ export declare function planFileSha256(absolutePath: string, digests?: PlanFileDigests): string;
121
+ /** Hash every file under `dir` into `digests` without blocking the event loop. */
122
+ export declare function prehashPlanFiles(dir: string, digests: PlanFileDigests): Promise<void>;
117
123
  /**
118
124
  * Read a frozen plan directory back from disk and recompute its
119
125
  * content-addressed `planHash` over the actual on-disk bytes — including
@@ -130,7 +136,7 @@ export interface FreezePlanResult {
130
136
  * callers should catch those as `MISSING_PLAN_ARTIFACT` rather than
131
137
  * lumping them with hash drift.
132
138
  */
133
- export declare function recomputePlanHashFromPlanDir(planDir: string): string;
139
+ export declare function recomputePlanHashFromPlanDir(planDir: string, digests?: PlanFileDigests): string;
134
140
  /**
135
141
  * Freeze a plan directory: write `meta/*.json` + top-level `plan.json`, then
136
142
  * compute `planHash` over the canonicalized contents.
@@ -94,3 +94,6 @@ export declare function canonicalJsonStringify(value: unknown): string;
94
94
  * by the eventual `freezePlan` to hash assets on disk.
95
95
  */
96
96
  export declare function sha256Hex(bytes: Uint8Array | string): string;
97
+ /** Hash large plan artifacts with bounded memory (important above the legacy 2 GiB cap). */
98
+ export declare function sha256File(path: string): string;
99
+ export declare function sha256FileAsync(path: string): Promise<string>;
@@ -846,7 +846,7 @@ export declare function shouldPreferSingleWorkerDrawElement(args: {
846
846
  * telemetry and keep the probe session alive through the heaviest stage.
847
847
  */
848
848
  layeredOrEffectRoute: boolean;
849
- /** deviceScaleFactor > 1 — the engine's supersampling gate blocks DE. */
849
+ /** deviceScaleFactor other than 1 — the engine's supersampling gate blocks DE. */
850
850
  supersampling: boolean;
851
851
  /**
852
852
  * The probe session already ran the engine's init-time DE gates and DE did
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/producer",
3
- "version": "0.8.167",
3
+ "version": "0.8.168",
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.2.1",
37
37
  "puppeteer-core": "^25.2.1",
38
- "@hyperframes/engine": "0.8.167",
39
- "@hyperframes/lint": "0.8.167",
40
- "@hyperframes/parsers": "0.8.167",
41
- "@hyperframes/core": "0.8.167",
42
- "@hyperframes/studio-server": "0.8.167"
38
+ "@hyperframes/core": "0.8.168",
39
+ "@hyperframes/parsers": "0.8.168",
40
+ "@hyperframes/lint": "0.8.168",
41
+ "@hyperframes/studio-server": "0.8.168",
42
+ "@hyperframes/engine": "0.8.168"
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.8.167"
51
+ "@hyperframes/aws-lambda": "0.8.168"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=22"