Files
hyperframes/packages/producer
James RussoandClaude Opus 4.8 cf573f7f3f fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions (#1831)
* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions

The #1 render failure bucket in production telemetry (PostHog project 356858,
dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K
occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent
authoring flows) is a `data-composition-src` reference pointing at a scene
file that is empty, malformed, or missing.

Root cause, traced end-to-end:
- The literal error "Composition HTML is empty or could not be parsed: <path>"
  is real (not a PostHog paraphrase) — thrown by a since-reverted guard in
  packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to
  a silent skip in #1678 to avoid aborting renders on partial content during
  authoring. #1629 added per-assembler guards for 3 skill workflows
  (product-launch-video, faceless-explainer, pr-to-video), but general-video
  and hand-authored flows — where the dominant filename `scene-title.html`
  (40K+/68K of the bucket) originates — have no assembler and thus no guard.
  #1678 assumed the assembler guards from #1629 covered this pre-render; they
  only covered 3 of the many authoring flows.
- On current `main`, an empty/malformed data-composition-src file no longer
  crashes or throws during render — it's silently dropped by the tolerant
  inliner. Reproduced locally: `hyperframes render` on a project with an
  empty scene-title.html "succeeds" after ~93s (two 45s
  pollSubCompositionTimelines timeouts) with the scene silently missing from
  the output video. `hyperframes validate` also reports "No console errors"
  for the same broken project.
