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.
This commit is contained in:
James Russo
2026-05-19 18:47:53 -04:00
committed by GitHub
parent 4237165517
commit 852008bd44
7 changed files with 259 additions and 0 deletions
@@ -176,6 +176,12 @@ export async function runDistributedSimulatedRender(
chunkSize: input.chunkSize,
maxParallelChunks: input.maxParallelChunks,
hdrMode: "force-sdr",
// Forward `variables` to plan() so distributed-simulated fixtures
// that declare `renderConfig.variables` produce the same pixels in
// distributed mode as in-process. Without this, the harness silently
// drops the variables for distributed/lambda-local modes and any
// composition that reads `window.__hfVariables` diverges.
variables: input.variables,
},
planDir,
);
@@ -92,6 +92,11 @@ export async function runLambdaLocalRender(input: RunLambdaLocalInput): Promise<
chunkSize: input.chunkSize,
maxParallelChunks: input.maxParallelChunks,
hdrMode: "force-sdr",
// Forward `variables` through the event boundary so lambda-local mode
// exercises the same variables-in-encoder.json path that real Lambda
// executions take. Without this, a fixture's `renderConfig.variables`
// would be silently dropped at the harness's serializer.
variables: input.variables,
};
// STEP A: plan
@@ -461,6 +461,111 @@ describe("plan() — codec knob", () => {
});
});
describe("plan() — variables", () => {
const TIMEOUT_MS = 30_000;
it(
"snapshots variables into meta/encoder.json so chunk workers see the controller's set",
async () => {
const planDir = join(runRoot, "plan-variables-snapshot");
mkdirSync(planDir, { recursive: true });
const variables = { title: "Hello", accent: "#ff0000" };
await plan(
projectDir,
{ fps: 30, width: 320, height: 240, format: "mp4", variables },
planDir,
);
const encoder = JSON.parse(
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
) as Record<string, unknown>;
expect(encoder.variables).toEqual(variables);
},
TIMEOUT_MS,
);
it(
"omits the variables key from canonical encoder.json when the caller passes no variables",
async () => {
// Backwards compat: a no-variables plan must hash identically to
// pre-Phase-9 plans. Since the canonical encoder.json strips
// `undefined` values, the key must not appear at all.
const planDir = join(runRoot, "plan-variables-absent");
mkdirSync(planDir, { recursive: true });
await plan(projectDir, { fps: 30, width: 320, height: 240, format: "mp4" }, planDir);
const encoderRaw = readFileSync(join(planDir, "meta", "encoder.json"), "utf-8");
expect(encoderRaw).not.toContain("variables");
},
TIMEOUT_MS,
);
it(
"produces a DIFFERENT planHash when variables differ",
async () => {
// Variables fold into planHash via meta/encoder.json bytes. Two plans
// with different variables must produce different hashes — chunked
// output depends on the injected values, and the byte-identical-
// retry contract has to bind to the controller's choice.
const planDirA = join(runRoot, "plan-variables-different-a");
const planDirB = join(runRoot, "plan-variables-different-b");
mkdirSync(planDirA, { recursive: true });
mkdirSync(planDirB, { recursive: true });
const base = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
const a = await plan(projectDir, { ...base, variables: { title: "Alice" } }, planDirA);
const b = await plan(projectDir, { ...base, variables: { title: "Bob" } }, planDirB);
expect(a.planHash).not.toBe(b.planHash);
},
TIMEOUT_MS,
);
it(
"produces the SAME planHash for two plans with the same variables",
async () => {
// Canonical-JSON sorts object keys, so the same variables (regardless
// of insertion order on the caller side) must round-trip to the
// same encoder.json bytes and therefore the same planHash.
const planDirA = join(runRoot, "plan-variables-same-a");
const planDirB = join(runRoot, "plan-variables-same-b");
mkdirSync(planDirA, { recursive: true });
mkdirSync(planDirB, { recursive: true });
const base = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
const a = await plan(
projectDir,
{ ...base, variables: { title: "Alice", accent: "#ff0000" } },
planDirA,
);
const b = await plan(
projectDir,
// Same values, opposite insertion order.
{ ...base, variables: { accent: "#ff0000", title: "Alice" } },
planDirB,
);
expect(a.planHash).toBe(b.planHash);
},
TIMEOUT_MS,
);
it(
"no-variables plan hashes identically to itself (backwards-compat baseline)",
async () => {
// Pin the no-variables path so adding the new field doesn't silently
// change the hash for callers who never opt in. Re-runs the
// determinism check from the golden block, scoped to the variables
// surface so a future refactor here trips a focused failure.
const planDirA = join(runRoot, "plan-no-variables-determinism-a");
const planDirB = join(runRoot, "plan-no-variables-determinism-b");
mkdirSync(planDirA, { recursive: true });
mkdirSync(planDirB, { recursive: true });
const config = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
const a = await plan(projectDir, config, planDirA);
const b = await plan(projectDir, config, planDirB);
expect(a.planHash).toBe(b.planHash);
},
TIMEOUT_MS,
);
});
describe("plan() — webm format (distributed VP9)", () => {
const TIMEOUT_MS = 30_000;
@@ -163,6 +163,26 @@ export interface DistributedRenderConfig {
* exercise the throw path.
*/
planDirSizeLimitBytes?: number;
/**
* Render-time variable overrides for the composition. Snapshotted into
* `meta/encoder.json` at plan time and re-injected by every chunk
* worker as `window.__hfVariables` before the first capture, mirroring
* the in-process renderer's
* `RenderConfig.variables` → `CaptureOptions.variables` path. The
* runtime helper `getVariables()` merges these over the declared
* defaults from `<html data-composition-variables="…">`.
*
* Folded into `planHash`: different variables produce different hashes
* because rendered frames depend on the injected values. Must be a
* JSON-serializable plain object — `freezePlan`'s canonical-JSON pass
* throws on non-serializable values (functions, Symbols, BigInts) when
* the variables reach this layer. Adapters that ship to Lambda (the
* `@hyperframes/aws-lambda` SDK) also validate the shape client-side
* before any AWS call so the rejection lands at the SDK boundary
* rather than mid-plan; the producer-side throw is the fallback.
*/
variables?: Record<string, unknown>;
}
/**
@@ -509,6 +529,7 @@ function buildLockedRenderConfig(input: {
chunkSize: input.effectiveChunkSize,
chunkCount: input.chunkCount,
runtimeEnv: input.runtimeEnv,
variables: config.variables,
};
}
@@ -296,6 +296,103 @@ describe("renderChunk()", () => {
);
});
describe("renderChunk() — variables threading", () => {
// 60s ceiling absorbs Chrome cold-start + 5-frame capture + ffmpeg encode
// on slower CI workers.
const TIMEOUT_MS = 60_000;
// Fixture whose pixels depend on `window.__hfVariables.color`. Read the
// variables on `DOMContentLoaded` and write the color onto a fullscreen
// element. Two plans with different `variables.color` MUST produce
// different chunk fingerprints — proves the controller's snapshotted
// variables reach the chunk worker's page.
const VARIABLES_FIXTURE_HTML = `<!doctype html>
<html data-composition-variables='{"color":"string"}'>
<head><meta charset="utf-8"><title>renderChunk variables fixture</title></head>
<body style="margin:0">
<div data-composition-id="root" data-width="160" data-height="120" data-duration="0.16667">
<div id="paint" style="width:160px;height:120px;background:#000"></div>
</div>
<script>
(function () {
var v = (window.__hfVariables && window.__hfVariables.color) || "#000";
var el = document.getElementById("paint");
if (el) el.style.background = v;
})();
</script>
</body>
</html>`;
it(
"chunks rendered with different variables produce different output fingerprints",
async () => {
if (!hasChrome) {
// Soft skip — Docker harness covers the real assertion.
console.warn(
"[renderChunk.test] skipping variables-threading test — chrome-headless-shell not available on this host",
);
return;
}
const variablesProjectDir = join(runRoot, "project-variables");
mkdirSync(variablesProjectDir, { recursive: true });
writeFileSync(join(variablesProjectDir, "index.html"), VARIABLES_FIXTURE_HTML, "utf-8");
const planDirRed = join(runRoot, "plan-variables-red");
const planDirBlue = join(runRoot, "plan-variables-blue");
mkdirSync(planDirRed, { recursive: true });
mkdirSync(planDirBlue, { recursive: true });
const baseConfig = {
fps: 30 as const,
width: 160,
height: 120,
format: "png-sequence" as const,
};
await plan(
variablesProjectDir,
{ ...baseConfig, variables: { color: "#ff0000" } },
planDirRed,
);
await plan(
variablesProjectDir,
{ ...baseConfig, variables: { color: "#0000ff" } },
planDirBlue,
);
const outRed = join(runRoot, "chunk-variables-red");
const outBlue = join(runRoot, "chunk-variables-blue");
let red, blue;
try {
red = await renderChunk(planDirRed, 0, outRed);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
console.warn(
"[renderChunk.test] skipping variables-threading test — host Chrome stack can't render. ",
"Docker harness covers the contract. Diagnostic:",
message.slice(0, 240),
);
return;
}
throw err;
}
blue = await renderChunk(planDirBlue, 0, outBlue);
expect(red.outputKind).toBe("frame-dir");
expect(blue.outputKind).toBe("frame-dir");
// Different variables.color → different rendered pixels → different
// fingerprint. The byte-identical-retry contract from the dedicated
// test above is what gives this assertion teeth: if variables
// weren't actually reaching the page, both chunks would hash the
// same #000 fallback.
expect(red.sha256).not.toBe(blue.sha256);
},
TIMEOUT_MS,
);
});
describe("resolvePresetForLockedEncoder", () => {
// Tiny fast tests for the codec-override helper. No Chrome, no ffmpeg —
// exists so a refactor that moves the override (e.g. into
@@ -462,6 +462,12 @@ export async function renderChunk(
format: plan.dimensions.format === "mp4" ? "jpeg" : "png",
quality: plan.dimensions.format === "mp4" ? 80 : undefined,
deviceScaleFactor: encoder.deviceScaleFactor,
// Re-inject the controller's snapshotted variables so the chunk's
// first capture sees the same `window.__hfVariables` the in-process
// renderer would have seen. Optional — compositions that don't
// declare `data-composition-variables` leave this undefined and the
// engine skips the `evaluateOnNewDocument` injection.
variables: encoder.variables,
// lock the BeginFrame warmup loop to a fixed iteration count so
// `beginFrameTimeTicks` is host-independent. Only chunks ever set this.
lockWarmupTicks: true,
@@ -65,6 +65,25 @@ export interface LockedRenderConfig {
/** Snapshot of `PRODUCER_RUNTIME_*` env vars at plan time. */
runtimeEnv: Record<string, string>;
/**
* Render-time variable overrides snapshotted at plan time. Chunk workers
* re-inject these into the page as `window.__hfVariables` before the
* first capture, so every chunk sees the same `getVariables()` resolution
* the controller used to size the plan.
*
* Folded into the canonical encoder.json bytes that feed `planHash`
* two plans with different variables produce different hashes (the
* intended behavior: different variables can produce different rendered
* frames). Two plans with the same variables produce identical hashes
* because canonical-JSON sorts object keys.
*
* Optional: omitted (undefined) when the caller doesn't pass variables;
* stripped from the canonical JSON via the same `stripUndefined` pass
* that handles `crf`/`bitrate`, so an absent value hashes the same as
* before this field existed.
*/
variables?: Record<string, unknown>;
}
export interface CompositionMetadataJson {