# Composition

> Build video compositions with Editframe. Use HTML web components or React. Add video, audio, images, text, captions, and transitions, then render to a file.

- Skill: `editframe/composition` (Agent Skill)
- Install (CLI): `npx skillmds@latest add editframe/composition`
- Raw SKILL.md: https://api.skillmd.com/api/skills/editframe/composition/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: editframe (https://skillmd.com/u/editframe)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/editframe/composition

---



# Video Composition

Build video scenes with HTML web components, for example `<ef-timegroup>` and `<ef-video>`. Or build them with React components, for example `<Timegroup>` and `<Video>`. Both syntaxes share the same composition model and the same rendering pipeline. Pick the syntax that fits your project.

Web component attributes use kebab-case, for example `file-id` and `api-host`. React props use camelCase, for example `fileId` and `apiHost`. Four attributes break this pattern: `sourcein`, `sourceout`, `trimstart`, and `trimend`. Each of these uses the same lowercase string as both the HTML attribute and the React prop. The Element Reference section lists both forms for every attribute.

## Quick Start

```html
<ef-timegroup mode="sequence" class="w-full h-full">
  <ef-timegroup mode="fixed" duration="2s" class="absolute w-full h-full">
    <ef-image src="logo.png" class="size-full object-contain"></ef-image>
    <ef-text class="absolute inset-x-0 bottom-12 text-center text-white text-3xl">My Brand</ef-text>
  </ef-timegroup>
  <ef-timegroup class="absolute w-full h-full">
    <ef-video src="main.mp4" class="size-full object-cover"></ef-video>
    <ef-audio src="music.mp3" volume="0.3"></ef-audio>
  </ef-timegroup>
</ef-timegroup>
```

```tsx
import { Timegroup, Video, Image, Text, Audio, TimelineRoot } from "@editframe/react";

const MyVideo = ({ id }: { id: string }) => (
  <Timegroup id={id} mode="sequence" className="w-full h-full">
    <Timegroup mode="fixed" duration="2s" className="absolute w-full h-full">
      <Image src="logo.png" className="size-full object-contain" />
      <Text className="absolute inset-x-0 bottom-12 text-center text-white text-3xl">My Brand</Text>
    </Timegroup>
    <Timegroup className="absolute w-full h-full">
      <Video src="main.mp4" className="size-full object-cover" />
      <Audio src="music.mp3" volume={0.3} />
    </Timegroup>
  </Timegroup>
);

export const App = () => <TimelineRoot id="my-video" component={MyVideo} />;
```

This example makes two duration decisions. The logo card uses `fixed duration="2s"`. A logo has no natural display length, so this duration is an editorial choice.

The second scene sets no `mode` and no `duration`. It defaults to `contain` mode. In `contain` mode, the timegroup's length equals the length of `main.mp4`. Swap in a longer or shorter clip. The composition needs no other changes. See Timegroups & sequencing below for the full duration model.

Every React composition that renders or exports needs a `TimelineRoot` wrapper. See React below for the reason.

This example uses no `<ef-configuration>`/`<Configuration>` wrapper. That element is opt-in infrastructure for production deployments. It is not a required part of a composition. See Advanced → Configuration below for more detail.

## Duration Units

`5s` (seconds) | `1000ms` (milliseconds) | `2m` (minutes)

Start a new project with `npm create @editframe`. See the `editframe-create` skill for details.

## Core Concepts

### Time model

Every element — `ef-timegroup`, `ef-video`, `ef-text`, and others — is a temporal element. Each temporal element carries two time properties, in seconds:

- `duration` — the length of the element.
- `ownCurrentTime` — the element's local playhead position. Editframe derives this value from the nearest ancestor's time, plus the element's own `offset`/window.

Media elements carry one more time property: `currentSourceTime`. This is the position within the underlying source file, after `sourcein`/trim.

Only the root element's `currentTime` accepts a direct write. Set it with `seek()` or `seekForRender()`. Editframe derives every other time value from the root. Each derived value flows one way, down the tree.

`*Ms` variants of these properties (`durationMs`, `ownCurrentTimeMs`, and others) still exist for backward compatibility. These variants are deprecated. Use the seconds fields in new code.

Editframe supports four trim attributes:

- `sourcein`/`sourceout` — select a window within the source file.
- `trimstart`/`trimend` — cut time from the start or the end of an element's own duration.

Use `offset` to position a child element within a `fixed`, `contain`, or `fit` parent.

### Timegroups & sequencing

`ef-timegroup` is the only container element. Its `mode` attribute controls two things: the timegroup's own duration, and the time window of each child. The table below lists each mode.

| Mode | Duration | Children |
|---|---|---|
| `fixed` | The `duration` attribute. | Each positioned at its own `offset`, in parallel. |
| `sequence` | Sum of children's durations, minus `overlap` between each adjacent pair. | Play back-to-back; only the active one(s) are visible (`hidden` toggles, `data-active` attribute). |
| `contain` (default) | Furthest extent of `offset + duration` across children. | Parallel, each at its own `offset`. |
| `fit` | Inherits the nearest ancestor timegroup's duration (falls back to `contain` if this is the root). | Parallel, each at its own `offset`. |

