Merge pull request #717 from heygen-com/refactor/producer-stages-1.1-scaffold-planhash

refactor(producer): scaffold services/render/stages/ + planHash utility
This commit is contained in:
James Russo
2026-05-11 16:27:08 -04:00
committed by GitHub
3 changed files with 531 additions and 0 deletions
@@ -0,0 +1,106 @@
/**
* freezePlan — write the meta/{composition,encoder,chunks}.json + plan.json
* manifest at the end of `plan()`, compute the planHash from the frozen
* artifacts, and return the manifest path.
*
* Phase 1 PR 1.1 ships the signature only: there are no callers yet. The
* function body is added in Phase 3 PR 3.1 when `services/distributed/plan.ts`
* lands and composes the Phase-1 stages. Keeping the skeleton in this PR
* means subsequent stage-extraction PRs can grow the `stages/` directory
* without touching the `producer/src/index.ts` export surface again.
*
* See DISTRIBUTED-RENDERING-PLAN.md §2.1 phase 6, §4.1 directory layout,
* §4.3 LockedRenderConfig.
*/
import type { Fps } from "@hyperframes/core";
import type { PlanDimensions } from "./planHash.js";
/**
* The encoder configuration locked in at plan time. Mirrors §4.3
* LockedRenderConfig in the design doc. Phase 1 declares the shape; Phase 2
* + Phase 3 populate it from the existing `renderOrchestrator` config and
* the new closed-GOP encoder args.
*/
export interface LockedRenderConfig {
// Capture
captureMode: "beginframe" | "screenshot";
forceScreenshot: boolean;
deviceScaleFactor: number;
useLayeredHdrComposite: boolean;
/** Hard-pinned to "software" in v1 distributed renders. */
browserGpuMode: "software";
warmupTicks: number;
// Encode
encoder: "libx264-software" | "libx265-software" | "prores-software" | "png-sequence";
ffmpegVersion: string;
preset: string;
crf?: number;
bitrate?: string;
/** Equal to chunkSize for closed-GOP concat-copy. */
gopSize: number;
closedGop: true;
forceKeyframes: "n=0";
pixelFormat: string;
// Chunking
chunkSize: number;
chunkCount: number;
/** Snapshot of `PRODUCER_RUNTIME_*` env vars at plan time. */
runtimeEnv: Record<string, string>;
}
export interface CompositionMetadataJson {
durationSeconds: number;
width: number;
height: number;
fps: Fps;
videoCount: number;
audioCount: number;
imageCount: number;
}
export interface ChunkSliceJson {
index: number;
startFrame: number;
/** Inclusive end frame for the chunk. */
endFrame: number;
}
/**
* Inputs to `freezePlan`. `planDir` already contains `compiled/`,
* `video-frames/`, and (optionally) `audio.aac` by the time freezePlan
* runs — see §2.1 phases 1-5.
*/
export interface FreezePlanInput {
/** Absolute path to the plan directory being frozen. */
planDir: string;
composition: CompositionMetadataJson;
encoder: LockedRenderConfig;
chunks: readonly ChunkSliceJson[];
dimensions: PlanDimensions;
producerVersion: string;
/** Hash of the deterministic-font snapshot baked into the plan. */
fontSnapshotSha: string;
}
export interface FreezePlanResult {
/** Absolute path to `plan.json`. */
planJsonPath: string;
/** Content-addressed planHash; see §4.2. */
planHash: string;
}
/**
* Freeze a plan directory: write `meta/*.json` + top-level `plan.json`, then
* compute `planHash` over the canonicalized contents.
*
* Skeleton only in Phase 1. Phase 3 PR 3.1 wires this up.
*/
export async function freezePlan(_input: FreezePlanInput): Promise<FreezePlanResult> {
throw new Error(
"freezePlan is not implemented yet — wired in Phase 3 PR 3.1 (see DISTRIBUTED-RENDERING-PLAN.md §11).",
);
}
@@ -0,0 +1,253 @@
import { describe, expect, it } from "bun:test";
import {
canonicalJsonStringify,
computePlanHash,
sha256Hex,
type PlanAssetHash,
type PlanHashInput,
} from "./planHash.js";
function utf8(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
function makeInput(overrides: Partial<PlanHashInput> = {}): PlanHashInput {
return {
compositionHtml: utf8("<!doctype html><html><body>hi</body></html>"),
assets: [
{ path: "assets/a.png", sha256: "a".repeat(64) },
{ path: "assets/b.png", sha256: "b".repeat(64) },
],
fontSnapshotSha: "f".repeat(64),
encoderConfigCanonicalJson: '{"closedGop":true,"encoder":"libx264-software","gopSize":240}',
producerVersion: "0.5.7",
ffmpegVersion: "ffmpeg version 6.1.1",
dimensions: {
fpsNum: 30,
fpsDen: 1,
width: 1920,
height: 1080,
format: "mp4",
},
...overrides,
};
}
describe("computePlanHash", () => {
it("is deterministic for identical inputs", () => {
const a = computePlanHash(makeInput());
const b = computePlanHash(makeInput());
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
// Pin the schema-prefix-mixed-in result for one fixed input. If the
// framing of `computePlanHash` ever changes silently, this test must
// be updated (which means bumping `PLAN_HASH_SCHEMA_PREFIX` in the
// source too). Catches accidental drift across producer versions.
it("matches the known digest for a fixed reference input", () => {
expect(computePlanHash(makeInput())).toBe(
"995b4105a1a629965e85dc5d92c6aab9b888e39acdb14369d1bc781aa3247a94",
);
});
it("ignores asset order in the input array", () => {
const ordered = computePlanHash(
makeInput({
assets: [
{ path: "assets/a.png", sha256: "a".repeat(64) },
{ path: "assets/b.png", sha256: "b".repeat(64) },
],
}),
);
const reversed = computePlanHash(
makeInput({
assets: [
{ path: "assets/b.png", sha256: "b".repeat(64) },
{ path: "assets/a.png", sha256: "a".repeat(64) },
],
}),
);
expect(ordered).toBe(reversed);
});
it("changes when composition HTML changes", () => {
const a = computePlanHash(makeInput());
const b = computePlanHash(
makeInput({ compositionHtml: utf8("<!doctype html><body>bye</body>") }),
);
expect(a).not.toBe(b);
});
it("changes when any asset sha changes", () => {
const a = computePlanHash(makeInput());
const b = computePlanHash(
makeInput({
assets: [
{ path: "assets/a.png", sha256: "a".repeat(64) },
{ path: "assets/b.png", sha256: "c".repeat(64) }, // changed
],
}),
);
expect(a).not.toBe(b);
});
it("changes when an asset path moves", () => {
const a = computePlanHash(makeInput());
const b = computePlanHash(
makeInput({
assets: [
{ path: "assets/a.png", sha256: "a".repeat(64) },
{ path: "assets/renamed.png", sha256: "b".repeat(64) },
],
}),
);
expect(a).not.toBe(b);
});
it("distinguishes path-vs-sha boundary via delimiter framing", () => {
// Without delimiters, concatenating ("ab", "cd...") and ("abc", "d...")
// could hash equal because the byte stream is identical. With the
// 0x00 delimiter between fields the two inputs produce distinct hashes.
const a = computePlanHash(
makeInput({
assets: [{ path: "assets/ab", sha256: "cd" + "0".repeat(62) }],
}),
);
const b = computePlanHash(
makeInput({
assets: [{ path: "assets/abc", sha256: "d" + "0".repeat(63) }],
}),
);
expect(a).not.toBe(b);
});
it("changes when font snapshot changes", () => {
const a = computePlanHash(makeInput());
const b = computePlanHash(makeInput({ fontSnapshotSha: "0".repeat(64) }));
expect(a).not.toBe(b);
});
it("changes when encoder config canonical JSON changes", () => {
const a = computePlanHash(makeInput());
const b = computePlanHash(
makeInput({ encoderConfigCanonicalJson: '{"encoder":"libx265-software"}' }),
);
expect(a).not.toBe(b);
});
it("changes when producer or ffmpeg version changes", () => {
const base = makeInput();
const a = computePlanHash(base);
const b = computePlanHash({ ...base, producerVersion: "0.5.8" });
const c = computePlanHash({ ...base, ffmpegVersion: "ffmpeg version 7.0.0" });
expect(a).not.toBe(b);
expect(a).not.toBe(c);
expect(b).not.toBe(c);
});
it("changes when dimensions or fps change", () => {
const a = computePlanHash(makeInput());
const b = computePlanHash(
makeInput({
dimensions: { fpsNum: 60, fpsDen: 1, width: 1920, height: 1080, format: "mp4" },
}),
);
const c = computePlanHash(
makeInput({
dimensions: { fpsNum: 30, fpsDen: 1, width: 3840, height: 2160, format: "mp4" },
}),
);
const d = computePlanHash(
makeInput({
dimensions: { fpsNum: 30, fpsDen: 1, width: 1920, height: 1080, format: "mov" },
}),
);
expect(new Set([a, b, c, d]).size).toBe(4);
});
it("does not collide on empty assets", () => {
const a = computePlanHash(makeInput({ assets: [] }));
expect(a).toMatch(/^[0-9a-f]{64}$/);
const b = computePlanHash(makeInput({ assets: [{ path: "", sha256: "0".repeat(64) }] }));
expect(a).not.toBe(b);
});
it("does not mutate the input assets array", () => {
const assets: PlanAssetHash[] = [
{ path: "b", sha256: "b".repeat(64) },
{ path: "a", sha256: "a".repeat(64) },
];
const snapshot = assets.map((a) => ({ ...a }));
computePlanHash(makeInput({ assets }));
expect(assets).toEqual(snapshot);
});
});
describe("canonicalJsonStringify", () => {
it("sorts object keys byte-wise", () => {
expect(canonicalJsonStringify({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
});
it("preserves array order", () => {
expect(canonicalJsonStringify([3, 1, 2])).toBe("[3,1,2]");
});
it("recurses into nested structures", () => {
expect(
canonicalJsonStringify({
z: [{ b: 1, a: 2 }, "x"],
a: { y: true, x: null },
}),
).toBe('{"a":{"x":null,"y":true},"z":[{"a":2,"b":1},"x"]}');
});
it("escapes strings via JSON.stringify", () => {
expect(canonicalJsonStringify('he said "hi"')).toBe('"he said \\"hi\\""');
});
it("rejects non-finite numbers", () => {
expect(() => canonicalJsonStringify(Number.NaN)).toThrow(TypeError);
expect(() => canonicalJsonStringify(Number.POSITIVE_INFINITY)).toThrow(TypeError);
});
it("rejects unsupported value types", () => {
expect(() => canonicalJsonStringify(() => 1)).toThrow(TypeError);
expect(() => canonicalJsonStringify(Symbol("s"))).toThrow(TypeError);
});
it("rejects undefined at the top level", () => {
expect(() => canonicalJsonStringify(undefined)).toThrow(TypeError);
});
it("produces equal output for semantically equal objects with different key order", () => {
const a = canonicalJsonStringify({
encoder: "libx264-software",
gopSize: 240,
closedGop: true,
});
const b = canonicalJsonStringify({
gopSize: 240,
closedGop: true,
encoder: "libx264-software",
});
expect(a).toBe(b);
});
});
describe("sha256Hex", () => {
it("matches the well-known empty-string digest", () => {
expect(sha256Hex("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
});
it("matches the well-known abc digest", () => {
expect(sha256Hex("abc")).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
});
it("treats string and equivalent Uint8Array the same", () => {
const s = "hyperframes";
expect(sha256Hex(s)).toBe(sha256Hex(new TextEncoder().encode(s)));
});
});
@@ -0,0 +1,172 @@
/**
* planHash — content-addressed hash for distributed render plans.
*
* See DISTRIBUTED-RENDERING-PLAN.md §4.2 for the contract:
*
* planHash = sha256(
* SCHEMA_PREFIX
* ⊕ composition_html_bytes
* ⊕ asset_shas (sorted by relative path)
* ⊕ font_snapshot_sha
* ⊕ encoder_config_canonical_json
* ⊕ producer_version
* ⊕ ffmpeg_version
* ⊕ fps ⊕ width ⊕ height ⊕ format
* )
*
* Two invocations with identical inputs MUST produce the same hash. Adapters
* use this to short-circuit `plan()` on workflow replay and to detect
* cross-version mismatches (§9.3 PLAN_HASH_MISMATCH).
*
* Pure utility; no caller exists yet — the distributed-render
* `services/distributed/plan.ts` will compose it.
*
* ## Encoding contract
*
* Every string-typed component (`fontSnapshotSha`,
* `encoderConfigCanonicalJson`, `producerVersion`, `ffmpegVersion`, asset
* paths and shas, the dimensions tuple) is hashed as UTF-8. External
* verifiers must encode the same way. Binary fields (`compositionHtml`)
* are hashed verbatim.
*/
import { createHash } from "node:crypto";
/**
* Schema-version prefix mixed into every digest. Bump the trailing version
* integer whenever the framing of `computePlanHash` changes (new fields,
* new delimiter, new field order, etc.) so every cached plan from an older
* producer is forced to mismatch and re-plan. This is impossible to
* backfill, so a deliberate bump is the only correct action.
*/
const PLAN_HASH_SCHEMA_PREFIX = "hyperframes-plan-hash-v1\x00";
/**
* 0x00 byte used to frame each `hash.update()` call. Hoisted to module
* scope so it's not reallocated on every `computePlanHash` invocation.
*/
const FIELD_DELIMITER = Buffer.from([0x00]);
/**
* SHA-256 hex digest of an asset, paired with its plan-relative path. Sort
* order across an asset list is by `path` (byte-wise ascending) to keep the
* digest deterministic regardless of filesystem walk order.
*/
export interface PlanAssetHash {
/** Plan-relative path. Stable across machines (no absolute paths). */
path: string;
/** Hex-encoded sha256 of the asset bytes. */
sha256: string;
}
/**
* Render dimensions + frame rate that affect the encoded output. Kept as a
* separate type so callers can reuse it for log lines and adapter payloads.
*/
export interface PlanDimensions {
/** Frame rate numerator (e.g. 30 or 30000 for NTSC). */
fpsNum: number;
/** Frame rate denominator (e.g. 1 or 1001 for NTSC). */
fpsDen: number;
width: number;
height: number;
format: "mp4" | "mov" | "png-sequence" | "webm";
}
export interface PlanHashInput {
/** Raw bytes of `compiled/index.html` after recompile. */
compositionHtml: Uint8Array;
/** All non-HTML assets referenced from the composition, in any order. */
assets: readonly PlanAssetHash[];
/** Hash of the deterministic-font snapshot used to render. */
fontSnapshotSha: string;
/** Canonical-JSON serialization of `meta/encoder.json` (LockedRenderConfig). */
encoderConfigCanonicalJson: string;
/** `@hyperframes/producer` package version that produced the plan. */
producerVersion: string;
/** ffmpeg `--version` line (e.g. "ffmpeg version 6.1.1"). */
ffmpegVersion: string;
dimensions: PlanDimensions;
}
/**
* Compute the content-addressed planHash for a frozen plan.
*
* The hash incorporates each component as a separate `update()` call after a
* fixed delimiter byte; that prevents two distinct inputs from accidentally
* sharing a hash if their concatenation happens to collide (e.g. asset count
* vs. asset bytes).
*/
export function computePlanHash(input: PlanHashInput): string {
const hash = createHash("sha256");
hash.update(PLAN_HASH_SCHEMA_PREFIX, "utf8");
hash.update(input.compositionHtml);
hash.update(FIELD_DELIMITER);
const sortedAssets = [...input.assets].sort((a, b) =>
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
);
for (const asset of sortedAssets) {
hash.update(asset.path, "utf8");
hash.update(FIELD_DELIMITER);
hash.update(asset.sha256, "utf8");
hash.update(FIELD_DELIMITER);
}
hash.update(input.fontSnapshotSha, "utf8");
hash.update(FIELD_DELIMITER);
hash.update(input.encoderConfigCanonicalJson, "utf8");
hash.update(FIELD_DELIMITER);
hash.update(input.producerVersion, "utf8");
hash.update(FIELD_DELIMITER);
hash.update(input.ffmpegVersion, "utf8");
hash.update(FIELD_DELIMITER);
const d = input.dimensions;
hash.update(`${d.fpsNum}/${d.fpsDen}x${d.width}x${d.height}x${d.format}`, "utf8");
return hash.digest("hex");
}
/**
* Canonical-JSON serialization helper. JSON keys are emitted in
* byte-wise-sorted order recursively, with no whitespace. Used to feed the
* encoder config into `computePlanHash` such that semantically-equal configs
* produce equal hashes regardless of source key ordering.
*
* Supports the subset that LockedRenderConfig values use: primitives, plain
* objects, and arrays. Throws on functions, symbols, BigInts, and Maps.
*/
export function canonicalJsonStringify(value: unknown): string {
if (value === null) return "null";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError(`canonicalJsonStringify: non-finite number ${value}`);
}
return JSON.stringify(value);
}
if (typeof value === "string") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map(canonicalJsonStringify).join(",")}]`;
}
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const parts = keys.map((k) => `${JSON.stringify(k)}:${canonicalJsonStringify(obj[k])}`);
return `{${parts.join(",")}}`;
}
throw new TypeError(`canonicalJsonStringify: unsupported value type ${typeof value}`);
}
/**
* Convenience helper: sha256 a file path or buffer, return hex digest. Used
* by the eventual `freezePlan` to hash assets on disk.
*/
export function sha256Hex(bytes: Uint8Array | string): string {
const h = createHash("sha256");
h.update(bytes);
return h.digest("hex");
}