Skip to content

Commit 852008b

Browse files
authored
feat(producer): thread variables through plan() + renderChunk() (#962)
Add `variables?: Record<string, unknown>` to DistributedRenderConfig (§4.4) and LockedRenderConfig (§4.3). plan() snapshots the value into meta/encoder.json so every chunk worker re-injects the same set via captureOptions.variables, mirroring the in-process renderer's path. The variables fold into planHash automatically because canonical encoder.json bytes feed the hash: two plans with different variables produce different hashes (chunked output depends on the injected values); two plans with the same variables produce identical hashes because canonical-JSON sorts keys. The regression harnesses (distributed-simulated, lambda-local) also forward the input's variables to plan() / Step Functions event so fixtures that declare `renderConfig.variables` produce the same pixels across modes. Previously the field was on the harness input shape but silently dropped at the call boundary. Phase 9 PR 9.1 of the distributed rendering plan.
1 parent 4237165 commit 852008b

7 files changed

Lines changed: 259 additions & 0 deletions

File tree

packages/producer/src/regression-harness-distributed.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ export async function runDistributedSimulatedRender(
176176
chunkSize: input.chunkSize,
177177
maxParallelChunks: input.maxParallelChunks,
178178
hdrMode: "force-sdr",
179+
// Forward `variables` to plan() so distributed-simulated fixtures
180+
// that declare `renderConfig.variables` produce the same pixels in
181+
// distributed mode as in-process. Without this, the harness silently
182+
// drops the variables for distributed/lambda-local modes and any
183+
// composition that reads `window.__hfVariables` diverges.
184+
variables: input.variables,
179185
},
180186
planDir,
181187
);

packages/producer/src/regression-harness-lambda-local.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
9292
chunkSize: input.chunkSize,
9393
maxParallelChunks: input.maxParallelChunks,
9494
hdrMode: "force-sdr",
95+
// Forward `variables` through the event boundary so lambda-local mode
96+
// exercises the same variables-in-encoder.json path that real Lambda
97+
// executions take. Without this, a fixture's `renderConfig.variables`
98+
// would be silently dropped at the harness's serializer.
99+
variables: input.variables,
95100
};
96101

97102
// STEP A: plan

packages/producer/src/services/distributed/plan.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,111 @@ describe("plan() — codec knob", () => {
461461
});
462462
});
463463

