VideoMobject — external-video ingestion
VideoMobject places an external video clip inside a scene: it is an
ImageMobject whose displayed bitmap is swapped, per scene frame, to the clip’s
frame for the current time. The clip stays in sync with scene time (updaters
accumulate dt), so it plays through play() and wait() like any other
mobject, and it composes with the partial-movie cache and parallel rendering
because the decode is deterministic.
The decode is frame-accurate by construction — instead of relying on
<video>.currentTime seeking (which snaps to keyframes), each backend extracts
the exact frame for a target time up front. The core (VideoMobject) is
isomorphic and depends only on a small VideoFrameProvider interface; the Node
and browser entry points each supply a provider.
Node (ecmanim/node)
Section titled “Node (ecmanim/node)”Frames are extracted with ffmpeg into a content-hash-keyed decode cache, then pre-decoded into memory so per-frame lookup is a synchronous index.
import { render, loadVideo, Scene, Text, Write } from "ecmanim/node";
class Clip extends Scene { async construct() { const video = await loadVideo("input.mp4", { width: 7, // on-screen width in scene units (aspect preserved) fps: 30, // decode fps (default = scene/source fps) scene: this, // required to mux audio audio: true, // extract the clip's audio track into the render start: 0, end: 4, // optional trim (seconds) playbackRate: 1, // optional speed loop: false, // optional }); this.add(video); // starts playing as scene time advances await this.play(new Write(new Text("hi"))); await this.wait(2); // the clip keeps playing through the wait }}
await render(Clip, { output: "out.mp4", fps: 30 });- Audio. With
{ scene, audio: true }, the clip’s audio is extracted and registered viascene.addSound(...)ataudioOffset(default = the scene time whenloadVideois called); the existing audio-mux path folds it into the render. The output mp4 then carries both video and audio streams. - Decode cache. Keyed by
(abs path + mtime + fps + scale + trim)under<tmpdir>/ecmanim-video/<hash>(override withcacheDir). A warm cache skips ffmpeg entirely and is byte-identical, so it plays nicely with the content-hash partial-movie cache. - Memory. All target frames are decoded to RGBA in memory
(~
frames × W × H × 4bytes; a 10s 1080p@30 clip ≈ 2.5 GB). Bound it withscale/width(downscale to on-screen size),fps(decode at scene fps, not source), andstart/end(trim to the played span).frameAt()must be synchronous, so there is no lazy/streaming path by design. - Lower-level helpers
probeVideo(path)andextractFrames(path, opts)are exported too.
Browser (ecmanim/browser)
Section titled “Browser (ecmanim/browser)”import { loadVideo, play } from "ecmanim/browser";
const video = await loadVideo("clip.mp4", { fps: 30, width: 7 }); // mode: "auto"// ...add to a Scene and play()/record() as usual.Three providers back it, selected by mode (default "auto"):
webcodecs— frame-accurate + single-pass (preferred). Demuxes an mp4/mov withmp4box.jsand decodes the whole stream in one pass through a WebCodecsVideoDecoder, then resamples the decoded frames onto the target fps grid asImageBitmaps. Far faster than seek-and-capture (one decode pass vs. O(frames) seeks). Requires a browser with WebCodecs and an mp4/mov (h264/h265/av1) source.precapture— frame-accurate, dependency-free fallback. Seeks a<video>to each target time, waits forseeked, and captures the frame into anImageBitmap. Works for any format the<video>can play; slower.live— real-time. Wraps a playing<video>and draws whatever it currently shows. Low-latency, not frame-accurate; right for liveplay().
"auto" uses webcodecs for a URL source when the browser supports it and
transparently falls back to precapture otherwise (or on any demux/decode
failure). Force a path with mode: "webcodecs" | "precapture" | "live";
webcodecs surfaces errors instead of falling back.
loadVideo accepts a URL or an HTMLVideoElement and normalizes it
(crossOrigin, muted, playsInline, awaits loadedmetadata). mp4box is
imported lazily (never at module load), so the module stays Node-import-safe and
unbundled-browser-safe; calling loadVideo without a DOM throws a clear error.
In an unbundled browser, add mp4box to your import map (like gifenc /
mp4-muxer) or use a bundler.
Limitations
Section titled “Limitations”- Both backends hold all target frames in memory (see above) — downscale/trim long clips.
- Browser
webcodecsis mp4/mov only (mp4box demux); other formats use the slowerprecaptureseek-and-capture fallback. - 3D/vector renderers draw a
VideoMobjectlike any rasterImageMobject(projected bbox); it is not a textured 3D surface.