Use `sequence` mode to play a series of scenes back-to-back. See the Quick Start example above. Set `overlap` (a CSS time, for example `overlap="1s"`) to shift the next scene's start earlier by that amount. This lets the next scene play at the same time as the previous scene's tail, for a crossfade or transition window.

**To choose a mode, ask one question: does the media set this element's duration, or do you set it?**

- **Media-driven.** Use this for a video clip, a voiceover, or anything else whose length comes from its own content. Do not set a `duration`. Leave `mode` unset, so it defaults to `contain`, and the timegroup's length comes from its content. This choice turns a composition into a reusable template. You can build the timeline before you know the final media duration. Later, swap in a different-length clip, and the timing still works with no manual changes.
- **Editorial choice.** Use this for a title card, a fixed-length outro, or any beat you time yourself. There is no natural duration to derive here. Set `fixed duration="..."`.
- **Inherited.** `fit` mode always tracks the nearest ancestor timegroup's duration, not its own content's duration. Use `fit` when an element must span exactly as long as a relative whose length you do not know in advance. The most common case: a background audio bed under a `sequence` of scenes.

```html
<ef-timegroup class="w-full h-full"> <!-- contain (default): sized to the sequence below -->
  <ef-timegroup mode="sequence" class="absolute w-full h-full">
    <ef-timegroup mode="fixed" duration="3s" class="absolute w-full h-full">
      <ef-video src="a.mp4" class="size-full object-cover"></ef-video>
    </ef-timegroup>
    <ef-timegroup class="absolute w-full h-full">
      <ef-video src="b.mp4" class="size-full object-cover"></ef-video>
    </ef-timegroup>
  </ef-timegroup>
  <ef-timegroup mode="fit">
    <ef-audio src="bed.mp3" volume="0.3"></ef-audio>
  </ef-timegroup>
</ef-timegroup>
```

The audio bed plays for exactly as long as the sequence lasts. Today the sequence lasts 8 seconds. Tomorrow, after a clip change, it lasts 12 seconds. Either way, no duration needs manual synchronization.

Do not default every scene to `fixed`. This throws away the main benefit of a template. It forces a manual re-time every time a media source's length changes, instead of letting the composition adapt on its own.

### Transitions

Editframe has no special transition element. Build a transition from `overlap` plus CSS. Set `overlap` on a `sequence` timegroup. Editframe then sets two CSS custom properties on each overlapping child scene:

- `--ef-transition-duration` — the overlap time, in seconds.
- `--ef-transition-out-start` — the time, in seconds, when the scene's exit animation should start. This equals `duration - overlap`.

Drive your fade, slide, or zoom animation with WAAPI or CSS, keyed off these two properties.

```html
<ef-timegroup mode="sequence" overlap="0.5s">
  <ef-timegroup mode="fixed" duration="3s" class="scene">
    <ef-video src="a.mp4" class="size-full object-cover"></ef-video>
  </ef-timegroup>
  <ef-timegroup mode="fixed" duration="3s" class="scene">
    <ef-video src="b.mp4" class="size-full object-cover"></ef-video>
  </ef-timegroup>
</ef-timegroup>

<style>
  .scene {
    animation: fade-out linear;
    animation-duration: var(--ef-transition-duration, 0s);
    animation-delay: var(--ef-transition-out-start, 9999s);
    animation-fill-mode: forwards;
  }
  @keyframes fade-out {
    to { opacity: 0; }
  }
</style>
```

### CSS variables for time-based animation

Every temporal element continuously publishes two CSS custom properties on itself while it plays:

- `--ef-duration` — its own duration, as a CSS time (for example `5s`).
- `--ef-progress` — a number from 0 to 1, with no unit.

Use these properties to drive scrub-synced CSS with no JavaScript.

```css
ef-timegroup[mode="fixed"] .progress-bar {
  transform: scaleX(var(--ef-progress));
}
```

`ef-text` segments (see below) also get `--ef-index`, `--ef-word-index`, `--ef-stagger-offset`, and `--ef-seed`. `ef-captions` words get `--ef-word-index`, `--ef-word-seed`, and `--ef-word-start`.

### Scripting

Editframe gives you two hooks to run JavaScript synchronized to the composition clock. Use these hooks instead of `requestAnimationFrame`.

**Never drive per-frame state with `requestAnimationFrame`, `setInterval`, or a wall-clock timer.** Browsers throttle or fully suspend `requestAnimationFrame` callbacks in a backgrounded tab or window. Headless and offscreen export always run in this backgrounded state. Ordinary editing often runs in this state too, whenever the preview tab loses focus. A composition driven by `requestAnimationFrame` silently stops animating, or drifts out of sync with the render clock.

`addFrameTask` has neither problem. The seek/render pipeline invokes it directly for every rendered frame. It stays correct regardless of tab visibility.

