mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
## Problem Two newly reported runtime issues break common local workflows: - Fixes #615: `<hyperframes-player loop>` reaches the final frame, receives a paused runtime state, and stays paused instead of wrapping. - Fixes #616: `hyperframes render` can finish writing the output and print `Render complete`, but still remain alive when a non-essential handle keeps Node's event loop open. The catalog block also used old VPN branding and slug/file names that should now be neutral. Renaming registry items also exposed a catalog-preview CI bug where deleted registry paths were treated as still-renderable changed items. ## What this fixes - detects player completion from the previous playing state before mutating the parent `_paused` cache from the runtime's final state - wraps looping players back to `0` and immediately resumes playback even when the runtime posts `isPlaying: false` at the end frame - keeps non-looping players dispatching the existing `ended` flow - lets the CLI command path schedule a short unref'd `process.exit(0)` after a successful local or Docker render - keeps `renderLocal()` importable for tests and internal callers without forcing process exit unless the CLI command explicitly opts in - adds regression coverage for the player loop end-state and successful render exit scheduling - renames the VPN catalog block to `vpn-youtube-spot` across registry, docs route, install command, composition filename, asset filename, composition id, and timeline key - keeps visible block/app copy friendly and named `VPN` - updates catalog-preview CI to ignore deleted registry paths when computing changed preview items ## Root cause The player message handler updated `_paused = !data.isPlaying` before checking for end-of-composition loop behavior. The runtime's legitimate final-frame state has `isPlaying: false`, so the existing `currentTime >= duration && !paused` loop branch was skipped. For render completion, the CLI returned after `printRenderComplete()`, leaving process lifetime entirely to Node's active handles. Most local renders in this checkout drain cleanly, but the reported npm flow shows a sleeping parent process after output is already complete. The CLI now schedules a short unref'd successful exit only from the command path after user-visible render work has completed. The catalog block issue was content/metadata drift: registry/docs/code identifiers still used the old slug, so the catalog route, install command, composition id, file names, and source prompt did not match the requested neutral VPN naming. The preview workflow used plain `git diff --name-only`, which includes deleted paths during renames; it now filters to added/copied/modified/renamed live paths. ## Verification ### Local checks - `bun run build:hyperframes-runtime` - `bun run --filter @hyperframes/player test -- src/hyperframes-player.test.ts` - `bun run --filter @hyperframes/cli test -- src/commands/render.test.ts` - `bun run --filter @hyperframes/player typecheck` - `bun run --filter @hyperframes/cli typecheck` - `bunx oxfmt --check packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts` - `bunx oxlint packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts` - `bun run --filter @hyperframes/player build` - `bun run --filter @hyperframes/studio build` - `bun run --filter @hyperframes/cli build` - `bunx oxfmt --check registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json` - `bunx oxlint registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json` - `bunx oxfmt --check .github/workflows/catalog-previews.yml` - `BASE_SHA=26b8e2a9853eb1a8f77c05fb0c8f0903cdb2cf18; git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ ...` returns only `vpn-youtube-spot` - `npx tsx scripts/sync-schemas.ts --check` - `npx mint validate` from `docs/` - `npx mint broken-links` from `docs/` - `git diff --check` - Lefthook pre-commit: format pass - Lefthook commit-msg: commitlint pass ### Browser verification - Built the player bundle and served a real local reproduction using the built player, the built HyperFrames runtime, and GSAP. - Used `agent-browser` to open the page, click `Seek near end`, and wait through the end-frame transition. - Verified the browser state after playback: `stuck=false`, `looped=true`, and playback continued after wrapping from ~4s back to the start. - Served `registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html` locally, used `agent-browser` to seek the timeline, and verified `window.__timelines` contains `vpn-youtube-spot`, not `goonvpn-youtube-spot`. - Served the docs locally with Mintlify, opened `/catalog/blocks/vpn-youtube-spot`, and verified the install command is `npx hyperframes add vpn-youtube-spot` with no old slug visible. ### Composition verification - `bun run --filter @hyperframes/cli dev lint /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if` returned 0 errors and 1 existing large-composition warning. - `bun run --filter @hyperframes/cli dev validate /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --timeout 5000` returned 0 console errors; it reported existing non-fatal contrast audit warnings from the block styling. - `bun run --filter @hyperframes/cli dev render /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --output /tmp/hf-vpn-renamed-proof.mp4 --fps 30 --quality draft --workers 1 --no-browser-gpu` completed successfully. - `ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 /tmp/hf-vpn-renamed-proof.mp4` reported `duration=7.000000`. ### Render verification - Ran a real 1920x1080, 5-second render with `--gpu --workers 6 --quality draft --fps 24`. - Verified the command printed `Render complete` and the parent process exited with code `0` in the wrapper: `RENDER_EXIT_PROOF code=0 signal=null sawComplete=true`. ## Notes - I could not reproduce the exact indefinite #616 render hang on this checkout; both tiny and GPU/6-worker local renders exited cleanly before and after the patch. The CLI guard still addresses the reported leaked-handle failure mode because it fires only after successful render completion. - Browser proof artifacts were local-only: `/tmp/hf-player-loop-proof-final.png`, `/tmp/hf-player-loop-proof-final.webm`, `/tmp/hf-vpn-code-rename-proof.png`, `/tmp/hf-vpn-code-rename-proof.webm`, `/tmp/hf-vpn-doc-route-rename-proof.png`, and `/tmp/hf-vpn-doc-route-rename-proof.webm`. - The renamed composition render artifact was local-only: `/tmp/hf-vpn-renamed-proof.mp4`. - The CLI exit guard is only enabled by the `render` command's top-level local/Docker calls. Direct test/internal calls to `renderLocal()` do not force process exit unless they pass `exitAfterComplete: true`.
927 lines
31 KiB
TypeScript
927 lines
31 KiB
TypeScript
import { defineCommand } from "citty";
|
|
import type { Example } from "./_examples.js";
|
|
import { mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
|
|
|
|
export const examples: Example[] = [
|
|
["Render to MP4", "hyperframes render --output output.mp4"],
|
|
["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"],
|
|
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
|
|
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
|
|
["Opt out of browser GPU render", "hyperframes render --no-browser-gpu --output cpu.mp4"],
|
|
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
|
|
[
|
|
"Override composition variables (parametrized render)",
|
|
'hyperframes render --variables \'{"title":"Q4 Report","theme":"dark"}\' --output q4.mp4',
|
|
],
|
|
[
|
|
"Variables from a JSON file",
|
|
"hyperframes render --variables-file ./vars.json --output out.mp4",
|
|
],
|
|
];
|
|
import { cpus, freemem, tmpdir } from "node:os";
|
|
import { resolve, dirname, join, basename } from "node:path";
|
|
import { execFileSync, spawn } from "node:child_process";
|
|
import { resolveProject } from "../utils/project.js";
|
|
import { lintProject, shouldBlockRender } from "../utils/lintProject.js";
|
|
import { formatLintFindings } from "../utils/lintFormat.js";
|
|
import { loadProducer } from "../utils/producer.js";
|
|
import { c } from "../ui/colors.js";
|
|
import { formatBytes, formatDuration, errorBox } from "../ui/format.js";
|
|
import { renderProgress } from "../ui/progress.js";
|
|
import { trackRenderComplete, trackRenderError } from "../telemetry/events.js";
|
|
import { bytesToMb } from "../telemetry/system.js";
|
|
import { VERSION } from "../version.js";
|
|
import { isDevMode } from "../utils/env.js";
|
|
import { buildDockerRunArgs } from "../utils/dockerRunArgs.js";
|
|
import { ensureDOMParser } from "../utils/dom.js";
|
|
import type { RenderJob } from "@hyperframes/producer";
|
|
import {
|
|
extractCompositionMetadata,
|
|
validateVariables,
|
|
formatVariableValidationIssue,
|
|
type VariableValidationIssue,
|
|
} from "@hyperframes/core";
|
|
|
|
const VALID_FPS = new Set([24, 30, 60]);
|
|
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
|
const VALID_FORMAT = new Set(["mp4", "webm", "mov"]);
|
|
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
|
|
|
const CPU_CORE_COUNT = cpus().length;
|
|
|
|
export default defineCommand({
|
|
meta: {
|
|
name: "render",
|
|
description: "Render a composition to MP4, WebM, or MOV",
|
|
},
|
|
args: {
|
|
dir: {
|
|
type: "positional",
|
|
description: "Project directory",
|
|
required: false,
|
|
},
|
|
output: {
|
|
type: "string",
|
|
alias: "o",
|
|
description: "Output path (default: renders/<name>.mp4)",
|
|
},
|
|
fps: {
|
|
type: "string",
|
|
alias: "f",
|
|
description: "Frame rate: 24, 30, 60",
|
|
default: "30",
|
|
},
|
|
quality: {
|
|
type: "string",
|
|
alias: "q",
|
|
description: "Quality: draft, standard, high",
|
|
default: "standard",
|
|
},
|
|
format: {
|
|
type: "string",
|
|
description: "Output format: mp4, webm, mov (MOV/WebM render with transparency)",
|
|
default: "mp4",
|
|
},
|
|
workers: {
|
|
type: "string",
|
|
alias: "w",
|
|
description:
|
|
"Parallel render workers (number or 'auto'). Default: auto. " +
|
|
"Each worker launches a separate Chrome process (~256 MB RAM).",
|
|
},
|
|
docker: {
|
|
type: "boolean",
|
|
description: "Use Docker for deterministic render",
|
|
default: false,
|
|
},
|
|
hdr: {
|
|
type: "boolean",
|
|
description: "Force HDR output even if no HDR sources are detected",
|
|
default: false,
|
|
},
|
|
sdr: {
|
|
type: "boolean",
|
|
description: "Force SDR output even if HDR sources are detected",
|
|
default: false,
|
|
},
|
|
crf: {
|
|
type: "string",
|
|
description: "Override encoder CRF. Mutually exclusive with --video-bitrate.",
|
|
},
|
|
"video-bitrate": {
|
|
type: "string",
|
|
description: "Target video bitrate such as 10M. Mutually exclusive with --crf.",
|
|
},
|
|
gpu: { type: "boolean", description: "Use GPU encoding", default: false },
|
|
"browser-gpu": {
|
|
type: "boolean",
|
|
description:
|
|
"Use host GPU acceleration for Chrome/WebGL capture. Enabled by default for local renders; use --no-browser-gpu to opt out.",
|
|
},
|
|
quiet: {
|
|
type: "boolean",
|
|
description: "Suppress verbose output",
|
|
default: false,
|
|
},
|
|
strict: {
|
|
type: "boolean",
|
|
description: "Fail render on lint errors",
|
|
default: false,
|
|
},
|
|
"strict-all": {
|
|
type: "boolean",
|
|
description: "Fail render on lint errors AND warnings",
|
|
default: false,
|
|
},
|
|
"max-concurrent-renders": {
|
|
type: "string",
|
|
description: "Max concurrent renders when using the producer server (1-10). Default: 2.",
|
|
},
|
|
variables: {
|
|
type: "string",
|
|
description:
|
|
'JSON object of variable values, merged over the composition\'s data-composition-variables defaults. Example: --variables \'{"title":"Hello"}\'. Read inside the composition via window.__hyperframes.getVariables().',
|
|
},
|
|
"variables-file": {
|
|
type: "string",
|
|
description:
|
|
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
|
|
},
|
|
"strict-variables": {
|
|
type: "boolean",
|
|
description:
|
|
"Fail render if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.",
|
|
default: false,
|
|
},
|
|
},
|
|
async run({ args }) {
|
|
// ── Resolve project ────────────────────────────────────────────────────
|
|
const project = resolveProject(args.dir);
|
|
|
|
// ── Validate fps ───────────────────────────────────────────────────────
|
|
const fpsRaw = parseInt(args.fps ?? "30", 10);
|
|
if (!VALID_FPS.has(fpsRaw)) {
|
|
errorBox("Invalid fps", `Got "${args.fps ?? "30"}". Must be 24, 30, or 60.`);
|
|
process.exit(1);
|
|
}
|
|
const fps = fpsRaw as 24 | 30 | 60;
|
|
|
|
// ── Validate quality ───────────────────────────────────────────────────
|
|
const qualityRaw = args.quality ?? "standard";
|
|
if (!VALID_QUALITY.has(qualityRaw)) {
|
|
errorBox("Invalid quality", `Got "${qualityRaw}". Must be draft, standard, or high.`);
|
|
process.exit(1);
|
|
}
|
|
const quality = qualityRaw as "draft" | "standard" | "high";
|
|
|
|
// ── Validate format ─────────────────────────────────────────────────
|
|
const formatRaw = args.format ?? "mp4";
|
|
if (!VALID_FORMAT.has(formatRaw)) {
|
|
errorBox("Invalid format", `Got "${formatRaw}". Must be mp4, webm, or mov.`);
|
|
process.exit(1);
|
|
}
|
|
const format = formatRaw as "mp4" | "webm" | "mov";
|
|
|
|
// ── Validate workers ──────────────────────────────────────────────────
|
|
let workers: number | undefined;
|
|
if (args.workers != null && args.workers !== "auto") {
|
|
const parsed = parseInt(args.workers, 10);
|
|
if (isNaN(parsed) || parsed < 1) {
|
|
errorBox("Invalid workers", `Got "${args.workers}". Must be a positive number or "auto".`);
|
|
process.exit(1);
|
|
}
|
|
workers = parsed;
|
|
}
|
|
|
|
// ── Validate max-concurrent-renders ─────────────────────────────────
|
|
if (args["max-concurrent-renders"] != null) {
|
|
const parsed = parseInt(args["max-concurrent-renders"], 10);
|
|
if (isNaN(parsed) || parsed < 1 || parsed > 10) {
|
|
errorBox(
|
|
"Invalid max-concurrent-renders",
|
|
`Got "${args["max-concurrent-renders"]}". Must be a number between 1 and 10.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
process.env.PRODUCER_MAX_CONCURRENT_RENDERS = String(parsed);
|
|
}
|
|
|
|
// ── Resolve output path ───────────────────────────────────────────────
|
|
const rendersDir = resolve("renders");
|
|
const ext = FORMAT_EXT[format] ?? ".mp4";
|
|
const now = new Date();
|
|
const datePart = now.toISOString().slice(0, 10);
|
|
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
|
|
const outputPath = args.output
|
|
? resolve(args.output)
|
|
: join(rendersDir, `${project.name}_${datePart}_${timePart}${ext}`);
|
|
|
|
// Ensure output directory exists
|
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
|
|
const useDocker = args.docker ?? false;
|
|
const useGpu = args.gpu ?? false;
|
|
const browserGpuArg = args["browser-gpu"];
|
|
const useBrowserGpu = resolveBrowserGpuForCli(useDocker, browserGpuArg);
|
|
const quiet = args.quiet ?? false;
|
|
const strictAll = args["strict-all"] ?? false;
|
|
const strictErrors = (args.strict ?? false) || strictAll;
|
|
const crfRaw = args.crf;
|
|
const videoBitrate = args["video-bitrate"]?.trim();
|
|
|
|
if (crfRaw != null && videoBitrate) {
|
|
errorBox("Conflicting encoder settings", "Use either --crf or --video-bitrate, not both.");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (useDocker && browserGpuArg === true) {
|
|
errorBox(
|
|
"Browser GPU is local-only",
|
|
"--browser-gpu uses the host Chrome GPU backend. Docker mode keeps browser rendering deterministic and does not expose a cross-platform Chrome GPU backend.",
|
|
"Run without --docker, or use --gpu for Docker GPU encoding where your Docker host supports GPU passthrough.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
let crf: number | undefined;
|
|
if (crfRaw != null) {
|
|
const parsed = Number(crfRaw);
|
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
errorBox("Invalid crf", `Got "${crfRaw}". Must be a non-negative integer.`);
|
|
process.exit(1);
|
|
}
|
|
crf = parsed;
|
|
}
|
|
|
|
if (args["video-bitrate"] != null && !videoBitrate) {
|
|
errorBox(
|
|
"Invalid video-bitrate",
|
|
`Got "${args["video-bitrate"]}". Must be a non-empty bitrate such as "10M".`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Print render plan ─────────────────────────────────────────────────
|
|
if (!quiet) {
|
|
const workerLabel =
|
|
workers != null ? `${workers} workers` : `auto workers (${CPU_CORE_COUNT} cores detected)`;
|
|
console.log("");
|
|
console.log(
|
|
c.accent("\u25C6") +
|
|
" Rendering " +
|
|
c.accent(project.name) +
|
|
c.dim(" \u2192 " + outputPath),
|
|
);
|
|
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel));
|
|
if (useGpu || useBrowserGpu) {
|
|
const gpuModes = [
|
|
useGpu ? "encoder GPU" : null,
|
|
useBrowserGpu ? "browser GPU (auto)" : null,
|
|
].filter(Boolean);
|
|
console.log(c.dim(" GPU: " + gpuModes.join(" + ")));
|
|
}
|
|
console.log("");
|
|
}
|
|
|
|
// ── Check FFmpeg for local renders ───────────────────────────────────
|
|
if (!useDocker) {
|
|
const { findFFmpeg, getFFmpegInstallHint } = await import("../browser/ffmpeg.js");
|
|
if (!findFFmpeg()) {
|
|
errorBox(
|
|
"FFmpeg not found",
|
|
"Rendering requires FFmpeg for video encoding.",
|
|
`Install: ${getFFmpegInstallHint()}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ── Ensure browser for local renders ────────────────────────────────
|
|
let browserPath: string | undefined;
|
|
if (!useDocker) {
|
|
const { ensureBrowser } = await import("../browser/manager.js");
|
|
const clack = await import("@clack/prompts");
|
|
const s = clack.spinner();
|
|
s.start("Checking browser...");
|
|
try {
|
|
const info = await ensureBrowser({
|
|
onProgress: (downloaded, total) => {
|
|
if (total <= 0) return;
|
|
const pct = Math.floor((downloaded / total) * 100);
|
|
s.message(
|
|
`Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
|
|
);
|
|
},
|
|
});
|
|
browserPath = info.executablePath;
|
|
s.stop(c.dim(`Browser: ${info.source}`));
|
|
} catch (err: unknown) {
|
|
s.stop(c.error("Browser not available"));
|
|
errorBox(
|
|
"Chrome not found",
|
|
err instanceof Error ? err.message : String(err),
|
|
"Run: npx hyperframes browser ensure",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ── Pre-render lint ──────────────────────────────────────────────────
|
|
{
|
|
const lintResult = lintProject(project);
|
|
if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) {
|
|
console.log("");
|
|
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line);
|
|
if (
|
|
shouldBlockRender(
|
|
strictErrors,
|
|
strictAll,
|
|
lintResult.totalErrors,
|
|
lintResult.totalWarnings,
|
|
)
|
|
) {
|
|
const mode = strictAll ? "--strict-all" : "--strict";
|
|
console.log("");
|
|
console.log(c.error(` Aborting render due to lint issues (${mode} mode).`));
|
|
console.log("");
|
|
process.exit(1);
|
|
}
|
|
console.log(c.dim(" Continuing render despite lint issues. Use --strict to block."));
|
|
console.log("");
|
|
}
|
|
}
|
|
|
|
// ── Validate HDR/SDR mutual exclusion ────────────────────────────────
|
|
if (args.hdr && args.sdr) {
|
|
console.error("Error: --hdr and --sdr are mutually exclusive.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Resolve --variables / --variables-file ──────────────────────────
|
|
const variables = resolveVariablesArg(args.variables, args["variables-file"]);
|
|
|
|
// ── Validate --variables against data-composition-variables ─────────
|
|
const strictVariables = args["strict-variables"] ?? false;
|
|
if (variables && Object.keys(variables).length > 0) {
|
|
const issues = validateVariablesAgainstProject(project.indexPath, variables);
|
|
if (issues.length > 0) {
|
|
if (!quiet) {
|
|
console.log("");
|
|
console.log(
|
|
c.warn(
|
|
`Variable ${issues.length === 1 ? "issue" : "issues"} (${issues.length}) — values may not render as expected:`,
|
|
),
|
|
);
|
|
for (const issue of issues) {
|
|
console.log(" " + c.dim(formatVariableValidationIssue(issue)));
|
|
}
|
|
console.log("");
|
|
}
|
|
if (strictVariables) {
|
|
console.log(
|
|
c.error(" Aborting render due to variable issues (--strict-variables mode)."),
|
|
);
|
|
console.log("");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────
|
|
if (useDocker) {
|
|
await renderDocker(project.dir, outputPath, {
|
|
fps,
|
|
quality,
|
|
format,
|
|
workers,
|
|
gpu: useGpu,
|
|
browserGpu: useBrowserGpu,
|
|
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
|
|
crf,
|
|
videoBitrate,
|
|
quiet,
|
|
variables,
|
|
exitAfterComplete: true,
|
|
});
|
|
} else {
|
|
await renderLocal(project.dir, outputPath, {
|
|
fps,
|
|
quality,
|
|
format,
|
|
workers,
|
|
gpu: useGpu,
|
|
browserGpu: useBrowserGpu,
|
|
hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
|
|
crf,
|
|
videoBitrate,
|
|
quiet,
|
|
browserPath,
|
|
variables,
|
|
exitAfterComplete: true,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
interface RenderOptions {
|
|
fps: 24 | 30 | 60;
|
|
quality: "draft" | "standard" | "high";
|
|
format: "mp4" | "webm" | "mov";
|
|
workers?: number;
|
|
gpu: boolean;
|
|
browserGpu: boolean;
|
|
hdrMode: "auto" | "force-hdr" | "force-sdr";
|
|
crf?: number;
|
|
videoBitrate?: string;
|
|
quiet: boolean;
|
|
browserPath?: string;
|
|
variables?: Record<string, unknown>;
|
|
exitAfterComplete?: boolean;
|
|
}
|
|
|
|
export type VariablesParseError =
|
|
| { kind: "conflict" }
|
|
| { kind: "read-error"; path: string; cause: string }
|
|
| { kind: "parse-error"; source: "inline" | "file"; cause: string }
|
|
| { kind: "shape-error" };
|
|
|
|
export type VariablesParseResult =
|
|
| { ok: true; value: Record<string, unknown> | undefined }
|
|
| { ok: false; error: VariablesParseError };
|
|
|
|
/**
|
|
* Pure parser for `--variables` / `--variables-file` flag pair. Splits out
|
|
* from `resolveVariablesArg` so validation paths are unit-testable without
|
|
* triggering `process.exit`. Reports failures via a structured `kind`
|
|
* discriminant so the side-effecting wrapper owns all UI strings.
|
|
*/
|
|
export function parseVariablesArg(
|
|
inline: string | undefined,
|
|
filePath: string | undefined,
|
|
readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"),
|
|
): VariablesParseResult {
|
|
if (inline != null && filePath != null) {
|
|
return { ok: false, error: { kind: "conflict" } };
|
|
}
|
|
let raw: string | undefined;
|
|
let source: "inline" | "file" | undefined;
|
|
if (inline != null) {
|
|
raw = inline;
|
|
source = "inline";
|
|
} else if (filePath != null) {
|
|
try {
|
|
raw = readFile(filePath);
|
|
source = "file";
|
|
} catch (error: unknown) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
kind: "read-error",
|
|
path: filePath,
|
|
cause: error instanceof Error ? error.message : String(error),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (raw == null) return { ok: true, value: undefined };
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error: unknown) {
|
|
return {
|
|
ok: false,
|
|
error: {
|
|
kind: "parse-error",
|
|
source: source ?? "inline",
|
|
cause: error instanceof Error ? error.message : String(error),
|
|
},
|
|
};
|
|
}
|
|
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return { ok: false, error: { kind: "shape-error" } };
|
|
}
|
|
return { ok: true, value: parsed as Record<string, unknown> };
|
|
}
|
|
|
|
function variablesErrorMessage(error: VariablesParseError): { title: string; message: string } {
|
|
switch (error.kind) {
|
|
case "conflict":
|
|
return {
|
|
title: "Conflicting variables flags",
|
|
message: "Use either --variables or --variables-file, not both.",
|
|
};
|
|
case "read-error":
|
|
return {
|
|
title: "Could not read --variables-file",
|
|
message: `${error.path}: ${error.cause}`,
|
|
};
|
|
case "parse-error":
|
|
return {
|
|
title:
|
|
error.source === "file"
|
|
? "Invalid JSON in --variables-file"
|
|
: "Invalid JSON in --variables",
|
|
message: error.cause,
|
|
};
|
|
case "shape-error":
|
|
return {
|
|
title: "Invalid variables payload",
|
|
message: 'Variables must be a JSON object (e.g. {"title":"Hello"}).',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve `--variables` / `--variables-file` into a plain object, or
|
|
* `undefined` when neither flag is set. Exits the process with a friendly
|
|
* error box on any validation failure.
|
|
*/
|
|
export function resolveVariablesArg(
|
|
inline: string | undefined,
|
|
filePath: string | undefined,
|
|
): Record<string, unknown> | undefined {
|
|
const result = parseVariablesArg(inline, filePath);
|
|
if (!result.ok) {
|
|
const { title, message } = variablesErrorMessage(result.error);
|
|
errorBox(title, message);
|
|
process.exit(1);
|
|
}
|
|
return result.value;
|
|
}
|
|
|
|
/**
|
|
* Validate `--variables` values against the project's top-level
|
|
* `data-composition-variables` declarations. Returns an empty array when
|
|
* the index has no declarations or when every key is declared with a
|
|
* matching type. Errors reading the index are silently treated as "no
|
|
* declarations" — the lint pass owns malformed-HTML diagnostics, render
|
|
* shouldn't fail just because the schema is unreadable.
|
|
*/
|
|
export function validateVariablesAgainstProject(
|
|
indexPath: string,
|
|
values: Record<string, unknown>,
|
|
): VariableValidationIssue[] {
|
|
let html: string;
|
|
try {
|
|
html = readFileSync(indexPath, "utf8");
|
|
} catch {
|
|
return [];
|
|
}
|
|
// extractCompositionMetadata uses DOMParser, which Node doesn't ship.
|
|
// Same pattern as `compositions.ts` and other CLI commands that touch
|
|
// @hyperframes/core's HTML parsers.
|
|
ensureDOMParser();
|
|
const meta = extractCompositionMetadata(html);
|
|
if (meta.variables.length === 0) return [];
|
|
return validateVariables(values, meta.variables);
|
|
}
|
|
|
|
export function resolveBrowserGpuForCli(
|
|
useDocker: boolean,
|
|
browserGpuArg: boolean | undefined,
|
|
envMode = process.env.PRODUCER_BROWSER_GPU_MODE,
|
|
): boolean {
|
|
if (useDocker) return false;
|
|
if (browserGpuArg !== undefined) return browserGpuArg;
|
|
if (envMode === "software") return false;
|
|
return true;
|
|
}
|
|
|
|
const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
|
|
|
|
function dockerImageTag(version: string): string {
|
|
return `${DOCKER_IMAGE_PREFIX}:${version}`;
|
|
}
|
|
|
|
function resolveDockerfilePath(): string {
|
|
// Built CLI: dist/docker/Dockerfile.render
|
|
const builtPath = resolve(__dirname, "docker", "Dockerfile.render");
|
|
// Dev mode: src/docker/Dockerfile.render
|
|
const devPath = resolve(__dirname, "..", "src", "docker", "Dockerfile.render");
|
|
for (const p of [builtPath, devPath]) {
|
|
try {
|
|
statSync(p);
|
|
return p;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
throw new Error("Dockerfile.render not found — CLI package may be corrupted");
|
|
}
|
|
|
|
function dockerImageExists(tag: string): boolean {
|
|
try {
|
|
execFileSync("docker", ["image", "inspect", tag], { stdio: "pipe", timeout: 10_000 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function ensureDockerImage(version: string, quiet: boolean): string {
|
|
const tag = dockerImageTag(version);
|
|
|
|
if (dockerImageExists(tag)) {
|
|
if (!quiet) console.log(c.dim(` Docker image: ${tag} (cached)`));
|
|
return tag;
|
|
}
|
|
|
|
if (!quiet) console.log(c.dim(` Building Docker image: ${tag}...`));
|
|
|
|
const dockerfilePath = resolveDockerfilePath();
|
|
|
|
// Copy Dockerfile to a temp build context so docker build has a clean context
|
|
const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`);
|
|
mkdirSync(tmpDir, { recursive: true });
|
|
writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath));
|
|
|
|
// linux/amd64 forced — chrome-headless-shell doesn't ship ARM Linux binaries
|
|
try {
|
|
execFileSync(
|
|
"docker",
|
|
[
|
|
"build",
|
|
"--platform",
|
|
"linux/amd64",
|
|
"--build-arg",
|
|
`HYPERFRAMES_VERSION=${version}`,
|
|
"-t",
|
|
tag,
|
|
tmpDir,
|
|
],
|
|
{ stdio: quiet ? "pipe" : "inherit", timeout: 600_000 },
|
|
);
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Failed to build Docker image: ${message}`);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
|
|
if (!quiet) console.log(c.dim(` Docker image: ${tag} (built)`));
|
|
return tag;
|
|
}
|
|
|
|
async function renderDocker(
|
|
projectDir: string,
|
|
outputPath: string,
|
|
options: RenderOptions,
|
|
): Promise<void> {
|
|
const startTime = Date.now();
|
|
|
|
// Dev mode (tsx/ts-node) uses "latest" since the local version isn't on npm
|
|
const dockerVersion = isDevMode() ? "latest" : VERSION;
|
|
if (!options.quiet && isDevMode()) {
|
|
console.log(c.dim(" Dev mode: using hyperframes@latest in Docker image"));
|
|
}
|
|
|
|
let imageTag: string;
|
|
try {
|
|
imageTag = ensureDockerImage(dockerVersion, options.quiet);
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const isDockerMissing = /connect|not found|ENOENT/i.test(message);
|
|
errorBox(
|
|
isDockerMissing ? "Docker not available" : "Docker image build failed",
|
|
message,
|
|
isDockerMissing
|
|
? "Install Docker: https://docs.docker.com/get-docker/"
|
|
: "Check Docker is running: docker info",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const outputDir = dirname(outputPath);
|
|
const outputFilename = basename(outputPath);
|
|
const dockerArgs = buildDockerRunArgs({
|
|
imageTag,
|
|
projectDir: resolve(projectDir),
|
|
outputDir: resolve(outputDir),
|
|
outputFilename,
|
|
options: {
|
|
fps: options.fps,
|
|
quality: options.quality,
|
|
format: options.format,
|
|
workers: options.workers,
|
|
gpu: options.gpu,
|
|
browserGpu: options.browserGpu,
|
|
hdrMode: options.hdrMode,
|
|
crf: options.crf,
|
|
videoBitrate: options.videoBitrate,
|
|
quiet: options.quiet,
|
|
variables: options.variables,
|
|
},
|
|
});
|
|
|
|
if (!options.quiet) {
|
|
console.log(c.dim(" Running render in Docker container..."));
|
|
console.log("");
|
|
}
|
|
|
|
try {
|
|
await new Promise<void>((resolvePromise, reject) => {
|
|
const child = spawn("docker", dockerArgs, {
|
|
// When quiet, still show stderr so container errors surface
|
|
stdio: options.quiet ? ["pipe", "pipe", "inherit"] : "inherit",
|
|
});
|
|
child.on("close", (code) => {
|
|
if (code === 0) resolvePromise();
|
|
else reject(new Error(`Docker render exited with code ${code}`));
|
|
});
|
|
child.on("error", (err) => reject(err));
|
|
});
|
|
} catch (error: unknown) {
|
|
handleRenderError(error, options, startTime, true, "Check Docker is running: docker info");
|
|
}
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
|
|
// Track metrics (no job object available from Docker — use a minimal stub)
|
|
trackRenderComplete({
|
|
durationMs: elapsed,
|
|
fps: options.fps,
|
|
quality: options.quality,
|
|
workers: options.workers,
|
|
docker: true,
|
|
gpu: options.gpu,
|
|
...getMemorySnapshot(),
|
|
});
|
|
|
|
printRenderComplete(outputPath, elapsed, options.quiet);
|
|
if (options.exitAfterComplete) scheduleRenderProcessExit();
|
|
}
|
|
|
|
export async function renderLocal(
|
|
projectDir: string,
|
|
outputPath: string,
|
|
options: RenderOptions,
|
|
): Promise<void> {
|
|
const producer = await loadProducer();
|
|
const startTime = Date.now();
|
|
|
|
// Pass the resolved browser path to the producer via env var so
|
|
// resolveConfig() picks it up. This bridges the CLI's ensureBrowser()
|
|
// (which knows about system Chrome on macOS) with the engine's
|
|
// acquireBrowser() (which only checks the puppeteer cache).
|
|
if (options.browserPath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) {
|
|
process.env.PRODUCER_HEADLESS_SHELL_PATH = options.browserPath;
|
|
}
|
|
|
|
const job = producer.createRenderJob({
|
|
fps: options.fps,
|
|
quality: options.quality,
|
|
format: options.format,
|
|
workers: options.workers,
|
|
useGpu: options.gpu,
|
|
producerConfig: producer.resolveConfig({
|
|
browserGpuMode: options.browserGpu ? "hardware" : "software",
|
|
}),
|
|
hdrMode: options.hdrMode,
|
|
crf: options.crf,
|
|
videoBitrate: options.videoBitrate,
|
|
variables: options.variables,
|
|
});
|
|
|
|
const onProgress = options.quiet
|
|
? undefined
|
|
: (progressJob: { progress: number }, message: string) => {
|
|
renderProgress(progressJob.progress, message);
|
|
};
|
|
|
|
try {
|
|
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
|
|
} catch (error: unknown) {
|
|
handleRenderError(error, options, startTime, false, "Try --docker for containerized rendering");
|
|
}
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
trackRenderMetrics(job, elapsed, options, false);
|
|
printRenderComplete(outputPath, elapsed, options.quiet);
|
|
if (options.exitAfterComplete) scheduleRenderProcessExit();
|
|
}
|
|
|
|
type UnrefableTimer = {
|
|
unref: () => void;
|
|
};
|
|
|
|
function isUnrefableTimer(
|
|
timer: ReturnType<typeof setTimeout>,
|
|
): timer is ReturnType<typeof setTimeout> & UnrefableTimer {
|
|
return (
|
|
typeof timer === "object" &&
|
|
timer !== null &&
|
|
"unref" in timer &&
|
|
typeof timer.unref === "function"
|
|
);
|
|
}
|
|
|
|
function scheduleRenderProcessExit(): void {
|
|
const timer = setTimeout(() => process.exit(0), 100);
|
|
if (isUnrefableTimer(timer)) timer.unref();
|
|
}
|
|
|
|
function getMemorySnapshot() {
|
|
return {
|
|
peakMemoryMb: bytesToMb(process.memoryUsage.rss()),
|
|
memoryFreeMb: bytesToMb(freemem()),
|
|
};
|
|
}
|
|
|
|
function handleRenderError(
|
|
error: unknown,
|
|
options: RenderOptions,
|
|
startTime: number,
|
|
docker: boolean,
|
|
hint: string,
|
|
): never {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
trackRenderError({
|
|
fps: options.fps,
|
|
quality: options.quality,
|
|
docker,
|
|
workers: options.workers,
|
|
gpu: options.gpu,
|
|
elapsedMs: Date.now() - startTime,
|
|
errorMessage: message,
|
|
...getMemorySnapshot(),
|
|
});
|
|
errorBox("Render failed", message, hint);
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Extract rich metrics from the completed render job and send to telemetry.
|
|
* speed_ratio = composition_duration / render_time — higher is better, >1 means faster than realtime.
|
|
*/
|
|
function trackRenderMetrics(
|
|
job: RenderJob,
|
|
elapsedMs: number,
|
|
options: RenderOptions,
|
|
docker: boolean,
|
|
): void {
|
|
const perf = job.perfSummary;
|
|
const compositionDurationMs = perf
|
|
? Math.round(perf.compositionDurationSeconds * 1000)
|
|
: undefined;
|
|
const speedRatio =
|
|
compositionDurationMs && compositionDurationMs > 0 && elapsedMs > 0
|
|
? Math.round((compositionDurationMs / elapsedMs) * 100) / 100
|
|
: undefined;
|
|
|
|
const stages = perf?.stages ?? {};
|
|
const extract = perf?.videoExtractBreakdown;
|
|
|
|
trackRenderComplete({
|
|
durationMs: elapsedMs,
|
|
fps: options.fps,
|
|
quality: options.quality,
|
|
workers: options.workers ?? perf?.workers,
|
|
docker,
|
|
gpu: options.gpu,
|
|
compositionDurationMs,
|
|
compositionWidth: perf?.resolution.width,
|
|
compositionHeight: perf?.resolution.height,
|
|
totalFrames: perf?.totalFrames,
|
|
speedRatio,
|
|
captureAvgMs: perf?.captureAvgMs,
|
|
capturePeakMs: perf?.capturePeakMs,
|
|
tmpPeakBytes: perf?.tmpPeakBytes,
|
|
stageCompileMs: stages.compileMs,
|
|
stageVideoExtractMs: stages.videoExtractMs,
|
|
stageAudioProcessMs: stages.audioProcessMs,
|
|
stageCaptureMs: stages.captureMs,
|
|
stageEncodeMs: stages.encodeMs,
|
|
stageAssembleMs: stages.assembleMs,
|
|
extractResolveMs: extract?.resolveMs,
|
|
extractHdrProbeMs: extract?.hdrProbeMs,
|
|
extractHdrPreflightMs: extract?.hdrPreflightMs,
|
|
extractHdrPreflightCount: extract?.hdrPreflightCount,
|
|
extractVfrProbeMs: extract?.vfrProbeMs,
|
|
extractVfrPreflightMs: extract?.vfrPreflightMs,
|
|
extractVfrPreflightCount: extract?.vfrPreflightCount,
|
|
extractPhase3Ms: extract?.extractMs,
|
|
extractCacheHits: extract?.cacheHits,
|
|
extractCacheMisses: extract?.cacheMisses,
|
|
...getMemorySnapshot(),
|
|
});
|
|
}
|
|
|
|
function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boolean): void {
|
|
if (quiet) return;
|
|
|
|
let fileSize = "unknown";
|
|
try {
|
|
fileSize = formatBytes(statSync(outputPath).size);
|
|
} catch {
|
|
// file doesn't exist or is inaccessible
|
|
}
|
|
|
|
const duration = formatDuration(elapsedMs);
|
|
console.log("");
|
|
console.log(c.success("\u25C7") + " " + c.accent(outputPath));
|
|
console.log(" " + c.bold(fileSize) + c.dim(" \u00B7 " + duration + " \u00B7 completed"));
|
|
}
|