---
title: Media, assets and fonts
description: How a scene names files, how hosts load them, and how video and audio play frame by frame.
---

A scene never holds bytes. Its signals hold asset references, small JSON values that name a
file. A host turns each reference into a URL through a resolver, loads what the composition
uses before showing frame 0, and holds every later frame until its images are decoded and
its videos show the right picture.

## Asset references

`image`, `video`, `audio` and `font` from `@motionactor/schema` each make references of
their type:

```ts twoslash
import { assetKey, image, video } from "@motionactor/schema";

const hero = image.file("photos/hero.jpg");
// { type: "image", kind: "file", src: "photos/hero.jpg" }

const trailer = video.url("https://cdn.example.com/trailer.mp4");
assetKey(trailer); // "url:https://cdn.example.com/trailer.mp4"
```

| Constructor | Names | Resolves to |
| --- | --- | --- |
| `.file(src)` | A file the host serves | `src` under the resolver's `publicBase` |
| `.url(src)` | An absolute URL | `src` itself |
| `.bytes(data, { mimeType })` | Bytes the scene carries | An object URL made from the bytes |
| `.blob(hash, { mimeType })` | Bytes a store keeps by SHA-256 | `blobBase` followed by the hash |

A signal declared with `signal.of(image)` accepts only image references. A reference with a
`mimeType` outside its type's range throws when it is made:
`image.file("a.png", { mimeType: "video/mp4" })` fails with "Invalid image ref: mimeType must
match image/\*".

## Resolvers

An `AssetResolver` has `url(ref)`, which must answer synchronously because views render
synchronously, and `bytes(ref)` for decoders. An optional `prepare(refs)` runs once with
every reference the composition uses, and a promise it returns holds the first frame.
That includes each reference a derived or bound field resolves to over the timeline. A reference
a host will publish on a channel later is not known yet, so `prepare` cannot see it.

```ts twoslash
import { createDefaultResolver } from "@motionactor/engine/react";
import { image } from "@motionactor/schema";

const resolver = createDefaultResolver({
  publicBase: "https://assets.example.com/reel/",
  blobBase: "https://store.example.com/blobs/",
});

resolver.url(image.file("photos/hero.jpg"));
// "https://assets.example.com/reel/photos/hero.jpg"
```

`locate(ref)` comes first when you supply it: return a URL to take over a reference, such as
one in a cache, or `undefined` to fall through to the rules above. Each host has a default
for `publicBase`. The native preview resolves against the page's base URL, Remotion uses
`staticFile`, and HyperFrames pages use their generated `assets` directory.

## Video and audio

`Video`, `Image`, `Audio`, `AudioTrack` and `AudioManager` come from `@motionactor/media`.
Video and audio play from one signal: `sourceTime`, the seconds into the source at each
frame. The commands are tweens on it, so playback seeks, scrubs and exports exactly.

```tsx twoslash title="reel.tsx"
// @jsx: react-jsx
// ---cut---
import { createScene, Easing, input } from "@motionactor/core";
import { Audio, Video } from "@motionactor/media";
import { audio, video } from "@motionactor/schema";
import {
  createRenderRuntime,
  defineComposition,
} from "@motionactor/renderers/react";
import { dom } from "@motionactor/dom/react";

const reelScene = createScene(
  function* (ctx, { footage, score }) {
    const clip = ctx.spawn(Video, {
      content: { source: footage },
      layout: { width: 1280, height: 720 },
    });
    const music = ctx.spawn(Audio, {
      content: { source: score },
      volume: 0.6,
      fade: { in: 0.5, out: 1 },
    });
    music.loop();

    yield* clip.play({ from: 4, to: 6 });
    yield* clip.hold(0.5);
    yield* clip.rewind({ to: 4, duration: 1, easing: Easing.inOutCubic });
    yield* clip.play({ rate: 2 });
    return { clip };
  },
  { inputs: { footage: input.of(video), score: input.of(audio) } },
);

export const reel = defineComposition({
  id: "Reel",
  width: 1920,
  height: 1080,
  fps: 30,
  defaultProps: {
    footage: video.file("media/surf.mp4"),
    score: audio.file("media/theme.mp3"),
  },
  runtime: createRenderRuntime({ backends: [dom] }),
  scene: reelScene,
});
```

| Command | `sourceTime` does | Scene waits |
| --- | --- | --- |
| `play({ from?, to?, rate? })` | Runs from `from` (or where it is) to `to` (or the end) at `rate` | Yes |
| `loop({ window?, rate? })` | Repeats a window until another command takes over | No |
| `hold(seconds)` | Stays put: a freeze frame | Yes |
| `seekTo(time)` | Jumps | Yes, zero frames |
| `fastForward` / `rewind({ to, duration, easing? })` | Tweens to `to`, in either direction | Yes |
| `rampTo({ to, rate, duration, fromRate? })` | Eases its rate from `fromRate` to `rate`, then plays on to `to` | Yes |

`timing: { start, end }` selects a window of the source, with `end: -1` meaning its end, and
`sourceTime` counts from `start`. `Video` is muted by default, and its sound joins the mix
when you set `muted: false`. `Audio` has `volume`, `muted` and `fade: { in, out }` in seconds.
Timed sound cues and sound packs are on [Audio](/docs/actors/audio).

Before playback, the host turns each clip's `sourceTime` curve into segments. A run of frames
with a steady positive slope becomes one segment played at that rate. A hold, a rewind or the
wrap of a loop becomes a seek on each frame. In this reel, frames 0 to 60 are one segment at
rate 1 and frames 105 to 224 one segment at rate 2; the hold and the rewind between them are
seeks.