- `timegroup.initializer = (timegroup) => { ... }` — a JavaScript-only property, not an HTML attribute. It runs exactly once per instance, and once per render clone (see Rendering below). Write it as a **synchronous** function. Do not await anything inside it. Use it to register `addFrameTask` callbacks and to set up state. You can call `resolveDuration()` or `addFrameTask` from inside it. A thrown error, or a run time past 10ms, logs a warning. A run time past 100ms logs an error.
- `timegroup.addFrameTask((info) => { ... })` — registers a per-frame callback. Editframe calls this callback on every seek, with `{ ownCurrentTime, currentTime, duration, percentComplete, element }` (all times in seconds). The method returns an unregister function.

```html
<ef-timegroup id="scene" mode="fixed" duration="5s">
  <ef-text id="counter" class="absolute inset-0 flex items-center justify-center text-6xl"></ef-text>
</ef-timegroup>
<script>
  const scene = document.getElementById("scene");
  scene.initializer = (timegroup) => {
    const counter = timegroup.querySelector("#counter");
    timegroup.addFrameTask(({ ownCurrentTime }) => {
      counter.textContent = Math.floor(ownCurrentTime * 10).toString();
    });
  };
</script>
```

In React, pass the same functions as the `initializer`/`onFrame` props on `<Timegroup>`. `onFrame` is shorthand for a single `addFrameTask` call.

## Media Elements

`ef-video`, `ef-audio`, and `ef-image` are simple temporal leaf elements. See the Element Reference below for the full attribute list: trimming, volume, variant ladders, FFT analysis, and more. A few elements have behavior worth extra explanation.

**`ef-text`** splits its text content into `ef-text-segment` children. Set `split="word"`, `split="char"`, or `split="line"` to choose the split unit. Set `stagger` (a CSS time) to delay each segment's start.

Each `ef-text-segment` renders in the light DOM, with no shadow root. It toggles a `data-active` attribute as it becomes active. It also carries `--ef-index`, `--ef-word-index`, `--ef-stagger-offset`, and `--ef-seed` custom properties.

Select and animate a segment from an ordinary stylesheet, the same as any other element. This element needs no JavaScript registration API.

```css
@keyframes pop-in {
  from { opacity: 0; transform: scale(0.5); }
}
ef-text-segment[data-active] {
  animation: pop-in 0.4s calc(var(--ef-stagger-offset)) both;
}
```

**`ef-captions`** accepts caption data in three ways:

- `captions-src` — a URL to a JSON file.
- `captions-script` — the id of an inline `<script type="application/json">` element.
- `captions.captionsData = {...}` — a JavaScript property, not an HTML attribute. Use this for a transcript your code generates dynamically.

The JSON data uses a `segments` shape or a `word_segments` shape. Each word or segment carries a `start` and an `end`, in seconds or in milliseconds. Editframe detects the unit from the magnitude of the number.

Style the active word through the `ef-captions-active-word` part or child. Or use `--ef-word-index`/`--ef-word-seed` for a per-word stagger effect.

**`ef-waveform`** visualizes an `ef-audio` or `ef-video` `target`. Choose a `mode`: `bars`, `line`, `curve`, `bricks`, `pixel`, `wave`, `spikes`, or `roundBars`.

**`ef-surface`** mirrors another element's pixels onto its own canvas. Set `target` to a canvas or to any `HTMLElement`. Use it to reuse a video's decoded frames in a second, differently-styled element, with no re-decoding.

## Rendering

**Browser export.** Call `renderTimegroupToVideo(timegroup, options)`, from `@editframe/elements`, to encode a live, laid-out `ef-timegroup` to a video file. This function uses WebCodecs and drives the same `seekForRender` path as playback.

Key options:

- `width`/`height`/`fps`
- `from`/`to` — the export range, in seconds.
- `videoCodec`/`audioCodec`/`videoBitrate`/`audioBitrate`
- `target` — a `mediabunny` output target.
- `signal` — an `AbortSignal`.
- `onProgress(({ frame, totalFrames, time, duration }) => ...)`

A direct call seeks and changes the `timegroup` you pass in. Only call it directly when that composition is not also a live, interactive preview.

To export a composition someone is actively viewing or editing, do not call `createRenderClone` directly. This function is an internal primitive, not a public API. Use one of these two paths instead:

- For a custom in-app export action, call `ef-workbench`'s `exportVideo()` method (see the `editor-gui` skill). This method clones, renders, and disposes of the offscreen copy for you.
- For CLI or cloud rendering (`editframe render`), do nothing extra. This path already runs through `window.EF_RENDER`, which installs automatically and handles the offscreen clone for you.

**Custom render data.** A CLI or Playwright host can inject arbitrary JSON into `window.EF_RENDER_DATA` before it runs your composition. Read this data in the browser with `getRenderData<T>()` (a module-level function) or with the `useRenderData<T>()` React hook. Use this to parameterize a render, for example with a user's name, without templating the composition itself.

