mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Opus 4.6
parent
2718de8776
commit
ebc12f7dc9
@@ -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.
|
||||
|
||||
@@ -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` | 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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user