Skip to content
Rendering

Rendering

@miraiclip/renderer turns a project document into pixels and sound: a WebCodecs media pipeline decodes video, a PixiJS compositor mirrors state.doc onto a canvas, a Web Audio engine schedules the mix, and the audio clock is the playback master. The core stays headless; the renderer is framework-agnostic but browser-only (WebCodecs, WebGL, Web Audio).

Install

npm install @miraiclip/core @miraiclip/renderer
# pixi.js and mediabunny are pulled in as dependencies

Quick start

createPlayer wires everything — media pipeline, compositor, audio engine, master clock, transport:

import { createProject } from "@miraiclip/core";
import {
  createPixiBackend,
  createPlayer,
  createWebAudioOutput,
  createWebCodecsDecoderFactory,
  isWebCodecsSupported,
  openMediabunnyAudio,
  openMediabunnyDemuxer,
} from "@miraiclip/renderer";

if (!isWebCodecsSupported()) throw new Error("WebCodecs required");

const project = createProject({ width: 1280, height: 720, fps: 30 });
// …dispatch asset/track/clip commands…

const backend = await createPixiBackend({ canvas, width: 1280, height: 720 });
const player = createPlayer(project, {
  backend,
  openDemuxer: openMediabunnyDemuxer, // mediabunny: MP4/MOV/WebM/MKV…
  // Proxy preview: cap decoded frames at preview resolution — full-res 4K60
  // drops frames on most machines. Layout is unaffected (fit-to-composition).
  createDecoder: createWebCodecsDecoderFactory({ maxOutputDimensionPx: 1920 }),
  audioOutput: createWebAudioOutput(),
  openAudio: openMediabunnyAudio,
  onError: (error, clipId) => showToast(`clip ${clipId}: ${error.message}`),
});

player.play(); // pause() · seek(us) · setRate(r) · timeUs · durationUs · destroy()

The player renders every animation frame and pushes the playhead into the core as ephemeral state — scrubbing never pollutes undo history. Any dispatched command (undo/redo and collaborator patches included) updates the scene granularly.

Creative features

Everything below is a command away — and because export drives the same compositor, whatever you build here looks and sounds identical in preview, browser export, and server export:

  • Animation — keyframe any clip property with easings.
  • Effects — color adjust, blur, chroma key per clip.
  • Transitions — dissolve, dips, wipe, slide across a cut.
  • Captions — reels-style karaoke text with word timing.

Layout

A clip’s transform.scale of 1 means “fit the composition” (contain, aspect preserved) — a 4K source on a 720p canvas fills the frame, never a native-pixel center crop. x/y are normalized composition coordinates. Rendered size is independent of decode resolution, so proxy playback and full-resolution decode produce the same layout.

Audio

Audio clips — and the embedded tracks of video clips — are decoded in streaming windows (never whole-file PCM) and scheduled on a WebAudio graph: one gain lane per clip, mixing clip volume with track muted/solo live. Because the master clock is the audio output’s clock, sound and the frames chasing the clock cannot drift apart.

The three layers

Media pipelineMediaManager owns one VideoPipeline per asset (clips sharing a source share it), capped at a decoder budget with LRU release. Pipelines stream continuously with a decode-ahead window; seeks are frame-accurate via the WebCodecs settle pattern.

Compositor — a scene graph behind the SceneBackend interface (PixiJS in production, a fake in tests). Clip kinds map to nodes through a factory registry — the extension seam custom clip kinds use.

ClocksRealtimeClock (wall time, rate control) powers preview; a step clock drives export frame by frame through the same compositor.

Bring your own clip kind

Custom clip kinds are a first-class seam — the same one built-in video uses. Register the kind’s schema in core, then hand the player (and export — same factories, so preview and export can’t diverge) a factory that builds its scene node:

import { registerClipKind } from "@miraiclip/core";
import { z } from "zod";

registerClipKind("countdown", {
  propsSchema: z.object({ from: z.number().int().positive() }),
  trackKinds: ["video"],
});

const factories = {
  countdown: (clip, { backend }) => {
    const asText = (c, seconds) =>
      ({ ...c, kind: "text", text: String(seconds), fontFamily: "monospace", fontSizePx: 96, color: "#ffffff" });
    const inner = backend.createText(asText(clip, clip.props.from));
    return {
      setPlacement: (placement) => inner.setPlacement(placement),
      setVisible: (visible) => inner.setVisible(visible),
      setZ: (z) => inner.setZ(z),
      update: () => undefined,
      // tick runs every rendered frame — time-dependent content lives here.
      tick: (c, timeUs) => inner.update(asText(c, Math.max(0, Math.ceil(c.props.from - (timeUs - c.startUs) / 1_000_000)))),
      destroy: () => inner.destroy(),
    };
  },
};

const player = createPlayer(project, { ...playerOptions, factories });
await exportProject(project, { format: "mp4", factories }); // identical pixels

project.dispatch({
  type: "clip/add",
  payload: { kind: "countdown", trackId: "overlay", startUs: 0, durationUs: 5_000_000, props: { from: 5 } },
});

A factory returns any SceneNode (setPlacement/setVisible/setZ/update/optional tick/destroy) — build on the backend’s primitives or drive your own drawing. One caveat: factories are functions, so @miraiclip/server-export (a process boundary) supports built-in kinds only for now.

Exact frames

videos.renderFrameAt(compositor, timeUs) awaits decode and draws one exact frame — the primitive for thumbnails, posters, and export. videos.prepare(timeUs) pre-decodes around a position without drawing.

Browser support

WebCodecs is required: Chrome/Edge 94+, Safari 16.4+, Firefox 130+. isWebCodecsSupported() gates the whole pipeline; per-asset codec problems surface as UnsupportedMediaError so one bad asset never takes down the renderer.

Try it

The repo’s playground exercises everything against real files — including one-click preset cards for effects, text, animation, captions, and transitions:

pnpm install
pnpm --filter miraiclip-playground dev
# open the URL, pick an MP4 — or load one by URL: /?src=/your.mp4

# golden-frame e2e suite:
pnpm --filter miraiclip-playground e2e