**CLI and cloud rendering.** This skill covers only in-browser export. For `editframe render`, `editframe transcribe`, and the Editframe API's server-side render and transcription jobs, see the `editframe-cli` and `editframe-api` skills. Transcription produces WhisperX-generated caption JSON, compatible with `ef-captions`.

## React

Every React composition that renders or exports needs a `TimelineRoot` wrapper. `TimelineRoot` mounts your composition. It also registers a clone factory. `exportVideo()` and CLI rendering (see Rendering above) use this factory to create the offscreen render clone. With the factory, the clone is a live second React mount, with working hooks, `initializer`, and `onFrame` closures. Without it, the clone is a dead `cloneNode` copy with none of these.

```tsx
const MyTimeline = ({ id }: { id: string }) => (
  <Timegroup id={id} mode="sequence">
    <MyScenes />
  </Timegroup>
);

<TimelineRoot id="root" component={MyTimeline} />
```

The `component` prop must be a `React.ComponentType<{ id: string }>`. It must render a `<Timegroup id={id}>` at its root, directly or through a nested custom component. `TimelineRoot` forwards `id` to `component` on every mount, live and cloned.

Hooks (all from `@editframe/react`):

- `useTimingInfo()` — attach the returned `ref` to a `Timegroup`. The component re-renders with `{ ownCurrentTime, duration, percentComplete }` (seconds) on every frame.
- `useMediaInfo(ref)` — pass a ref to a `Video` or `Audio` element. Returns `{ readyState, duration, currentTime, paused }`.
- `usePanZoomTransform(ref)` — pass a ref to a `PanZoom` element. Returns `{ transform: { x, y, scale }, reset(), fitToContent(padding?), screenToCanvas(x, y), canvasToScreen(x, y) }`.
- `useRenderData<T>()` — see Rendering above.
- `usePlayback(target)` — subscribes to play, pause, seek, and volume state, for building custom preview controls. The ready-made GUI components in the `editor-gui` skill replace most uses of this hook.

## Advanced

**Configuration.** `ef-configuration`/`<Configuration>` is an opt-in element, not a required wrapper (see Quick Start above). Skip it for a composition that uses only local files, with no cross-origin or authenticated media.

Add `ef-configuration` when you deploy against Editframe's API. Use it for signed-URL auth on cross-origin media requests, with the `api-host` and `signing-url` attributes. Use it for cross-origin image proxying, with the `image-proxy` attribute. See the Element Reference below for its full attribute list.

`media-engine` is reserved for future use. Local rendering is currently the only engine.

**React Three Fiber.** Wrap a `<Timegroup>` child in `<CompositionCanvas>`, from `@editframe/react/r3f`. This gives you an R3F `<Canvas>` synced to composition time. Read the clock inside a scene component with `useCompositionTime()`, which returns `{ time, duration }` in seconds.

```tsx
<Timegroup mode="fixed" duration="14s">
  <CompositionCanvas shadows>
    <MyScene />
  </CompositionCanvas>
</Timegroup>
```

To render 3D in a Web Worker, and keep the main thread free, use `OffscreenCompositionCanvas`. Construct the `Worker` yourself. Drive it with `@react-three/offscreen`'s own `render()` function, called inside the worker script. There is no `renderOffscreen` export.

**Server-side rendering.** `@editframe/react/server` and `@editframe/elements/server` export composition components and types only. They contain no custom-element registration and no DOM, canvas, or WebCodecs code. Import them safely in Next.js or Remix server code.

These server entry points do not include GUI components or hooks. Render those on the client instead, with `dynamic(() => import("@editframe/react"), { ssr: false })` or an equivalent.

For Next.js, wrap `next.config.js` with `withEditframe()`, from `@editframe/nextjs-plugin`. See the `dev-server` skill for setup and options. See the `editframe-create` skill's `nextjs` template for a working example.

`getRenderInfo()`, from either server entry point, still requires a live `document` with a mounted `ef-timegroup` at call time. Call it only inside a rendered page, for example from a Playwright-driven CLI or cloud host. Do not call it to parse an HTML string on the server.

**Package entry points**

| Import | Environment | Contains |
|---|---|---|
| `@editframe/elements` | Browser | All custom elements, canvas/WebCodecs rendering. |
| `@editframe/elements/server` | Browser, Node, SSR | Types only, plus `getRenderInfo()` (browser-only at runtime). |
| `@editframe/elements/gui` | Browser | Editor GUI custom elements (timeline, scrubber, handles, ...) — see the `editor-gui` skill. |
| `@editframe/elements/styles.css`, `/theme.css` | Browser | Base + theme styles. |
| `@editframe/react` | Browser | React composition + GUI components, hooks. |
| `@editframe/react/server` | Browser, Node, SSR | Composition components only, no hooks/GUI. |
| `@editframe/react/r3f` | Browser | `CompositionCanvas`, `OffscreenCompositionCanvas`, `useCompositionTime`. |

