Skip to content
Merged
7 changes: 4 additions & 3 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -465,14 +465,15 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--output` | path | `renders/<name>.mp4` | Output file path |
| `--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` |
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
| `--hdr` || off | HDR output (H.265 10-bit, BT.2020 HLG/PQ). MP4 only |
| `--workers` | 1-8 | 4 | Parallel render workers |
| `--gpu` || off | GPU encoding (NVENC, VideoToolbox, VAAPI) |
| `--docker` || off | Use Docker for [deterministic rendering](/concepts/determinism) |
| `--quiet` || off | Suppress verbose output |

CRF and target bitrate are now driven by `--quality`. For programmatic renders, `RenderConfig.crf` and `RenderConfig.videoBitrate` still accept overrides.

#### WebM with Transparency

Use `--format webm` to render compositions with a transparent background. This produces VP9 video with alpha channel in a WebM container — the standard format for overlayable video.
Expand Down
71 changes: 10 additions & 61 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ 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"],
["HDR output (H.265 10-bit)", "hyperframes render --hdr --output hdr-output.mp4"],
];
import { cpus, freemem, tmpdir } from "node:os";
import { resolve, dirname, join, basename } from "node:path";
Expand Down Expand Up @@ -81,19 +80,10 @@ 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.",
hdr: {
type: "boolean",
description: "Enable HDR: probe sources for PQ/HLG, output H.265 10-bit BT.2020",
default: false,
},
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
quiet: {
Expand Down Expand Up @@ -144,36 +134,6 @@ 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 @@ -231,12 +191,7 @@ export default defineCommand({
c.accent(project.name) +
c.dim(" \u2192 " + outputPath),
);
const encodeLabel = videoBitrate
? `bitrate ${videoBitrate}`
: crf != null
? `crf ${crf}`
: quality;
console.log(c.dim(" " + fps + "fps \u00B7 " + encodeLabel + " \u00B7 " + workerLabel));
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel));
console.log("");
}

Expand Down Expand Up @@ -316,9 +271,8 @@ export default defineCommand({
format,
workers: workerCount,
gpu: useGpu,
hdr: args.hdr ?? false,
quiet,
crf,
videoBitrate,
});
} else {
await renderLocal(project.dir, outputPath, {
Expand All @@ -327,10 +281,9 @@ export default defineCommand({
format,
workers: workerCount,
gpu: useGpu,
hdr: args.hdr ?? false,
quiet,
browserPath,
crf,
videoBitrate,
});
}
},
Expand All @@ -342,10 +295,9 @@ interface RenderOptions {
format: "mp4" | "webm" | "mov";
workers: number;
gpu: boolean;
hdr: boolean;
quiet: boolean;
browserPath?: string;
crf?: number;
videoBitrate?: string;
}

const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
Expand Down Expand Up @@ -480,8 +432,6 @@ 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 @@ -543,8 +493,7 @@ async function renderLocal(
format: options.format,
workers: options.workers,
useGpu: options.gpu,
crf: options.crf,
videoBitrate: options.videoBitrate,
hdr: options.hdr,
});

const onProgress = options.quiet
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/lint/rules/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
const timedTagPositions: Array<{ name: string; start: number; id?: string }> = [];
for (const tag of tags) {
if (tag.name === "video" || tag.name === "audio") continue;
// Skip the composition root — it uses data-start as a playback anchor, not as a clip timer
if (readAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-start")) {
timedTagPositions.push({
name: tag.name,
Expand Down
17 changes: 17 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
export type {
HfProtocol,
HfMediaElement,
HfTransitionMeta,
CaptureOptions,
CaptureResult,
CaptureBufferResult,
Expand Down Expand Up @@ -79,6 +80,9 @@ export {
cdpSessionCache,
initTransparentBackground,
captureAlphaPng,
applyDomLayerMask,
removeDomLayerMask,
DOM_LAYER_MASK_STYLE_ID,
type BeginFrameResult,
} from "./services/screenshotService.js";

Expand Down Expand Up @@ -155,6 +159,7 @@ export {
} from "./utils/ffprobe.js";

export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
export { runFfmpeg, type RunFfmpegOptions, type RunFfmpegResult } from "./utils/runFfmpeg.js";

export {
decodePng,
Expand All @@ -164,10 +169,22 @@ export {
blitRgb48leAffine,
parseTransformMatrix,
getSrgbToHdrLut,
roundedRectAlpha,
} from "./utils/alphaBlit.js";

export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";

// ── Shader transitions ────────────────────────────────────────────────────────
export {
type TransitionFn,
TRANSITIONS,
crossfade,
sampleRgb48le,
hdrToLinear,
linearToHdr,
convertTransfer,
} from "./utils/shaderTransitions.js";

export {
initHdrReadback,
uploadAndReadbackHdrFrame,
Expand Down
Loading
Loading