lehar-render
v0.5.0
Published
A drop-in Remotion renderer: bundle an unmodified Remotion composition and render it to video with the lehar engine (headless-Chrome capture + in-process FFmpeg). renderMedia()-shaped API, a bundle/render CLI, and a live in-browser player with audible med
Maintainers
Readme
lehar-render
A drop-in Remotion renderer. Bundle an unmodified Remotion composition and render it to video with the lehar engine (headless-Chrome capture + in-process FFmpeg) instead of Remotion's own renderer — same compositions, a different (faster) engine underneath.
npm install lehar-renderRequires the lehar binary at render time: point
LEHAR_BINARY_PATHat it, or haveleharon yourPATH. (Automatic per-platform binary provisioning is planned; today the binary is supplied by the host — e.g. baked into a Docker image.)
Two steps: bundle, then render
1. Bundle the composition into a lehar template (a build-time step):
npx lehar-render bundle --entry src/remotion/index.ts --out dist/lehar-bundle --public public2. Render it — programmatically, via the renderMedia-shaped API a service
swaps in for @remotion/renderer:
import { renderMedia } from 'lehar-render';
await renderMedia({
serveUrl: 'dist/lehar-bundle', // the bundle dir from step 1
outputLocation: 'out.mp4',
durationInFrames: 150,
fps: 30,
width: 1920,
height: 1080,
inputProps: { /* your composition props */ },
onProgress: ({ progress }) => console.log(`${Math.round(progress * 100)}%`),
});…or from the CLI:
npx lehar-render render --serve-url dist/lehar-bundle -o out.mp4 --duration-in-frames 150 --fps 30.
Audio: lehar captures pixels only, so <Audio> renders as a visual no-op — pass
a mixed soundtrack via audioManifest (a lehar --audio-manifest JSON the
caller computes from its timeline). See below.
The bundler esbuild-aliases remotion and @remotion/media-utils to this
package; the composition's own entry point (registerRoot(...)) becomes a
lehar-renderable index.html + bundle.js.
How it maps onto the lehar contract
registerRoot()mounts the tree and installswindow.__LEHAR_READY. Per captured frame the hook: commits the new frame synchronously (flushSync), waits for every opendelayRenderhandle (fonts,<Img>decodes,<Video>metadata,useAudioDatadecodes), then runs per-frame tasks (<Video>seek + canvas draw). It never awaitsrequestAnimationFrame.- Width/height/fps/duration come from
window.__LEHAR(the CLI flags), not the<Composition>registration — the same overriderenderMedia()performs. Props merge{...defaultProps, ...window.__LEHAR_PROPS}. <Video>implements the settled seek+draw design (docs/spike-video-sources.md): hidden<video>, visible<canvas>, drawImage inside the frame gate. Sources must be all-intra conformed.<Audio>renders nothing: lehar captures pixels; the soundtrack is mixed in-process by the CLI from--audio-manifest(the invoking service computes the manifest from the same timeline data).- Opening the bundle in a plain browser (no lehar) shows frame 0 with the registered defaults — handy for debugging.
Supported surface (exactly what the host app's Timeline uses)
AbsoluteFill, Sequence (from, durationInFrames, layout="none"),
Img, Video (startFrom, endAt, loop; volume/muted accepted and
ignored), Audio (visual no-op), Composition, registerRoot,
useCurrentFrame, useVideoConfig, interpolate, Easing, random,
staticFile, delayRender/continueRender/cancelRender;
@remotion/media-utils: useAudioData, visualizeAudio, getAudioData.
Anything else (spring, OffthreadVideo, <Series>, premountFor,
playbackRate≠1, lazyComponent, ...) fails at bundle time with a
"no matching export" error, or throws at mount — loud by design. Extend the
shim deliberately, with a parity test, when a composition needs more.
Exactness
interpolate, Easing, random, staticFile and the whole
visualizeAudio FFT chain are line-for-line ports of [email protected] (MIT),
quirks included — tests/parity.test.mjs pins them bit-for-bit against
vectors generated from the real packages (npm run gen-reference, which
needs a node_modules containing remotion, e.g. the host app's). Regenerate the
reference only when deliberately tracking a new upstream version.
Deliberate deviations (all invisible to the host app's composition):
getAudioDataids results with a counter, notMath.random()(lehar determinism dogma; it's only a cache key), and skips upstream's 3-concurrent-fetch throttle.useAudioDatacommits its state viaflushSyncbefore clearing its delayRender handle. Under Remotion the renderer re-polls the page, so async scheduling is fine; under lehar the frame gate captures immediately after handles clear, so the decoded-state DOM must already be committed. Any future async-state-behind-delayRender in the shim must follow this rule.<Img>fails the render on a broken image without upstream's retries.
Porting the host app Timeline
The whole port is one bundle invocation — the composition's own entry, unmodified:
node scripts/bundle.mjs --entry <remotion-project>/src/remotion/index.ts \
--out dist/timeline \
--public <remotion-project>/public
lehar render dist/timeline/index.html --props <props.json> --fps 25 --duration <s> ...Validated bit-identical to real Remotion on photo/DOM frames and
frame-exact on video frames — method, numbers, and the two operational
requirements (asset URLs must be absolute; the asset server must support
HTTP Range) in docs/timeline-port-validation.md. A reproduction props
file is at examples/timeline-validation-props.json.
Live player (lehar-render/player)
The same contract that makes renders deterministic makes templates playable
live: the player drives window.__LEHAR + await window.__LEHAR_READY from a
requestAnimationFrame clock in an ordinary browser — no headless Chrome, no
capture, no ffmpeg. Instant preview and free scrubbing; export stays
lehar render. Design and measured numbers: docs/design-live-player.md in
the lehar repo.
import { createLeharPlayer } from 'lehar-render/player';
const player = createLeharPlayer({
container: document.querySelector('#preview'),
src: '/templates/timeline/index.html', // same-origin, any lehar template
fps: 25, width: 1920, height: 1080, durationInSeconds: 12,
props: { clips: [] }, // installed at document-start
});
player.play(); player.seek(120);
player.setProps(next); // live editing: re-presents the current frame
// React apps (host app provides React >= 18):
// import { LeharPlayer } from 'lehar-render/player/react';
// <LeharPlayer src="..." fps={25} width={1920} height={1080}
// durationInSeconds={12} props={timelineProps} autoplay loop />The player must be same-origin with the template (it reaches into the
iframe's window). It loads the template through a document-start bootstrap
that installs __LEHAR_PROPS / __LEHAR_COMPOSITION and sets
window.remotion_staticBase to the template's directory, so staticFile()
assets resolve when the template is served under a path prefix (under
lehar render the template directory is the origin root, so this never
comes up).
Controls chrome: the built-in bar auto-hides after ~2.5 s idle (playing
or paused) so bottom-positioned template overlays (lyrics, captions) read
exactly like the chrome-less capture; pointer activity, transport changes,
or keyboard focus summon it back, and the cursor hides with it. The video
area is one pointer surface owned by the player (a transparent overlay above
the template iframe — required, or events over the video would vanish into
the iframe and hidden chrome could never be summoned in a snug embed): click
toggles play/pause, double-click toggles fullscreen, and on the focused
player Space toggles, ArrowLeft/Right steps ±1 frame (Shift ±10), F
fullscreens, M mutes. With controls: false no click behavior is attached,
but pointer events still land on the host — bubbling to your own listeners —
instead of disappearing into the iframe. Compositions are pure functions of
__LEHAR.frame (non-interactive by contract), so the inert stage costs
nothing.
Media playback (P2): the bootstrap also marks the document with
window.__LEHAR_PLAYBACK, and the player injects per-frame transport into
__LEHAR (playback/playing/playbackRate/volume — additive fields the
renderer never sets, so capture output is untouched). Under a player,
<Video> plays natively (drift-corrected against the player clock instead
of capture's per-frame seek+draw) and <Audio> is a real audible element —
an unmodified composition's music/clip audio, trims, loops, ducking and
fades preview correctly because the audio markup is the same source of
truth the render manifest is derived from. Requirements: the origin serving
media must support HTTP Range (Chromium cannot seek unbuffered media
without it — same requirement as the renderer's asset server), and audible
autoplay is subject to browser policy: a blocked unmuted play() falls
back to muted playback, the player emits 'autoplayblocked', and the
controls' unmute button (a user gesture) restores sound. Set
window.__LEHAR_MEDIA_TRACE = [] on the template window to log every media
reconcile decision when debugging.
Pass prefetchMedia (player option / React prop) with the playable-media
universe in playback order — every clip source plus the music bed, which
the integrating app knows from its own timeline model. The shim treats the
list as a schedule, not an order to buffer everything now: audio beds and a
rolling window of the next few clips warm ahead of the playhead (bounded
concurrency, re-anchored by every clip mount so it follows seeks both
directions); everything beyond the window is untouched until the playhead
approaches. Warm boundary mounts start without the cold-load silence the
render wouldn't have, while a many-clip montage against a remote/metered
media origin (presigned URLs, per-host connection limits) never sees the
whole timeline requested at once. Playback-mode deviations: <Video> forwards style/className to
the pooled media element with React semantics; other DOM props (id,
crossOrigin, data attributes) are not forwarded, and a display:contents
wrapper div is visible to structural selectors.
Tests
npm test— bit-for-bit parity of the pure ports (node:test, no build).npm run build-smoke— regeneratestests/fixtures/remotion_shim_smoke/in the lehar repo: a committed, built smoke composition whose probe squares (frame bit-strip, Sequence windowing, Img gating, props flow, WebAudio decode) are pixel-asserted bytests/shim_smoke.rsunder the real engine, plus a bit-determinism check. Rebuild the fixture whenever shim runtime code changes, in the same commit.node scripts/build-bench-timeline.mjs— regenerates lehar's FROZENbench/timeline/scenario (the host app Timeline + self-contained synthetic assets). Unlike the smoke fixture this is NOT rebuilt on shim changes: it is a benchmark workload, and regenerating it starts a new measurement series inbench/history.jsonl(pins intests/constraints.rs; E2E guard intests/bench_timeline.rs).