## Styling

Elements use standard CSS or Tailwind. Position a child with `absolute`. Size it with `w-full h-full` or with `size-full`.

## Best Practices

Follow these rules when you author a multi-scene composition. They prevent the most common render bugs. The examples use React. The same rules apply to HTML web components.

### Scene structure

Structure a multi-beat video as a tree of timegroups, not as one master clock:

```
Timegroup (mode="contain")        — composition root
  ├─ Timegroup (mode="sequence")  — the beats, back-to-back
  │    ├─ SceneA (mode="fixed")
  │    ├─ SceneB (mode="fixed")
  │    └─ ...
  ├─ Ambient overlays             — grain, particles; they span every scene
  └─ Audio bed                    — sibling of the sequence, never a child
```

Each scene is a `mode="fixed"` timegroup with its own local clock: `ownCurrentTime` is 0 at the scene's start. Do not drive the video from one root `onFrame` callback that reads a master clock and writes to refs.

- Give each scene its own component and file, for example `src/scenes/Title.tsx`. A scene knows only about itself.
- Keep every scene's `duration` in one constants module. Do the timing arithmetic there, once, before you write any JSX.
- Set one `overlap` value on the sequence. Do not tune overlap per transition.
- Count the overlap into each scene's declared `duration`. For every scene except the first: declared = screen time + overlap. Then `sum(durations) − overlap × (n−1)` equals the total runtime. Check this arithmetic before you render.
- In React, pass `overlap` as a plain number of seconds, for example `overlap={0.7}`. The element appends the `s` unit itself.
- Write every event time inside a scene relative to that scene's start. Never use video-wide absolute times.
- The sequence hides inactive scenes on its own. Do not add your own visibility or opacity gating at the scene level.
- Time each scene's exit animation with `--ef-transition-out-start` and `--ef-transition-duration` (see Transitions above). Both cascade from the scene's timegroup to its children.

### Animation: CSS first

Use `addFrameTask` only as a last resort. Pick the first technique that fits:

1. **One-shot enter/exit.** This covers most elements. Use one reusable reveal-style component backed by shared `@keyframes`. Wire its exit to `var(--ef-transition-out-start)` and `var(--ef-transition-duration)`.
2. **Staggered repeats** (chips, tiles). Compute `animation-delay: base + i * stagger` once from the array index. Do not recompute styles per frame.
3. **Continuous ambient motion** (drift, bob, pulse). Use an infinite `@keyframes` loop. Give each item its own phase with a negative `animation-delay`, computed once from the item's index. To combine a loop with an enter/exit, nest two elements. Put the enter/exit on the outer wrapper and the loop on the inner element.
4. **Shape morphs and multi-property transitions.** Use a bespoke `@keyframes` pair, timed with the same transition variables. Use `clip-path: circle(N% at x% y%)` for circular wipes.
5. **Irreducible per-frame math** (a count-up, a physics sim). Use a small `onFrame`/`addFrameTask`, scoped to the one scene that needs it. Never use a root-level switchboard.

Common easings as cubic-bezier: `easeOutCubic ≈ cubic-bezier(0.33,1,0.68,1)`, `easeInCubic ≈ cubic-bezier(0.32,0,0.67,0)`, `easeInOutQuad ≈ cubic-bezier(0.45,0,0.55,1)`.

A minimal React reveal component:

```css
@keyframes reveal-in {
  from { opacity: 0; transform: translateY(var(--reveal-y, 20px)); }
}
@keyframes reveal-out {
  to { opacity: 0; }
}
```

```tsx
type RevealProps = {
  enter: readonly [number, number]; // [delay, end] in local ms, from the scene's own start
  y?: number;                       // px to translate in from (default 20)
  children?: React.ReactNode;
};

export function Reveal({ enter, y = 20, children }: RevealProps) {
  const [inAt, inEnd] = enter;
  const animation = [
    `reveal-in ${inEnd - inAt}ms ${inAt}ms cubic-bezier(0.33,1,0.68,1) backwards`,
    `reveal-out var(--ef-transition-duration) var(--ef-transition-out-start) forwards`,
  ].join(", ");
  return <div style={{ "--reveal-y": `${y}px`, animation } as React.CSSProperties}>{children}</div>;
}
```

Check every `animation` declaration for fill-mode:

- Add `backwards` when the `from` state differs from the element's static CSS and the animation has a delay. Without it, the element shows its static state during the whole delay.
- Add `forwards` when the `to` state differs from the static CSS. Without it, the element snaps back the moment the animation ends.
- When in doubt, use `both`. A held end state that needed no hold is harmless. A missing hold is a visible bug.

`ef-text` segments are the exception: they get `backwards` fill-mode automatically. Set fill-mode on a segment only for an exit animation that needs `forwards`.

A CSS animation replaces the element's whole `transform`. It does not compose with a static transform. Do not combine `translate(-50%, -50%)` centering and an enter/exit animation on one element. Center with an unanimated flex wrapper. Animate only the inner element.

