Project structure
The videos source root and the special files Odori discovers, inherits, and compiles.
Odori’s primary convention is a root-level videos/ folder. It can coexist with Next.js, live in a standalone repository, or use another configured source root without changing the meaning of its files. Odori discovers resources by entry filename, not by directory name.
- videos/
- layout.tsx
- components/
- title-reveal/
- title-reveal.tsx
- title-reveal.preview.tsx
- product/
- deployment-card.tsx
- title-reveal/
- lib/
- brands/
- paper.ts
- launch/
- video.tsx
- schema.ts
- prepare.ts
- scenes/
- social/
- layout.tsx
- announcement/
- video.tsx
- components/
- dashboard/
- deployments/
- public/
- fonts/
- .odori/
- imports.generated.ts
- catalog.json
- components.json
- builds/
- odori.config.ts
Special files
| File | Role |
|---|---|
video.tsx |
Required React entry and static video metadata |
layout.tsx |
Inherited format, brand, safe areas, motion, and audio policy |
schema.ts |
Serializable input contract and defaults |
prepare.ts |
Async work that resolves once before playback or render |
brands/*.ts |
Brand modules Studio can preview any video with |
scenes/*.tsx |
Ordinary source organization with no discovery semantics |
*.preview.tsx |
Development fixture that makes a component available in Studio |
Odori treats every videos/**/video.tsx module as an exportable video, every videos/**/*.preview.tsx module as a component preview, and every module under a brands/ directory as a source of brand tokens. Other .tsx files remain ordinary source unless a discovered entry imports them.
Directories are ids
A video’s id is its directory path under videos/, nested to any depth. The
tree above holds launch and social/announcement. Two videos cannot collide,
because two directories cannot share a path.
odori export social/announcement # writes out/social-announcement.mp4
Ids are paths and output files are flat, so an id’s separators become dashes in a written file.
Set metadata.id to override the path. That keeps an id stable when a video
moves, at the cost of the guarantee above: an override that names another
video’s id fails discovery, reporting both files.
Configuration
odori.config.ts is optional. It names the source root, the export directory, the audio library, the Studio port, the Chrome executable, and any static asset references.
import {defineConfig} from "@odori/cli";
export default defineConfig({
videosDir: "videos",
exportDir: "out",
audioDir: "public/audio",
port: 4300,
assets: [{reference: "brand-mark", url: "/brand/mark.svg"}],
});
Files in public/ are served at the root of the Studio dev server and by the
render worker, so a font, logo, or sound resolves at the same URL in preview and
in export. audioDir is the slice of it Studio lists as an audio library.
Organize components by ownership
Keep shared video-native components in videos/components/. These components can use Odori frame state, timeline contracts, brand context, asset readiness, and video-only transitions.
Keep real application interface components in the application’s existing components/ directory. Videos can import deterministic application components directly:
import {DeploymentCard} from "@/components/deployments/deployment-card";
export function ProductProof({deployment}) {
return <DeploymentCard deployment={deployment} interactive={false} />;
}
If an application component depends on routing, live data, or browser state, add a video adapter under videos/components/product/. The adapter receives frozen props from prepare.ts and removes interactions that cannot produce deterministic frames.
Keep one-off scene components beside their composition. Move a component to videos/components/ after another video needs it.
Preview reusable components
A *.preview.tsx module is a development-only fixture. It supplies representative props, duration, canvas dimensions, controls, and edge cases so Studio can display a reusable component without turning it into an exportable video.
import {defineComponentPreview} from "odori/preview";
import {TitleReveal} from "./title-reveal";
export default defineComponentPreview({
title: "Title reveal",
category: "Typography",
component: TitleReveal,
canvas: {width: 1920, height: 1080, duration: "4s"},
examples: [
{name: "Default", props: {title: "Ship the story."}},
{name: "Two lines", props: {title: "Build videos\nlike applications."}},
],
});
Studio can play, pause, seek, loop, switch brands, vary props, and test formats against this fixture. Production builds exclude preview modules.
Generated files
Odori scans video, preview, and brand modules and writes static imports to .odori/imports.generated.ts. Studio uses all three. The embedded Player, tests, and render workers consume only the production video graph.
import Launch, {metadata as launchMetadata} from "../videos/launch/video";
import Social, {metadata as socialMetadata} from "../videos/social/announcement/video";
export const videos = [
{component: Launch, metadata: launchMetadata},
{component: Social, metadata: socialMetadata},
];
This generated module keeps the module graph compatible with Next.js, tests, browsers, and Node render workers. Odori regenerates .odori/, so projects should not edit or commit its cache and build output.
Why not put videos under app/?
app/ maps URLs to web routes. videos/ maps IDs to time-based React entries.
Keeping them adjacent makes Next.js compatibility explicit without forcing a
video-only project to adopt application routing.