diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index deb259767c..625cc0ddb3 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -627,6 +627,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { ...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}), ...(opts.composition ? { entryFile: opts.composition } : {}), ...(opts.variables ? { variables: opts.variables } : {}), + ...(opts.crf !== undefined ? { crf: opts.crf } : {}), + ...(opts.videoBitrate ? { videoBitrate: opts.videoBitrate } : {}), + ...(opts.videoFrameFormat ? { videoFrameFormat: opts.videoFrameFormat } : {}), + ...(opts.hdrMode ? { hdrMode: opts.hdrMode } : {}), ...(opts.useGpu ? { useGpu: true } : {}), }); renderJob = job; diff --git a/packages/studio-server/src/helpers/encoderOptions.test.ts b/packages/studio-server/src/helpers/encoderOptions.test.ts new file mode 100644 index 0000000000..1c0b7b7960 --- /dev/null +++ b/packages/studio-server/src/helpers/encoderOptions.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { parseEncoderOptions } from "./encoderOptions"; + +describe("parseEncoderOptions", () => { + it("returns no overrides for an empty request", () => { + expect(parseEncoderOptions({}, "mp4")).toEqual({ ok: true, options: {} }); + }); + + it("accepts a bitrate for mp4 and webm and ignores rate control for mov", () => { + expect(parseEncoderOptions({ videoBitrate: "10M" }, "mp4")).toEqual({ + ok: true, + options: { videoBitrate: "10M" }, + }); + expect(parseEncoderOptions({ videoBitrate: "2500k" }, "webm").ok).toBe(true); + expect(parseEncoderOptions({ videoBitrate: "10M", crf: 12 }, "mov")).toEqual({ + ok: true, + options: {}, + }); + }); + + it("rejects crf and videoBitrate together", () => { + expect(parseEncoderOptions({ crf: 12, videoBitrate: "10M" }, "mp4")).toEqual({ + ok: false, + error: "use either crf or videoBitrate, not both", + }); + }); + + it.each(["", "10 M", "-5M", "10M; rm", 10])("rejects bitrate %j", (videoBitrate) => { + expect(parseEncoderOptions({ videoBitrate }, "mp4").ok).toBe(false); + }); + + it("passes frame format and HDR mode through for every format", () => { + expect(parseEncoderOptions({ videoFrameFormat: "png", hdrMode: "force-sdr" }, "mov")).toEqual({ + ok: true, + options: { videoFrameFormat: "png", hdrMode: "force-sdr" }, + }); + }); + + it("rejects an unknown frame format or HDR mode", () => { + expect(parseEncoderOptions({ videoFrameFormat: "webp" }, "mp4").ok).toBe(false); + expect(parseEncoderOptions({ hdrMode: "on" }, "mp4").ok).toBe(false); + }); +}); diff --git a/packages/studio-server/src/helpers/encoderOptions.ts b/packages/studio-server/src/helpers/encoderOptions.ts new file mode 100644 index 0000000000..c99c9b1efa --- /dev/null +++ b/packages/studio-server/src/helpers/encoderOptions.ts @@ -0,0 +1,74 @@ +const CRF_MAX_BY_FORMAT: Record = { mp4: 51, webm: 63 }; +const VIDEO_BITRATE = /^\d+(\.\d+)?[kKmMgG]?$/; +const VIDEO_FRAME_FORMATS = ["auto", "jpg", "png"] as const; +const HDR_MODES = ["auto", "force-hdr", "force-sdr"] as const; + +interface EncoderOptions { + crf?: number; + videoBitrate?: string; + videoFrameFormat?: (typeof VIDEO_FRAME_FORMATS)[number]; + hdrMode?: (typeof HDR_MODES)[number]; +} + +export interface EncoderOptionsBody { + crf?: unknown; + videoBitrate?: unknown; + videoFrameFormat?: unknown; + hdrMode?: unknown; +} + +type Parsed = { ok: true; options: EncoderOptions } | { ok: false; error: string }; + +const isSet = (value: unknown) => value !== undefined && value !== null; + +function oneOf(value: unknown, allowed: readonly T[]): value is T { + return typeof value === "string" && (allowed as readonly string[]).includes(value); +} + +function parseCrf(crf: unknown, format: string, max: number): Parsed { + if (typeof crf !== "number" || !Number.isInteger(crf) || crf < 0 || crf > max) { + return { ok: false, error: `crf must be an integer from 0 to ${max} for ${format}` }; + } + return { ok: true, options: { crf } }; +} + +function parseVideoBitrate(videoBitrate: unknown): Parsed { + if (typeof videoBitrate !== "string" || !VIDEO_BITRATE.test(videoBitrate)) { + return { ok: false, error: 'videoBitrate must look like "10M", "2500k" or "8000000"' }; + } + return { ok: true, options: { videoBitrate } }; +} + +function parseRateControl(body: EncoderOptionsBody, format: string): Parsed { + const max = CRF_MAX_BY_FORMAT[format]; + if (max === undefined) return { ok: true, options: {} }; + if (isSet(body.crf) && isSet(body.videoBitrate)) { + return { ok: false, error: "use either crf or videoBitrate, not both" }; + } + if (isSet(body.crf)) return parseCrf(body.crf, format, max); + if (isSet(body.videoBitrate)) return parseVideoBitrate(body.videoBitrate); + return { ok: true, options: {} }; +} + +/** Explicit encoder overrides from a render request. MOV (ProRes) takes no rate control. */ +export function parseEncoderOptions(body: EncoderOptionsBody, format: string): Parsed { + const rate = parseRateControl(body, format); + if (!rate.ok) return rate; + const options: EncoderOptions = { ...rate.options }; + if (isSet(body.videoFrameFormat)) { + if (!oneOf(body.videoFrameFormat, VIDEO_FRAME_FORMATS)) { + return { + ok: false, + error: `videoFrameFormat must be one of ${VIDEO_FRAME_FORMATS.join(", ")}`, + }; + } + options.videoFrameFormat = body.videoFrameFormat; + } + if (isSet(body.hdrMode)) { + if (!oneOf(body.hdrMode, HDR_MODES)) { + return { ok: false, error: `hdrMode must be one of ${HDR_MODES.join(", ")}` }; + } + options.hdrMode = body.hdrMode; + } + return { ok: true, options }; +} diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index 893b1f83a2..9db0ee5499 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -324,6 +324,69 @@ describe("POST /projects/:id/render — fps wire format", () => { }); }); +describe("POST /projects/:id/render — crf", () => { + async function post(body: Record) { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ fps: 30, quality: "standard", ...body }), + }); + return { res, opts: spy.mock.calls[0]?.[0] }; + } finally { + cleanup(); + } + } + + it("forwards a valid crf for mp4 and webm", async () => { + expect((await post({ format: "mp4", crf: 12 })).opts.crf).toBe(12); + expect((await post({ format: "webm", crf: 63 })).opts.crf).toBe(63); + }); + + it("omits crf when the request does not specify one", async () => { + const { res, opts } = await post({ format: "mp4" }); + expect(res.status).toBe(200); + expect(opts).not.toHaveProperty("crf"); + }); + + it("ignores crf for mov, which has no CRF", async () => { + const { res, opts } = await post({ format: "mov", crf: 12 }); + expect(res.status).toBe(200); + expect(opts).not.toHaveProperty("crf"); + }); + + it("forwards the other encoder options and refuses crf with a bitrate", async () => { + const { opts } = await post({ + format: "mp4", + videoBitrate: "10M", + videoFrameFormat: "png", + hdrMode: "force-sdr", + }); + expect(opts).toMatchObject({ + videoBitrate: "10M", + videoFrameFormat: "png", + hdrMode: "force-sdr", + }); + const both = await post({ format: "mp4", crf: 12, videoBitrate: "10M" }); + expect(both.res.status).toBe(400); + expect(both.opts).toBeUndefined(); + }); + + it.each([ + ["mp4", 52], + ["mp4", -1], + ["mp4", 12.5], + ["mp4", "12"], + ["webm", 64], + ])("rejects %s crf %j with 400 and does not start a render", async (format, crf) => { + const { res, opts } = await post({ format, crf }); + expect(res.status).toBe(400); + expect(opts).toBeUndefined(); + }); +}); + describe("POST /projects/:id/render — composition path safety", () => { const tmpDirs: string[] = []; diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 3823eecd09..c082db3c5b 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -13,6 +13,7 @@ import { } from "../helpers/safePath.js"; import { projectDirMissing } from "../helpers/projectDirMissing.js"; import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js"; +import { parseEncoderOptions, type EncoderOptionsBody } from "../helpers/encoderOptions.js"; const VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS); const RENDER_MIME: Record = { @@ -85,7 +86,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); - const body = (await c.req.json().catch(() => ({}))) as { + const body = (await c.req.json().catch(() => ({}))) as EncoderOptionsBody & { // Polymorphic per design note in core.types.Fps: // number → integer fps (e.g. 30) // string → rational fps (e.g. "30000/1001" for NTSC 29.97) @@ -146,6 +147,9 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void variables = body.variables; } + const encoder = parseEncoderOptions(body, format); + if (!encoder.ok) return c.json({ error: encoder.error }, 400); + const now = new Date(); const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`; const rendersDir = adapter.rendersDir(project); @@ -163,6 +167,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void outputResolution, composition, variables, + ...encoder.options, useGpu: body.gpu === true && format === "mp4", distinctId: typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined, diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index 8aa2e602db..0f0782ae40 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -171,6 +171,10 @@ export interface StudioApiAdapter { */ fps: import("@hyperframes/core").Fps; quality: string; + crf?: number; + videoBitrate?: string; + videoFrameFormat?: "auto" | "jpg" | "png"; + hdrMode?: "auto" | "force-hdr" | "force-sdr"; jobId: string; /** * The triggering browser profile has telemetry disabled (localStorage diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index b37eb7221f..340bacebb9 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -351,6 +351,10 @@ export function createViteAdapter( outputResolution: opts.outputResolution, ...(opts.composition ? { entryFile: opts.composition } : {}), ...(opts.variables ? { variables: opts.variables } : {}), + ...(opts.crf !== undefined ? { crf: opts.crf } : {}), + ...(opts.videoBitrate ? { videoBitrate: opts.videoBitrate } : {}), + ...(opts.videoFrameFormat ? { videoFrameFormat: opts.videoFrameFormat } : {}), + ...(opts.hdrMode ? { hdrMode: opts.hdrMode } : {}), ...(opts.useGpu ? { useGpu: true } : {}), }); const onProgress = (j: { progress: number; currentStage?: string }) => {