Skip to content

Commit b47a3e7

Browse files
authored
feat(producer): refuse distributed-unsupported formats (webm + HDR mp4) (#815)
## What Phase 3 of the distributed rendering plan: §11 PR 3.5 format banlist. Extends `plan()` to refuse two v1-unsupported formats up front with a typed non-retryable `FormatNotSupportedInDistributedError` (`code === "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED"`). ## Why Both webm and HDR mp4 are documented as deferred to v1.5 (§7.2 + §12), but until this PR the only signal at the runtime layer is the in-process pipeline silently producing wrong output (chunk concat-copy doesn't round-trip VP9; HDR signaling gets stripped at the chunk boundary). Failing fast at `plan()` time keeps adopters from spending fan-out compute on a render that can't succeed and gives them a typed error code their workflow adapter can route on. ## How - New exports in `services/distributed/plan.ts`: - `FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED` — non-retryable error code matching §11's wording. - `FormatNotSupportedInDistributedError` — typed error class with `code`, `format`, and `reason` fields. Message names the rejected format and tells adopters to fall back to the in-process renderer (`executeRenderJob`) which has full format support. - `rejectUnsupportedDistributedFormat(config)` — pure helper exported separately so adapters can run the same gate at their input layer (Step Functions input validation, Temporal workflow start) before the activity even runs. - `plan()` calls `rejectUnsupportedDistributedFormat(config)` as the first line of the function — BEFORE `mkdirSync(planDir)` so a banned input never produces a partial planDir. - Replaced the previous ad-hoc `if (hdrMode === "force-hdr") throw new Error(...)` with the typed error class. ### What did NOT change `executeRenderJob`, the in-process orchestrator, the `hyperframes render` CLI, producer HTTP routes — all unchanged. The in-process renderer continues to accept webm + HDR (its existing functionality). ## Test plan - [x] Unit tests added — `packages/producer/src/services/distributed/planFormatBanlist.test.ts`. 5 cases: - `rejectUnsupportedDistributedFormat` accepts the v1-supported formats (mp4, mov, png-sequence) with both `auto` and `force-sdr` hdrMode. - Rejects webm — error has `code === FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED`, `format === "webm"`, message mentions in-process renderer. - Rejects HDR mp4 (`hdrMode === "force-hdr"`) — error has `format === "mp4-hdr"`, message mentions HDR. - End-to-end via `plan()`: webm throws with no planDir leaking to disk. - End-to-end via `plan()`: HDR mp4 throws with no planDir leaking to disk. - [x] `bun test packages/producer/src/services/distributed/` — 30 pass. - [x] `bun run --filter @hyperframes/producer typecheck` — clean. - [x] `bunx oxlint` + `bunx oxfmt --check` — clean on changed files. - [ ] Producer Docker regression harness — pending CI. `executeRenderJob` is unchanged; PSNR baselines should hold. This is PR 5 of a 6-PR Phase 3 stack: - 3.1 — `services/distributed/plan.ts` (#808) - 3.2 — `services/distributed/renderChunk.ts` (#809) - 3.3 — `services/distributed/assemble.ts` (#813) - 3.4 — `planDir` size cap (`PLAN_TOO_LARGE`) (#814) - **3.5 (this PR)** — distributed format banlist (webm + HDR mp4) - 3.6 — public exports + `@hyperframes/producer/distributed` subpath 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 1596fcb + e506848 commit b47a3e7

2 files changed

Lines changed: 218 additions & 3 deletions

File tree

packages/producer/src/services/distributed/plan.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,83 @@ export class PlanTooLargeError extends Error {
171171
}
172172
}
173173