464+
describe("plan() — variables", () => {
465+
const TIMEOUT_MS = 30_000;
466+
467+
it(
468+
"snapshots variables into meta/encoder.json so chunk workers see the controller's set",
469+
async () => {
470+
const planDir = join(runRoot, "plan-variables-snapshot");
471+
mkdirSync(planDir, { recursive: true });
472+
const variables = { title: "Hello", accent: "#ff0000" };
473+
await plan(
474+
projectDir,
475+
{ fps: 30, width: 320, height: 240, format: "mp4", variables },
476+
planDir,
477+
);
478+
const encoder = JSON.parse(
479+
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
480+
) as Record<string, unknown>;
481+
expect(encoder.variables).toEqual(variables);
482+
},
483+
TIMEOUT_MS,
484+
);
485+
486+
it(
487+
"omits the variables key from canonical encoder.json when the caller passes no variables",
488+
async () => {
489+
// Backwards compat: a no-variables plan must hash identically to
490+
// pre-Phase-9 plans. Since the canonical encoder.json strips
491+
// `undefined` values, the key must not appear at all.
492+
const planDir = join(runRoot, "plan-variables-absent");
493+
mkdirSync(planDir, { recursive: true });
494+
await plan(projectDir, { fps: 30, width: 320, height: 240, format: "mp4" }, planDir);
495+
const encoderRaw = readFileSync(join(planDir, "meta", "encoder.json"), "utf-8");
496+
expect(encoderRaw).not.toContain("variables");
497+
},
498+
TIMEOUT_MS,
499+
);
500+
501+
it(
502+
"produces a DIFFERENT planHash when variables differ",
503+
async () => {
504+
// Variables fold into planHash via meta/encoder.json bytes. Two plans
505+
// with different variables must produce different hashes — chunked
506+
// output depends on the injected values, and the byte-identical-
507+
// retry contract has to bind to the controller's choice.
508+
const planDirA = join(runRoot, "plan-variables-different-a");
509+
const planDirB = join(runRoot, "plan-variables-different-b");
510+
mkdirSync(planDirA, { recursive: true });
511+
mkdirSync(planDirB, { recursive: true });
512+
513+
const base = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
514+
const a = await plan(projectDir, { ...base, variables: { title: "Alice" } }, planDirA);
515+
const b = await plan(projectDir, { ...base, variables: { title: "Bob" } }, planDirB);
516+
expect(a.planHash).not.toBe(b.planHash);
517+
},
518+
TIMEOUT_MS,
519+
);
520+
521+
it(
522+
"produces the SAME planHash for two plans with the same variables",
523+
async () => {
524+
// Canonical-JSON sorts object keys, so the same variables (regardless
525+
// of insertion order on the caller side) must round-trip to the
526+
// same encoder.json bytes and therefore the same planHash.
527+
const planDirA = join(runRoot, "plan-variables-same-a");
528+
const planDirB = join(runRoot, "plan-variables-same-b");
529+
mkdirSync(planDirA, { recursive: true });
530+
mkdirSync(planDirB, { recursive: true });
531+
532+
const base = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
533+
const a = await plan(
534+
projectDir,
535+
{ ...base, variables: { title: "Alice", accent: "#ff0000" } },
536+
planDirA,
537+
);
538+
const b = await plan(
539+
projectDir,
540+
// Same values, opposite insertion order.
541+
{ ...base, variables: { accent: "#ff0000", title: "Alice" } },
542+
planDirB,
543+
);
544+
expect(a.planHash).toBe(b.planHash);
545+
},
546+
TIMEOUT_MS,
547+
);
548+
549+
it(
550+
"no-variables plan hashes identically to itself (backwards-compat baseline)",
551+
async () => {
552+
// Pin the no-variables path so adding the new field doesn't silently
553+
// change the hash for callers who never opt in. Re-runs the
554+
// determinism check from the golden block, scoped to the variables
555+
// surface so a future refactor here trips a focused failure.
556+
const planDirA = join(runRoot, "plan-no-variables-determinism-a");
557+
const planDirB = join(runRoot, "plan-no-variables-determinism-b");
558+
mkdirSync(planDirA, { recursive: true });
559+
mkdirSync(planDirB, { recursive: true });
560+
const config = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
561+
const a = await plan(projectDir, config, planDirA);
562+
const b = await plan(projectDir, config, planDirB);
563+
expect(a.planHash).toBe(b.planHash);
564+
},
565+
TIMEOUT_MS,
566+
);
567+
});
568+
464569
describe("plan() — webm format (distributed VP9)", () => {
465570
const TIMEOUT_MS = 30_000;
466571

packages/producer/src/services/distributed/plan.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,26 @@ export interface DistributedRenderConfig {
163163
* exercise the throw path.
164164
*/
165165
planDirSizeLimitBytes?: number;
166+
167+
/**
168+
* Render-time variable overrides for the composition. Snapshotted into
169+
* `meta/encoder.json` at plan time and re-injected by every chunk
170+
* worker as `window.__hfVariables` before the first capture, mirroring
171+
* the in-process renderer's
172+
* `RenderConfig.variables` → `CaptureOptions.variables` path. The
173+
* runtime helper `getVariables()` merges these over the declared
174+
* defaults from `<html data-composition-variables="…">`.
175+
*
176+
* Folded into `planHash`: different variables produce different hashes
177+
* because rendered frames depend on the injected values. Must be a
178+
* JSON-serializable plain object — `freezePlan`'s canonical-JSON pass
179+
* throws on non-serializable values (functions, Symbols, BigInts) when
180+
* the variables reach this layer. Adapters that ship to Lambda (the
181+
* `@hyperframes/aws-lambda` SDK) also validate the shape client-side
182+
* before any AWS call so the rejection lands at the SDK boundary
183+
* rather than mid-plan; the producer-side throw is the fallback.
184+
*/
185+
variables?: Record<string, unknown>;
166186
}
167187

168188
/**
@@ -509,6 +529,7 @@ function buildLockedRenderConfig(input: {
509529
chunkSize: input.effectiveChunkSize,
510530
chunkCount: input.chunkCount,
511531
runtimeEnv: input.runtimeEnv,
532+
variables: config.variables,
512533
};
513534
}
514535

packages/producer/src/services/distributed/renderChunk.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,103 @@ describe("renderChunk()", () => {
296296
);
297297
});
298298

299+
describe("renderChunk() — variables threading", () => {
300+
// 60s ceiling absorbs Chrome cold-start + 5-frame capture + ffmpeg encode
301+
// on slower CI workers.
302+
const TIMEOUT_MS = 60_000;
303+
304+
// Fixture whose pixels depend on `window.__hfVariables.color`. Read the
305+
// variables on `DOMContentLoaded` and write the color onto a fullscreen
306+
// element. Two plans with different `variables.color` MUST produce
307+
// different chunk fingerprints — proves the controller's snapshotted
308+
// variables reach the chunk worker's page.
309+
const VARIABLES_FIXTURE_HTML = `<!doctype html>
310+
<html data-composition-variables='{"color":"string"}'>
311+
<head><meta charset="utf-8"><title>renderChunk variables fixture</title></head>
312+
<body style="margin:0">
313+
<div data-composition-id="root" data-width="160" data-height="120" data-duration="0.16667">
314+
<div id="paint" style="width:160px;height:120px;background:#000"></div>
315+
</div>
316+
<script>
317+
(function () {
318+
var v = (window.__hfVariables && window.__hfVariables.color) || "#000";
319+
var el = document.getElementById("paint");
320+
if (el) el.style.background = v;
321+
})();
322+
</script>
323+
</body>
324+
</html>`;
325+
326+
it(
327+
"chunks rendered with different variables produce different output fingerprints",
328+
async () => {
329+
if (!hasChrome) {
330+
// Soft skip — Docker harness covers the real assertion.
331+
console.warn(
332+
"[renderChunk.test] skipping variables-threading test — chrome-headless-shell not available on this host",
333+
);
334+
return;
335+
}
336+
337+
const variablesProjectDir = join(runRoot, "project-variables");
338+
mkdirSync(variablesProjectDir, { recursive: true });
339+
writeFileSync(join(variablesProjectDir, "index.html"), VARIABLES_FIXTURE_HTML, "utf-8");
340+
341+
const planDirRed = join(runRoot, "plan-variables-red");
342+
const planDirBlue = join(runRoot, "plan-variables-blue");
343+
mkdirSync(planDirRed, { recursive: true });
344+
mkdirSync(planDirBlue, { recursive: true });
345+
346+
const baseConfig = {
347+
fps: 30 as const,
348+
width: 160,
349+
height: 120,
350+
format: "png-sequence" as const,
351+
};
352+
await plan(
353+
variablesProjectDir,
354+
{ ...baseConfig, variables: { color: "#ff0000" } },
355+
planDirRed,
356+
);
357+
await plan(
358+
variablesProjectDir,
359+
{ ...baseConfig, variables: { color: "#0000ff" } },
360+
planDirBlue,
361+
);
362+
363+
const outRed = join(runRoot, "chunk-variables-red");
364+
const outBlue = join(runRoot, "chunk-variables-blue");
365+
366+
let red, blue;
367+
try {
368+
red = await renderChunk(planDirRed, 0, outRed);
369+
} catch (err) {
370+
const message = err instanceof Error ? err.message : String(err);
371+
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
372+
console.warn(
373+
"[renderChunk.test] skipping variables-threading test — host Chrome stack can't render. ",
374+
"Docker harness covers the contract. Diagnostic:",
375+
message.slice(0, 240),
376+
);
377+
return;
378+
}
379+
throw err;
380+
}
381+
blue = await renderChunk(planDirBlue, 0, outBlue);
382+
383+
expect(red.outputKind).toBe("frame-dir");
384+
expect(blue.outputKind).toBe("frame-dir");
385+
// Different variables.color → different rendered pixels → different
386+
// fingerprint. The byte-identical-retry contract from the dedicated
387+
// test above is what gives this assertion teeth: if variables
388+
// weren't actually reaching the page, both chunks would hash the
389+
// same #000 fallback.
390+
expect(red.sha256).not.toBe(blue.sha256);
391+
},
392+
TIMEOUT_MS,
393+
);
394+
});
395+
299396
describe("resolvePresetForLockedEncoder", () => {
300397
// Tiny fast tests for the codec-override helper. No Chrome, no ffmpeg —
301398
// exists so a refactor that moves the override (e.g. into

packages/producer/src/services/distributed/renderChunk.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,12 @@ export async function renderChunk(
462462
format: plan.dimensions.format === "mp4" ? "jpeg" : "png",
463463
quality: plan.dimensions.format === "mp4" ? 80 : undefined,
464464
deviceScaleFactor: encoder.deviceScaleFactor,
465+
// Re-inject the controller's snapshotted variables so the chunk's
466+
// first capture sees the same `window.__hfVariables` the in-process
467+
// renderer would have seen. Optional — compositions that don't
468+
// declare `data-composition-variables` leave this undefined and the
469+
// engine skips the `evaluateOnNewDocument` injection.
470+
variables: encoder.variables,
465471
// lock the BeginFrame warmup loop to a fixed iteration count so
466472
// `beginFrameTimeTicks` is host-independent. Only chunks ever set this.
467473
lockWarmupTicks: true,

packages/producer/src/services/render/stages/freezePlan.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,25 @@ export interface LockedRenderConfig {
6565

6666
/** Snapshot of `PRODUCER_RUNTIME_*` env vars at plan time. */
6767
runtimeEnv: Record<string, string>;
68+
69+
/**
70+
* Render-time variable overrides snapshotted at plan time. Chunk workers
71+
* re-inject these into the page as `window.__hfVariables` before the
72+
* first capture, so every chunk sees the same `getVariables()` resolution
73+
* the controller used to size the plan.
74+
*
75+
* Folded into the canonical encoder.json bytes that feed `planHash` —
76+
* two plans with different variables produce different hashes (the
77+
* intended behavior: different variables can produce different rendered
78+
* frames). Two plans with the same variables produce identical hashes
79+
* because canonical-JSON sorts object keys.
80+
*
81+
* Optional: omitted (undefined) when the caller doesn't pass variables;
82+
* stripped from the canonical JSON via the same `stripUndefined` pass
83+
* that handles `crf`/`bitrate`, so an absent value hashes the same as
84+
* before this field existed.
85+
*/
86+
variables?: Record<string, unknown>;
6887
}
6988

7089
export interface CompositionMetadataJson {

0 commit comments

Comments
 (0)