Files
hyperframes/packages/engine
Xuanru Li e786b78b33 feat(producer): renderStretch to re-time short compositions across longer scenes (#2676)
Linear: VA-1859

## Problem

For a `fit_to_scene` B-roll where the composition's intrinsic timeline (e.g. `data-duration=1.0s` → 30 frames) is shorter than the scene it fills (e.g. 4.8s narration), the producer renders only the intrinsic 30 frames and the downstream compositor frame-holds/PTS-stretches that fixed clip to the scene length. Spreading 30 unique frames over 4.8s starves motion to ~6 effective fps → a visibly choppy result. Root cause: the producer welds one `composition.duration` to both the frame count and the 1:1 seek mapping, with no notion of a target output length.

## Fix

Add optional `renderStretch: number` (default `1.0` = no-op), `renderStretch = intrinsic / target`:

- **Frame count** comes from the target: `outputDuration = intrinsic / renderStretch`, `totalFrames = outputDuration × fps` (`probeStage.ts`). `composition.duration` stays intrinsic (drives video/audio windows).
- **Per-frame seek** is scaled: `time = (frameIndex / fps) × renderStretch`, so the N output frames map across `[0, intrinsic]` — a fresh frame per output frame.

All seek sites go through a single shared `outputFrameToTimelineSeconds(frameIndex, fps, renderStretch)` helper (`core.types.ts`), consumed by every capture path so none can silently diverge:
- parallel (`parallelCoordinator.ts`), `sdr_streaming` (`captureStreamingStage.ts` ×3), `sdr_disk` (`captureStage.ts`), HDR loops.
- DrawElement + static self-verify (`frameCapture.ts`) — ground-truth seek uses the same mapping, so PSNR compares like-for-like (no spurious verification failure on stretched comps).
- Distributed path: `renderStretch` threaded through `DistributedRenderConfig` → chunk workers, and **folded into the plan hash only when `!= 1`** so a pre-stretch cached plan is never reused.

With `renderStretch = 1` (or omitted → `?? 1`): every seek is `×1.0` (IEEE-754 identity), frame counts unchanged, and the plan hash is byte-identical — a provable no-op. `player.ts` absolute-seek is untouched.

## Verify

- typecheck (core + engine + producer): pass. lint/format/fallow/commitlint: pass. `planHash` + `renderRequest` unit suites: pass.
- Adversarial self-review found + fixed three capture-path gaps (streaming, self-verify, distributed) before this revision.
- **Not yet runtime-verified** on a real render — needs a fit_to_scene render at `renderStretch < 1` confirming N distinct frames over the target length (draft until then).

Paired with experiment-framework#42766, which computes and forwards `renderStretch = hf intrinsic / scene duration`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-21 13:30:44 -07:00
..
2026-07-21 14:36:22 +02:00
2026-03-21 22:43:56 -07:00

@hyperframes/engine

Seekable web-page-to-video rendering engine built on Puppeteer and FFmpeg.

Framework-agnostic: works with GSAP, Lottie, Three.js, CSS animations, or any web content that implements the window.__hf seek protocol.

Install

npm install @hyperframes/engine

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

What it does

The engine opens your HTML composition in a headless Chrome instance, seeks frame-by-frame using Chrome's HeadlessExperimental.beginFrame API, captures screenshots, and encodes them into video with FFmpeg.

Key services

Service Description
browserManager Launches and pools headless Chrome instances (chrome-headless-shell)
frameCapture Manages capture sessions — seek, screenshot, buffer lifecycle
screenshotService BeginFrame-based capture with CDP (Chrome DevTools Protocol)
chunkEncoder FFmpeg encoding with chunked concat, GPU detection, faststart
streamingEncoder Pipe frames to FFmpeg in real time (no intermediate PNGs on disk)
audioMixer Parse <audio> elements and mix audio tracks via FFmpeg
videoFrameExtractor Extract frames from <video> elements for compositing
parallelCoordinator Split frame ranges across worker processes
fileServer Serve local HTML files to the browser via Hono

Usage

import {
  acquireBrowser,
  createCaptureSession,
  initializeSession,
  captureFrame,
  closeCaptureSession,
} from "@hyperframes/engine";

// 1. Launch browser
const browserLease = await acquireBrowser({ captureMode: "beginFrame" });

// 2. Open a capture session
const session = createCaptureSession({
  browser: browserLease.browser,
  url: "http://localhost:3000/my-composition.html",
  width: 1920,
  height: 1080,
  fps: 30,
});
await initializeSession(session);

// 3. Capture frames
for (let i = 0; i < totalFrames; i++) {
  await captureFrame(session, i, `/tmp/frames/frame-${i}.png`);
}

// 4. Clean up
await closeCaptureSession(session);
await browserLease.release();

Most users should use @hyperframes/producer or the hyperframes CLI instead of calling the engine directly.

Documentation

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