174+
/**
175+
* Non-retryable error code raised when `plan()` is asked for an output
176+
* format that distributed mode doesn't support (webm, HDR mp4). The same
177+
* config would fail on every retry, so the failure must not auto-retry.
178+
*/
179+
export const FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED";
180+
181+
/**
182+
* Typed error raised by `plan()` for outputs that distributed mode
183+
* refuses to ship.
184+
*
185+
* - webm — VP9 + matroska concat-copy is fragile across libvpx-vp9
186+
* builds, and the chunked pipeline can't guarantee bit-identical
187+
* concat output across worker versions.
188+
* - mp4 + HDR (PQ / HLG) — chunked HDR pre-extract + HDR signaling
189+
* re-apply on the assembled file is not implemented yet.
190+
*
191+
* The in-process renderer (`executeRenderJob`) handles both natively.
192+
*/
193+
export class FormatNotSupportedInDistributedError extends Error {
194+
readonly code: typeof FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
195+
readonly format: string;
196+
readonly reason: string;
197+
constructor(format: string, reason: string) {
198+
super(
199+
`[plan] format ${JSON.stringify(format)} is not supported in distributed mode: ${reason}. ` +
200+
`Render with the in-process renderer (\`executeRenderJob\`) — it has full format ` +
201+
`support — or pick a distributed-supported format: mp4 SDR, mov ProRes 4444, or ` +
202+
`png-sequence.`,
203+
);
204+
this.name = "FormatNotSupportedInDistributedError";
205+
this.format = format;
206+
this.reason = reason;
207+
}
208+
}
209+
174210
function formatBytes(bytes: number): string {
175211
if (bytes < 1024) return `${bytes} B`;
176212
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
177213
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
178214
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
179215
}
180216

