@busyexplore/zotonic-video-engine
v0.0.1-beta.0
Published
A React hook that wraps a canvas-based video compositor — feed it frames drawn to a `<canvas>`, an audio track, and a frame range, and it composites, crops, and exports a video. Processing/render state is broadcast as events via the re-exported `useWatchC
Readme
@busyexplore/zotonic-video-engine
A React hook that wraps a canvas-based video compositor — feed it frames drawn to a <canvas>, an audio track, and a frame range, and it composites, crops, and exports a video. Processing/render state is broadcast as events via the re-exported useWatchCommandEmitter, so distant components can react without prop drilling.
Features
- 🖼️ Frame-by-frame canvas compositing — draw each frame yourself, hand it to the engine, and it assembles the video
- ✂️ Center-crop support — render to a larger "working area" (e.g. for overscan/bleed) and crop down to the camera region on export
- 🔊 Audio muxing — attach an audio source and a frame range to sync it against your rendered frames
- 📡 Broadcasts processing/render lifecycle events (start, stop, rendering progress, update, error, ready) so a progress bar or toast can live anywhere in your app
- ⚛️ Single hook API —
useZotonicVideoEngine()
Installation
npm install @busyexplore/zotonic-video-engineyarn add @busyexplore/zotonic-video-enginepnpm add @busyexplore/zotonic-video-engineQuick start
import { useRef, useState } from "react";
import {useZotonicVideoEngine} from "@busyexplore/zotonic-video-engine";
function Renderer() {
const engine = useZotonicVideoEngine();
const canvasRef = useRef(null);
const [rendering, setRendering] = useState(false);
const render = async () => {
setRendering(true);
engine.setExternalCanvasInfo(1280, 720, 2560, 1440); // camera size, working-area size
engine.setFPS(24);
engine.setTotalFrames(24 * 3); // 3 seconds
await engine.loadAudio(audioInput, 0, 24 * 3);
canvasRef.current.width = 2560;
canvasRef.current.height = 1440;
for (let frame = 0; frame < engine.getTotalFrames(); frame++) {
const ctx = canvasRef.current.getContext("2d");
// ...draw your scene for this frame onto ctx...
await engine.drawFrame(frame, canvasRef.current, true); // true = crop to camera region
}
await engine.exportVideo(false, true, "animation.webm");
setRendering(false);
};
return (
<div>
<canvas ref={canvasRef} width={1280} height={720} />
<button onClick={render} disabled={rendering}>
{rendering ? "Rendering…" : "Render"}
</button>
</div>
);
}API
useZotonicVideoEngine()
React hook, no arguments. Fires ACTION_READY shortly after mount. Returns:
| Key | Type | Description |
|---|---|---|
| setExternalCanvasInfo | (cameraWidth?, cameraHeight?, workingAreaWidth?, workingAreaHeight?) => void | Sets the visible "camera" (output) size and the larger "working area" you draw onto. Defaults to 1280×720 for all four if omitted. |
| setFPS | (fps?: number) => void | Sets the frame rate (default 24). |
| setTotalFrames | (totalFrames?: number) => void | Sets how many frames the render will produce (default 24). |
| getTotalFrames | () => number | Returns the total frame count previously set. |
| loadAudio | (audioInput, startFrame?: number, endFrame?: number) => Promise<void> | Attaches an audio source, trimmed/aligned to [startFrame, endFrame]. |
| drawFrame | (currentFrame: number, sourceCanvas: HTMLCanvasElement, cropCenter?: boolean) => Promise<void> | Takes the frame you drew on sourceCanvas (at working-area size) and commits it as currentFrame. If cropCenter is true, crops down to the centered camera region set via setExternalCanvasInfo. |
| nextFrame | (currentFrame?: number) => void | Advances the engine's internal frame cursor. |
| getCanvaContext | () => CanvasRenderingContext2D | Returns the engine's internal canvas 2D context. |
| exportVideo | (returnBuffer?: boolean, autoDownload?: boolean, fileName?: string) => Promise<Buffer \| void> | Finalizes and exports the rendered video. If returnBuffer is true, resolves with the file's buffer; if autoDownload is true, triggers a browser download as fileName (default "file.webm"). |
| dispose | () => void | Tears down the current engine instance and replaces it with a fresh one, carrying over the current fps and totalFrames. |
| useWatchCommandEmitter | hook | Re-exported so other components can subscribe to video-engine events. See Listening for events. |
setExternalCanvasInfo,setFPS, andsetTotalFramesconfigure the next render — call them before yourdrawFrameloop, not mid-loop.
VIDEO_ENGINE_TOOL
Command/action name constants, importable directly:
import { VIDEO_ENGINE_TOOL } from "@busyexplore/zotonic-video-engine";
export const VIDEO_ENGINE_TOOL = {
ACTION_START_PROCESSING: "START_PROCESSING",
ACTION_STOP_PROCESSING: "STOP_PROCESSING",
ACTION_RENDERING: "ACTION_RENDERING",
ACTION_UPDATE: "update",
ACTION_READY: "ready",
ACTION_ERROR: "ERROR",
VIDEO_ENGINE_COMMAND: "VIDEO_ENGINE",
};| Action | Emitted when |
|---|---|
| ACTION_READY | The hook has mounted and the engine is ready to use |
| ACTION_START_PROCESSING | A render/export operation begins |
| ACTION_RENDERING | A frame has been drawn/committed — event data includes current (frame index), useful for progress tracking |
| ACTION_UPDATE | Engine state has changed and fresh data is available — event data is the relevant payload |
| ACTION_STOP_PROCESSING | A render/export operation finishes (success path) |
| ACTION_ERROR | An operation (e.g. loading audio, exporting) failed — event data has a message |
All events are emitted under VIDEO_ENGINE_TOOL.VIDEO_ENGINE_COMMAND, so filter on that first, then switch on action.
Listening for events
Because useWatchCommandEmitter is re-exported from this package, any component can subscribe to render progress without receiving props from the component that owns the render loop. It subscribes for the lifetime of the calling component and cleans up automatically on unmount:
import { useZotonicVideoEngine,VIDEO_ENGINE_TOOL, useWatchCommandEmitter } from "@busyexplore/zotonic-video-engine";
function RenderProgress() {
const { useWatchCommandEmitter } = useWatchCommandEmitter();
useWatchCommandEmitter((command, action, data) => {
if (command !== VIDEO_ENGINE_TOOL.VIDEO_ENGINE_COMMAND) return;
switch (action) {
case VIDEO_ENGINE_TOOL.ACTION_START_PROCESSING:
console.log("Render started…");
break;
case VIDEO_ENGINE_TOOL.ACTION_RENDERING:
console.log("Frame:", data.current);
break;
case VIDEO_ENGINE_TOOL.ACTION_STOP_PROCESSING:
console.log("Render finished");
break;
case VIDEO_ENGINE_TOOL.ACTION_ERROR:
console.error(data.message);
break;
}
});
return null;
}This is useful for surfacing a global progress bar or toast from a component that isn't the one calling drawFrame/exportVideo.
Behavior notes
drawFrameexpects working-area coordinates — draw your full scene (including any off-camera bleed) at theworkingAreaWidth/workingAreaHeightsize set insetExternalCanvasInfo, then letcropCenter: truehandle cropping down to the camera region on export.- Configure before you loop —
setExternalCanvasInfo,setFPS, andsetTotalFramesshould be called once before thedrawFrameloop starts; changing them mid-render is not supported. - Events are global, not scoped to the calling component — every subscriber in your app receives every emitted command, so filter by
command(andaction) in your listener. disposepreserves fps/totalFrames — calling it tears down and replaces the underlying engine instance, but the frame rate and total frame count you'd already set carry over to the new instance.exportVideocan both return and download — passreturnBuffer: trueif you need the raw file (e.g. to upload it) in addition to, or instead of, triggering a browser download.
Happy rendering! 🎬