### Assets

- Keep media as real files under `src/assets/`. Reference them by path, for example `<Image src="/assets/foo.jpg">` or `<Audio src="/assets/bed.mp3">`. Never inline `data:` URIs in source.
- Preprocess each asset once, offline, into the committed file: trims, fades, loudness normalization, gain. The composition only points at the final file. Never preprocess at render time.
- Keep every load-bearing file inside `src/`, so the single-file bundle inlines it. Vite serves `public/` as-is, uninlined.
- Delete assets and exports that nothing references.
- In React, `<Image>` wraps a custom element, not a native `<img>`. It takes no `alt` prop. Type its ref as `useRef<HTMLElement>`, not `useRef<HTMLImageElement>`.

### Audio

Author audio inside the composition. Do not render a silent video and mux music in afterwards with `ffmpeg`.

- Never place `<Audio>` as a child of a `mode="sequence"` timegroup. The sequence treats it as one more beat. A bed with a full-runtime `duration` then silently doubles the total render length. Place the bed as a sibling of the sequence, under an outer `mode="contain"` wrapper:

```tsx
<Timegroup mode="contain" className="w-[1920px] h-[1080px]">
  <Timegroup mode="sequence" className="absolute w-full h-full">
    <SceneA /> <SceneB /> <SceneC />
  </Timegroup>
  <Audio src={MUSIC} volume={1} duration="60s" />
</Timegroup>
```

- Give a music bed an explicit `duration` equal to the total runtime. For a media-driven total, wrap the bed in `mode="fit"` instead (see Timegroups & sequencing above).
- Place each SFX cue inside the scene that contains its moment. Set `offset` (its start time relative to the parent timegroup) and `duration`. Trim the source file itself with `sourcein`/`sourceout`/`trimstart`/`trimend`.
- `volume` is a plain number from 0 to 1. It is not decibels and not a multiplier. A value above 1 throws `IndexSizeError` at render time. If a clip is too quiet, bake gain into the file offline, with a limiter, and keep `volume={1}`.

### Dead code

- Read or delete every exported constant. Never keep a constant and a hardcoded duplicate of its value.
- Before you call a composition done, count usages of each exported name across `src/`. Delete every name with no uses.

### Verification

A composition has no screenshot preview. The only proof of correctness is an actual render:

```bash
npm install && npm run render
ffprobe output/demo.mp4                                # duration and streams match the plan
ffmpeg -i output/demo.mp4 -af volumedetect -f null -   # audio is audible, not silent
```

Code that never rendered is not done, no matter how clean it reads.

## Element Reference

### `ef-audio`

`<ef-audio>` plays audio in a composition.

**Attributes**

| Attribute | React prop | Type | Default | Description |
|---|---|---|---|---|
| `duration` | `duration` | string | — | How long this element is active (CSS time, e.g. `"5s"`). |
| `fft-decay` | `fftDecay` | number | `8` | Decay frames averaged into `getFrequencyData` (default 8). |
| `fft-gain` | `fftGain` | number | `3` | Linear gain applied before frequency analysis (default 3.0). |
| `fft-size` | `fftSize` | number | `128` | FFT window size for `getFrequencyData`/`getTimeDomainData` (rounded to a power of two, default 128). |
| `file-id` | `fileId` | string \| null | — | Cloud file identifier — resolves to `${apiHost}/api/v1/files/${fileId}`, taking priority over `src`. |
| `interpolate-frequencies` | `interpolateFrequencies` | boolean | `false` | Smooths `getFrequencyData` bins by interpolating between FFT bands instead of nearest-neighbor sampling. |
| `loop` | `loop` | boolean | `false` | Loop attribute — when this temporal is a self-driving root under a `PlaybackController`, the controller wraps back to `0` instead of pausing at `duration`. |
| `mute` | `muted` | boolean | `false` | Mutes this element's audio. |
| `offset` | `offset` | number | — | Start offset (seconds) within a parallel (`fixed`/`contain`/`fit`) parent timegroup. |
| `sourcein` | `sourcein` | string | — | Start time within the source file (CSS time, e.g. `"1.5s"`). |
| `sourceout` | `sourceout` | string | — | End time within the source file (CSS time, e.g. `"1.5s"`). |
| `src` | `src` | string | — | URL of the source audio file. |
| `trimend` | `trimend` | string | — | Trims this much off the end of the source (CSS time). |
| `trimstart` | `trimstart` | string | — | Trims this much off the start of the source (CSS time). |
| `volume` | `volume` | number | `1` | Audio volume (0.0 to 1.0). |

**Fires**

- `child-duration-changed` (`{ duration: number }`) — Fires on the nearest context-providing ancestor, when a nested media element's duration becomes known or changes.

### `ef-captions`

`<ef-captions>` displays synchronized captions with word-level highlighting.

**Attributes**

