mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user