Captions
Frame-accurate caption cues for sound-off playback.
import {Scene, Video} from "odori";
import {Captions} from "../components/captions/captions";
<Video>
<Scene id="captions" duration="2s">
<Captions
cues={[
{
text: "Preview needs no encode.",
fromFrame: 0,
durationInFrames: 60
},
{
text: "Export freezes the approved cut.",
fromFrame: 62,
durationInFrames: 70
}
]}
/>
</Scene>
</Video>Installation
pnpm odori add component captionsnpx odori add component captionsbunx odori add component captionsEdit the source in videos/components/captions/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/captions/captions.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type CaptionCue = {text: string; fromFrame: number; durationInFrames: number};
export type CaptionsProps = {
cues: CaptionCue[];
position?: "bottom" | "center";
};
/** Frame-accurate captions, safe-area aware. */
export const Captions = ({cues, position = "bottom"}: CaptionsProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const active = cues.find((cue) => frame >= cue.fromFrame && frame < cue.fromFrame + cue.durationInFrames);
if (!active) return null;
const local = frame - active.fromFrame;
const opacity = interpolate(
local,
[0, 6, Math.max(7, active.durationInFrames - 6), active.durationInFrames],
[0, 1, 1, 0],
{easing: Easing.standard},
);
return (
<Fill
style={{
alignItems: "center",
justifyContent: position === "bottom" ? "flex-end" : "center",
padding: `${72 * scale}px ${96 * scale}px`,
pointerEvents: "none",
}}
>
<div
style={{
background: "rgba(0,0,0,0.55)",
border: `1px solid ${brand.colors.border}`,
borderRadius: 14 * scale,
fontSize: 40 * scale,
fontWeight: 520,
maxWidth: 1400 * scale,
opacity,
padding: `${16 * scale}px ${28 * scale}px`,
textAlign: "center",
}}
>
{active.text}
</div>
</Fill>
);
};
videos/components/captions/captions.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Captions} from "./captions";
export default defineComponentPreview({
title: "Captions",
category: "Typography/Captions",
description: "Frame-accurate caption cues for sound-off playback.",
component: Captions,
canvas: {width: 1920, height: 1080, duration: "5s"},
examples: [
{
name: "Two cues",
props: {
cues: [
{text: "Preview needs no encode.", fromFrame: 0, durationInFrames: 60},
{text: "Export freezes the approved cut.", fromFrame: 62, durationInFrames: 70},
],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Captions} from "../components/captions/captions";
<Video>
<Scene id="captions" duration="2s">
<Captions
cues={[
{
text: "Preview needs no encode.",
fromFrame: 0,
durationInFrames: 60
},
{
text: "Export freezes the approved cut.",
fromFrame: 62,
durationInFrames: 70
}
]}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Typography
- Recommended duration
- 60 frames · 2s
- Minimum duration
- 20 frames · 0.67s
- Entrance
- 6 frames
- Exit
- 6 frames
- Reduced motion
- cuts instead of fading
- Requires
- sans font
- Content limits
- text ≤ 84
Props
| Prop | Type | Default | Required |
|---|---|---|---|
cues | CaptionCue[] | — | Yes |
position | "bottom" | "center" | "bottom" | — |
Related components
All componentsCharacter riseCharacters rise with a crisp per-letter stagger.Draw titleAn outline draws across the line, then fills solid.Flap boardA split-flap board clatters through to the target string.Focus inWords settle from a soft blur into a crisp line.Glitch textA title that arrives through brief seeded interference bursts.Kinetic centerA centered statement assembled from timed word groups.