| Attribute | React prop | Type | Default | Description |
|---|---|---|---|---|
| `captions-script` | `captionsScript` | string | `""` | ID of a `<script type="application/json">` element containing caption data, as an alternative to `captionsSrc`. |
| `captions-src` | `captionsSrc` | string | `""` | URL or path to a JSON file with `segments`/`word_segments` caption data. |
| `duration` | `duration` | string | — | How long this element is active (CSS time, e.g. `"5s"`). |
| `loop` | `loop` | boolean | `false` | Loop attribute — when this temporal is a self-driving root under a `PlaybackController`, the controller wraps back to `0` instead of pausing at `duration`. |
| `offset` | `offset` | number | — | Start offset (seconds) within a parallel (`fixed`/`contain`/`fit`) parent timegroup. |
| `target` | `target` | string | `""` | ID of the `<ef-video>`/`<ef-audio>` element these captions are timed against. |
| `word-style` | `wordStyle` | string | `""` | Free-form styling hook (no built-in behavior) — mirrors `@editframe/elements`' `word-style` attribute so consumer CSS/selectors targeting it keep working (e.g. `ef-captions[word-style="highlight"]`). |

**Fires**

- `child-duration-changed` (`{ duration: number }`) — Fires on the nearest context-providing ancestor, when a nested media element's duration becomes known or changes.

### `ef-captions-active-word`

`<ef-captions-active-word>` wraps the word `<ef-captions>` is currently speaking.

### `ef-captions-after-active-word`

`<ef-captions-after-active-word>` wraps the caption text that comes after the active word, inside `<ef-captions>`.

### `ef-captions-before-active-word`

`<ef-captions-before-active-word>` wraps the caption text that comes before the active word, inside `<ef-captions>`.

### `ef-captions-segment`

`<ef-captions-segment>` groups one caption line's before/active/after word elements. `<ef-captions>` creates this element automatically unless you author your own inside it.

### `ef-configuration`

`<ef-configuration>` is the root configuration element.

**Attributes**

| Attribute | React prop | Type | Default | Description |
|---|---|---|---|---|
| `api-host` | `apiHost` | string | `""` | Base URL of the Editframe API used for file/asset requests. |
| `image-proxy` | `imageProxy` | "auto" \\| "none" | `"auto"` | Controls whether cross-origin `ef-image` sources are routed through the `/api/v1/assets/image` proxy (`"auto"`, default) or fetched directly (`"none"`). |
| `media-engine` | `mediaEngine` | string | `"local"` | Reserved for future engine switching; local rendering always uses the default codec set. |
| `signing-url` | `signingURL` | string | `"/@ef-sign-url"` | URL used to request signed bearer tokens for authenticated cross-origin media access (default `"/@ef-sign-url"`, matching `@editframe/elements`). |

**Fires**

- `playback-attached` (`{ controller: PlaybackController }`) — Fires when `EFConfigurationElement.attachPlayback` creates a new `PlaybackController`. This lets GUI controls that resolved their `PlaybackBridge` before the attach notice it without polling.

### `ef-image`

`<ef-image>` is a static image temporal leaf.

**Attributes**

| Attribute | React prop | Type | Default | Description |
|---|---|---|---|---|
| `asset-id` | `assetId` | string \| null | — | Deprecated alias for `file-id`. |
| `duration` | `duration` | string | — | How long this element is active (CSS time, e.g. `"5s"`). |
| `file-id` | `fileId` | string \| null | — | Cloud file identifier — resolves to `${apiHost}/api/v1/files/${fileId}`, taking priority over `src`. |
| `loop` | `loop` | boolean | `false` | Loop attribute — when this temporal is a self-driving root under a `PlaybackController`, the controller wraps back to `0` instead of pausing at `duration`. |
| `offset` | `offset` | number | — | Start offset (seconds) within a parallel (`fixed`/`contain`/`fit`) parent timegroup. |
| `src` | `src` | string | — | URL of the source image file. |

**CSS parts**

- `canvas` — The rendering canvas. This canvas is a light DOM child element, not a shadow DOM element.

**Fires**

- `child-duration-changed` (`{ duration: number }`) — Fires on the nearest context-providing ancestor, when a nested media element's duration becomes known or changes.
- `contentchange` (`{ reason: string }`) — Fires when the image source changes in a way that affects the rendering. For example, a new value for `src`, `file-id`, or `asset-id` triggers this event.

### `ef-motionblur`

`<ef-motionblur>` applies motion blur to its child.

**Attributes**

| Attribute | React prop | Type | Default | Description |
|---|---|---|---|---|
| `amount` | `amount` | number | `0` | Explicit blur amount in pixels. |
| `angle` | `angle` | number | `0` | Explicit blur direction in degrees. |
| `fps` | `fps` | number | `30` | Frame rate used to convert `shutterAngle` into a shutter duration. |
| `sensitivity` | `sensitivity` | number | `1` | Multiplier applied to detected motion before computing blur amount. |
| `shutter-angle` | `shutterAngle` | number | `180` | Simulated camera shutter angle in degrees — controls blur strength when auto-sampling (default 180). |
| `threshold` | `threshold` | number | `0.5` | Minimum motion magnitude (px/degrees) before any blur is applied. |

