Scene timing and motion

Build motion from the current frame so scrubbing, stills, and exports show the same result. Use scenes for ordered sections and clips for content placed at a specific time.

Choose a time unit

Odori accepts seconds as numbers, or strings with an explicit unit:

ValueMeaning
2 or "2s"Two seconds
"500ms"Half a second
"15f"Fifteen frames

At 30 fps, "15f" is half a second. At 60 fps, it is a quarter second. Use seconds for pacing that should survive a frame-rate change; use frames for exact frame placement.

Sequence scenes

Scene durations determine where later scenes begin. In this timeline, proof starts at four seconds and the closing scene starts at ten seconds:

import {Fill, Scene, Video} from "odori";

export function Timeline() {
  return (
    <Video>
      <Scene id="opening" duration="4s">
        <Fill>Meet the editor</Fill>
      </Scene>
      <Scene id="proof" duration="6s">
        <Fill>Show the workflow</Fill>
      </Scene>
      <Scene id="closing" duration="3s">
        <Fill>Start building</Fill>
      </Scene>
    </Video>
  );
}

Set the video's metadata duration to "13s" for this sequence. Give scenes stable, distinct IDs so inspection and diagnostics can identify them.

Place an overlay

A clip does not extend the scene sequence. This overlay begins two seconds into its scene and lasts three seconds:

import {Clip, Scene} from "odori";

export function DemoScene() {
  return (
    <Scene id="demo" duration="8s">
      <div>Product demonstration</div>
      <Clip from="2s" duration="3s">
        <div style={{position: "absolute", bottom: 80}}>
          Changes save automatically
        </div>
      </Clip>
    </Scene>
  );
}

Place the clip directly under Video when its start should be measured from the beginning of the whole video. Inside either container, the clip's children see a local frame starting at zero.

Animate a reusable element

Read the frame inside the animated component. Its entrance then starts at zero wherever a scene or clip mounts it:

import {interpolate, useFrame, useVideo} from "odori";

export function FadeIn({text}: {text: string}) {
  const frame = useFrame();
  const {fps} = useVideo();
  const opacity = interpolate(frame, [0, fps * 0.4], [0, 1]);
  const y = interpolate(frame, [0, fps * 0.4], [24, 0]);

  return (
    <div style={{opacity, transform: `translateY(${y}px)`}}>
      {text}
    </div>
  );
}

Avoid timers, random values created during render, and CSS keyframe animations for exported motion. A render worker may jump directly to any frame rather than play all preceding frames.

Review boundaries

Render frames on both sides of an important cut, then inspect the timeline:

pnpm odori frame launch --at 119f --output out/before-cut.png
pnpm odori frame launch --at 120f --output out/after-cut.png
pnpm odori inspect launch --json
pnpm odori test launch

At 30 fps, these stills bracket a cut at four seconds. Check that entrances finish before exits begin and that text remains visible long enough to read. See testing and review for a complete review pass.

On this page