Changelog
A release note as a cut: what changed, the diff that proves it, and the upgrade line.
16:9 · every frame here is the frame odori export writes.
The files
A block is a directory, not a bundle. Pick a file to read the source it ships with.
videos/changelog/components/diff-proof/end-card/stage/title-reveal/
videos/changelog/video.tsx
import {Audio, Scene, Video, defineVideoMetadata} from "odori";
import {DiffProof} from "../components/diff-proof/diff-proof";
import {EndCard} from "../components/end-card/end-card";
import {Stage} from "../components/stage/stage";
import {TitleReveal} from "../components/title-reveal/title-reveal";
import {productLayout} from "../layout";
import {changelogInput} from "./schema";
/**
* A release note as a video: what changed, the diff that proves it, and where
* to get it. Swap the diff lines and the three strings and this ships.
*/
export const metadata = defineVideoMetadata({
title: "Changelog",
description: "A release note: the change, the diff, and the upgrade line.",
duration: "12s",
layout: productLayout,
schema: changelogInput,
tags: ["changelog", "release"],
thumbnailFrame: 40,
});
export default function ChangelogVideo({version, headline, upgrade}: {version: string; headline: string; upgrade: string}) {
return (
<Video>
<Scene id="opening" duration="4s" name="Opening">
<Stage>
<TitleReveal title={headline} detail={`${version} · shipped this week.`} />
</Stage>
</Scene>
<Scene id="diff" duration="5s" name="Diff">
{/* A generated cue: the score lives in videos/components/ui-key. */}
{/* A curve, not a number: the click ducks away as the diff settles. */}
<Audio src="ui.key" from="0.3s" gain={(frame) => (frame < 3 ? 0.5 : 0.2)} />
<Stage grid={false}>
<DiffProof
title="videos/launch/video.tsx"
lines={[
{text: "<Video>", kind: "context"},
{text: ' <Scene duration="4s">', kind: "removed"},
{text: ' <Scene id="opening" duration="4s" name="Opening">', kind: "added"},
{text: " <TitleReveal title={headline} />", kind: "context"},
{text: " </Scene>", kind: "context"},
{text: "</Video>", kind: "context"},
]}
/>
</Stage>
</Scene>
<Scene id="end" duration="3s" name="End">
<Stage>
<EndCard title="Named scenes." detail="Readable timelines, unchanged renders." callToAction={upgrade} />
</Stage>
</Scene>
</Video>
);
}
videos/changelog/schema.ts
import {defineInputSchema} from "odori";
export const changelogInput = defineInputSchema({
version: {type: "text", defaultValue: "v0.4", maxLength: 12},
headline: {type: "text", defaultValue: "Scenes you can name.", maxLength: 48},
upgrade: {type: "text", defaultValue: "pnpm up odori", maxLength: 28},
});
videos/layout.tsx
import {defineBrand, defineVideoLayout} from "odori";
import {uiReveal} from "./components/ui-reveal/ui-reveal";
import {uiKey} from "./components/ui-key/ui-key";
/**
* Brand is policy: tokens, fonts, motion, and audio defaults that every video
* inherits. Font files are served from public/, so preview and render resolve
* the same faces.
*/
export const odoriBrand = defineBrand({
name: "odori",
colors: {
background: "#000000",
surface: "#0a0a0a",
foreground: "#ededed",
muted: "#a1a1a1",
accent: "#ffffff",
border: "#1f1f1f",
},
typography: {
sans: '"Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
},
fonts: [
{family: "Geist Sans", url: "/fonts/Geist-Variable.woff2", weight: "100 900"},
{family: "Geist Mono", url: "/fonts/GeistMono-Variable.woff2", weight: "100 900"},
],
motion: {standard: [0.16, 1, 0.3, 1], staggerFrames: 3},
audio: {
// Named so a video asks for a role and the brand decides the track.
cues: {
"bed.main": "/audio/bed-energetic.m4a",
"bed.calm": "/audio/bed-soft-pulse.m4a",
"bed.warm": "/audio/bed-warm-minimal.m4a",
"ui.confirm": "/audio/ui-reveal.m4a",
// A generated cue: source in this repository, rendered identically for
// preview and for the encode.
"ui.key": uiKey(),
"ui.reveal": uiReveal(),
},
targetLufs: -14,
},
});
export const productLayout = defineVideoLayout({
format: {width: 1920, height: 1080, fps: 30},
brand: odoriBrand,
safeArea: {x: 96, y: 72},
audio: {palette: "product-cinematic", targetLufs: -14},
});
videos/components/stage/stage.tsx
import type {CSSProperties, ReactNode} from "react";
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";
export type StageProps = {
children?: ReactNode;
/** A hairline grid keeps large empty frames from feeling flat. */
grid?: boolean;
/** A slow radial glow behind the content. */
glow?: boolean;
align?: "center" | "start";
style?: CSSProperties;
};
/**
* The shared backdrop. Every scene sits on the same surface so cuts feel like
* one film rather than a stack of unrelated slides.
*/
/** Relative luminance, so the backdrop adapts to a light or dark brand. */
const isLight = (hex: string) => {
const value = hex.replace("#", "");
const full = value.length === 3 ? value.split("").map((part) => part + part).join("") : value;
const [red, green, blue] = [0, 2, 4].map((offset) => parseInt(full.slice(offset, offset + 2), 16) / 255);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue > 0.5;
};
export const Stage = ({children, grid = true, glow = true, align = "center", style}: StageProps) => {
const frame = useFrame();
const brand = useBrand();
const {width, height} = useVideo();
const scale = useDesignScale();
const drift = interpolate(frame, [0, 240], [0, 1], {extrapolateRight: "extend"});
const cell = 80 * scale;
const light = isLight(brand.colors.background);
const glowColor = light ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.07)";
const vignette = light ? "rgba(0,0,0,0.06)" : "rgba(0,0,0,0.55)";
return (
<Fill style={{background: brand.colors.background, ...style}}>
{grid ? (
<Fill
style={{
backgroundImage: `linear-gradient(to right, ${brand.colors.border} 1px, transparent 1px), linear-gradient(to bottom, ${brand.colors.border} 1px, transparent 1px)`,
backgroundSize: `${cell}px ${cell}px`,
maskImage: `radial-gradient(ellipse ${width * 0.55}px ${height * 0.5}px at 50% 45%, rgba(0,0,0,0.65), transparent 78%)`,
WebkitMaskImage: `radial-gradient(ellipse ${width * 0.55}px ${height * 0.5}px at 50% 45%, rgba(0,0,0,0.65), transparent 78%)`,
opacity: interpolate(frame, [0, 24], [0, 0.7], {easing: Easing.standard}),
transform: `translateY(${drift * -14 * scale}px)`,
}}
/>
) : null}
{glow ? (
<Fill
style={{
backgroundImage: `radial-gradient(ellipse ${width * 0.45}px ${height * 0.42}px at 50% ${
38 + drift * 3
}%, ${glowColor}, transparent 70%)`,
}}
/>
) : null}
<Fill
style={{
alignItems: "center",
justifyContent: align === "center" ? "center" : "flex-start",
}}
>
{children}
</Fill>
<Fill
style={{
boxShadow: `inset 0 0 ${240 * scale}px ${vignette}`,
pointerEvents: "none",
}}
/>
</Fill>
);
};
videos/components/title-reveal/title-reveal.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type TitleRevealProps = {
title: string;
detail?: string;
align?: "left" | "center";
};
/**
* A masked, word-staggered headline. Each word rises out of a clipped line
* box, so the reveal reads as typography rather than a fade.
*/
export const TitleReveal = ({title, detail, align = "center"}: TitleRevealProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const lines = title.split("\n");
let wordIndex = 0;
return (
<Fill
style={{
alignItems: align === "center" ? "center" : "flex-start",
gap: 34 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${140 * scale}px`,
textAlign: align,
}}
>
<div style={{display: "grid", gap: 6 * scale, maxWidth: 1560 * scale}}>
{lines.map((line, lineNumber) => (
<div
key={`${line}-${lineNumber}`}
style={{
display: "flex",
flexWrap: "wrap",
gap: `0 ${26 * scale}px`,
justifyContent: align === "center" ? "center" : "flex-start",
overflow: "hidden",
paddingBottom: 12 * scale,
}}
>
{line.split(/\s+/).map((word) => {
const delay = 4 + wordIndex * brand.motion.staggerFrames;
wordIndex += 1;
const progress = interpolate(frame, [delay, delay + 24], [0, 1], {easing: Easing.standard});
return (
<span
key={`${word}-${wordIndex}`}
style={{
display: "inline-block",
fontSize: 116 * scale,
fontWeight: 560,
letterSpacing: "-0.045em",
lineHeight: 1.04,
opacity: progress,
transform: `translateY(${(1 - progress) * 78 * scale}px)`,
}}
>
{word}
</span>
);
})}
</div>
))}
</div>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 36 * scale,
letterSpacing: "-0.015em",
maxWidth: 1100 * scale,
opacity: interpolate(frame, [16, 34], [0, 1], {easing: Easing.standard}),
transform: `translateY(${interpolate(frame, [16, 34], [14 * scale, 0], {easing: Easing.standard})}px)`,
}}
>
{detail}
</div>
) : null}
</Fill>
);
};
videos/components/diff-proof/diff-proof.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type DiffLine = {
text: string;
kind?: "added" | "removed" | "context";
};
export type DiffProofProps = {
lines: DiffLine[];
title?: string;
/** Frames between one changed line and the next. */
stagger?: number;
};
/**
* A patch that reveals its changed lines in order. Context lines are present
* from the first frame, so the eye reads the change rather than the file.
*/
export const DiffProof = ({lines, title = "videos/launch/video.tsx", stagger = 8}: DiffProofProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
let changed = 0;
return (
<Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 24 * scale,
minWidth: 1100 * scale,
overflow: "hidden",
}}
>
<div
style={{
borderBottom: `1px solid ${brand.colors.border}`,
color: brand.colors.muted,
fontFamily: brand.typography.mono,
fontSize: 24 * scale,
padding: `${20 * scale}px ${26 * scale}px`,
}}
>
{title}
</div>
<div style={{padding: `${18 * scale}px 0`}}>
{lines.map((line, index) => {
const kind = line.kind ?? "context";
const context = kind === "context";
const delay = context ? 0 : 12 + changed * stagger;
if (!context) changed += 1;
const enter = context ? 1 : interpolate(frame, [delay, delay + 14], [0, 1], {easing: Easing.standard});
const tint =
kind === "added" ? brand.colors.accent : kind === "removed" ? brand.colors.muted : "transparent";
return (
<div
key={`${line.text}-${index}`}
style={{
background: context ? "transparent" : `color-mix(in srgb, ${tint} ${enter * 14}%, transparent)`,
borderLeft: `${4 * scale}px solid ${context ? "transparent" : tint}`,
color: context ? brand.colors.muted : brand.colors.foreground,
fontFamily: brand.typography.mono,
fontSize: 30 * scale,
opacity: context ? 0.7 : enter,
padding: `${8 * scale}px ${26 * scale}px`,
textDecoration: kind === "removed" ? "line-through" : "none",
whiteSpace: "pre",
}}
>
{kind === "added" ? "+ " : kind === "removed" ? "- " : " "}
{line.text}
</div>
);
})}
</div>
</div>
</Fill>
);
};
videos/components/end-card/end-card.tsx
import {Fill, Easing, interpolate, spring, useBrand, useFrame, useDesignScale, useVideo} from "odori";
export type EndCardProps = {
title: string;
detail?: string;
callToAction?: string;
};
/** The closing lockup: the statement, and one command to run. */
export const EndCard = ({title, detail, callToAction}: EndCardProps) => {
const frame = useFrame();
const brand = useBrand();
const {fps} = useVideo();
const scale = useDesignScale();
const rise = spring({frame, fps, from: 0.96, to: 1, damping: 20});
const line = interpolate(frame, [8, 34], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center", padding: 110 * scale}}>
<div
style={{
fontSize: 108 * scale,
fontWeight: 560,
letterSpacing: "-0.05em",
lineHeight: 1.02,
maxWidth: 1400 * scale,
opacity: interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard}),
textAlign: "center",
transform: `scale(${rise})`,
whiteSpace: "pre-line",
}}
>
{title}
</div>
<div
style={{
background: `linear-gradient(90deg, transparent, ${brand.colors.border}, transparent)`,
height: 1,
opacity: line,
width: `${line * 46}%`,
}}
/>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 34 * scale,
letterSpacing: "-0.015em",
opacity: interpolate(frame, [14, 30], [0, 1], {easing: Easing.standard}),
textAlign: "center",
}}
>
{detail}
</div>
) : null}
{callToAction ? (
<div
style={{
alignItems: "center",
background: "rgba(255,255,255,0.04)",
border: `1px solid ${brand.colors.border}`,
borderRadius: 10 * scale,
color: brand.colors.foreground,
display: "flex",
fontFamily: brand.typography.mono,
fontSize: 28 * scale,
gap: 14 * scale,
marginTop: 8 * scale,
opacity: interpolate(frame, [22, 38], [0, 1], {easing: Easing.standard}),
padding: `${16 * scale}px ${28 * scale}px`,
transform: `translateY(${interpolate(frame, [22, 38], [12 * scale, 0], {easing: Easing.standard})}px)`,
}}
>
<span style={{color: brand.colors.muted}}>{"$"}</span>
{callToAction}
</div>
) : null}
</Fill>
);
};
Remix it
Scaffold a project, install the components this block uses, then paste the entry into videos/changelog/video.tsx. Every file above is yours to edit from that point on.
pnpm create odori@latest my-videopnpm odori add @odori/stage @odori/title-reveal @odori/diff-proof @odori/end-card