- The raw `Cannot destructure property 'firstElementChild' of
  'documentElement' as it is null` crash reproduces directly against
  linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in
  the real CLI runtime) for empty and non-HTML input — confirmed with a
  standalone repro script, not just inferred. jsdom/happy-dom (used in this
  repo's own test environment) are spec-compliant and never produce a null
  documentElement, which is why this needed a linkedom-specific test file.

Fix:
- New shared helper `checkSubCompositionUsability`
  (packages/core/src/compiler/subCompositionValidity.ts) is the single
  source of truth for "is this data-composition-src file usable" — mirrors
  the inliner's own parse/template/body logic so all callers agree.
- `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared
  helper internally but keeps its #1678 tolerant skip-and-continue behavior
  unchanged — mid-authoring iteration on a partial project must keep
  working. `onMissingComposition` now also receives a human-readable reason.
- New render-only pre-flight (`assertSubCompositionsUsable` in
  packages/producer/src/services/htmlCompiler.ts) walks every
  data-composition-src reference (including nested ones, root-relative,
  matching parseSubCompositions' own resolution) before any compilation
  work starts, and throws naming every offending file at once. This is
  unconditional — not gated behind --strict — because a render that
  silently drops a scene is strictly worse than one that refuses to start.
  Confirmed locally: render now fails in ~0.4s with an actionable message
  instead of "succeeding" after 93s with a missing scene.
- New `hyperframes lint` rule `missing_or_empty_sub_composition`
  (packages/cli/src/utils/lintProject.ts) surfaces the same check as a
  file-scoped, actionable lint error (already unconditional — lint exits 1
  on any error).
- `hyperframes validate` now also runs this check before launching a
  browser, so it no longer reports "No console errors" for a project with a
  broken sub-composition.
- `packages/core/src/parsers/htmlParser.ts`: guarded every
  `documentElement`-may-be-null access (parseHtml, updateElementInHtml,
  addElementToHtml, removeElementFromHtml, extractCompositionMetadata,
  validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or,
  for validateCompositionHtml's collect-and-report contract, a typed
  validation failure) instead of a raw crash.

Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested
sub-compositions (both happy path and broken-grandchild), and the happy path
— at the shared-helper, lint, and render pre-flight layers.

Not changed: the AI-agent authoring skills (skills/*). general-video and
hand-authored flows have no assemble-index.mjs equivalent to guard, so the
fix is at the CLI/render layer instead — flow-agnostic, covers every
authoring path, and the skills' existing "run lint/validate and stop on
failure" guidance now actually catches this class of mistake once run.

Not run in this environment: the producer package's full regression-harness
test suite (`bun test` in packages/producer) — it performs heavy real
rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and
did not complete in a reasonable time in this sandbox. Verified instead via
the targeted test file for all touched code (76/76 passing), whole-repo
typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code
gate, clean), and manual end-to-end CLI runs (render/lint/validate) against
reproduction projects, including a nested sub-composition scenario. CI
should run the full producer suite before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(parsers,lint): port empty-composition pre-flight to extracted packages

Rebased onto main, which extracted @hyperframes/lint from core (lint depends
only on parsers, not core). Relocate checkSubCompositionUsability from core to
@hyperframes/parsers so both core (inliner) and lint can consume it without a
core<->lint cycle; core keeps a @deprecated re-export shim.

Correctness fixes from code review:
- checkSubCompositionUsability now returns "no-composition-root" when the
  <template>/<body> content has no [data-composition-id] element (previously
  a marker-free placeholder body passed both guards).
- lint's missing/empty sub-composition rule now only checks files reachable
  via data-composition-src from the root (matching render pre-flight), instead
  of a raw filesystem walk that false-positived on orphaned files.
- drop `as string` cast in inlineSubCompositions in favor of an explicit
  null guard (per CLAUDE.md).

Review-comment items:
- move EmptyCompositionError JSDoc above the class (was above the adapter fn).
- correct stale circular-ref comment to match actual silent-skip behavior.
- rewrite self-contradicting lint message ("silently drop") to describe the
  new loud render-pre-flight abort.
- add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so
  it agrees with lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:25:44 -07:00
..

@hyperframes/producer

Full HTML-to-video rendering pipeline: capture frames with Chrome's BeginFrame API, encode with FFmpeg, mix audio — all in one call.

Install

npm install @hyperframes/producer

Requirements: Node.js >= 22, Chrome/Chromium (auto-downloaded), FFmpeg

Usage

Render a video

import { createRenderJob, executeRenderJob } from "@hyperframes/producer";

const job = createRenderJob({
  inputPath: "./my-composition.html",
  outputPath: "./output.mp4",
  width: 1920,
  height: 1080,
  fps: 30,
});

const result = await executeRenderJob(job, (progress) => {
  console.log(`${Math.round(progress.percent * 100)}%`);
});

console.log(result.outputPath); // ./output.mp4

Run as an HTTP server

The producer can also run as a render server, accepting render requests over HTTP:

import { startServer } from "@hyperframes/producer";

await startServer({ port: 8080 });
// POST /render with a RenderConfig body

Configuration

RenderConfig controls the render pipeline:

Option Default Description
inputPath Path to the HTML composition
outputPath Output video file path (or directory, for format: "png-sequence")
width 1920 Frame width in pixels
height 1080 Frame height in pixels
fps 30 Frames per second (24, 30, or 60)
quality "standard" Encoder preset ("draft", "standard", "high")
format "mp4" Output container — "mp4", "webm", "mov", or "png-sequence". See Transparent Video Output below.
videoFrameFormat "auto" Source video frame extraction format — "auto", "jpg", or "png". Use "png" for UI recordings, screen captures, and color-sensitive source videos.

Transparent Video Output

The producer can render HTML compositions to formats that carry a true alpha channel — not chroma key. The same composition that renders an opaque MP4 renders a layerable overlay when you set format.

format Codec / pixel format Alpha Audio Use case
"mp4" (default) H.264 (yuv420p) or H.265 + HDR10 No AAC Streaming, social, default deliverable
"webm" VP9 + yuva420p True alpha Opus Web playback as overlay (<video> over background); supported in Chrome, Edge, Firefox
"mov" ProRes 4444 + yuva444p10le True alpha + 10-bit AAC Editor ingest (Premiere, Final Cut Pro, DaVinci Resolve)
"png-sequence" Numbered RGBA PNGs in a directory Lossless alpha Sidecar audio.aac After Effects / Nuke / Fusion, or pipelines that post-process frames before encoding

Example

import { createRenderJob, executeRenderJob } from "@hyperframes/producer";

const job = createRenderJob({
  inputPath: "./my-composition.html",
  outputPath: "./output.webm", // or a directory for "png-sequence"
  width: 1080,
  height: 1920,
  fps: 30,
  format: "webm", // "mp4" | "webm" | "mov" | "png-sequence"
});

await executeRenderJob(job);

What "transparent background" means here

The producer captures Chrome screenshots with the page background forced transparent (html, body, [data-composition-id] { background: transparent !important }) and the CDP default background override set to RGBA 0,0,0,0. The captured PNGs carry a real alpha channel and that channel is preserved end-to-end:

  • VP9 (webm) is encoded with -pix_fmt yuva420p, -auto-alt-ref 0, -cpu-used 4 by default, and alpha_mode=1 metadata. Tune the speed/quality tradeoff with PRODUCER_VP9_CPU_USED (-8 to 8) or local CLI --vp9-cpu-used.
  • ProRes 4444 (mov) is encoded with -pix_fmt yuva444p10le.
  • PNG sequences are written without re-encoding (zero-padded frame_NNNNNN.png).

This is not chroma keying. There is no green/blue background to remove and no "key" tolerance to tune — pixels that were transparent in the browser are transparent in the output.

Caveats

  • Linux + alpha forces screenshot capture. Chrome's BeginFrame compositor (the default deterministic capture path on Linux headless-shell) does not preserve alpha; the orchestrator falls back to Page.captureScreenshot, which is slower per frame. macOS and Windows already use screenshot mode by default, so they are unaffected.
  • HDR + alpha is not supported. Setting hdr: true together with an alpha-capable format logs a warning and falls back to SDR. Use format: "mp4" for HDR10 output.
  • png-sequence does not produce a single muxed file. When the composition contains audio elements, an audio.aac sidecar is written alongside the PNGs in outputPath.
  • Safari + WebM alpha is incomplete. For broad browser playback of an alpha video, ship format: "mov" to your editor and re-encode for the codec your distribution target supports.

Authoring transparent compositions

Don't paint a fullscreen background in your HTML. The default body background is overridden to transparent automatically — any body { background: ... }, #root { background: ... }, or [data-composition-id] { background: ... } rule is force-overridden during alpha rendering. Backgrounds on inner elements (cards, scenes, components) are kept.

Distributed rendering

For renders too large for a single machine, the producer ships a public set of distributed-render primitives. They are pure functions over local file paths — networking and orchestration live in adapter packages (Temporal, AWS Lambda + Step Functions, Cloud Run Jobs, K8s Jobs).

import { plan, renderChunk, assemble } from "@hyperframes/producer/distributed";

// Controller-side: produce a self-contained planDir + content-addressed planHash.
const planResult = await plan(
  projectDir,
  { fps: 30, width: 1920, height: 1080, format: "mp4" },
  "/tmp/plan",
);

// Worker-side: render one chunk. Byte-identical retries on the same
// `(planDir, chunkIndex)` — Temporal / Step Functions retry policies are safe
// to point at this.
const chunk = await renderChunk("/tmp/plan", 0, "/tmp/chunks/0.mp4");

// Controller-side: stitch chunks into the final deliverable.
await assemble(
  "/tmp/plan",
  ["/tmp/chunks/0.mp4", "/tmp/chunks/1.mp4"],
  "/tmp/plan/audio.aac",
  "/tmp/output.mp4",
);

The three activity functions plus their result types are also re-exported from @hyperframes/producer so callers that pin the main package don't need a separate subpath import. Supported formats: mp4 SDR, mov ProRes 4444, and png-sequence. webm and HDR mp4 trip a typed FormatNotSupportedInDistributedError — use the in-process renderer (executeRenderJob) for those.

How it works

  1. Serve — spins up a local file server for the HTML composition
  2. Capture — opens the page in headless Chrome, seeks frame-by-frame via HeadlessExperimental.beginFrame (or Page.captureScreenshot for transparent / non-Linux renders), captures screenshots
  3. Encode — pipes frames through FFmpeg (with GPU encoder detection and chunked concat). Skipped for format: "png-sequence".
  4. Mix — extracts <audio> elements and mixes them into the final video. For png-sequence, audio is written as an audio.aac sidecar.
  5. Finalize — applies faststart for streaming-friendly MP4 (no-op for WebM, MOV, and png-sequence)

Documentation

Full documentation: hyperframes.heygen.com/packages/producer