Camera stage
An animated camera that moves through your scene.
Customize
import {Scene, Video} from "odori";
import {CameraStage} from "../components/camera-stage/camera-stage";
<Video>
<Scene id="camera-stage" duration="5s">
<CameraStage
depthOfField={8}
/>
</Scene>
</Video>Installation
pnpm odori add component camera-stagenpx odori add component camera-stagebunx odori add component camera-stageEdit the source in videos/components/camera-stage/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/camera-stage/camera-stage.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame} from "odori";
export type CameraStop = {
/** The frame the camera is at these settings. */
frame: number;
/** Where the camera looks, in world coordinates. */
x: number;
y: number;
/** How close it is. 1 shows the world at its own size. */
scale?: number;
/** Which depth is sharp. Items away from it blur. */
focus?: number;
/** Camera roll in degrees. The world turns the other way. */
rotate?: number;
};
export type CameraStageProps = {
/** The camera's authored path. Held before the first and after the last. */
stops: CameraStop[];
/** Everything that exists in this world, placed with CameraItem. */
children?: ReactNode;
/** How strongly an out-of-focus depth blurs, in pixels per unit. */
depthOfField?: number;
/** Paint the brand background behind the world. */
background?: boolean;
};
export type CameraItemProps = {
/** Where this sits in world coordinates, from its top left. */
x: number;
y: number;
width?: number;
height?: number;
/** Distance from the focal plane. Zero is sharp when the camera focuses there. */
depth?: number;
/**
* Degrees this item stands rotated on the plane, about its own top left.
* An item laid down at 90 is read by rolling the camera to meet it, which
* is how a film turns sideways without anything being redrawn.
*/
rotate?: number;
children?: ReactNode;
};
/**
* One thing standing somewhere in the world the camera moves through.
*
* World coordinates are design pixels, and this is the only place they are
* scaled. Whatever stands here scales itself the way every component does, so
* a registry component can be placed in a world without knowing it is in one.
*/
export const CameraItem = ({x, y, width, height, depth = 0, rotate = 0, children}: CameraItemProps) => {
const scale = useDesignScale();
return (
<div
data-depth={depth}
style={{
position: "absolute",
left: x * scale,
top: y * scale,
width: width === undefined ? undefined : width * scale,
height: height === undefined ? undefined : height * scale,
transform: rotate ? `rotate(${rotate}deg)` : undefined,
transformOrigin: "0 0",
}}
>
{children}
</div>
);
};
type CameraPose = {x: number; y: number; zoom: number; focus: number; roll: number};
const poseOf = (stop: CameraStop): CameraPose => ({
x: stop.x,
y: stop.y,
zoom: stop.scale ?? 1,
focus: stop.focus ?? 0,
roll: stop.rotate ?? 0,
});
/**
* The camera at one frame. Zoom and position are solved together: a naive
* pair of lerps makes the destination swing across the screen, because the
* growing zoom amplifies whatever centre offset is still left, so a push-in
* reads as centring first and panning after. Here the zoom runs on a
* logarithmic ramp and the centre is placed so the destination's screen
* offset shrinks linearly, which is what a straight dolly onto the target
* looks like. When a segment does not change zoom the formula reduces to the
* plain lerp it replaced.
*/
const cameraAt = (stops: CameraStop[], frame: number): CameraPose => {
if (stops.length === 0) return {x: 0, y: 0, zoom: 1, focus: 0, roll: 0};
if (frame <= stops[0].frame) return poseOf(stops[0]);
const last = stops[stops.length - 1];
if (frame >= last.frame) return poseOf(last);
for (let index = 0; index < stops.length - 1; index += 1) {
const from = stops[index];
const to = stops[index + 1];
if (frame >= from.frame && frame <= to.frame) {
const p = interpolate(frame, [from.frame, to.frame], [0, 1], {easing: Easing.standard});
const a = poseOf(from);
const b = poseOf(to);
const zoom = a.zoom === b.zoom ? a.zoom : a.zoom * Math.pow(b.zoom / a.zoom, p);
const k = (a.zoom / zoom) * (1 - p);
return {
x: b.x - (b.x - a.x) * k,
y: b.y - (b.y - a.y) * k,
zoom,
focus: a.focus + (b.focus - a.focus) * p,
roll: a.roll + (b.roll - a.roll) * p,
};
}
}
return poseOf(last);
};
/**
* One world, and a camera that moves through it.
*
* Most product video is cut together: a scene ends, another begins, and the
* first one stops existing. Some of the best of it is not. It builds a single
* flat world with everything already standing in it, and then simply moves a
* camera, so a transition is the camera arriving somewhere rather than one
* shot replacing another. The interface you were just looking at is still
* there behind the headline, thrown out of focus, and that residue is what
* makes the film feel continuous instead of assembled.
*
* That is impossible to express with scenes, because scenes are cuts by
* construction. It needs world coordinates, one authored camera path, and a
* focal plane, which is what this is.
*
* The camera is a list of stops rather than an animation: the position at any
* frame is read from the path, so scrubbing backwards is exact and two renders
* of the same frame agree. Depth is a number per item rather than a physical
* distance, because what a video actually needs is "this is the subject and
* that is behind it", not a lens simulation.
*/
export const CameraStage = ({stops, children, depthOfField = 8, background = true}: CameraStageProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {x, y, zoom, focus, roll} = cameraAt(stops, frame);
return (
<Fill style={{background: background ? brand.colors.background : "transparent", overflow: "hidden"}}>
{/* The world is moved under a fixed camera, which is the same thing as
moving a camera over a fixed world and far cheaper to express. */}
<div
style={{
position: "absolute",
left: "50%",
top: "50%",
// Only the camera transform lives here. Scaling the world as well
// would scale its contents twice, since everything standing in it
// already scales itself.
// Roll turns the world about the point the camera looks at, which
// is what a camera rolling on its own axis does to the picture.
transform: `scale(${zoom}) rotate(${-roll}deg) translate(${-x * scale}px, ${-y * scale}px)`,
transformOrigin: "0 0",
}}
>
{children}
</div>
{/* Blur is addressed by depth so an item can sit behind the subject
without knowing where the camera is looking. */}
<FocusBlur focus={focus} strength={depthOfField * scale} />
</Fill>
);
};
/**
* Blur is set per depth as a stylesheet rather than per element, so a world of
* fifty items costs one rule instead of fifty inline filters.
*/
const FocusBlur = ({focus, strength}: {focus: number; strength: number}) => {
const depths = [-3, -2, -1, 0, 1, 2, 3];
return (
<style>
{depths
.map((depth) => {
const blur = Math.abs(depth - focus) * strength;
return `[data-depth="${depth}"] { filter: blur(${blur.toFixed(2)}px); }`;
})
.join("\n")}
</style>
);
};
videos/components/camera-stage/camera-stage.preview.tsx
import {useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {CameraItem, CameraStage} from "./camera-stage";
/**
* A world with a headline standing in front of an interface, and a camera that
* travels from one to the other. The interface never leaves; it goes soft.
*
* Everything is drawn from the brand's own tokens: the stage paints the brand
* ground, so hard-coded light-world ink left the headline invisible on a dark
* brand and the card glaring on it.
*/
const Travelling = ({depthOfField}: {depthOfField?: number}) => {
const brand = useBrand();
return (
<CameraStage
depthOfField={depthOfField}
stops={[
{frame: 0, x: 0, y: 0, scale: 1, focus: 0},
{frame: 40, x: 0, y: 0, scale: 1, focus: 0},
{frame: 90, x: 1500, y: 220, scale: 1.15, focus: 1},
]}
>
<CameraItem x={-620} y={-90} width={1240} depth={0}>
<div
style={{
color: brand.colors.foreground,
fontSize: 108,
fontWeight: 400,
letterSpacing: "-0.02em",
lineHeight: 1.05,
}}
>
Introducing Build Mode
</div>
</CameraItem>
<CameraItem x={900} y={-260} width={1200} height={720} depth={1}>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 28,
display: "grid",
gap: 18,
height: "100%",
padding: 40,
}}
>
<div style={{background: brand.colors.border, borderRadius: 12, height: 46, width: "42%"}} />
<div style={{background: brand.colors.border, borderRadius: 12, height: 46, width: "78%"}} />
<div style={{background: brand.colors.border, borderRadius: 12, height: 46, width: "60%"}} />
<div
style={{background: brand.colors.foreground, borderRadius: 14, height: 58, marginTop: 20, width: 220}}
/>
</div>
</CameraItem>
</CameraStage>
);
};
export default defineComponentPreview({
title: "Camera stage",
category: "Motion",
description: "An animated camera that moves through your scene.",
component: Travelling,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
depthOfField: {type: "number", defaultValue: 8, min: 0, max: 24, step: 1},
},
examples: [
{name: "Travelling", props: {}},
{name: "Deep focus", props: {depthOfField: 0}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {CameraStage} from "../components/camera-stage/camera-stage";
<Video>
<Scene id="camera-stage" duration="5s">
<CameraStage
depthOfField={8}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Motion
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 60 frames · 2s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the camera holds its first stop
- Requires
- Odori
- Content limits
- stops ≤ 8, items ≤ 12
Props
| Prop | Type | Default | Required |
|---|---|---|---|
stopsThe camera's authored path. Held before the first and after the last. | CameraStop[] | — | Yes |
childrenEverything that exists in this world, placed with CameraItem. | ReactNode | — | — |
depthOfFieldHow strongly an out-of-focus depth blurs, in pixels per unit. | number | — | — |
backgroundPaint the brand background behind the world. | boolean | — | — |
Related components
All componentsCell revealTiles slide into place to reveal your content.Charged borderA moving glow around a rounded frame.Dissolve revealA noise threshold sweeps across the frame, so content arrives as a torn edge.Gradient fieldA looping gradient in your brand colors.Halftone revealContent revealed through expanding dots.KaleidoscopeThe picture folded into mirrored wedges, so type and interface turn into pattern.