From 50184680b6ab09b9643e9f88365ecec77f986015 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Apr 2026 08:32:22 +0000 Subject: [PATCH] feat(render): add CRF/bitrate controls and improve default quality 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) --- docs/guides/rendering.mdx | 24 +++++++ docs/packages/cli.mdx | 4 +- packages/cli/src/commands/render.ts | 63 ++++++++++++++++++- packages/engine/src/services/chunkEncoder.ts | 4 +- .../src/services/renderOrchestrator.ts | 50 +++++++++------ packages/studio/src/App.tsx | 2 +- .../src/components/renders/RenderQueue.tsx | 39 ++++++++++-- .../src/components/renders/useRenderQueue.ts | 6 +- 8 files changed, 163 insertions(+), 29 deletions(-) diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx index e30e033a0..683ae5ba4 100644 --- a/docs/guides/rendering.mdx +++ b/docs/guides/rendering.mdx @@ -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` | 0–51 | — | 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. diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 9ec9f0013..66682edcd 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -398,9 +398,11 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | Flag | Values | Default | Description | |------|--------|---------|-------------| | `--output` | path | `renders/.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` | 0–51 | — | 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) | diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index c6dd7359e..265c82fd0 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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: 0–51 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 diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index 9cd9c43cc..bfd03ee75 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -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 }, }; /** diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 69a86a72f..4480aff3a 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -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: 0–51 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, diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 9c42a7969..1c632001a 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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} /> )} diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 654028433..8a6e69909 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -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" }) { {open && ( -
+

{info.label}

{info.desc}

@@ -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 (
+ {showQuality && ( + + )}