## Lengths a scene needs

`play()` with no `to` and `loop()` with no `window` need the source's length, and so does
any `timing.end` of -1. The scene cannot read a file, so the first build stops with a
`MediaDurationRequired` naming the reference. A host catches it, measures the file in the
browser, and builds again with the length known. Outside a host, pass the lengths yourself,
keyed by `assetKey`:

```ts twoslash title="reel.test.ts"
// @jsx: react-jsx
// @filename: reel.tsx
import { createScene, Easing, input } from "@motionactor/core";
import { Audio, Video } from "@motionactor/media";
import { audio, video } from "@motionactor/schema";
import {
  createRenderRuntime,
  defineComposition,
} from "@motionactor/renderers/react";
import { dom } from "@motionactor/dom/react";
const reelScene = createScene(function* (ctx, { footage, score }) {
  const clip = ctx.spawn(Video, { content: { source: footage }, layout: { width: 1280, height: 720 } });
  const music = ctx.spawn(Audio, { content: { source: score }, volume: 0.6, fade: { in: 0.5, out: 1 } });
  music.loop();
  yield* clip.play({ from: 4, to: 6 });
  yield* clip.hold(0.5);
  yield* clip.rewind({ to: 4, duration: 1, easing: Easing.inOutCubic });
  yield* clip.play({ rate: 2 });
  return { clip };
}, { inputs: { footage: input.of(video), score: input.of(audio) } });
export const reel = defineComposition({
  id: "Reel", width: 1920, height: 1080, fps: 30,
  defaultProps: { footage: video.file("media/surf.mp4"), score: audio.file("media/theme.mp3") },
  runtime: createRenderRuntime({ backends: [dom] }),
  scene: reelScene,
});
// @filename: reel.test.ts
// ---cut---
import { buildComposition } from "@motionactor/renderers";
import { assetKey } from "@motionactor/schema";
import { reel } from "./reel";

const { footage, score } = reel.defaultProps;
using build = buildComposition(reel, reel.defaultProps, {
  mediaDurations: { [assetKey(footage)]: 12, [assetKey(score)]: 30 },
});

build.compiled.totalDuration; // 225
build.compiled.at(90).scene.clip?.sourceTime; // 5, halfway back
```

A host can only catch the error during a build it runs. That is why `reelScene` takes its
media as inputs, so the host builds it. A scene built when its module loads asks for the
length before any host is there to measure it.

## Drawing media

`mediaRendererRegistry` from `@motionactor/media/react` holds the DOM renderers for the media
actors and the collector that finds every clip a scene plays. Every host (the native preview,
HyperFrames and Remotion) adds it after the composition's own renderers with
`withMediaFamily`, so `reel` lists none. The views are DOM renderers, so `Image` and `Video`
actors belong in a DOM layer.

For a designed variant, extend the actor and reuse its view. `style` is merged over the
view's own box:

```tsx twoslash title="photo.tsx"
// @jsx: react-jsx
// ---cut---
import { signal } from "@motionactor/core";
import { Image } from "@motionactor/media";
import { ImageView } from "@motionactor/media/react";
import { dom } from "@motionactor/dom/react";

export class Photo extends Image {
  radius = signal(24);
}

export const photo = dom.render(Photo, ({ actor }) => (
  <ImageView
    actor={actor}
    style={{ borderRadius: actor.signals.radius }}
  />
));
```

List `photo` in the runtime's `renderers`. The media family comes after them, so `Photo` gets
this renderer and a plain `Image` still gets the built-in one.

An image holds its frame until the browser has decoded it, so the first frame that shows
it already shows all of it.

## Fonts

A composition lists the font faces it draws with. The host loads each one through the
`FontFace` API and holds the first frame until all of them are ready, so text measures and
paints in the right face from frame 0.

```tsx twoslash title="title-card.tsx"
// @jsx: react-jsx
// @filename: title.scene.ts
import { createScene, LayoutActor, signal } from "@motionactor/core";
export class Title extends LayoutActor { text = signal(""); }
export const titleScene = createScene(function* (ctx) {
  ctx.spawn(Title, { text: "Launch" });
  yield* ctx.wait(2);
});
// @filename: title-card.tsx
import { createRenderRuntime, SceneRenderer } from "@motionactor/renderers/react";
import { dom } from "@motionactor/dom/react";
import { titleScene } from "./title.scene";
const runtime = createRenderRuntime({ backends: [dom] });
// ---cut---
import { font } from "@motionactor/schema";
import { defineComposition } from "@motionactor/renderers/react";

export const titleCard = defineComposition({
  id: "TitleCard",
  width: 1920,
  height: 1080,
  fps: 30,
  runtime,
  fonts: [
    { family: "Inter", source: font.file("fonts/Inter.woff2") },
    {
      family: "Inter",
      source: font.file("fonts/Inter-Bold.woff2"),
      weight: "700",
    },
  ],
  scene: titleScene,
  render: ({ compiled, frame, width, height }) => (
    <SceneRenderer
      compiled={compiled}
      frame={frame}
      width={width}
      height={height}
    />
  ),
});
```

Each face takes the `@font-face` descriptors: `weight`, `style`, `stretch`, `unicodeRange`
and `featureSettings`. A face that has not loaded after 15 seconds fails the frame. Loaded
faces are cached per document, so remounting a composition does not fetch them again.
