feat(render): add CRF/bitrate controls and improve default quality (#292)

Raise default encoding quality to visually lossless at 1080p (CRF 18)
and expose fine-grained encoding controls for power users.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-04-16 12:02:24 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 2718de8776
commit ebc12f7dc9
8 changed files with 163 additions and 29 deletions
+62 -1
View File
@@ -7,6 +7,8 @@ export const examples: Example[] = [
["Render transparent overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
["Custom CRF for maximum quality", "hyperframes render --crf 15 --output pristine.mp4"],
["Target bitrate encoding", "hyperframes render --video-bitrate 10M --output hq.mp4"],
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
];
@@ -79,6 +81,20 @@ export default defineCommand({
description: "Use Docker for deterministic render",
default: false,
},
crf: {
type: "string",
description:
"CRF (Constant Rate Factor) for the video encoder. " +
"Lower = higher quality / larger file. Range: 051 for H.264. " +
"Overrides the quality preset CRF. Cannot be used with --video-bitrate.",
},
"video-bitrate": {
type: "string",
description:
"Target video bitrate (e.g. '10M', '5000k'). " +
"Uses bitrate-based encoding instead of CRF. " +
"Cannot be used with --crf.",
},
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
quiet: {
type: "boolean",
@@ -128,6 +144,36 @@ export default defineCommand({
}
const format = formatRaw as "mp4" | "webm" | "mov";
// ── Validate CRF / video-bitrate ────────────────────────────────────
let crf: number | undefined;
let videoBitrate: string | undefined;
if (args.crf != null && args["video-bitrate"] != null) {
errorBox(
"Conflicting options",
"--crf and --video-bitrate cannot be used together. Choose one.",
);
process.exit(1);
}
if (args.crf != null) {
const parsed = parseInt(args.crf, 10);
if (isNaN(parsed) || parsed < 0 || parsed > 51) {
errorBox("Invalid CRF", `Got "${args.crf}". Must be a number between 0 and 51.`);
process.exit(1);
}
crf = parsed;
}
if (args["video-bitrate"] != null) {
const raw = args["video-bitrate"];
if (!/^\d+(\.\d+)?[kKM]$/.test(raw)) {
errorBox(
"Invalid video bitrate",
`Got "${raw}". Must be a number followed by k, K, or M (e.g. "10M", "5000k", "1.5M").`,
);
process.exit(1);
}
videoBitrate = raw;
}
// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
if (args.workers != null && args.workers !== "auto") {
@@ -185,7 +231,12 @@ export default defineCommand({
c.accent(project.name) +
c.dim(" \u2192 " + outputPath),
);
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel));
const encodeLabel = videoBitrate
? `bitrate ${videoBitrate}`
: crf != null
? `crf ${crf}`
: quality;
console.log(c.dim(" " + fps + "fps \u00B7 " + encodeLabel + " \u00B7 " + workerLabel));
console.log("");
}
@@ -266,6 +317,8 @@ export default defineCommand({
workers: workerCount,
gpu: useGpu,
quiet,
crf,
videoBitrate,
});
} else {
await renderLocal(project.dir, outputPath, {
@@ -276,6 +329,8 @@ export default defineCommand({
gpu: useGpu,
quiet,
browserPath,
crf,
videoBitrate,
});
}
},
@@ -289,6 +344,8 @@ interface RenderOptions {
gpu: boolean;
quiet: boolean;
browserPath?: string;
crf?: number;
videoBitrate?: string;
}
const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
@@ -423,6 +480,8 @@ async function renderDocker(
String(options.workers),
...(options.quiet ? ["--quiet"] : []),
...(options.gpu ? ["--gpu"] : []),
...(options.crf != null ? ["--crf", String(options.crf)] : []),
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
];
if (!options.quiet) {
@@ -484,6 +543,8 @@ async function renderLocal(
format: options.format,
workers: options.workers,
useGpu: options.gpu,
crf: options.crf,
videoBitrate: options.videoBitrate,
});
const onProgress = options.quiet