Rendering

Turning a compiled scene into pixels — SceneRenderer, defineSceneComposition, and the backend seam.

Rendering is deliberately dumb. A renderer resolves a frame, reads the state, and draws. It never owns a clock, never decides what comes next, and never writes authored state.

SceneRenderer

The general path. SceneRenderer resolves the frame, dispatches each resolved actor to its registered renderer, and memoizes by default.

<SceneRenderer compiled={compiled} frame={frame} width={1920} height={1080} />

Use it when actors have registered renderers — that is, for reusable families where the scene does not know in advance what it contains.

defineSceneComposition

The Remotion path. It supplies the render runtime, binds the Remotion host, derives duration from the compiled scene through calculateMetadata, and hands your render callback the resolved frame.

export const CardComposition = defineSceneComposition({
  id: "ActorCard",
  width: 1280,
  height: 720,
  fps: FPS,
  buildScene,
  render: ({ compiled, frame, width }) => {
    const { card, caption } = resolveScene(compiled, frame, width * 0.75).scene;
    if (!card || !caption) return <AbsoluteFill />;

    return (
      <AbsoluteFill className="items-center justify-center bg-slate-950 text-white">
        <div
          className="overflow-hidden rounded-xl bg-indigo-600 p-8 text-6xl whitespace-nowrap"
          style={{ width: card.revealWidth, opacity: card.progress }}
        >
          {caption.text}
        </div>
      </AbsoluteFill>
    );
  },
});

export function RemotionRoot() {
  return <Composition {...CardComposition.remotion} />;
}

registerRoot(RemotionRoot);

Direct render or dispatched render

A small composition can render its typed scene exports directly, as above. Reusable visual families should register renderers and let SceneRenderer dispatch instead — otherwise every composition has to know every actor's markup.

Options

Prop

Type

Passing host values

A renderer knows things the scene cannot: the real width, a measured box, a decoded video frame. Those enter as channels — and the safest form is a per-resolution override, because it writes nothing:

export function resolveScene(compiled, frame: number, width: number) {
  const card = compiled.at(frame).scene.card;
  if (!card) return compiled.at(frame);

  return compiled.at(frame, {
    channelValues: new Map([[createCellKey(card.id, "availableWidth"), width]]),
  });
}

React may render the same frame twice. A publication would make the second render depend on the first; an override does not.

Backends

PackageSurface
@motionactor/domDOM/React — the release target
@motionactor/threeThree.js
@motionactor/pixiPixi layer host
@motionactor/skiaSkia layer host

@motionactor/react currently re-exports the DOM backend, so importing either is equivalent.

What a renderer must not do

  • Hold a timer or a requestAnimationFrame loop. The frame number is an input.
  • Write authored state. Publish to a channel instead.
  • Compute presentation geometry the actor could have computed. Put it in a derived so tests and other backends get it too.

On this page