feat(producer): apply the studio encoder options to the render server (#5550)

Use parseEncoderOptions from @hyperframes/studio-server in /render and
/render/stream so the producer server takes the same crf, videoBitrate,
videoFrameFormat and hdrMode as the studio render route, with the same
rules and messages. crf and videoBitrate are forwarded to the render job
and are ignored for mov; a bad value is a 400 (an SSE error event on the
stream route). The studio-server package gains an encoder-options subpath
export so the producer imports the helper instead of copying it.

An hdrMode that disagrees with outputDynamicRange is still rejected, as
before, so there is no precedence to resolve between the two.

— Jerrai
This commit is contained in:
Jerrai
2026-10-13 22:10:26 +00:00
committed by GitHub
parent 401354cd8f
commit 5de2712ad5
6 changed files with 219 additions and 4 deletions
@@ -0,0 +1,160 @@
import { Hono } from "hono";
import { beforeEach, describe, expect, it, vi } from "vitest";
const capturedRenderConfigs = vi.hoisted(() => new Array<Record<string, unknown>>());
vi.mock("./services/renderOrchestrator.js", () => {
class RenderCancelledError extends Error {}
return {
RenderCancelledError,
createRenderJob: (config: Record<string, unknown>) => {
capturedRenderConfigs.push(config);
return {
config,
progress: 0,
currentStage: "queued",
framesRendered: 0,
totalFrames: 0,
warnings: [],
};
},
executeRenderJob: async (job: Record<string, unknown>) => {
job.outcome = "completed";
job.currentStage = "complete";
},
};
});
import { createRenderHandlers } from "./server.js";
function createApp(): Hono {
const app = new Hono();
const handlers = createRenderHandlers({
getRequestId: () => "encoder-options-test",
maxConcurrentRenders: 1,
});
app.post("/render", handlers.render);
app.post("/render/stream", handlers.renderStream);
return app;
}
async function requestRender(
path: "/render" | "/render/stream",
overrides: Record<string, unknown>,
) {
const response = await createApp().request(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ html: "<html><body></body></html>", ...overrides }),
});
return { status: response.status, text: await response.text() };
}
const ROUTES = ["/render", "/render/stream"] as const;
describe.each(ROUTES)("POST %s — encoder options", (path) => {
beforeEach(() => capturedRenderConfigs.splice(0));
async function expectRejected(body: Record<string, unknown>, message: string) {
const response = await requestRender(path, body);
expect(response.text).toContain(message);
expect(capturedRenderConfigs).toHaveLength(0);
}
it("sends no encoder overrides when none are requested", async () => {
const response = await requestRender(path, { quality: "high" });
expect(response.status).toBe(200);
expect(capturedRenderConfigs).toHaveLength(1);
for (const field of ["crf", "videoBitrate", "videoFrameFormat"]) {
expect(capturedRenderConfigs[0]).not.toHaveProperty(field);
}
});
it.each([
["mp4", 0],
["mp4", 12],
["mp4", 51],
["webm", 63],
] as const)("forwards %s crf %d", async (format, crf) => {
const response = await requestRender(path, { format, crf, quality: "high" });
expect(response.status).toBe(200);
expect(capturedRenderConfigs[0]?.crf).toBe(crf);
expect(capturedRenderConfigs[0]?.quality).toBe("high");
});
it.each([
["mp4", "12", "0 to 51 for mp4"],
["mp4", 12.5, "0 to 51 for mp4"],
["mp4", -1, "0 to 51 for mp4"],
["mp4", 52, "0 to 51 for mp4"],
["webm", 64, "0 to 63 for webm"],
] as const)("rejects %s crf %j", async (format, crf, range) => {
await expectRejected({ format, crf }, `crf must be an integer from ${range}`);
});
it("defaults the crf range to mp4 when no format is given", async () => {
await expectRejected({ crf: 52 }, "crf must be an integer from 0 to 51 for mp4");
});
it("ignores crf and videoBitrate for mov, which has no rate control", async () => {
const response = await requestRender(path, { format: "mov", crf: 999, videoBitrate: "10M" });
expect(response.status).toBe(200);
expect(capturedRenderConfigs).toHaveLength(1);
expect(capturedRenderConfigs[0]).not.toHaveProperty("crf");
expect(capturedRenderConfigs[0]).not.toHaveProperty("videoBitrate");
});
it.each(["10M", "2500k", "8000000"])("forwards videoBitrate %s", async (videoBitrate) => {
const response = await requestRender(path, { videoBitrate });
expect(response.status).toBe(200);
expect(capturedRenderConfigs[0]?.videoBitrate).toBe(videoBitrate);
expect(capturedRenderConfigs[0]).not.toHaveProperty("crf");
});
it.each(["", "10 M", "-5M", "10M; rm", 10])("rejects videoBitrate %j", async (videoBitrate) => {
await expectRejected({ videoBitrate }, "videoBitrate must look like");
});
it("rejects crf and videoBitrate together", async () => {
await expectRejected({ crf: 12, videoBitrate: "10M" }, "use either crf or videoBitrate");
});
it("forwards videoFrameFormat", async () => {
await requestRender(path, { videoFrameFormat: "png" });
expect(capturedRenderConfigs[0]?.videoFrameFormat).toBe("png");
});
it("rejects an unknown videoFrameFormat", async () => {
await expectRejected({ videoFrameFormat: "webp" }, "videoFrameFormat must be one of");
});
it("forwards hdrMode", async () => {
await requestRender(path, { hdrMode: "force-sdr" });
expect(capturedRenderConfigs[0]?.hdrMode).toBe("force-sdr");
});
it("rejects an unknown hdrMode", async () => {
await expectRejected({ hdrMode: "on" }, "hdrMode must be one of");
});
it("accepts hdrMode alongside the matching outputDynamicRange", async () => {
const response = await requestRender(path, { hdrMode: "force-sdr", outputDynamicRange: "sdr" });
expect(response.status).toBe(200);
expect(capturedRenderConfigs[0]?.hdrMode).toBe("force-sdr");
});
it("rejects hdrMode that conflicts with outputDynamicRange rather than picking a winner", async () => {
await expectRejected(
{ hdrMode: "force-hdr", outputDynamicRange: "sdr" },
"outputDynamicRange and legacy hdrMode must describe the same output policy",
);
});
});
+31
View File
@@ -38,6 +38,25 @@ describe("parseRenderOptions — outputResolution", () => {
});
});
describe("parseRenderOptions — encoder options", () => {
it("forwards crf, videoBitrate and videoFrameFormat from a valid body", () => {
expect(parseRenderOptions({ crf: 12, format: "webm", videoFrameFormat: "png" })).toMatchObject({
crf: 12,
videoFrameFormat: "png",
});
expect(parseRenderOptions({ videoBitrate: "10M" }).videoBitrate).toBe("10M");
});
it("drops every encoder override when any of them is invalid, and for mov rate control", () => {
expect(parseRenderOptions({ crf: 52 }).crf).toBeUndefined();
expect(parseRenderOptions({ crf: 12, videoBitrate: "10M" })).toMatchObject({
crf: undefined,
videoBitrate: undefined,
});
expect(parseRenderOptions({ crf: 12, format: "mov" }).crf).toBeUndefined();
});
});
describe("parseRenderOptions — render strictness", () => {
it("preserves best-effort compatibility and requires an explicit strict opt-in", () => {
expect(parseRenderOptions({}).strictness).toBe("best-effort");
@@ -105,6 +124,18 @@ describe("prepareRenderBody — validation", () => {
);
});
it("rejects an out-of-range crf with the studio-server message", async () => {
const result = await prepareRenderBody({ format: "webm", crf: 64, html: "<html></html>" });
expect((result as { error: string }).error).toBe(
"crf must be an integer from 0 to 63 for webm",
);
});
it("rejects crf together with videoBitrate", async () => {
const result = await prepareRenderBody({ crf: 12, videoBitrate: "10M", html: "<html></html>" });
expect((result as { error: string }).error).toBe("use either crf or videoBitrate, not both");
});
it("rejects conflicting canonical and legacy policies", async () => {
const result = await prepareRenderBody({
outputDynamicRange: "sdr",
+11 -4
View File
@@ -39,7 +39,6 @@ import {
} from "./services/renderOrchestrator.js";
import { prepareHyperframeLintBody, runHyperframeLint } from "./services/hyperframeLint.js";
import { startHealthWorker, type HealthWorkerHandle } from "./services/healthWorker.js";
import { isVideoFrameFormat } from "@hyperframes/engine";
import { resolveRenderPaths } from "./utils/paths.js";
import { defaultLogger, type ProducerLogger } from "./logger.js";
import { Semaphore } from "./utils/semaphore.js";
@@ -49,6 +48,7 @@ import {
isAspectAgnosticResolutionAlias,
type CanvasResolution,
} from "@hyperframes/core";
import { parseEncoderOptions } from "@hyperframes/studio-server/encoder-options";
import { createRenderRequest, renderConfigFromRequest } from "./renderRequest.js";
// ---------------------------------------------------------------------------
@@ -86,6 +86,8 @@ interface RenderInput {
videoFrameFormat?: RenderConfig["videoFrameFormat"];
outputDynamicRange?: "auto" | "hdr" | "sdr";
workers?: number;
crf?: number;
videoBitrate?: string;
useGpu: boolean;
debug: boolean;
strictness: RenderConfig["strictness"];
@@ -233,9 +235,8 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
const outputDynamicRange =
parseServerOutputDynamicRange(body.outputDynamicRange) ??
fromRenderHdrMode(parseLegacyServerHdrMode(body.hdrMode));
const videoFrameFormat = isVideoFrameFormat(body.videoFrameFormat)
? body.videoFrameFormat
: undefined;
const encoder = parseEncoderOptions(body, format ?? "mp4");
const { crf, videoBitrate, videoFrameFormat } = encoder.ok ? encoder.options : {};
const { variables, outputResolution, outputResolutionAspectAgnostic } =
parseRenderOverrides(body);
@@ -245,6 +246,8 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
fps,
quality,
workers,
crf,
videoBitrate,
useGpu,
debug,
strictness,
@@ -304,6 +307,8 @@ function buildRenderJobConfig(input: RenderInput, outputPath: string, log: Produ
quality: input.quality,
format: input.format ?? "mp4",
workers: input.workers,
crf: input.crf,
videoBitrate: input.videoBitrate,
useGpu: input.useGpu,
debug: input.debug,
strictness: input.strictness,
@@ -342,6 +347,8 @@ function resolvePreparedRenderOutput(
* acceptable (including when the fields are simply absent).
*/
function validateRenderOverrides(body: Record<string, unknown>): string | undefined {
const encoder = parseEncoderOptions(body, parseServerFormat(body.format) ?? "mp4");
if (!encoder.ok) return encoder.error;
if (body.variables !== undefined && !isPlainObject(body.variables)) {
return 'variables must be a JSON object keyed by variable id (e.g. {"title":"Hello"})';
}
@@ -91,6 +91,12 @@
"runtime": "./dist/helpers/mediaProxyPreview.js",
"types": "./dist/helpers/mediaProxyPreview.d.ts",
"environments": ["bun", "node"]
},
"./encoder-options": {
"source": "./src/helpers/encoderOptions.ts",
"runtime": "./dist/helpers/encoderOptions.js",
"types": "./dist/helpers/encoderOptions.d.ts",
"environments": ["bun", "node"]
}
}
}
+10
View File
@@ -98,6 +98,12 @@
"node": "./dist/helpers/mediaProxyPreview.js",
"import": "./src/helpers/mediaProxyPreview.ts",
"types": "./src/helpers/mediaProxyPreview.ts"
},
"./encoder-options": {
"bun": "./src/helpers/encoderOptions.ts",
"node": "./dist/helpers/encoderOptions.js",
"import": "./src/helpers/encoderOptions.ts",
"types": "./src/helpers/encoderOptions.ts"
}
},
"publishConfig": {
@@ -159,6 +165,10 @@
"./media-proxy-preview": {
"import": "./dist/helpers/mediaProxyPreview.js",
"types": "./dist/helpers/mediaProxyPreview.d.ts"
},
"./encoder-options": {
"import": "./dist/helpers/encoderOptions.js",
"types": "./dist/helpers/encoderOptions.d.ts"
}
},
"main": "./dist/index.js",
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
"helpers/sourceMutation": "src/helpers/sourceMutation.ts",
"helpers/safePath": "src/helpers/safePath.ts",
"helpers/hfIdPersist": "src/helpers/hfIdPersist.ts",
"helpers/encoderOptions": "src/helpers/encoderOptions.ts",
"history/index": "src/history/index.ts",
},
format: ["esm"],