diff --git a/packages/producer/src/server.encoderOptions.test.ts b/packages/producer/src/server.encoderOptions.test.ts new file mode 100644 index 0000000000..86064c0f3b --- /dev/null +++ b/packages/producer/src/server.encoderOptions.test.ts @@ -0,0 +1,160 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const capturedRenderConfigs = vi.hoisted(() => new Array>()); + +vi.mock("./services/renderOrchestrator.js", () => { + class RenderCancelledError extends Error {} + + return { + RenderCancelledError, + createRenderJob: (config: Record) => { + capturedRenderConfigs.push(config); + return { + config, + progress: 0, + currentStage: "queued", + framesRendered: 0, + totalFrames: 0, + warnings: [], + }; + }, + executeRenderJob: async (job: Record) => { + 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, +) { + const response = await createApp().request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ 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, 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", + ); + }); +}); diff --git a/packages/producer/src/server.test.ts b/packages/producer/src/server.test.ts index d6509d8cf4..942853c231 100644 --- a/packages/producer/src/server.test.ts +++ b/packages/producer/src/server.test.ts @@ -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: "" }); + 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: "" }); + 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", diff --git a/packages/producer/src/server.ts b/packages/producer/src/server.ts index 2a5fd770f2..b56f3fa24c 100644 --- a/packages/producer/src/server.ts +++ b/packages/producer/src/server.ts @@ -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): Omit): Omit): 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"})'; } diff --git a/packages/studio-server/package-subpaths.json b/packages/studio-server/package-subpaths.json index 287eda0b10..5a92364153 100644 --- a/packages/studio-server/package-subpaths.json +++ b/packages/studio-server/package-subpaths.json @@ -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"] } } } diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json index 00901e4849..e0d70196dc 100644 --- a/packages/studio-server/package.json +++ b/packages/studio-server/package.json @@ -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", diff --git a/packages/studio-server/tsup.config.ts b/packages/studio-server/tsup.config.ts index bdad8ff3f1..42051312b1 100644 --- a/packages/studio-server/tsup.config.ts +++ b/packages/studio-server/tsup.config.ts @@ -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"],