feat(studio-server): accept crf, videoBitrate, videoFrameFormat and hdrMode on the render route (#5549)

* feat(studio-server): accept a crf override on the render route

The Studio render route only forwarded draft/standard/high, so a Studio or
desktop export could not ask for a specific CRF the way `hyperframes render
--crf` can. Film-grain finishing passes need CRF 12 to keep the grain.

The route now takes an optional integer `crf` (0-51 for mp4, 0-63 for webm),
rejects an out-of-range value with 400 instead of dropping it, ignores it for
mov, and both Studio adapters pass it to the render job.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(studio-server): keep the crf change free of new comment lines

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(studio-server): accept videoBitrate, videoFrameFormat and hdrMode on the render route

Same options `hyperframes render` already takes (--video-bitrate,
--video-frame-format, --sdr/--hdr), so Studio callers are not limited to
the quality presets. crf and videoBitrate are an either/or, matching the CLI;
an invalid value is a 400. Parsing moves to helpers/encoderOptions.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-10-13 20:09:58 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8147f7b940
commit ab8a4da53f
7 changed files with 198 additions and 1 deletions
+4
View File
@@ -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;
@@ -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);
});
});
@@ -0,0 +1,74 @@
const CRF_MAX_BY_FORMAT: Record<string, number> = { 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<T extends string>(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 };
}
@@ -324,6 +324,69 @@ describe("POST /projects/:id/render — fps wire format", () => {
});
});
describe("POST /projects/:id/render — crf", () => {
async function post(body: Record<string, unknown>) {
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[] = [];
+6 -1
View File
@@ -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<string>(VALID_CANVAS_RESOLUTIONS);
const RENDER_MIME: Record<string, string> = {
@@ -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,
+4
View File
@@ -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
+4
View File
@@ -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 }) => {