Image stage
A still with a slow push, held until it has decoded.
Customize
import {Scene, Video} from "odori";
import {ImageStage} from "../components/image-stage/image-stage";
<Video>
<Scene id="image-stage" duration="5s">
<ImageStage
fit="cover"
zoom={0.06}
entranceFrames={16}
/>
</Scene>
</Video>Installation
pnpm odori add component image-stagenpx odori add component image-stagebunx odori add component image-stageEdit the source in videos/components/image-stage/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/image-stage/image-stage.tsx
import {Easing, Fill, interpolate, placeholderImage, useDesignScale, useFrame, useReadiness} from "odori";
import {useEffect, useState} from "react";
export type ImageStageProps = {
/** Image URL. Defaults to a generated placeholder, so a fixture needs no file. */
src?: string;
alt?: string;
/** How the image meets the frame. */
fit?: "contain" | "cover";
/** Slow push in or out across the scene, as a scale delta. */
zoom?: number;
/** Pan across the scene, as a fraction of the frame. */
pan?: {x?: number; y?: number};
entranceFrames?: number;
};
/**
* A still, with the slow move that keeps it from reading as a slide.
*
* The move is the whole component. A static image in a video looks like the
* player has frozen, and a fraction of a percent of drift per frame is what
* tells the eye it has not — so `zoom` and `pan` are small by default and are
* derived from the frame rather than from a CSS transition.
*
* The frame is held until the image decodes. Without that the capture takes a
* screenshot of a gap, which is the classic way a rendered video ends up with
* the first second missing its picture.
*/
export const ImageStage = ({src, alt = "", fit = "cover", zoom = 0.06, pan, entranceFrames = 16}: ImageStageProps) => {
const frame = useFrame();
const scale = useDesignScale();
const readiness = useReadiness();
const [ready, setReady] = useState(false);
const url = src ?? placeholderImage({label: "image-stage", seed: "image"});
useEffect(() => {
setReady(false);
const release = readiness.hold();
const image = new Image();
let cancelled = false;
const done = () => {
if (cancelled) return;
setReady(true);
release();
};
image.onload = done;
// A failed decode still releases: a missing file should show as a missing
// picture, not as a render that hangs until the driver times out.
image.onerror = done;
image.src = url;
return () => {
cancelled = true;
release();
};
}, [readiness, url]);
const progress = frame / 300;
const appear = entranceFrames <= 0 ? 1 : interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});
const offsetX = (pan?.x ?? 0) * progress * 100;
const offsetY = (pan?.y ?? 0) * progress * 100;
return (
<Fill style={{overflow: "hidden"}}>
<img
src={url}
alt={alt}
style={{
height: "100%",
objectFit: fit,
opacity: ready ? appear : 0,
transform: `scale(${1 + zoom * progress}) translate(${offsetX}%, ${offsetY}%)`,
transformOrigin: "center",
width: "100%",
}}
/>
<span style={{display: "none"}}>{scale}</span>
</Fill>
);
};
videos/components/image-stage/image-stage.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ImageStage} from "./image-stage";
export default defineComponentPreview({
title: "Image stage",
category: "Media/Footage",
description: "A still with a slow push, held until it has decoded.",
component: ImageStage,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
fit: {type: "select", defaultValue: "cover", options: ["cover", "contain"]},
zoom: {type: "number", defaultValue: 0.06, min: -0.2, max: 0.4, step: 0.02},
entranceFrames: {type: "number", defaultValue: 16, min: 0, max: 60},
},
examples: [
{name: "Default", props: {}},
{name: "Still", props: {zoom: 0, entranceFrames: 0}},
{name: "Pan right", props: {pan: {x: -0.04}, zoom: 0.1}},
{name: "Contain", props: {fit: "contain", zoom: 0}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ImageStage} from "../components/image-stage/image-stage";
<Video>
<Scene id="image-stage" duration="5s">
<ImageStage
fit="cover"
zoom={0.06}
entranceFrames={16}
/>
</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
- 16 frames
- Exit
- 0 frames
- Reduced motion
- the still holds without drifting
- Requires
- Odori
- Content limits
- alt ≤ 120
Props
| Prop | Type | Default | Required |
|---|---|---|---|
srcImage URL. Defaults to a generated placeholder, so a fixture needs no file. | string | — | — |
alt | string | "" | — |
fitHow the image meets the frame. | "contain" | "cover" | "cover" | — |
zoomSlow push in or out across the scene, as a scale delta. | number | 0.06 | — |
panPan across the scene, as a fraction of the frame. | {x?: number; y?: number} | — | — |
entranceFrames | number | 16 | — |
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.