Video stage
Video footage synchronized to the scene frame.
Customize
import {Scene, Video} from "odori";
import {VideoStage} from "../components/video-stage/video-stage";
<Video>
<Scene id="video-stage" duration="5s">
<VideoStage
trimStart={0}
rate={1}
fit="cover"
/>
</Scene>
</Video>Installation
pnpm odori add component video-stagenpx odori add component video-stagebunx odori add component video-stageEdit the source in videos/components/video-stage/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/video-stage/video-stage.tsx
import {Easing, Fill, interpolate, placeholderFrame, useFrame, useReadiness, useVideo} from "odori";
import {useEffect, useRef, useState} from "react";
export type VideoStageProps = {
/** Clip URL. Without one, a generated placeholder clip plays. */
src?: string;
/** Seconds into the clip that frame zero shows. */
trimStart?: number;
/** Playback rate relative to the composition's clock. */
rate?: number;
fit?: "contain" | "cover";
entranceFrames?: number;
};
/**
* Nested media, seeked by frame rather than played by clock.
*
* A `<video>` element plays on wall time, which is the one thing a render
* cannot use: four workers rendering four chunks would each be somewhere
* different in the clip. So the element is never played. Every frame computes
* the exact time it wants, seeks there, and holds the composition until the
* decoder reports it has that picture — the same handshake an image decode
* uses, and the reason a nested clip can be scrubbed backwards.
*
* With no `src` it draws a generated clip: a placeholder that visibly advances
* and is still a pure function of the frame, so the component previews with no
* file in the repository.
*/
export const VideoStage = ({src, trimStart = 0, rate = 1, fit = "cover", entranceFrames = 12}: VideoStageProps) => {
const frame = useFrame();
const {fps} = useVideo();
const readiness = useReadiness();
const element = useRef<HTMLVideoElement | null>(null);
const [ready, setReady] = useState(false);
const target = trimStart + (frame / fps) * rate;
useEffect(() => {
const media = element.current;
if (!media || !src) return;
const release = readiness.hold();
let cancelled = false;
const settle = () => {
if (cancelled) return;
setReady(true);
release();
};
// `seeked` is the only signal that the decoder has the requested picture.
media.addEventListener("seeked", settle, {once: true});
media.addEventListener("error", settle, {once: true});
if (Math.abs(media.currentTime - target) > 0.001) media.currentTime = target;
else settle();
return () => {
cancelled = true;
media.removeEventListener("seeked", settle);
media.removeEventListener("error", settle);
release();
};
}, [readiness, src, target]);
const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});
if (!src) {
// The generated clip is a still per frame, which is exactly what a
// frame-addressed video is anyway.
return (
<Fill style={{overflow: "hidden"}}>
<img
src={placeholderFrame(frame, {fps, label: "video-stage", seed: "clip"})}
alt=""
style={{height: "100%", objectFit: fit, opacity: appear, width: "100%"}}
/>
</Fill>
);
}
return (
<Fill style={{overflow: "hidden"}}>
<video
ref={element}
src={src}
muted
playsInline
preload="auto"
style={{height: "100%", objectFit: fit, opacity: ready ? appear : 0, width: "100%"}}
/>
</Fill>
);
};
videos/components/video-stage/video-stage.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {VideoStage} from "./video-stage";
export default defineComponentPreview({
title: "Video stage",
category: "Media/Footage",
description: "Video footage synchronized to the scene frame.",
component: VideoStage,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
trimStart: {type: "number", defaultValue: 0, min: 0, max: 30},
rate: {type: "number", defaultValue: 1, min: 0.25, max: 3, step: 0.25},
fit: {type: "select", defaultValue: "cover", options: ["cover", "contain"]},
},
examples: [
{name: "Default", props: {}},
{name: "Half speed", props: {rate: 0.5}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {VideoStage} from "../components/video-stage/video-stage";
<Video>
<Scene id="video-stage" duration="5s">
<VideoStage
trimStart={0}
rate={1}
fit="cover"
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Media
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 30 frames · 1s
- Entrance
- 12 frames
- Exit
- 0 frames
- Reduced motion
- the clip holds its first frame
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
srcClip URL. Without one, a generated placeholder clip plays. | string | — | — |
trimStartSeconds into the clip that frame zero shows. | number | 0 | — |
ratePlayback rate relative to the composition's clock. | number | 1 | — |
fit | "contain" | "cover" | "cover" | — |
entranceFrames | number | 12 | — |
Related components
All componentsB-roll windowSupporting footage framed beside the main narrative.Canvas magnifierA magnifying lens over your content.Canvas stageFrame-driven canvas drawing with export parity.CarouselA paced media sequence with stable aspect handling.CRT terminalA retro terminal with curved glass and glowing text.Glare sweepA light reflection that sweeps across your content.