Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
63 changes: 62 additions & 1 deletion packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
];
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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("");
}

Expand Down Expand Up @@ -266,6 +317,8 @@ export default defineCommand({
workers: workerCount,
gpu: useGpu,
quiet,
crf,
videoBitrate,
});
} else {
await renderLocal(project.dir, outputPath, {
Expand All @@ -276,6 +329,8 @@ export default defineCommand({
gpu: useGpu,
quiet,
browserPath,
crf,
videoBitrate,
});
}
},
Expand All @@ -289,6 +344,8 @@ interface RenderOptions {
gpu: boolean;
quiet: boolean;
browserPath?: string;
crf?: number;
videoBitrate?: string;
}

const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};

/**
Expand Down
50 changes: 32 additions & 18 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)}
Expand Down
39 changes: 34 additions & 5 deletions packages/studio/src/components/renders/RenderQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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">
Expand All @@ -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"
>
Expand Down
6 changes: 5 additions & 1 deletion packages/studio/src/components/renders/useRenderQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading