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
+24
View File
@@ -117,12 +117,36 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W
| `--format` | mp4, mov, webm | mp4 | Output format (see [Transparent Video](#transparent-video) below) |
| `--fps` | 24, 30, 60 | 30 | Frames per second |
| `--quality` | draft, standard, high | standard | Encoding quality preset |
| `--crf` | 051 | — | Override CRF (lower = higher quality). Cannot combine with `--video-bitrate` |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate encoding. Cannot combine with `--crf` |
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
| `--max-concurrent-renders` | 1-10 | 2 | Max simultaneous renders via the producer server (see [Concurrent Renders](#concurrent-renders) below) |
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
| `--quiet` | — | off | Suppress verbose output |
## Quality and Encoding
The `--quality` flag selects a preset that controls the H.264 CRF (Constant Rate Factor) and encoder speed:
| Preset | CRF | x264 Preset | Best For |
|--------|-----|-------------|----------|
| `draft` | 28 | ultrafast | Quick previews, iteration |
| `standard` | 18 | medium | General use — visually lossless at 1080p |
| `high` | 15 | slow | Final delivery, near-lossless quality |
For finer control, use `--crf` or `--video-bitrate` to override the preset:
```bash
# Near-lossless quality (CRF 15 = very high quality, large file)
npx hyperframes render --crf 15 --output pristine.mp4
# Target a specific bitrate (useful for size-constrained delivery)
npx hyperframes render --video-bitrate 10M --output controlled.mp4
```
**Tip**: The default `standard` preset (CRF 18) is visually lossless at 1080p — most people cannot distinguish it from the source. Use `--quality draft` for faster iteration, or `--quality high` / `--crf 10` when file size is no concern.
## Workers
Each render worker launches a **separate Chrome browser process** to capture frames in parallel. More workers can speed up rendering, but each one consumes ~256 MB of RAM and significant CPU.
+3 -1
View File
@@ -398,9 +398,11 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| Flag | Values | Default | Description |
|------|--------|---------|-------------|
| `--output` | path | `renders/<name>.mp4` | Output file path |
| `--format` | mp4, webm | mp4 | Output format (WebM renders with transparency) |
| `--format` | mp4, webm, mov | mp4 | Output format (WebM/MOV render with transparency) |
| `--fps` | 24, 30, 60 | 30 | Frames per second |
| `--quality` | draft, standard, high | standard | Encoding quality preset |
| `--crf` | 051 | — | Override CRF (lower = higher quality). Cannot combine with `--video-bitrate` |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate encoding. Cannot combine with `--crf` |
| `--workers` | 1-8 | 4 | Parallel render workers |
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
+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
+2 -2
View File
@@ -17,8 +17,8 @@ export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.typ
export const ENCODER_PRESETS = {
draft: { preset: "ultrafast", quality: 28, codec: "h264" as const },
standard: { preset: "medium", quality: 23, codec: "h264" as const },
high: { preset: "slow", quality: 18, codec: "h264" as const },
standard: { preset: "medium", quality: 18, codec: "h264" as const },
high: { preset: "slow", quality: 15, codec: "h264" as const },
};
/**
@@ -110,6 +110,19 @@ export interface RenderConfig {
producerConfig?: EngineConfig;
/** Custom logger. Defaults to console-based defaultLogger. */
logger?: ProducerLogger;
/**
* Override CRF (Constant Rate Factor) for the video encoder.
* Lower values = higher quality / larger files. Range: 051 for H.264.
* When set, overrides the CRF from the quality preset.
* Mutually exclusive with `videoBitrate`.
*/
crf?: number;
/**
* Target video bitrate (e.g. "10M", "5000k").
* When set, uses bitrate-based encoding instead of CRF.
* Mutually exclusive with `crf`.
*/
videoBitrate?: string;
}
export interface RenderPerfSummary {
@@ -793,6 +806,23 @@ export async function executeRenderJob(
const videoOnlyPath = join(workDir, `video-only${videoExt}`);
const preset = getEncoderPreset(job.config.quality, outputFormat);
// User-level CRF/bitrate overrides take precedence over presets.
const effectiveQuality = job.config.crf ?? preset.quality;
const effectiveBitrate = job.config.videoBitrate;
// Shared encoder options used by both streaming and chunk encode paths.
const baseEncoderOpts = {
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: effectiveQuality,
bitrate: effectiveBitrate,
pixelFormat: preset.pixelFormat,
useGpu: job.config.useGpu,
};
job.framesRendered = 0;
// Streaming encode mode: pipe frame buffers directly to FFmpeg stdin,
@@ -803,14 +833,7 @@ export async function executeRenderJob(
streamingEncoder = await spawnStreamingEncoder(
videoOnlyPath,
{
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: preset.quality,
pixelFormat: preset.pixelFormat,
useGpu: job.config.useGpu,
...baseEncoderOpts,
imageFormat: captureOptions.format || "jpeg",
},
abortSignal,
@@ -1025,16 +1048,7 @@ export async function executeRenderJob(
const frameExt = needsAlpha ? "png" : "jpg";
const framePattern = `frame_%06d.${frameExt}`;
const encoderOpts = {
fps: job.config.fps,
width,
height,
codec: preset.codec,
preset: preset.preset,
quality: preset.quality,
pixelFormat: preset.pixelFormat,
useGpu: job.config.useGpu,
};
const encoderOpts = baseEncoderOpts;
const encodeResult = enableChunkedEncode
? await encodeFramesChunkedConcat(
framesDir,
+1 -1
View File
@@ -893,7 +893,7 @@ export function StudioApp() {
projectId={projectId}
onDelete={renderQueue.deleteRender}
onClearCompleted={renderQueue.clearCompleted}
onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
onStartRender={(format, quality) => renderQueue.startRender(30, quality, format)}
isRendering={renderQueue.isRendering}
/>
)}
@@ -7,7 +7,7 @@ interface RenderQueueProps {
projectId: string;
onDelete: (jobId: string) => void;
onClearCompleted: () => void;
onStartRender: (format: "mp4" | "webm" | "mov") => void;
onStartRender: (format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high") => void;
isRendering: boolean;
}
@@ -57,7 +57,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
{open && (
<div className="absolute bottom-full right-0 mb-1.5 w-52 p-2 rounded bg-neutral-900 border border-neutral-700 shadow-lg z-50">
<div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-neutral-900 border border-neutral-700 shadow-lg z-50">
<p className="text-[10px] font-semibold text-neutral-200 mb-0.5">{info.label}</p>
<p className="text-[9px] text-neutral-400 leading-tight">{info.desc}</p>
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
@@ -77,30 +77,59 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
);
}
const QUALITY_OPTIONS: {
value: "draft" | "standard" | "high";
label: string;
title: string;
}[] = [
{ value: "draft", label: "Draft", title: "Fast render, smaller file" },
{ value: "standard", label: "Standard", title: "Good quality, balanced file size" },
{ value: "high", label: "High Quality", title: "Best quality, larger file" },
];
function FormatExportButton({
onStartRender,
isRendering,
}: {
onStartRender: (format: "mp4" | "webm" | "mov") => void;
onStartRender: (format: "mp4" | "webm" | "mov", quality: "draft" | "standard" | "high") => void;
isRendering: boolean;
}) {
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
const [quality, setQuality] = useState<"draft" | "standard" | "high">("standard");
// MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
const showQuality = format !== "mov";
return (
<div className="flex items-center gap-1">
<FormatInfoTooltip format={format} />
{showQuality && (
<select
value={quality}
onChange={(e) => setQuality(e.target.value as "draft" | "standard" | "high")}
disabled={isRendering}
title={QUALITY_OPTIONS.find((q) => q.value === quality)?.title}
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
{QUALITY_OPTIONS.map((q) => (
<option key={q.value} value={q.value} title={q.title}>
{q.label}
</option>
))}
</select>
)}
<select
value={format}
onChange={(e) => setFormat(e.target.value as "mp4" | "webm" | "mov")}
disabled={isRendering}
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
className={`h-5 px-1 text-[10px] bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50 ${showQuality ? "" : "rounded-l"}`}
>
<option value="mp4">MP4</option>
<option value="mov">MOV</option>
<option value="webm">WebM</option>
</select>
<button
onClick={() => onStartRender(format)}
onClick={() => onStartRender(format, quality)}
disabled={isRendering}
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
>
@@ -59,7 +59,11 @@ export function useRenderQueue(projectId: string | null) {
// Start a render and track progress via SSE
const startRender = useCallback(
async (fps = 30, quality = "standard", format: "mp4" | "webm" | "mov" = "mp4") => {
async (
fps = 30,
quality: "draft" | "standard" | "high" = "standard",
format: "mp4" | "webm" | "mov" = "mp4",
) => {
if (!projectId) return;
const startTime = Date.now();