Skip to content

Commit fad8cb6

Browse files
authored
Merge pull request #717 from heygen-com/refactor/producer-stages-1.1-scaffold-planhash
refactor(producer): scaffold services/render/stages/ + planHash utility
2 parents a81bfb8 + 426bd98 commit fad8cb6

3 files changed

Lines changed: 531 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* freezePlan — write the meta/{composition,encoder,chunks}.json + plan.json
3+
* manifest at the end of `plan()`, compute the planHash from the frozen
4+
* artifacts, and return the manifest path.
5+
*
6+
* Phase 1 PR 1.1 ships the signature only: there are no callers yet. The
7+
* function body is added in Phase 3 PR 3.1 when `services/distributed/plan.ts`
8+
* lands and composes the Phase-1 stages. Keeping the skeleton in this PR
9+
* means subsequent stage-extraction PRs can grow the `stages/` directory
10+
* without touching the `producer/src/index.ts` export surface again.
11+
*
12+
* See DISTRIBUTED-RENDERING-PLAN.md §2.1 phase 6, §4.1 directory layout,
13+
* §4.3 LockedRenderConfig.
14+
*/
15+
16+
import type { Fps } from "@hyperframes/core";
17+
import type { PlanDimensions } from "./planHash.js";
18+
19+
/**
20+
* The encoder configuration locked in at plan time. Mirrors §4.3
21+
* LockedRenderConfig in the design doc. Phase 1 declares the shape; Phase 2
22+
* + Phase 3 populate it from the existing `renderOrchestrator` config and
23+
* the new closed-GOP encoder args.
24+
*/
25+
export interface LockedRenderConfig {
26+
// Capture
27+
captureMode: "beginframe" | "screenshot";
28+
forceScreenshot: boolean;
29+
deviceScaleFactor: number;
30+
useLayeredHdrComposite: boolean;
31+
/** Hard-pinned to "software" in v1 distributed renders. */
32+
browserGpuMode: "software";
33+
warmupTicks: number;
34+
35+
// Encode
36+
encoder: "libx264-software" | "libx265-software" | "prores-software" | "png-sequence";
37+
ffmpegVersion: string;
38+
preset: string;
39+
crf?: number;
40+
bitrate?: string;
41+
/** Equal to chunkSize for closed-GOP concat-copy. */
42+
gopSize: number;
43+
closedGop: true;
44+
forceKeyframes: "n=0";
45+
pixelFormat: string;
46+
47+
// Chunking
48+
chunkSize: number;
49+
chunkCount: number;
50+
51+
/** Snapshot of `PRODUCER_RUNTIME_*` env vars at plan time. */
52+
runtimeEnv: Record<string, string>;
53+
}
54+
55+
export interface CompositionMetadataJson {
56+
durationSeconds: number;
57+
width: number;
58+
height: number;
59+
fps: Fps;
60+
videoCount: number;
61+
audioCount: number;
62+
imageCount: number;
63+
}
64+
65+
export interface ChunkSliceJson {
66+
index: number;
67+
startFrame: number;
68+
/** Inclusive end frame for the chunk. */
69+
endFrame: number;
70+
}
71+
72+
/**
73+
* Inputs to `freezePlan`. `planDir` already contains `compiled/`,
74+
* `video-frames/`, and (optionally) `audio.aac` by the time freezePlan
75+
* runs — see §2.1 phases 1-5.
76+
*/
77+
export interface FreezePlanInput {
78+
/** Absolute path to the plan directory being frozen. */
79+
planDir: string;
80+
composition: CompositionMetadataJson;
81+
encoder: LockedRenderConfig;
82+
chunks: readonly ChunkSliceJson[];
83+
dimensions: PlanDimensions;
84+
producerVersion: string;
85+
/** Hash of the deterministic-font snapshot baked into the plan. */
86+
fontSnapshotSha: string;
87+
}
88+
89+
export interface FreezePlanResult {
90+
/** Absolute path to `plan.json`. */
91+
planJsonPath: string;
92+
/** Content-addressed planHash; see §4.2. */
93+
planHash: string;
94+
}
95+
96+
/**
97+
* Freeze a plan directory: write `meta/*.json` + top-level `plan.json`, then
98+
* compute `planHash` over the canonicalized contents.
99+
*
100+
* Skeleton only in Phase 1. Phase 3 PR 3.1 wires this up.
101+
*/
102+
export async function freezePlan(_input: FreezePlanInput): Promise<FreezePlanResult> {
103+
throw new Error(
104+
"freezePlan is not implemented yet — wired in Phase 3 PR 3.1 (see DISTRIBUTED-RENDERING-PLAN.md §11).",
105+
);
106+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
canonicalJsonStringify,
4+
computePlanHash,
5+
sha256Hex,
6+
type PlanAssetHash,
7+
type PlanHashInput,
8+
} from "./planHash.js";
9+
10+
function utf8(s: string): Uint8Array {
11+
return new TextEncoder().encode(s);
12+
}
13+
14+
function makeInput(overrides: Partial<PlanHashInput> = {}): PlanHashInput {
15+
return {
16+
compositionHtml: utf8("<!doctype html><html><body>hi</body></html>"),
17+
assets: [
18+
{ path: "assets/a.png", sha256: "a".repeat(64) },
19+
{ path: "assets/b.png", sha256: "b".repeat(64) },
20+
],
21+
fontSnapshotSha: "f".repeat(64),
22+
encoderConfigCanonicalJson: '{"closedGop":true,"encoder":"libx264-software","gopSize":240}',
23+
producerVersion: "0.5.7",
24+
ffmpegVersion: "ffmpeg version 6.1.1",
25+
dimensions: {
26+
fpsNum: 30,
27+
fpsDen: 1,
28+
width: 1920,
29+
height: 1080,
30+
format: "mp4",
31+
},
32+
...overrides,
33+
};
34+
}
35+
36+
describe("computePlanHash", () => {
37+
it("is deterministic for identical inputs", () => {
38+
const a = computePlanHash(makeInput());
39+
const b = computePlanHash(makeInput());
40+
expect(a).toBe(b);
41+
expect(a).toMatch(/^[0-9a-f]{64}$/);
42+
});
43+
44+
// Pin the schema-prefix-mixed-in result for one fixed input. If the
45+
// framing of `computePlanHash` ever changes silently, this test must
46+
// be updated (which means bumping `PLAN_HASH_SCHEMA_PREFIX` in the
47+
// source too). Catches accidental drift across producer versions.
48+
it("matches the known digest for a fixed reference input", () => {
49+
expect(computePlanHash(makeInput())).toBe(
50+
"995b4105a1a629965e85dc5d92c6aab9b888e39acdb14369d1bc781aa3247a94",
51+
);
52+
});
53+
54+
it("ignores asset order in the input array", () => {
55+
const ordered = computePlanHash(
56+
makeInput({
57+
assets: [
58+
{ path: "assets/a.png", sha256: "a".repeat(64) },
59+
{ path: "assets/b.png", sha256: "b".repeat(64) },
60+
],
61+
}),
62+
);
63+
const reversed = computePlanHash(
64+
makeInput({
65+
assets: [
66+
{ path: "assets/b.png", sha256: "b".repeat(64) },
67+
{ path: "assets/a.png", sha256: "a".repeat(64) },
68+
],
69+
}),
70+
);
71+
expect(ordered).toBe(reversed);
72+
});
73+
74+
it("changes when composition HTML changes", () => {
75+
const a = computePlanHash(makeInput());
76+
const b = computePlanHash(
77+
makeInput({ compositionHtml: utf8("<!doctype html><body>bye</body>") }),
78+
);
79+
expect(a).not.toBe(b);
80+
});
81+
82+
it("changes when any asset sha changes", () => {
83+
const a = computePlanHash(makeInput());
84+
const b = computePlanHash(
85+
makeInput({
86+
assets: [
87+
{ path: "assets/a.png", sha256: "a".repeat(64) },
88+
{ path: "assets/b.png", sha256: "c".repeat(64) }, // changed
89+
],
90+
}),
91+
);
92+
expect(a).not.toBe(b);
93+
});
94+
95+
it("changes when an asset path moves", () => {
96+
const a = computePlanHash(makeInput());
97+
const b = computePlanHash(
98+
makeInput({
99+
assets: [
100+
{ path: "assets/a.png", sha256: "a".repeat(64) },
101+
{ path: "assets/renamed.png", sha256: "b".repeat(64) },
102+
],
103+
}),
104+
);
105+
expect(a).not.toBe(b);
106+
});
107+
108+
it("distinguishes path-vs-sha boundary via delimiter framing", () => {
109+
// Without delimiters, concatenating ("ab", "cd...") and ("abc", "d...")
110+
// could hash equal because the byte stream is identical. With the
111+
// 0x00 delimiter between fields the two inputs produce distinct hashes.
112+
const a = computePlanHash(
113+
makeInput({
114+
assets: [{ path: "assets/ab", sha256: "cd" + "0".repeat(62) }],
115+
}),
116+
);
117+
const b = computePlanHash(
118+
makeInput({
119+
assets: [{ path: "assets/abc", sha256: "d" + "0".repeat(63) }],
120+
}),
121+
);
122+
expect(a).not.toBe(b);
123+
});
124+
125+
it("changes when font snapshot changes", () => {
126+
const a = computePlanHash(makeInput());
127+
const b = computePlanHash(makeInput({ fontSnapshotSha: "0".repeat(64) }));
128+
expect(a).not.toBe(b);
129+
});
130+
131+
it("changes when encoder config canonical JSON changes", () => {
132+
const a = computePlanHash(makeInput());
133+
const b = computePlanHash(
134+
makeInput({ encoderConfigCanonicalJson: '{"encoder":"libx265-software"}' }),
135+
);
136+
expect(a).not.toBe(b);
137+
});
138+
139+
it("changes when producer or ffmpeg version changes", () => {
140+
const base = makeInput();
141+
const a = computePlanHash(base);
142+
const b = computePlanHash({ ...base, producerVersion: "0.5.8" });
143+
const c = computePlanHash({ ...base, ffmpegVersion: "ffmpeg version 7.0.0" });
144+
expect(a).not.toBe(b);
145+
expect(a).not.toBe(c);
146+
expect(b).not.toBe(c);
147+
});
148+
149+
it("changes when dimensions or fps change", () => {
150+
const a = computePlanHash(makeInput());
151+
const b = computePlanHash(
152+
makeInput({
153+
dimensions: { fpsNum: 60, fpsDen: 1, width: 1920, height: 1080, format: "mp4" },
154+
}),
155+
);
156+
const c = computePlanHash(
157+
makeInput({
158+
dimensions: { fpsNum: 30, fpsDen: 1, width: 3840, height: 2160, format: "mp4" },
159+
}),
160+
);
161+
const d = computePlanHash(
162+
makeInput({
163+
dimensions: { fpsNum: 30, fpsDen: 1, width: 1920, height: 1080, format: "mov" },
164+
}),
165+
);
166+
expect(new Set([a, b, c, d]).size).toBe(4);
167+
});
168+
169+
it("does not collide on empty assets", () => {
170+
const a = computePlanHash(makeInput({ assets: [] }));
171+
expect(a).toMatch(/^[0-9a-f]{64}$/);
172+
const b = computePlanHash(makeInput({ assets: [{ path: "", sha256: "0".repeat(64) }] }));
173+
expect(a).not.toBe(b);
174+
});
175+
176+
it("does not mutate the input assets array", () => {
177+
const assets: PlanAssetHash[] = [
178+
{ path: "b", sha256: "b".repeat(64) },
179+
{ path: "a", sha256: "a".repeat(64) },
180+
];
181+
const snapshot = assets.map((a) => ({ ...a }));
182+
computePlanHash(makeInput({ assets }));
183+
expect(assets).toEqual(snapshot);
184+
});
185+
});
186+
187+
describe("canonicalJsonStringify", () => {
188+
it("sorts object keys byte-wise", () => {
189+
expect(canonicalJsonStringify({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
190+
});
191+
192+
it("preserves array order", () => {
193+
expect(canonicalJsonStringify([3, 1, 2])).toBe("[3,1,2]");
194+
});
195+
196+
it("recurses into nested structures", () => {
197+
expect(
198+
canonicalJsonStringify({
199+
z: [{ b: 1, a: 2 }, "x"],
200+
a: { y: true, x: null },
201+
}),
202+
).toBe('{"a":{"x":null,"y":true},"z":[{"a":2,"b":1},"x"]}');
203+
});
204+
205+
it("escapes strings via JSON.stringify", () => {
206+
expect(canonicalJsonStringify('he said "hi"')).toBe('"he said \\"hi\\""');
207+
});
208+
209+
it("rejects non-finite numbers", () => {
210+
expect(() => canonicalJsonStringify(Number.NaN)).toThrow(TypeError);
211+
expect(() => canonicalJsonStringify(Number.POSITIVE_INFINITY)).toThrow(TypeError);
212+
});
213+
214+
it("rejects unsupported value types", () => {
215+
expect(() => canonicalJsonStringify(() => 1)).toThrow(TypeError);
216+
expect(() => canonicalJsonStringify(Symbol("s"))).toThrow(TypeError);
217+
});
218+
219+
it("rejects undefined at the top level", () => {
220+
expect(() => canonicalJsonStringify(undefined)).toThrow(TypeError);
221+
});
222+
223+
it("produces equal output for semantically equal objects with different key order", () => {
224+
const a = canonicalJsonStringify({
225+
encoder: "libx264-software",
226+
gopSize: 240,
227+
closedGop: true,
228+
});
229+
const b = canonicalJsonStringify({
230+
gopSize: 240,
231+
closedGop: true,
232+
encoder: "libx264-software",
233+
});
234+
expect(a).toBe(b);
235+
});
236+
});
237+
238+
describe("sha256Hex", () => {
239+
it("matches the well-known empty-string digest", () => {
240+
expect(sha256Hex("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
241+
});
242+
243+
it("matches the well-known abc digest", () => {
244+
expect(sha256Hex("abc")).toBe(
245+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
246+
);
247+
});
248+
249+
it("treats string and equivalent Uint8Array the same", () => {
250+
const s = "hyperframes";
251+
expect(sha256Hex(s)).toBe(sha256Hex(new TextEncoder().encode(s)));
252+
});
253+
});

0 commit comments

Comments
 (0)