mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
co-authored by
Claude Opus 5.5
parent
8147f7b940
commit
ab8a4da53f
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user