217+
/**
218+
* Reject formats the distributed pipeline cannot ship (webm + HDR mp4).
219+
* Throws {@link FormatNotSupportedInDistributedError} with a message
220+
* naming the rejected format. Runs at the very top of `plan()` so a
221+
* banned input never produces a partial planDir.
222+
*
223+
* Exported so adapters can call the same gate at their own input layer
224+
* (Step Functions input validation, Temporal workflow start) before the
225+
* activity even runs — the resulting non-retryable error then matches
226+
* what `plan()` would have thrown.
227+
*/
228+
export function rejectUnsupportedDistributedFormat(
229+
config: Pick<DistributedRenderConfig, "format" | "hdrMode">,
230+
): void {
231+
// The TypeScript type for `DistributedRenderConfig.format` already
232+
// excludes webm, but a JS caller (or a caller that built the config
233+
// dynamically from JSON) can still pass it. Belt-and-suspenders runtime
234+
// check at the gate.
235+
if ((config.format as string) === "webm") {
236+
throw new FormatNotSupportedInDistributedError(
237+
"webm",
238+
"VP9 + matroska concat-copy is fragile across libvpx-vp9 builds, so chunked output " +
239+
"can't be guaranteed byte-identical across workers",
240+
);
241+
}
242+
if ((config.hdrMode as string) === "force-hdr") {
243+
throw new FormatNotSupportedInDistributedError(
244+
"mp4-hdr",
245+
"HDR (PQ / HLG) requires per-source HDR pre-extract + HDR signaling re-apply on the " +
246+
"assembled file; neither is implemented for the distributed pipeline",
247+
);
248+
}
249+
}
250+
181251
/**
182252
* Walk `<planDir>/` depth-first and sum all regular file sizes. Symlinks
183253
* are not traversed — they shouldn't appear inside a planDir to begin with
@@ -373,9 +443,11 @@ export async function plan(
373443
config: DistributedRenderConfig,
374444
planDir: string,
375445
): Promise<PlanResult> {
376-
// ── Plan-time validation ──
377-
// Rejections here surface as typed `PlanValidationError`s with non-retryable
378-
// codes so workflow adapters don't waste retry budget on banned configs.
446+
// Plan-time validation. Rejections here surface as typed errors with
447+
// non-retryable codes so workflow adapters don't waste retry budget on
448+
// banned configs. Runs BEFORE any directory creation so a banned input
449+
// never produces a partial planDir.
450+
rejectUnsupportedDistributedFormat(config);
379451
validateNoGpuEncode({
380452
useGpu: false,
381453
browserGpuMode: "software",
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Unit tests for the distributed format banlist.
3+
*
4+
* Two formats `plan()` refuses up front:
5+
* - webm — VP9 + matroska concat-copy is fragile across libvpx-vp9 builds.
6+
* - mp4 + HDR (`hdrMode === "force-hdr"`) — chunked HDR pre-extract +
7+
* HDR signaling re-apply on the assembled file is not implemented.
8+
*
9+
* The banlist must trip BEFORE any other work runs (file server, browser,
10+
* ffprobe) — otherwise a banned config can leak a partial planDir on disk.
11+
* Each case asserts `existsSync(planDir)` is `false` after the throw to
12+
* pin the early-exit contract.
13+
*/
14+
15+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
16+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17+
import { tmpdir } from "node:os";
18+
import { join } from "node:path";
19+
import {
20+
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
21+
FormatNotSupportedInDistributedError,
22+
plan,
23+
rejectUnsupportedDistributedFormat,
24+
type DistributedRenderConfig,
25+
} from "./plan.js";
26+
27+
const FIXTURE_HTML = `<!doctype html>
28+
<html><body>
29+
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">hi</div>
30+
</body></html>`;
31+
32+
let runRoot: string;
33+
let projectDir: string;
34+
35+
beforeAll(() => {
36+
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-format-ban-"));
37+
projectDir = join(runRoot, "project");
38+
mkdirSync(projectDir, { recursive: true });
39+
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
40+
});
41+
42+
afterAll(() => {
43+
rmSync(runRoot, { recursive: true, force: true });
44+
});
45+
46+
describe("rejectUnsupportedDistributedFormat (pure)", () => {
47+
it("accepts the v1-supported formats (mp4 / mov / png-sequence)", () => {
48+
expect(() => rejectUnsupportedDistributedFormat({ format: "mp4" })).not.toThrow();
49+
expect(() => rejectUnsupportedDistributedFormat({ format: "mov" })).not.toThrow();
50+
expect(() => rejectUnsupportedDistributedFormat({ format: "png-sequence" })).not.toThrow();
51+
expect(() =>
52+
rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "auto" }),
53+
).not.toThrow();
54+
expect(() =>
55+
rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "force-sdr" }),
56+
).not.toThrow();
57+
});
58+
59+
it("rejects webm with FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", () => {
60+
let caught: unknown;
61+
try {
62+
// Cast forces the runtime check even though the type narrows webm out.
63+
rejectUnsupportedDistributedFormat({
64+
format: "webm" as DistributedRenderConfig["format"],
65+
});
66+
} catch (err) {
67+
caught = err;
68+
}
69+
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
70+
expect((caught as FormatNotSupportedInDistributedError).code).toBe(
71+
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
72+
);
73+
expect((caught as FormatNotSupportedInDistributedError).format).toBe("webm");
74+
expect((caught as Error).message).toMatch(/webm/);
75+
expect((caught as Error).message).toMatch(/in-process|executeRenderJob/);
76+
});
77+
78+
it('rejects HDR mp4 (`hdrMode === "force-hdr"`)', () => {
79+
let caught: unknown;
80+
try {
81+
rejectUnsupportedDistributedFormat({
82+
format: "mp4",
83+
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
84+
});
85+
} catch (err) {
86+
caught = err;
87+
}
88+
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
89+
expect((caught as FormatNotSupportedInDistributedError).code).toBe(
90+
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
91+
);
92+
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
93+
expect((caught as Error).message).toMatch(/HDR/);
94+
});
95+
});
96+
97+
describe("plan() banlist (end-to-end)", () => {
98+
it("throws on webm and does not create the planDir", async () => {
99+
const planDir = join(runRoot, "plandir-webm-bans");
100+
// Don't pre-create planDir — plan() shouldn't create it on the throw path.
101+
let caught: unknown;
102+
try {
103+
await plan(
104+
projectDir,
105+
{
106+
format: "webm" as DistributedRenderConfig["format"],
107+
fps: 30,
108+
width: 320,
109+
height: 240,
110+
},
111+
planDir,
112+
);
113+
} catch (err) {
114+
caught = err;
115+
}
116+
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
117+
expect((caught as FormatNotSupportedInDistributedError).format).toBe("webm");
118+
expect(existsSync(planDir)).toBe(false);
119+
});
120+
121+
it("throws on HDR mp4 and does not create the planDir", async () => {
122+
const planDir = join(runRoot, "plandir-hdr-bans");
123+
let caught: unknown;
124+
try {
125+
await plan(
126+
projectDir,
127+
{
128+
format: "mp4",
129+
fps: 30,
130+
width: 320,
131+
height: 240,
132+
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
133+
},
134+
planDir,
135+
);
136+
} catch (err) {
137+
caught = err;
138+
}
139+
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
140+
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
141+
expect(existsSync(planDir)).toBe(false);
142+
});
143+
});

0 commit comments

Comments
 (0)