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>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/captionsnpx odori add @odori/captionsbunx odori add @odori/captionsSource is copied into videos/components/captions/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code 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",
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},
],
},
},
],
});
Use it in 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>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Typography
- Aspect ratios
- 16:9 · 9:16 · 1:1
- 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" | — |