### `ef-pan-zoom`

`<ef-pan-zoom>` is a pan and zoom viewport.

**Attributes**

| Attribute | React prop | Type | Default | Description |
|---|---|---|---|---|
| `auto-fit` | `autoFit` | boolean | `false` | Automatically fits content to the viewport instead of using explicit `x`/`y`/`scale`. |
| `scale` | `scale` | number | `1` | Current zoom scale factor. |
| `x` | `x` | number | `0` | Current horizontal pan offset in pixels. |
| `y` | `y` | number | `0` | Current vertical pan offset in pixels. |

**CSS parts**

- `content` — The transformed wrapper. It applies the pan and zoom to the slotted content.

**Fires**

- `transform-changed` (`{ x: number; y: number; scale: number; }`) — Fires with `{ x, y, scale }` on every pan or zoom.

**Methods**

- `screenToCanvas(screenX: number, screenY: number): { x: number; y: number }` — Convert screen coordinates, for example `event.clientX` and `event.clientY`, to canvas space.
- `canvasToScreen(canvasX: number, canvasY: number): { x: number; y: number }` — Convert canvas-space coordinates to screen space.
- `reset(): void` — Resets the pan and zoom to the identity transform (`x: 0, y: 0, scale: 1`).
- `fitToContent(padding?: number): void` — Centers and scales the content to fit the viewport. The `padding` parameter sets the margin, as a number from 0 to 1.

### `ef-surface`

`<ef-surface>` mirrors another element's pixels onto an internal canvas.

**Attributes**

| Attribute | React prop | Type | Default | Description |
|---|---|---|---|---|
| `duration` | `duration` | string | — | How long this element is active (CSS time, e.g. `"5s"`). |
| `loop` | `loop` | boolean | `false` | Loop attribute — when this temporal is a self-driving root under a `PlaybackController`, the controller wraps back to `0` instead of pausing at `duration`. |
| `offset` | `offset` | number | — | Start offset (seconds) within a parallel (`fixed`/`contain`/`fit`) parent timegroup. |
| `target` | `target` | string | `""` | ID of the element (bitmap source or arbitrary `HTMLElement`) to mirror. |

**CSS parts**

- `canvas` — The rendering canvas. This canvas is a light DOM child element, not a shadow DOM element.

**Fires**

- `child-duration-changed` (`{ duration: number }`) — Fires on the nearest context-providing ancestor, when a nested media element's duration becomes known or changes.

### `ef-text`

`<ef-text>` is animated text with line, word, or character splitting.

**Attributes**

| Attribute | React prop | Type | Default | Description |
|---|---|---|---|---|
| `blur-amount` | `blur-amount` | number | — | Forwarded to the wrapping `<ef-motionblur>`'s `amount` when `motion-blur` is set. |
| `blur-angle` | `blur-angle` | number | — | Forwarded to the wrapping `<ef-motionblur>`'s `angle` when `motion-blur` is set. |
| `blur-fps` | `blur-fps` | number | — | Forwarded to the wrapping `<ef-motionblur>`'s `fps` when `motion-blur` is set. |
| `blur-sensitivity` | `blur-sensitivity` | number | — | Forwarded to the wrapping `<ef-motionblur>`'s `sensitivity` when `motion-blur` is set. |
| `blur-shutter-angle` | `blur-shutter-angle` | number | — | Forwarded to the wrapping `<ef-motionblur>`'s `shutter-angle` when `motion-blur` is set. |
| `blur-threshold` | `blur-threshold` | number | — | Forwarded to the wrapping `<ef-motionblur>`'s `threshold` when `motion-blur` is set. |
| `duration` | `duration` | string | — | How long this element is active (CSS time, e.g. `"5s"`). |
| `easing` | `easing` | string | `"linear"` | CSS easing keyword applied to stagger timing. |
| `loop` | `loop` | boolean | `false` | Loop attribute — when this temporal is a self-driving root under a `PlaybackController`, the controller wraps back to `0` instead of pausing at `duration`. |
| `motion-blur` | `motionBlur` | boolean | `false` | Wraps each segment in an `<ef-motionblur>` element for auto-detected directional blur. |
| `offset` | `offset` | number | — | Start offset (seconds) within a parallel (`fixed`/`contain`/`fit`) parent timegroup. |
| `split` | `split` | "line" \\| "word" \\| "char" | `"word"` | Splits text into segments by line, word, or character for staggered animation. |
| `stagger` | `stagger` | string | `""` | Delay between each split segment's animation start (CSS time, e.g. `"100ms"`). |

**Fires**

- `child-duration-changed` (`{ duration: number }`) — Fires on the nearest context-providing ancestor, when a nested media element's duration becomes known or changes.

### `ef-text-segment`

`<ef-text-segment>` is one staggered unit of an `EFTextElement`.

### `

…(truncated)
