diff --git a/packages/producer/src/services/render/stages/freezePlan.ts b/packages/producer/src/services/render/stages/freezePlan.ts new file mode 100644 index 000000000..f2cf28236 --- /dev/null +++ b/packages/producer/src/services/render/stages/freezePlan.ts @@ -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; +} + +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 { + throw new Error( + "freezePlan is not implemented yet — wired in Phase 3 PR 3.1 (see DISTRIBUTED-RENDERING-PLAN.md §11).", + ); +} diff --git a/packages/producer/src/services/render/stages/planHash.test.ts b/packages/producer/src/services/render/stages/planHash.test.ts new file mode 100644 index 000000000..2ecd5913f --- /dev/null +++ b/packages/producer/src/services/render/stages/planHash.test.ts @@ -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 { + return { + compositionHtml: utf8("hi"), + 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("bye") }), + ); + 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))); + }); +}); diff --git a/packages/producer/src/services/render/stages/planHash.ts b/packages/producer/src/services/render/stages/planHash.ts new file mode 100644 index 000000000..effa2ea50 --- /dev/null +++ b/packages/producer/src/services/render/stages/planHash.ts @@ -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; + 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"); +}