mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
feat(producer): refuse distributed-unsupported formats early
This commit is contained in:
@@ -171,6 +171,42 @@ export class PlanTooLargeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-retryable error code raised when `plan()` is asked for an output
|
||||
* format that distributed mode doesn't support (webm, HDR mp4). The same
|
||||
* config would fail on every retry, so the failure must not auto-retry.
|
||||
*/
|
||||
export const FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED";
|
||||
|
||||
/**
|
||||
* Typed error raised by `plan()` for outputs that distributed mode
|
||||
* refuses to ship.
|
||||
*
|
||||
* - webm — VP9 + matroska concat-copy is fragile across libvpx-vp9
|
||||
* builds, and the chunked pipeline can't guarantee bit-identical
|
||||
* concat output across worker versions.
|
||||
* - mp4 + HDR (PQ / HLG) — chunked HDR pre-extract + HDR signaling
|
||||
* re-apply on the assembled file is not implemented yet.
|
||||
*
|
||||
* The in-process renderer (`executeRenderJob`) handles both natively.
|
||||
*/
|
||||
export class FormatNotSupportedInDistributedError extends Error {
|
||||
readonly code: typeof FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED = FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED;
|
||||
readonly format: string;
|
||||
readonly reason: string;
|
||||
constructor(format: string, reason: string) {
|
||||
super(
|
||||
`[plan] format ${JSON.stringify(format)} is not supported in distributed mode: ${reason}. ` +
|
||||
`Render with the in-process renderer (\`executeRenderJob\`) — it has full format ` +
|
||||
`support — or pick a distributed-supported format: mp4 SDR, mov ProRes 4444, or ` +
|
||||
`png-sequence.`,
|
||||
);
|
||||
this.name = "FormatNotSupportedInDistributedError";
|
||||
this.format = format;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
@@ -178,6 +214,40 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject formats the distributed pipeline cannot ship (webm + HDR mp4).
|
||||
* Throws {@link FormatNotSupportedInDistributedError} with a message
|
||||
* naming the rejected format. Runs at the very top of `plan()` so a
|
||||
* banned input never produces a partial planDir.
|
||||
*
|
||||
* Exported so adapters can call the same gate at their own input layer
|
||||
* (Step Functions input validation, Temporal workflow start) before the
|
||||
* activity even runs — the resulting non-retryable error then matches
|
||||
* what `plan()` would have thrown.
|
||||
*/
|
||||
export function rejectUnsupportedDistributedFormat(
|
||||
config: Pick<DistributedRenderConfig, "format" | "hdrMode">,
|
||||
): void {
|
||||
// The TypeScript type for `DistributedRenderConfig.format` already
|
||||
// excludes webm, but a JS caller (or a caller that built the config
|
||||
// dynamically from JSON) can still pass it. Belt-and-suspenders runtime
|
||||
// check at the gate.
|
||||
if ((config.format as string) === "webm") {
|
||||
throw new FormatNotSupportedInDistributedError(
|
||||
"webm",
|
||||
"VP9 + matroska concat-copy is fragile across libvpx-vp9 builds, so chunked output " +
|
||||
"can't be guaranteed byte-identical across workers",
|
||||
);
|
||||
}
|
||||
if ((config.hdrMode as string) === "force-hdr") {
|
||||
throw new FormatNotSupportedInDistributedError(
|
||||
"mp4-hdr",
|
||||
"HDR (PQ / HLG) requires per-source HDR pre-extract + HDR signaling re-apply on the " +
|
||||
"assembled file; neither is implemented for the distributed pipeline",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `<planDir>/` depth-first and sum all regular file sizes. Symlinks
|
||||
* are not traversed — they shouldn't appear inside a planDir to begin with
|
||||
@@ -373,9 +443,11 @@ export async function plan(
|
||||
config: DistributedRenderConfig,
|
||||
planDir: string,
|
||||
): Promise<PlanResult> {
|
||||
// ── Plan-time validation ──
|
||||
// Rejections here surface as typed `PlanValidationError`s with non-retryable
|
||||
// codes so workflow adapters don't waste retry budget on banned configs.
|
||||
// Plan-time validation. Rejections here surface as typed errors with
|
||||
// non-retryable codes so workflow adapters don't waste retry budget on
|
||||
// banned configs. Runs BEFORE any directory creation so a banned input
|
||||
// never produces a partial planDir.
|
||||
rejectUnsupportedDistributedFormat(config);
|
||||
validateNoGpuEncode({
|
||||
useGpu: false,
|
||||
browserGpuMode: "software",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Unit tests for the distributed format banlist.
|
||||
*
|
||||
* Two formats `plan()` refuses up front:
|
||||
* - webm — VP9 + matroska concat-copy is fragile across libvpx-vp9 builds.
|
||||
* - mp4 + HDR (`hdrMode === "force-hdr"`) — chunked HDR pre-extract +
|
||||
* HDR signaling re-apply on the assembled file is not implemented.
|
||||
*
|
||||
* The banlist must trip BEFORE any other work runs (file server, browser,
|
||||
* ffprobe) — otherwise a banned config can leak a partial planDir on disk.
|
||||
* Each case asserts `existsSync(planDir)` is `false` after the throw to
|
||||
* pin the early-exit contract.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
|
||||
FormatNotSupportedInDistributedError,
|
||||
plan,
|
||||
rejectUnsupportedDistributedFormat,
|
||||
type DistributedRenderConfig,
|
||||
} from "./plan.js";
|
||||
|
||||
const FIXTURE_HTML = `<!doctype html>
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">hi</div>
|
||||
</body></html>`;
|
||||
|
||||
let runRoot: string;
|
||||
let projectDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-format-ban-"));
|
||||
projectDir = join(runRoot, "project");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("rejectUnsupportedDistributedFormat (pure)", () => {
|
||||
it("accepts the v1-supported formats (mp4 / mov / png-sequence)", () => {
|
||||
expect(() => rejectUnsupportedDistributedFormat({ format: "mp4" })).not.toThrow();
|
||||
expect(() => rejectUnsupportedDistributedFormat({ format: "mov" })).not.toThrow();
|
||||
expect(() => rejectUnsupportedDistributedFormat({ format: "png-sequence" })).not.toThrow();
|
||||
expect(() =>
|
||||
rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "auto" }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
rejectUnsupportedDistributedFormat({ format: "mp4", hdrMode: "force-sdr" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects webm with FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
// Cast forces the runtime check even though the type narrows webm out.
|
||||
rejectUnsupportedDistributedFormat({
|
||||
format: "webm" as DistributedRenderConfig["format"],
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
|
||||
expect((caught as FormatNotSupportedInDistributedError).code).toBe(
|
||||
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
|
||||
);
|
||||
expect((caught as FormatNotSupportedInDistributedError).format).toBe("webm");
|
||||
expect((caught as Error).message).toMatch(/webm/);
|
||||
expect((caught as Error).message).toMatch(/in-process|executeRenderJob/);
|
||||
});
|
||||
|
||||
it('rejects HDR mp4 (`hdrMode === "force-hdr"`)', () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
rejectUnsupportedDistributedFormat({
|
||||
format: "mp4",
|
||||
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
|
||||
expect((caught as FormatNotSupportedInDistributedError).code).toBe(
|
||||
FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED,
|
||||
);
|
||||
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
|
||||
expect((caught as Error).message).toMatch(/HDR/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan() banlist (end-to-end)", () => {
|
||||
it("throws on webm and does not create the planDir", async () => {
|
||||
const planDir = join(runRoot, "plandir-webm-bans");
|
||||
// Don't pre-create planDir — plan() shouldn't create it on the throw path.
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
{
|
||||
format: "webm" as DistributedRenderConfig["format"],
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
planDir,
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
|
||||
expect((caught as FormatNotSupportedInDistributedError).format).toBe("webm");
|
||||
expect(existsSync(planDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("throws on HDR mp4 and does not create the planDir", async () => {
|
||||
const planDir = join(runRoot, "plandir-hdr-bans");
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plan(
|
||||
projectDir,
|
||||
{
|
||||
format: "mp4",
|
||||
fps: 30,
|
||||
width: 320,
|
||||
height: 240,
|
||||
hdrMode: "force-hdr" as DistributedRenderConfig["hdrMode"],
|
||||
},
|
||||
planDir,
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(FormatNotSupportedInDistributedError);
|
||||
expect((caught as FormatNotSupportedInDistributedError).format).toBe("mp4-hdr");
|
||||
expect(existsSync(planDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user