Project structure

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
    • lib/
    • brands/
      • paper.ts
    • launch/
      • video.tsx
      • schema.ts
      • prepare.ts
      • scenes/
    • social/
      • layout.tsx
      • announcement/
        • video.tsx
  • odori.config.ts

Special files

FileRole
video.tsxRequired React entry and static video metadata
layout.tsxInherited format, brand, safe areas, motion, and audio policy
schema.tsSerializable input contract and defaults
prepare.tsAsync work that resolves once before playback or render
brands/*.tsBrand modules Studio can preview any video with
scenes/*.tsxOrdinary source organization with no discovery semantics
*.preview.tsxDevelopment 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.

odori.config.ts
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.

The render toolchain

Odori uses Chrome to capture frames and FFmpeg to encode them. Pinned versions reduce differences between render environments.

odori install downloads them into a shared cache outside the project (~/.cache/odori, or wherever ODORI_CACHE points). One download serves every project on the machine, and a Docker layer or a CI cache key can hold it.

A render resolves each binary in this order, and odori doctor prints which one it found:

  1. chromePath / ffmpegPath in odori.config.ts
  2. ODORI_CHROME / ODORI_FFMPEG
  3. Odori's managed copy
  4. Whatever the machine has installed

odori doctor reports the selected binaries. System-installed versions may produce different output from the pinned versions.

Exports record the selected tools in the manifest. The frame cache includes the browser build, so changing Chrome invalidates cached frames.

odori.config.ts
export default defineConfig({
  // Only when a machine has to use its own build.
  chromePath: "/usr/bin/chromium",
  ffmpegPath: "/usr/local/bin/ffmpeg",
});

Export formats

odori export writes H.264 in MP4 by default. --format changes the codec and the container, and without it the output's extension decides, so --output cut.webm is not silently H.264.

FormatCarriesFor
mp4H.264, AACAnywhere. The default.
webmVP9 with alpha, OpusThe web, and transparent overlays.
proresProRes 4444 with alphaHanding to an editor.
gifPalette-optimised framesA README. Silent, by the format.
pngA numbered sequence with alphaA compositor.

Alpha only survives in a format that has it: a transparent composition exported to MP4 is a black rectangle, not an error, so choose webm, prores, or png when the background is meant to be see-through.

GIF and PNG exports use one encoding pass rather than parallel chunks.

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:

videos/launch/scenes/product-proof.tsx
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.

videos/components/title-reveal/title-reveal.preview.tsx
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 Viewer, tests, and render workers consume only the production video graph.

.odori/imports.generated.ts
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.

Beside the imports, odori graph compiles the whole project into .odori/graph.json: every video with its resolved format, duration, brand, and audio variants, the component catalog with usage, and the audio library. Anything that wants to know what a project holds, whether an agent, a CI step, or a script, reads that one file instead of importing modules or starting a browser.

odori graph, odori doctor, and odori test report naming errors, including video.ts entries and preview filenames that don't match their directory.

Videos in a Next.js project

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.

On this page