@odori/cli 0.0.11 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/{chunk-77R3UC27.js → chunk-FS2BJU5Q.js} +741 -72
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +95 -1
- package/dist/index.js +5 -1
- package/dist/{registry-snapshot-TKH2KAC3.js → registry-snapshot-TSTAA6NT.js} +1815 -245
- package/package.json +17 -20
- package/src/cli.ts +29 -0
- package/src/commands/blocks.ts +188 -0
- package/src/commands/docs.ts +88 -0
- package/src/commands/doctor.ts +38 -0
- package/src/commands/graph.ts +141 -0
- package/src/commands/test.ts +10 -1
- package/src/discovery.ts +8 -2
- package/src/docs-snapshot.json +130 -0
- package/src/docs.ts +54 -0
- package/src/index.ts +2 -0
- package/src/registry-snapshot.json +1647 -206
- package/src/registry-source.ts +139 -0
- package/src/structure.ts +177 -0
package/src/discovery.ts
CHANGED
|
@@ -65,16 +65,22 @@ export type ProjectGraph = {
|
|
|
65
65
|
|
|
66
66
|
const IGNORED = new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Every file under a directory, skipping what a build always skips. Exported
|
|
70
|
+
* for the structure checks, which must read the same tree discovery reads:
|
|
71
|
+
* two walkers with two ignore lists would disagree exactly when it matters.
|
|
72
|
+
*/
|
|
73
|
+
export const walkSource = async (directory: string, files: string[] = []): Promise<string[]> => {
|
|
69
74
|
const entries = await readdir(directory, {withFileTypes: true});
|
|
70
75
|
for (const entry of entries) {
|
|
71
76
|
if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
|
|
72
77
|
const full = join(directory, entry.name);
|
|
73
|
-
if (entry.isDirectory()) await
|
|
78
|
+
if (entry.isDirectory()) await walkSource(full, files);
|
|
74
79
|
else files.push(full);
|
|
75
80
|
}
|
|
76
81
|
return files;
|
|
77
82
|
};
|
|
83
|
+
const walk = walkSource;
|
|
78
84
|
|
|
79
85
|
const toIdentifier = (value: string, prefix: string) => {
|
|
80
86
|
const cleaned = value.replace(/[^a-zA-Z0-9]+(.)?/g, (_, character: string | undefined) =>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pages": [
|
|
3
|
+
{
|
|
4
|
+
"slug": "concepts/lifecycle",
|
|
5
|
+
"title": "Preview and render lifecycle",
|
|
6
|
+
"description": "One prepared manifest drives live preview, deterministic stills, tests, and on-demand encoded exports.",
|
|
7
|
+
"section": "concepts",
|
|
8
|
+
"body": "Odori separates authoring from encoding:\n\n```text\nDiscover source\n ↓\nResolve layout, props, data, fonts, and assets\n ↓\nFreeze a render manifest\n ↓\nLive React preview ── stills and visual tests\n ↓\nExplicit export request\n ↓\nRender worker → encoded media → durable storage\n```\n\n## Live preview\n\n`odori dev` mounts the video component in Odori Studio or an embedded Odori\nViewer. The viewer owns a seekable frame clock and passes deterministic local\nframe state to React. No screenshot sequence, headless browser job, encoder, or\nMP4 upload is needed for preview.\n\n## Prepared manifest\n\nBefore rendering, Odori resolves asynchronous inputs into a serializable,\ncontent-addressed manifest:\n\n```ts\ntype RenderManifest = {\n videoId: string;\n sourceHash: string;\n manifestHash: string;\n input: unknown;\n prepared: unknown;\n format: {width: number; height: number; fps: number; duration: number; durationInFrames: number};\n scenes: Array<{id: string; start: number; durationInFrames: number}>;\n audio: Array<{src: string; fromFrame: number; durationInFrames: number; gain: number; integrity: string}>;\n assets: Array<{url: string; integrity: string}>;\n fonts: Array<{family: string; url: string; integrity: string}>;\n createdAt: string;\n};\n```\n\n`odori inspect <id> --json` prints the manifest for any video. The same inputs\nalways hash to the same manifest, so network drift, mutable URLs, and database\nchanges cannot silently produce a different MP4 from the approved cut.\n\n## Render jobs\n\nAn export records the video ID, manifest hash, output path, progress, attempts,\nlogs, and result under `.odori/builds/`. Jobs run through a single-lane queue,\nso an export started from Studio and one started from the CLI take exactly the\nsame path and never fight for the machine. Every write to a job record is\nserialized and lands atomically, so progress updates cannot corrupt it.\n\nOdori's render worker opens the same compiled video in render mode, seeks one\nframe at a time through a readiness handshake, captures those frames, then\nmixes any [audio cues](/docs/guides/audio) and encodes with FFmpeg. Frames are\ncaptured by several browser workers in parallel; each shard walks an\ninterleaved slice of the timeline, so the work spreads evenly.\n\n```text\nodori export launch\n job job-88e09129a0 manifest 88e09129a05765b874e102653aba07ed\n 2 audio cue(s) at 360 frames\n captured 360 frames in 7.5s on 4 workers, encoded in 1.1s\n ok Exported launch to out/launch.mp4\n```\n\nTune the workers with `--concurrency`, and the encoder with `--preset`.\n\n## Retries\n\nA failed job keeps its frozen manifest, so a retry re-renders the approved cut\nwithout re-resolving inputs or rerunning `prepare.ts`.\n\n```bash\nodori jobs\nodori export --retry job-88e09129a0-msw17lz9\n```\n\nStudio shows the same history and offers a retry button on a failed job.\n\n## Checks before export\n\n`odori test` mounts every discovered video, reads the compiled timeline, and\nsamples representative frames. It fails when a declared duration disagrees with\nthe compiled scene total, when a frame is blank, when content escapes the\ncanvas, or when text is too small to read at 1080p.\n\n:::note\nAn export URL means a job was accepted, not that rendering succeeded. Clients\nshould follow job state until it is `ready` or `failed`.\n:::"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"slug": "concepts/project-structure",
|
|
12
|
+
"title": "Project structure",
|
|
13
|
+
"description": "The videos source root and the special files Odori discovers, inherits, and compiles.",
|
|
14
|
+
"section": "concepts",
|
|
15
|
+
"body": "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.\n\n<FileTree>\n\n- videos/\n - layout.tsx\n - components/\n - title-reveal/\n - title-reveal.tsx\n - title-reveal.preview.tsx\n - product/\n - deployment-card.tsx\n - lib/\n - brands/\n - paper.ts\n - launch/\n - video.tsx\n - schema.ts\n - prepare.ts\n - scenes/\n - social/\n - layout.tsx\n - announcement/\n - video.tsx\n- odori.config.ts\n\n</FileTree>\n\n## Special files\n\n| File | Role |\n| --- | --- |\n| `video.tsx` | Required React entry and static video metadata |\n| `layout.tsx` | Inherited format, brand, safe areas, motion, and audio policy |\n| `schema.ts` | Serializable input contract and defaults |\n| `prepare.ts` | Async work that resolves once before playback or render |\n| `brands/*.ts` | Brand modules Studio can preview any video with |\n| `scenes/*.tsx` | Ordinary source organization with no discovery semantics |\n| `*.preview.tsx` | Development fixture that makes a component available in Studio |\n\nOdori 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.\n\n## Directories are ids\n\nA video's id is its directory path under `videos/`, nested to any depth. The\ntree above holds `launch` and `social/announcement`. Two videos cannot collide,\nbecause two directories cannot share a path.\n\n```bash\nodori export social/announcement # writes out/social-announcement.mp4\n```\n\nIds are paths and output files are flat, so an id's separators become dashes in\na written file.\n\nSet `metadata.id` to override the path. That keeps an id stable when a video\nmoves, at the cost of the guarantee above: an override that names another\nvideo's id fails discovery, reporting both files.\n\n## Configuration\n\n`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.\n\n```ts title=\"odori.config.ts\"\nimport {defineConfig} from \"@odori/cli\";\n\nexport default defineConfig({\n videosDir: \"videos\",\n exportDir: \"out\",\n audioDir: \"public/audio\",\n port: 4300,\n assets: [{reference: \"brand-mark\", url: \"/brand/mark.svg\"}],\n});\n```\n\nFiles in `public/` are served at the root of the Studio dev server and by the\nrender worker, so a font, logo, or sound resolves at the same URL in preview and\nin export. `audioDir` is the slice of it Studio lists as an audio library.\n\n## The render toolchain\n\nTwo programs draw and encode a video: Chrome and FFmpeg. Odori pins both and\nkeeps its own copies, because a video that is reproducible from its source is\nonly reproducible if the software is too — a different browser lays out text a\nfraction differently, and a different encoder build ships different defaults.\nFrames that are *almost* the same are the worst outcome: invisible in review,\npresent in the file.\n\n`odori install` downloads them into a shared cache outside the project\n(`~/.cache/odori`, or wherever `ODORI_CACHE` points). One download serves every\nproject on the machine, and a Docker layer or a CI cache key can hold it.\n\nA render resolves each binary in this order, and `odori doctor` prints which\none it found:\n\n1. `chromePath` / `ffmpegPath` in `odori.config.ts`\n2. `ODORI_CHROME` / `ODORI_FFMPEG`\n3. Odori's managed copy\n4. Whatever the machine has installed\n\nThe last step keeps a machine that already has Chrome working with no download.\nIt is also the only step that cannot promise the same frames as another\nmachine, which is why `doctor` calls it out rather than staying quiet.\n\nEvery export records what it used in the frozen manifest, and the frame cache\nis keyed by the browser build — so upgrading Chrome re-renders rather than\nsilently reusing pixels the new build would not have produced.\n\n```ts title=\"odori.config.ts\"\nexport default defineConfig({\n // Only when a machine has to use its own build.\n chromePath: \"/usr/bin/chromium\",\n ffmpegPath: \"/usr/local/bin/ffmpeg\",\n});\n```\n\n## Export formats\n\n`odori export` writes H.264 in MP4 by default. `--format` changes the codec and\nthe container, and without it the output's extension decides, so\n`--output cut.webm` is not silently H.264.\n\n| Format | Carries | For |\n| --- | --- | --- |\n| `mp4` | H.264, AAC | Anywhere. The default. |\n| `webm` | VP9 with alpha, Opus | The web, and transparent overlays. |\n| `prores` | ProRes 4444 with alpha | Handing to an editor. |\n| `gif` | Palette-optimised frames | A README. Silent, by the format. |\n| `png` | A numbered sequence with alpha | A compositor. |\n\nAlpha only survives in a format that has it: a transparent composition\nexported to MP4 is a black rectangle, not an error, so choose `webm`,\n`prores`, or `png` when the background is meant to be see-through.\n\nA GIF's palette and a PNG sequence are computed across the whole animation, so\nthose two encode in one pass rather than in parallel chunks. They are slower,\nand that is the cost of them being right.\n\n## Organize components by ownership\n\nKeep shared video-native components in `videos/components/`. These components can use Odori frame state, timeline contracts, brand context, asset readiness, and video-only transitions.\n\nKeep real application interface components in the application's existing `components/` directory. Videos can import deterministic application components directly:\n\n```tsx title=\"videos/launch/scenes/product-proof.tsx\"\nimport {DeploymentCard} from \"@/components/deployments/deployment-card\";\n\nexport function ProductProof({deployment}) {\n return <DeploymentCard deployment={deployment} interactive={false} />;\n}\n```\n\nIf 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.\n\nKeep one-off scene components beside their composition. Move a component to `videos/components/` after another video needs it.\n\n## Preview reusable components\n\nA `*.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.\n\n```tsx title=\"videos/components/title-reveal/title-reveal.preview.tsx\"\nimport {defineComponentPreview} from \"odori/preview\";\nimport {TitleReveal} from \"./title-reveal\";\n\nexport default defineComponentPreview({\n title: \"Title reveal\",\n category: \"Typography\",\n component: TitleReveal,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n examples: [\n {name: \"Default\", props: {title: \"Ship the story.\"}},\n {name: \"Two lines\", props: {title: \"Build videos\\nlike applications.\"}},\n ],\n});\n```\n\nStudio can play, pause, seek, loop, switch brands, vary props, and test formats against this fixture. Production builds exclude preview modules.\n\n## Generated files\n\nOdori 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.\n\n```ts title=\".odori/imports.generated.ts\"\nimport Launch, {metadata as launchMetadata} from \"../videos/launch/video\";\nimport Social, {metadata as socialMetadata} from \"../videos/social/announcement/video\";\n\nexport const videos = [\n {component: Launch, metadata: launchMetadata},\n {component: Social, metadata: socialMetadata},\n];\n```\n\nThis 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.\n\nBeside 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.\n\nBecause discovery is filename driven, a file that almost matches the contract does not exist to it: `video.ts` is not a video, and a fixture named after something other than its directory is never credited with its usage. `odori graph`, `odori doctor`, and `odori test` all check for those near misses and say them out loud, so the convention stays something the tools enforce rather than something a reader has to remember.\n\n## Why not put videos under `app/`?\n\n`app/` maps URLs to web routes. `videos/` maps IDs to time-based React entries.\nKeeping them adjacent makes Next.js compatibility explicit without forcing a\nvideo-only project to adopt application routing."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"slug": "concepts/video-files",
|
|
19
|
+
"title": "The video file",
|
|
20
|
+
"description": "Author timelines as normal JSX while exporting static metadata for discovery and production rendering.",
|
|
21
|
+
"section": "concepts",
|
|
22
|
+
"body": "Every discovered entry is named `video.tsx`. The module has two surfaces:\n\n1. a static `metadata` export that Odori can read without rendering frames\n2. a default React component that composes the timeline naturally\n\n```tsx title=\"videos/launch/video.tsx\" lineNumbers\nimport {Scene, Video, defineVideoMetadata} from \"odori\";\nimport {BrowserDemo} from \"../components/browser-demo/browser-demo\";\nimport {EndCard} from \"../components/end-card/end-card\";\nimport {TitleReveal} from \"../components/title-reveal/title-reveal\";\nimport {launchInput} from \"./schema\";\n\nexport const metadata = defineVideoMetadata({\n title: \"Product launch\",\n duration: \"24s\",\n schema: launchInput,\n defaultProps: {\n headline: \"Meet the new workflow.\",\n productUrl: \"https://example.com\",\n },\n});\n\nexport default function LaunchVideo({headline, productUrl}) {\n return (\n <Video>\n <Scene id=\"opening\" duration=\"4s\">\n <TitleReveal title={headline} />\n </Scene>\n <Scene id=\"demo\" duration=\"16s\">\n <BrowserDemo url={productUrl} />\n </Scene>\n <Scene id=\"resolution\" duration=\"4s\">\n <EndCard title=\"Available today.\" />\n </Scene>\n </Video>\n );\n}\n```\n\n## Why `.tsx`?\n\nThe entry is a visual React module. Components, conditionals, loops, context,\nand local composition should remain ordinary JSX rather than being encoded into\na separate scene configuration language.\n\nStatic metadata is deliberately separate because video discovery must know the\nID, format, schema, defaults, and duration before mounting React.\n\n## Structured scenes and direct React\n\nUse `<Scene>` for most work. It gives Odori inspectable boundaries, local frame\nzero, duration checks, scene names in Studio, and better diagnostics.\n\n```tsx\n<Scene id=\"proof\" duration=\"6s\">\n <CodeProof code={source} />\n</Scene>\n```\n\nFor continuous motion, use Odori frame primitives inside the video:\n\n```tsx\nexport default function GenerativeVideo() {\n const frame = useFrame();\n return (\n <Video>\n <GenerativeCanvas progress={frame / 360} />\n </Video>\n );\n}\n```\n\nWhen Odori cannot derive scene duration, `metadata.duration` is authoritative.\nThe framework validates that structured scene totals agree with it.\n\n## Scenes are sequenced, clips are placed\n\nA scene is a length in a sequence: it starts where the previous one ended, so\nits position follows from its siblings and changing one length moves\neverything after it. That is the right model for the spine of a cut, and the\nwrong one for something that has to appear over a scene already running.\n\n`<Clip>` is placed instead. It names its own start, takes no space in the\nsequence, and moves nothing when its length changes:\n\n```tsx\n<Video>\n <Scene id=\"demo\" duration=\"10s\">\n <BrowserDemo url=\"odori.dev\" />\n </Scene>\n\n {/* Two seconds in, over the demo, for three seconds. */}\n <Clip from=\"2s\" duration=\"3s\">\n <Captions lines={[\"Every frame is a function of one number.\"]} />\n </Clip>\n</Video>\n```\n\nA clip reads its clock from wherever it sits, which is what makes it compose.\nA direct child of `<Video>` is placed against the whole cut; the same clip\ninside a `<Scene>` is placed against that scene and disappears with it. Either\nway its children see a clock starting at zero, so a component written for a\nscene behaves identically inside a clip.\n\nLeave `duration` off and it runs to the end of whatever contains it, which is\nwhat a watermark wants:\n\n```tsx\n<Clip from=\"1s\">\n <Watermark />\n</Clip>\n```\n\nAdjacent scenes that should cross rather than cut are still a scene concern:\n`overlap` pulls a scene back into the one before it, and `useSceneTransition()`\nreports how far into that join a frame is.\n\n## Inputs\n\nAll preview and render inputs must be serializable and schema-valid. A single\ncontract powers Studio controls, embedded Viewer props, CLI input, API requests,\nand export jobs."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"slug": "guides/audio",
|
|
26
|
+
"title": "Audio",
|
|
27
|
+
"description": "Place sound on the timeline declaratively, preview it against the frame clock, and mix it into the export.",
|
|
28
|
+
"section": "guides",
|
|
29
|
+
"body": "Audio is declarative like a scene. `<Audio>` registers a cue rather than\nstarting playback, so the player, the manifest, and the encoder all read the\nsame track.\n\n```tsx title=\"videos/launch/video.tsx\" lineNumbers\nimport {Audio, Scene, Video, defineVideoMetadata} from \"odori\";\n\nexport default function LaunchVideo() {\n return (\n <Video>\n {/* One bed under the whole cut, ducked so scene sounds stay audible. */}\n <Audio src=\"score\" gain={0.75} fadeIn=\"1s\" fadeOut=\"1.5s\" duckUnder />\n\n <Scene id=\"opening\" duration=\"4s\">\n <TitleReveal title=\"Author the story.\" />\n </Scene>\n <Scene id=\"resolution\" duration=\"3s\">\n <Audio src=\"confirm\" from=\"0.2s\" duration=\"1s\" gain={0.6} />\n <EndCard title=\"Available today.\" />\n </Scene>\n </Video>\n );\n}\n```\n\n## Where audio lives\n\nFiles live under `public/`, because that is the one directory the dev server\nand the render worker both serve, so a URL means the same thing in Studio, in a\nstill, and in an export. `audioDir` names the library Studio lists, and\ndefaults to `public/audio`.\n\nA cue takes either a path or a reference:\n\n```ts title=\"odori.config.ts\"\nexport default defineConfig({\n audioDir: \"public/audio\",\n assets: [{reference: \"score\", url: \"/audio/product-cinematic.m4a\"}],\n});\n```\n\n```tsx\n<Audio src=\"score\" /> // declared once, swapped in one place\n<Audio src=\"/audio/product-cinematic.m4a\" /> // still valid\n```\n\nA reference resolves through the same registry `useAssets()` reads, so a cue\nand a component name the same file the same way. An undeclared reference throws\ninstead of playing silence. A file found outside `public/` is reported during\nexport, because it would hash into the manifest but never be served.\n\n## Choosing a sound\n\nStart with one quiet bed (`bed-tomorrow` if you want a recording, `bed-drift`\nif you want source) and stop.\nStings and interface sounds exist for the moment that genuinely needs one, and\na mix that uses all of them at once reads as a slideshow with a sound board.\nThe strongest default after that restraint is still silence.\n\nThe library is deliberately short. Every sound in it earns its place by\nsounding like the thing it is named after, and the ones that did not are gone\nrather than available: a catalog that offers a keyboard which sounds like a\nmetronome is worse than one that offers no keyboard at all.\n\nTwo kinds of thing can back a cue name, and the difference is what installing\none means.\n\n**Scores install as source.** They are synthesis, so they diff in a review,\ntake the brand's loudness target, and cost a few kilobytes instead of a binary\nin the repository. Synthesis is honest about music and about abstract motion,\nwhich is what these are:\n\n| Cue | Character | Install |\n| --- | --- | --- |\n| `bed.drift` | A pad that breathes instead of keeping time, for narration | `odori add bed-drift` |\n| `bed.pulse` | A soft repeating pulse over a held chord, for momentum | `odori add bed-pulse` |\n| `bed.lift` | A held chord with a quiet shimmer circling above it | `odori add bed-lift` |\n| `sting.open` | The opening mark, for a first frame | `odori add sting-open` |\n| `sting.close` | The closing mark, under an end card | `odori add sting-close` |\n| `ui.pop` | A short rise, for something arriving | `odori add pop` |\n| `ui.success` | Two notes rising, for a state that landed | `odori add success` |\n| `ui.error` | Two notes falling, for a failure | `odori add error` |\n| `ui.notify` | A single soft note, for an alert | `odori add notify` |\n\n`odori add` copies the score and registers the cue in your brand, so the name\nworks immediately:\n\n```bash\npnpm odori add bed-pulse\n```\n\n**Recorded music is a produced file, not source.** A track is not reachable\nfrom oscillators and should not pretend to be, so these install as assets: the\nbytes are fetched, checked against the hash the registry publishes, and written\ninto `public/` where preview and render both read them.\n\n### The beds\n\nThese five were generated with ElevenLabs Music by this project, on a plan\nwhose terms assign the output to the account that generated it, and prepared\nwith `odori bed` to the same stem target the scores render at. Provenance for\nevery shipped file is recorded in `public/audio/CREDITS.md`. Each installs\nlike everything else:\n\n```bash\npnpm odori add bed-horizons\n```\n\n<audio controls preload=\"none\" src=\"/audio/bed-horizons.m4a\" style={{width: \"100%\", marginBottom: \"12px\"}} />\n\n`bed-horizons` — 60s, registers `bed.horizons`. Steady and wide, for a cut\nthat wants scale without hurry.\n\n<audio controls preload=\"none\" src=\"/audio/bed-tomorrow.m4a\" style={{width: \"100%\", marginBottom: \"12px\"}} />\n\n`bed-tomorrow` — 60s, registers `bed.tomorrow`. Bright and open, for showing\nsomething new without shouting.\n\n<audio controls preload=\"none\" src=\"/audio/bed-innovation.m4a\" style={{width: \"100%\", marginBottom: \"12px\"}} />\n\n`bed-innovation` — 60s, registers `bed.innovation`. Confident and even, for a\nproduct walking through its paces.\n\n<audio controls preload=\"none\" src=\"/audio/bed-launch.m4a\" style={{width: \"100%\", marginBottom: \"12px\"}} />\n\n`bed-launch` — 60s, registers `bed.launch`. A build with a destination, for\nthe run-up to a reveal.\n\n<audio controls preload=\"none\" src=\"/audio/bed-velocity.m4a\" style={{width: \"100%\", marginBottom: \"12px\"}} />\n\n`bed-velocity` — 60s, registers `bed.velocity`. The fast one, with the widest\nloudness range in the library: forward motion for a cut with momentum to\nspend.\n\nA bed of your own — produced, purchased, or generated with a provider from the\n[integrations page](/docs/guides/integrations) — arrives the same way the\nshipped ones did:\n\n```bash\nodori bed ./track.wav --role bed.main\n```\n\nIt is measured, levelled to the stem target with a single linear gain, encoded\nonce, and written where preview and render both read it, with the numbers\nprinted so a flat or clipping source is caught before it is under a video.\n\n### Recordings that install\n\nSome sound cannot be reached from oscillators. A keyboard is the clearest\ncase: a keystrike is a cluster of plastic resonances, and every synthesized\nversion of it lands somewhere between a metronome and a notification. So the\nregistry publishes recordings too, and they install exactly like everything\nelse:\n\n```bash\npnpm odori add typing ui-key\n```\n\n<audio controls preload=\"none\" src=\"/audio/typing.m4a\" style={{width: \"100%\", marginBottom: \"12px\"}} />\n\n`typing.m4a` — 15s of someone actually typing, public domain, cut to loop\ninside a pause.\n\n<audio controls preload=\"none\" src=\"/audio/ui-key.m4a\" style={{width: \"100%\", marginBottom: \"12px\"}} />\n\n`ui-key.m4a` — one keystroke from the same take, for a single beat rather than\na passage.\n\n`odori add` fetches either one, checks it against the hash the registry\npublishes, writes it to `public/audio/`, and registers the cue in your brand,\nso the next line already works:\n\n```tsx\n<Audio src=\"ui.typing\" loop gain={0.5} duckUnder />\n```\n\nAn installed recording is bytes, not source, so there is nothing to review and\nnothing to diff. It is a file in your repository like a logo: yours, replaced\nwith `--force`, and served from the one directory preview and render agree on.\n\n`odori add` writes that registration for you. Naming one by hand looks the\nsame, and is what you edit later to swap the score of every video at once:\n\n```ts title=\"videos/layout.tsx\"\nexport const odoriBrand = defineBrand({\n name: \"product\",\n audio: {\n cues: {\"bed.warm\": \"/audio/bed-innovation.m4a\"},\n targetLufs: -14,\n },\n});\n```\n\nA bed shorter than the video repeats when the placement says so. A generated\nbed already declares that it loops, so `<Audio src=\"bed.pulse\" />` fills its\nscene with nothing else to write; a file does not declare anything, so say it:\n\n```tsx\n<Audio src=\"bed.main\" loop gain={0.7} duckUnder />\n```\n\nThe showcase's social announcement places its bed like this, and the recipe is\nworth stealing whole:\n\n```tsx\n<Audio src=\"bed.main\" gain={0.65} fadeIn=\"0.5s\" fadeOut=\"1s\" trimStart=\"6s\" duckUnder />\n```\n\n`trimStart` is the quiet trick. A produced track spends its first seconds\narriving; skipping into it starts the video where the music is already moving,\nwhich reads as confidence instead of throat-clearing. The short fade in and the\nlonger fade out do the rest.\n\n`odori test` fails a generated cue placed over a window longer than its phrase\nwithout looping, because the rest of that window is silence and silence is hard\nto notice in review.\n\n## Naming sounds in the brand\n\nA brand can name cues symbolically, so a video says what a sound means and the\nbrand decides which file that is:\n\n```ts title=\"videos/layout.tsx\"\nexport const productBrand = defineBrand({\n audio: {\n cues: {\"bed.main\": \"score\", \"ui.confirm\": \"confirm\"},\n targetLufs: -14,\n },\n});\n```\n\n```tsx\n<Audio src=\"bed.main\" duckUnder />\n```\n\nRe-scoring a library is then one brand edit rather than a change in every\n`video.tsx`. A source resolves in three steps: a path is used as written, a\nname the brand knows becomes whatever the brand points at, and anything else is\nan asset reference.\n\n## Auditioning: audio variants\n\nOne film sometimes needs to exist in several sounds: a narration audition, a\nlocalized voice track, a bed the team has not agreed on. Declaring variants on\nthe video keeps that one video with a switch, rather than a copy of the video\nper option:\n\n```tsx\nexport const metadata = defineVideoMetadata({\n id: \"launch\",\n audio: {\n variants: {\n calm: {\"bed.pulse\": bedPulse({bpm: 56})},\n brian: {\"vo-thesis\": \"/audio/vo/brian/thesis.m4a\"},\n },\n },\n});\n```\n\nA variant re-points cue names the composition already uses, so the scenes\nnever know which voice they have. Values are files or generated cues, the\nsame union the brand's own map takes. Studio offers them as the Variant\ncontrol in the inspector's Audio section, the player switches live, and\n`odori export <id> --audio-variant <name>` ships one. `odori test` exercises\nthe default track; an exported variant validates its own files by failing\nloudly when one is missing.\n\n## Narration\n\nA voice is kept as source, the way everything else here is. One command\nrecords the script and writes two files: the audio under `public/audio/`, and\na `.narration.json` beside it holding the script, the voice, and the time\nevery word starts and ends.\n\n```bash\nodori narrate \"One definition. Every render path.\" --output public/audio/opener\n```\n\nThe timings arrive with the recording, in the same call, so there is no\ntranscription step and nothing to line up by hand. Captions are computed from\nthem at the project's fps when the video composes:\n\n```tsx\nimport {Audio, captionCues, useVideo} from \"odori\";\nimport {Captions} from \"../components/captions/captions\";\nimport narration from \"../../public/audio/opener.narration.json\";\n\nconst Narrated = () => {\n const {fps} = useVideo();\n return (\n <>\n <Audio src=\"voice.narration\" />\n <Captions cues={captionCues(narration, fps)} />\n </>\n );\n};\n```\n\nBecause the captions derive from the recording's own timings, they cannot\ndrift from the voice: re-record with a different read and the captions move\nwith it, and the diff on the JSON shows exactly which words moved. A scene can\nbe sized to the sentence that plays over it, because `narrationEndSeconds`\nmakes the sentence's end a number rather than a guess.\n\nLike every generation, narrating is an authoring step. The provider is called\nonce, at the keyboard, with your key; the render reads a file and some\ntimings and never touches the network. The cue role (`voice.narration` by\ndefault) registers in the brand the way a bed does, so a video names the role\nand the brand decides the file.\n\n## Placement\n\nA cue declared inside a scene is offset by that scene's start, so moving a\nscene moves its sound with it. A cue declared at the top level of `<Video>`\nstarts at frame zero and runs for the whole composition unless you give it a\nduration.\n\n| Prop | Meaning |\n| --- | --- |\n| `src` | A path under `public/`, or a reference declared in `odori.config.ts` |\n| `from` | Offset from the enclosing scene, or from the video at the top level |\n| `duration` | Cue length. Defaults to the enclosing scene, or the video |\n| `gain` | Linear gain. `1` is unchanged |\n| `fadeIn` / `fadeOut` | Fade lengths, applied identically in preview and export |\n| `trimStart` | Seconds skipped at the head of the source file |\n| `loop` | Repeat the source to fill the cue |\n| `duckUnder` | Attenuate while another cue plays over it |\n\n## Collection without seeking\n\nScenes only mount while they are on screen, so Odori runs a hidden collection\npass that mounts every scene at once and gathers its cues. That is how\n`odori inspect` can print the whole track, and how the encoder knows about a\nsound in the last scene without rendering the first one.\n\n```bash\nodori inspect launch\n```\n\n```text\nAudio\n /audio/product-cinematic.m4a 0 to 359 gain 0.75 (ducked)\n sha256-LDDbqQD4gCPt/iV+/hz5y42SCfX1NuKnH8d/Emv1/vM=\n /audio/ui-confirm.m4a 276 to 305 gain 0.6\n```\n\n## Preview\n\nStudio plays the track against the frame clock. The timeline is not dragged:\nhovering it previews the frame under the cursor, silently, and clicking commits\nthe playhead to that frame. Scrubbing audio sounds like a stuck record, because\nevery intermediate frame reseeks the same few milliseconds, so preview stays\nquiet and sound belongs to playback. Clicking a cue solos it; clicking it again\nreturns to the whole mix. Press `m` to mute.\n\nWhether a video opens with sound is a workspace preference, under the gear in\nthe header. It applies to the next video you open, not the one playing.\n\nBrowsers refuse to start audio until a page has been interacted with, so a\nfreshly loaded Studio plays silently until you click or press a key. Rather\nthan failing quietly, the transport says so and offers an **Enable sound**\nbutton; the click itself is the gesture the browser is waiting for.\n\nThe Assets view lists the audio library with a play button, a waveform, and a\nlength per file, so a sound can be auditioned, and recognized as a short quiet\none, before it is placed on a timeline.\n\nPreview audio follows the frame clock as closely as the browser allows. The\nexported mix is built separately from the same cues, so the file itself is\nframe accurate.\n\n## Export\n\nThe encoder trims, delays, fades, gains, and mixes every cue, then normalizes\nthe result to the brand's target loudness before muxing it with the video.\n\nDucking is a window, not a constant. A ducked cue drops to 0.35 only while a\nnon-ducked cue overlaps it, with a six frame ramp either side, so a one second\nconfirmation does not hold a music bed down for the length of the video. The\nplayer evaluates that envelope and the encoder compiles the same points into\nits volume filter, so what you hear while mixing is what the file carries.\n\n```ts title=\"videos/layout.tsx\"\nexport const odoriBrand = defineBrand({\n name: \"odori\",\n audio: {cues: {\"bed.main\": \"score\"}, targetLufs: -14},\n});\n```\n\nStudio measures the mix it is playing against that target. The track is\nrendered offline through the same envelopes the player uses and measured to\nITU-R BS.1770, so the Audio panel reports real integrated loudness beside the\nbrand's target rather than only the goal.\n\nThe resulting file carries stereo AAC at 48 kHz, normalized to that target with\na true peak ceiling of -1.5 dBTP. Cues whose source cannot be resolved locally\nare skipped with a warning rather than silently dropped.\n\n:::note\nOnly project-local audio can be encoded today. A remote URL previews in Studio\nand is hashed into the manifest, but the encoder needs the file on disk.\n:::"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"slug": "guides/components",
|
|
33
|
+
"title": "Video components",
|
|
34
|
+
"description": "Install polished, temporal React components as source and compose them directly inside video.tsx.",
|
|
35
|
+
"section": "guides",
|
|
36
|
+
"body": "Odori's component registry follows the shadcn model: installation copies\nsource into your project instead of hiding implementation behind a package.\n\n```bash\npnpm odori add \\\n @odori/stage \\\n @odori/title-reveal \\\n @odori/code-proof \\\n @odori/terminal \\\n @odori/browser-demo \\\n @odori/connection-story \\\n @odori/end-card\n```\n\n`pnpm odori registry` prints the catalog with each component's aspect ratios,\nminimum duration, and reduced-motion behavior. Registry dependencies install\nwith the component that needs them.\n\nEvery component also has a page in the [component catalog](/components), one per\nregistry entry, where the real component plays in the browser at any frame and\nformat, next to its timing contract, props, and the exact source `odori add`\ncopies.\n\n<FileTree>\n\n- videos/\n - components/\n - stage/\n - stage.tsx\n - stage.preview.tsx\n - title-reveal/\n - title-reveal.tsx\n - title-reveal.preview.tsx\n - code-proof/\n - code-proof.tsx\n - code-proof.preview.tsx\n - terminal/\n - terminal.tsx\n - terminal.preview.tsx\n - end-card/\n - end-card.tsx\n - end-card.preview.tsx\n\n</FileTree>\n\nUse the components as ordinary React:\n\n```tsx\n<Scene id=\"proof\" duration=\"8s\">\n <Stage grid={false}>\n <CodeProof\n code={'export const preview = \"instant\";'}\n focus={[1]}\n language=\"tsx\"\n title=\"videos/launch/video.tsx\"\n />\n </Stage>\n</Scene>\n```\n\n## Temporal contracts\n\nVideo components need more than prop types. Registry metadata describes:\n\n- supported aspect ratios\n- recommended and minimum duration\n- natural entrance and exit frames\n- content limits, such as maximum headline length\n- reduced-motion behavior\n- required fonts and audio\n\n`odori test` and the registry test suite check these contracts, while the\ncopied implementation stays fully editable.\n\n## Scale with the frame, not the width\n\nComponents multiply their design values by `useDesignScale()`, which measures\nthe shorter side of the frame against a 1080px reference. The same component\nreads correctly at 1920x1080, 1080x1920, and 1080x1080 without a separate\nvertical variant.\n\n```tsx\nconst scale = useDesignScale();\n<div style={{fontSize: 116 * scale, letterSpacing: \"-0.045em\"}}>{title}</div>;\n```\n\n## Component development in Studio\n\nThe component source stays independent from its development fixture. A sibling\n`*.preview.tsx` file defines representative props, controls, duration, canvas\nsize, and edge cases for Studio. The fixture is similar to a Storybook story\nwith a deterministic frame clock.\n\n## How a library organises itself\n\nStudio groups components by category, and a category is a path: `Interface`\nis a family, `Interface/Controls` is a group inside it. Three things can say\nwhere a component belongs, and the first one that speaks wins.\n\n**The directory it sits in.** This is the ordinary case, and it needs no\nmetadata at all. A component at `videos/components/interface/controls/combobox/`\nis in `Interface/Controls`, so a library is organised by moving folders, and\nno group can come into existence because somebody mistyped one.\n\n**A `category.json` beside the components.** A directory name is read as\nwords, which makes `developer-proof` the family \"Developer proof\" and\n`product-ui` the family \"Product ui\". When that is wrong, or when families\nshould not be alphabetical, the directory says so itself:\n\n```json title=\"videos/components/product-ui/category.json\"\n{\"name\": \"Product UI\", \"order\": 1}\n```\n\n`order` is the only way to sequence your own families; without one they follow\nthe ones the registry ships, alphabetically.\n\n**A `category` on the fixture.** This is for source that travels. `odori add`\ncopies a component into your tree flat, where the directory says nothing about\nwhat it is for, so a registry component carries its family with it:\n\n```tsx\nexport default defineComponentPreview({\n title: \"Combobox\",\n category: \"Interface/Controls\",\n // ...\n});\n```\n\nYour own components rarely need it. Reach for it when a component must keep\nits family regardless of where the file ends up.\n\n## Reuse application components\n\nVideos can import deterministic components from an application's root\n`components/` directory. Keep those components under their existing product\nownership instead of duplicating them for video.\n\nAdd an adapter under `videos/components/product/` when the application\ncomponent expects routing, live data, or browser interaction. The adapter\nsupplies frozen data and disables behavior that depends on wall-clock or\napplication state.\n\n## Initial component library\n\n| Family | Components |\n| --- | --- |\n| Typography | Title reveal, metric callout, captions |\n| Developer proof | Terminal, code proof |\n| Interface | Browser demo |\n| Narrative | Connection story |\n| Brand | Stage, end card |\n\n## Install the agent quickstart\n\nThe repository also ships an agent skill that turns project evidence into a\ncoherent first cut and installs a small, story-specific component set:\n\n```bash\nnpx skills add allenzhou101/odori \\\n --skill odori-quickstart \\\n --yes\n```\n\n## Where components come from\n\n`odori add` fetches from the registry published at\n[odori.dev/r](https://odori.dev/r/v1/registry.json) and caches it beside the\npinned binaries, so a component can ship without you upgrading anything. It\ntells you which source answered:\n\n```bash\n$ pnpm odori add cursor-focus\nregistry: https://odori.dev/r/v1\n create videos/components/cursor-focus/cursor-focus.tsx\n create videos/components/cursor-focus/cursor-focus.preview.tsx\nok @odori/cursor-focus to videos/components/cursor-focus/\n```\n\nThe files are listed before they are written, `--dry-run` prints them and\nstops, and every item carries a hash of its contents that is checked before\nanything touches your disk. With no network it falls back to the cache, and\nthen to a copy built into the CLI — older, but enough to keep working on a\nplane.\n\nPoint `registryUrl` in `odori.config.ts` at a fork or a pinned version, or set\n`ODORI_REGISTRY` for one command.\n\n### Installing with the shadcn CLI\n\nThe registry is [shadcn-compatible](https://ui.shadcn.com/docs/registry), so\nany project can take the source:\n\n```bash\nnpx shadcn@latest add https://odori.dev/r/v1/title-reveal.json\n```\n\nThat copies the same files to the same place. What it does not do is the part\nspecific to Odori: a cue installed this way is **not registered in your\nbrand**, so `<Audio src=\"ui.pop\" />` resolves to nothing until you add it by\nhand. `odori add` writes that line for you. Use the shadcn path to read the\nsource or to lift a component into a project that is not an Odori project; use\n`odori add` inside one.\n\n## Three kinds of entry\n\nMost of the registry is source. Two smaller families are not, and the\ndifference is what installing means:\n\n| Kind | What it is | What `odori add` does |\n| --- | --- | --- |\n| Component | React that renders frames | Copies source into `videos/components/<name>/` |\n| Cue | Synthesis that renders samples | Copies source, and registers the cue in your brand |\n| Asset | A produced file, for sound synthesis cannot reach | Fetches the file into `public/`, verifies it against the published hash, and registers the cue by URL |\n\nAn asset is bytes rather than source, so it has no diff and no update: it is a\nfile in your repository like a logo, replaced with `--force`. The shadcn CLI\ncan read an asset's document but will not install it, because fetching media\nis not something its schema describes.\n\n## Source ownership\n\nRegistry provenance is recorded in `odori.lock.json`, so the CLI can tell\na pristine component from one you have edited and from one the registry has\nmoved past. Commit it: `odori update` and `odori diff` are only as good as the\nlockfile, and a teammate who clones without one gets neither.\n\n```bash\nodori diff # what changed, in your project and upstream\nodori diff terminal --full # the diff itself\nodori update # apply upstream changes to untouched components\n```\n\nUpdates are opt-in and source-aware. A component that is both edited locally and\nchanged upstream is reported as diverged and left alone until you pass\n`--force`."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"slug": "guides/data",
|
|
40
|
+
"title": "Data and assets",
|
|
41
|
+
"description": "Resolve async work before playback, freeze mutable inputs, and keep frame rendering deterministic.",
|
|
42
|
+
"section": "guides",
|
|
43
|
+
"body": "React frame rendering must be pure: the same frame and props should produce the\nsame pixels. Put network, database, filesystem, and secret-backed work in\n`prepare.ts`.\n\n```ts title=\"videos/changelog/prepare.ts\" lineNumbers\nimport {definePrepare} from \"odori\";\n\nexport const prepare = definePrepare(async ({input, assets, cache, signal}) => {\n const release = await cache.getOrSet(\n `release:${input.tag}`,\n () => fetchRelease(input.tag, {signal}),\n );\n\n const logo = await assets.resolve(\"brand:product-mark\");\n\n return {release, logo};\n});\n```\n\nThe prepared result becomes a prop of the video entry:\n\n```tsx\nexport default function ChangelogVideo({prepared}) {\n return (\n <Video>\n <Scene duration=\"6s\">\n <ReleaseTitle release={prepared.release} logo={prepared.logo} />\n </Scene>\n </Video>\n );\n}\n```\n\nBecause the result is frozen into the manifest, a video keeps a sensible\nfallback for the case where preparation has not run:\n\n```tsx\nexport default function WorkflowVideo({prepared}: {prepared?: {commands: Step[]}}) {\n return <Terminal steps={prepared?.commands ?? FALLBACK} />;\n}\n```\n\n## Inputs\n\nAll preview and render inputs must be serializable and schema-valid. One\ncontract powers Studio controls, embedded Viewer props, CLI input, and export\njobs.\n\n```ts title=\"videos/launch/schema.ts\"\nimport {defineInputSchema} from \"odori\";\n\nexport const launchInput = defineInputSchema({\n headline: {type: \"text\", defaultValue: \"Author the story.\", maxLength: 64, multiline: true},\n});\n```\n\n`defineInputSchema` validates, fills defaults, and describes itself so Studio\ncan generate controls. Any zod-compatible object with a `parse()` method is\naccepted instead.\n\n## Readiness and integrity\n\nFonts and images resolve before the first frame is captured, and\n[audio](/docs/guides/audio) is collected into a track the encoder mixes.\nAssets declared in `odori.config.ts` are addressable by reference through\n`useAssets()`.\n\nEvery font, asset, and audio source in the manifest carries a real content\nhash. Local files hash their bytes; remote files are fetched once and cached by\nURL under `.odori/cache/integrity.json`. A source that cannot be read is\nrecorded as `unresolved` rather than pretending to be verified.\n\n```text\nfont Geist Sans /fonts/Geist-Variable.woff2 sha256-o2n89WKOoqpOG54uxqWzYk42W9pYjh8PLxK1ZPco+7g=\n```\n\n## Cache keys\n\nPrepared data is cached on disk under `.odori/cache/prepare/`, keyed by video\nsource hash, validated input, and prepare version. Changing scene styling does\nnot refetch source data; changing a data dependency invalidates deterministically.\nRepeated stills and exports of an approved cut reuse the cached result, and a\nretry never reruns preparation at all because it replays the frozen manifest."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"slug": "guides/effects",
|
|
47
|
+
"title": "Effects",
|
|
48
|
+
"description": "Author a scene as React, capture it deterministically, and post-process it through shaders.",
|
|
49
|
+
"section": "guides",
|
|
50
|
+
"body": "Odori composes video out of ordinary React. `odori/effects` keeps that true when\nyou want a lens, a glitch, or a screen treatment over it: the typography and the\nproduct UI stay markup, and only the compositing is different.\n\n```tsx\nimport {EffectSurface, barrelDistortion, rgbSplit, scanlines} from \"odori/effects\";\n\n<EffectSurface\n effects={[\n barrelDistortion({amount: 0.32}),\n rgbSplit({amount: 5}),\n scanlines({opacity: 0.08}),\n ]}\n>\n <TitleArtwork />\n</EffectSurface>\n```\n\nThe children are rendered by the browser, captured once per frame, and handed\nto every effect in one pipeline. Nesting two surfaces would photograph the same\npicture twice, which is why effects are a list rather than something to wrap\nrepeatedly.\n\n## What ships\n\n`barrelDistortion`, `magnify`, `rgbSplit`, `pixelate`, `scanlines`, and\n`filmGrain`. Each is one fragment shader with named uniforms, and each is\nreadable source rather than a black box.\n\n## Writing one\n\n`defineShaderEffect` owns the context, the program, the full-screen triangle,\nthe source texture, and the framebuffers a chain hands results through. You\nwrite the body of a fragment shader.\n\n```tsx\nconst vignette = defineShaderEffect({\n name: \"vignette\",\n uniforms: {amount: numberUniform(0.4)},\n fragmentShader: `\nuniform float amount;\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n float edge = 1.0 - amount * length(uv - 0.5);\n odoriColour = tex(uv) * edge;\n}`,\n});\n```\n\n`tex(uv)` reads the captured picture, and `resolution`, `frame`, `fps`,\n`seconds` and `progress` are already in scope. The helper is `tex` rather than\n`sample` because `sample` is a reserved word in GLSL ES 3.00. A shader that\nfails to compile puts the compiler's message and the offending line on the\nstage rather than rendering black.\n\n## Three.js, and anything else that owns a canvas\n\n`EffectSurface` is for filtering a picture. A scene graph wants to keep a\nrenderer alive across frames instead, which is what the lower-level surface is\nfor.\n\n```tsx\n<HtmlInCanvas\n onInit={({canvas, width, height}) => createRenderer(canvas, width, height)}\n onPaint={({renderer, source, frame, fps, progress}) => renderer.draw(source, frame)}\n onDispose={(renderer) => renderer.dispose()}\n>\n <Overlay />\n</HtmlInCanvas>\n```\n\n`onInit` runs once and its return value comes back to every `onPaint`. Two\nrules make a Three.js scene safe here: drive everything from the `frame` you\nare given, and never call `setAnimationLoop`. A renderer that animates itself\nis reading a wall clock, and two workers rendering neighbouring chunks will\ndisagree about what frame 200 looks like.\n\nYou do not have to take that on trust. `odori test` renders the same frame\ntwice and compares the pixels, so a scene driven by anything other than the\nframe fails on its own.\n\n## Randomness\n\nUse the prelude's `hash(vec2)`. The idiom every shader tutorial reaches for,\n`fract(sin(dot(p, k)) * 43758.5453)`, is not reproducible: `sin()` of a large\nargument is implementation defined, so the same frame comes out differently\ndepending on which graphics backend drew it. Measured on one frame of grain,\nthat idiom scored 0.75 SSIM between software and GPU rendering, where the\nprelude's integer hash scores 0.999993.\n\n`defineShaderEffect` refuses a shader containing it rather than warning about\nit, because the failure is otherwise invisible until somebody renders the same\nvideo on another machine.\n\nSeed from `frame`, and the same frame keeps the same noise wherever it is\nrendered.\n\n## Determinism\n\nEverything in a surface derives from the frame. No `requestAnimationFrame`, no\n`Date.now()`, no unseeded randomness: `filmGrain` seeds from the frame index so\nthe same frame has the same grain every time it is rendered.\n\nFonts are awaited before the capture, because a face that arrives late is baked\ninto the pixels as its fallback.\n\n## What the browser has to provide\n\nThere is a native path for this, `CanvasRenderingContext2D.drawElement`, and it\nis not in the pinned render browser, so capture goes through an SVG\n`foreignObject` with computed styles inlined onto the subtree. Studio and the\nexport use the same path, which is the point.\n\nShaders need WebGL2. Headless Chrome has no GPU, so the render worker enables\nANGLE's software backend itself and you do not pass a flag.\n\nSoftware is the default because it draws the same pixels on every machine,\nwhich is what the chunk cache, parallel workers, and a byte-for-byte test all\nrest on. `odori export --fast` uses the machine's GPU instead. Measured here at\n1080p over 60 frames:\n\n| Work | Software | GPU |\n| --- | --- | --- |\n| Five full-screen post-processing passes | 86ms a frame | 81ms |\n| A raymarch that exits early on most rays | 46ms | 38ms |\n| 1500 fixed iterations, no early exit | 507ms | 37ms |\n\nThe GPU stays under 40ms whatever the shader, because it is waiting on the\ncapture and the encode rather than on itself. So `--fast` is worth nothing for\nordinary post-processing and worth fourteen times for work that is genuinely\nper-pixel expensive.\n\nThe pixels differ slightly between the two, at 0.999993 SSIM, which is texture\nfiltering the spec allows to vary. The cache key includes the backend, so a\n`--fast` render never reuses chunks a software render drew, and an export\nalways prints which one it used.\n\nWebGPU is not available: the pinned build exposes `navigator.gpu` but returns\nno adapter, so a feature check on the property alone will mislead you."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"slug": "guides/integrations",
|
|
54
|
+
"title": "Integrations",
|
|
55
|
+
"description": "Generate media with a provider at author time, and keep the render deterministic.",
|
|
56
|
+
"section": "guides",
|
|
57
|
+
"body": "Odori renders the same frames twice. That property survives generated media\nfor one reason: **generation is an authoring step, not a render step.** A\nprovider is called once, at the keyboard; what it returns is prepared,\ncommitted, and hash-addressed like any other file. The render never calls a\nnetwork, so the cut a reviewer approves is the cut that ships — however the\nsound in it was made.\n\nThe same structure answers the licensing question. Odori redistributes\nnothing a provider makes: your key, your account, your output. The only\ngenerated media in the registry itself was generated by this project, on an\naccount whose terms assign the output to it, and its provenance is written\ndown in `public/audio/CREDITS.md`.\n\n## Music\n\nEvery path lands in the same place — `odori bed`, which measures, levels to\nthe stem target, and registers a role — so choosing a source never changes\nthe mix.\n\n```bash\nodori bed ./track.wav --role bed.main # bring your own\nodori bed \"warm minimal ambient\" --generate # generate, then the same pipeline\n```\n\n| Provider | Status | Key | Notes |\n| --- | --- | --- | --- |\n| [ElevenLabs Music](https://elevenlabs.io/music) | **Native** | `ELEVENLABS_API_KEY` | Trained on licensed catalogues; paid plans own their output |\n\nA key can come from two places, and the environment always wins: set the\nprovider's variable in a shell or a runner, or paste it once into **Studio's\nsettings** (`odori dev`, then the gear in the header, under Audio). Studio\nchecks the key against the provider before storing it in `~/.config/odori` —\non the machine, mode 0600, never inside a project, and never readable back out\nof the dialog.\nElevenLabs authenticates with API keys only; there is no OAuth flow for\nthird-party applications to offer instead.\n\nStudio also generates directly, from the Assets view: prompt, length, role, and\nthe same pipeline runs server-side — the result plays in place with the\nnumbers the terminal would have printed. Either way the role is registered in\nthe brand automatically when the file lands under `public/`, the same way\n`odori add` registers an installed asset, so the name works immediately.\n\nTwo more places answer \"is this connected\": `odori integrations` lists every\nprovider and where its key would come from, and `odori doctor` warns (without\nfailing) when none is configured. A project can commit its default provider —\nthe choice, never the key — in `odori.config.ts`:\n\n```ts title=\"odori.config.ts\"\nexport default defineConfig({\n generation: {music: \"elevenlabs\"},\n});\n```\n\n`--generate` sends the prompt to the provider and keeps the raw answer beside\nthe prepared file — it is the one copy of that generation that will ever\nexist. `--provider` picks the backend, `--seconds` the length:\n\n```bash\nexport ELEVENLABS_API_KEY=...\nodori bed \"steady ambient bed, no drums, warm\" --generate --seconds 60 --role bed.main\n```\n\nThe pipeline prints what it measured either way, and warns when a track is\ntoo compressed to sit under a cut or clipped at the source — the two defects\nthat reach a video before anyone hears them.\n\n## Voiceover\n\nNot yet native. Generate narration with a provider's own tools —\n[ElevenLabs](https://elevenlabs.io) covers text-to-speech on the same key —\nand place the file directly; narration is timed to scenes, so it belongs in\n`public/` next to the video that speaks it, not in a brand role.\n\n## Images and video\n\nGenerated stills and b-roll are files like any other asset: put them in\n`public/`, reference them from a scene, and they hash into the manifest with\neverything else. There is no native integration yet because there is nothing\nto normalise — an image needs no loudness pass. If a preparation step earns\nits place (safe-area crops, brand palette grading), a provider integration\nwill follow the same shape: author time, your key, prepared output, committed.\n\n## What native means\n\nA native integration is one command from prompt to registered role, with the\npreparation Odori would want anyway applied on the way. It is deliberately\nnot a runtime feature: no provider is called during preview or export, no\nkey ever ships, and removing every key changes nothing about how a project\nrenders. A provider integration that cannot keep that contract does not\nbecome one."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"slug": "guides/layouts",
|
|
61
|
+
"title": "Layouts and brands",
|
|
62
|
+
"description": "Inherit format, typography, motion, safe areas, audio policy, and design tokens through the videos tree.",
|
|
63
|
+
"section": "guides",
|
|
64
|
+
"body": "Layouts are modules that establish presentation policy without adding timeline\nframes.\n\n```tsx title=\"videos/layout.tsx\" lineNumbers\nimport {defineBrand, defineVideoLayout} from \"odori\";\n\nexport const odoriBrand = defineBrand({\n name: \"odori\",\n colors: {\n background: \"#000000\",\n surface: \"#0a0a0a\",\n foreground: \"#ededed\",\n muted: \"#a1a1a1\",\n accent: \"#ffffff\",\n border: \"#1f1f1f\",\n },\n typography: {sans: '\"Geist Sans\", sans-serif', mono: '\"Geist Mono\", monospace'},\n fonts: [\n {family: \"Geist Sans\", url: \"/fonts/Geist-Variable.woff2\", weight: \"100 900\"},\n {family: \"Geist Mono\", url: \"/fonts/GeistMono-Variable.woff2\", weight: \"100 900\"},\n ],\n motion: {standard: [0.16, 1, 0.3, 1], staggerFrames: 3},\n audio: {cues: {\"bed.main\": \"score\"}, targetLufs: -14},\n});\n\nexport const productLayout = defineVideoLayout({\n format: {width: 1920, height: 1080, fps: 30},\n brand: odoriBrand,\n safeArea: {x: 96, y: 72},\n});\n```\n\n`videos/social/layout.tsx` can override the format while inheriting the brand:\n\n```tsx\nimport {defineVideoLayout} from \"odori\";\nimport {productLayout} from \"../layout\";\n\nexport const socialLayout = defineVideoLayout({\n extends: productLayout,\n format: {width: 1080, height: 1920},\n safeArea: {x: 64, y: 120},\n});\n```\n\n## Merge rules\n\n- scalar format values replace inherited values\n- safe areas replace as a unit\n- motion and audio policies merge onto the inherited policy\n- a brand replaces the inherited brand, because a brand is itself resolved policy\n\n## Fonts are policy, not imports\n\nA brand declares font families and URLs. The runtime injects the matching\n`@font-face` rules and the render worker waits for `document.fonts.ready`\nbefore capturing a frame, so a still, a preview, and an export use identical\nfaces. Serve font files from `public/`.\n\n## Brands are policy, not a template\n\nA brand provides tokens, fonts, motion curves, audio options, and component\ndefaults. It should guide installed components without forcing every video into\nthe same scene sequence.\n\nAny module under a `brands/` directory is discovered, so Studio can preview a\nvideo with a different token set without editing component source:\n\n```ts title=\"videos/brands/paper.ts\"\nimport {defineBrand} from \"odori\";\n\nexport const paperBrand = defineBrand({\n name: \"paper\",\n colors: {background: \"#fafafa\", surface: \"#ffffff\", foreground: \"#0a0a0a\", border: \"#e5e5e5\"},\n});\n```\n\nComponents read tokens through `useBrand()`, and the runtime also exposes them\nas CSS variables such as `--odori-foreground` for styles that are easier to\nexpress in CSS."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"slug": "guides/mcp",
|
|
68
|
+
"title": "MCP server",
|
|
69
|
+
"description": "Odori's registry, blocks, and setup guide, available to any agent that speaks MCP.",
|
|
70
|
+
"section": "guides",
|
|
71
|
+
"body": "Odori runs an MCP server at `https://odori.dev/api/mcp`. It is the front door\nfor an agent that has not installed anything yet: search the component\nregistry, read a timing contract, pull a block's source, or fetch the setup\nguide, all before a project exists.\n\nIt is read-only on purpose. Rendering, previewing, and validating need a\ncheckout and the CLI, so every answer ends by pointing at those rather than\npretending a docs route can do the work.\n\n## Connect\n\n```bash\nclaude mcp add --transport http odori https://odori.dev/api/mcp\n```\n\nFor a client configured by file, the same server as JSON:\n\n```json\n{\n \"mcpServers\": {\n \"odori\": {\n \"type\": \"http\",\n \"url\": \"https://odori.dev/api/mcp\"\n }\n }\n}\n```\n\nNo key, no account, no session. The server holds nothing about you.\n\n## What it exposes\n\n| Tool | Answers |\n| --- | --- |\n| `search_components` | Which components exist for \"background\", \"chart\", \"terminal\", each with its install command |\n| `get_component` | One component's description, install command, and timing contract: recommended and minimum frames, entrance and exit, aspect ratios, content limits, reduced-motion behaviour |\n| `list_blocks` | Every block: a complete video, with tags and aspect ratio |\n| `get_block` | One block's components, file list, and entry source, ready to write into a project |\n| `get_started` | [start.md](https://odori.dev/start.md), verbatim |\n\nThe timing contract is the part worth having. A component that needs 90\nframes to land its entrance cannot be given 30, and `get_component` says so\nbefore a cut is built around it rather than after it is rendered.\n\n## The workflow it hands off to\n\n```bash\npnpm create odori@latest my-video\npnpm odori add title-reveal end-card\npnpm odori dev\n```\n\nThe MCP finds the pieces; the CLI installs them as source, previews them in\nStudio, and renders the file. Odori's [quickstart](/docs/quickstart) is the\nsame path written for a person."
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"slug": "guides/studio",
|
|
75
|
+
"title": "Studio",
|
|
76
|
+
"description": "The filesystem-driven workspace for discovering, previewing, and stress-testing Odori videos and components.",
|
|
77
|
+
"section": "guides",
|
|
78
|
+
"body": "`pnpm odori dev` opens one local workspace for complete videos and for the\nsource-owned components used to build them. Authors do not need a separate\napplication, a custom preview route, or an encoded MP4 while editing.\n\n## Filesystem contract\n\nStudio discovers project resources under `videos/`. The entry filename\ndetermines how each module is treated.\n\n```text\nvideos/**/video.tsx → complete exportable videos\nvideos/**/*.preview.tsx → development-only component previews\nvideos/**/brands/*.ts → brand token sets\n```\n\nDirectory names carry no discovery semantics. `videos/components/` is\ndiscoverable through its preview files, while `videos/lib/` remains ordinary\nsource because it contains no entry filename.\n\n```tsx title=\"videos/components/title-reveal/title-reveal.preview.tsx\" lineNumbers\nimport {defineComponentPreview} from \"odori/preview\";\nimport {TitleReveal} from \"./title-reveal\";\n\nexport default defineComponentPreview({\n title: \"Title reveal\",\n category: \"Typography\",\n component: TitleReveal,\n canvas: {width: 1920, height: 1080, duration: \"4s\"},\n controls: {\n title: {type: \"text\", defaultValue: \"Ship the story.\", maxLength: 64},\n align: {type: \"select\", options: [\"left\", \"center\"], defaultValue: \"center\"},\n },\n examples: [\n {name: \"Default\", props: {title: \"Ship the story.\"}},\n {name: \"Two lines\", props: {title: \"Build videos\\nlike applications.\"}},\n ],\n});\n```\n\n## One workspace for the whole project\n\n| View | Purpose |\n| --- | --- |\n| Videos | Browse every composition, select inputs, play the timeline, inspect scenes, and export. |\n| Components | Browse every `*.preview.tsx` fixture, vary props, switch examples, and stress-test formats and brands. |\n| Brands | Preview any video with any discovered token set, and read the resolved policy. |\n| Assets | Inspect declared assets, brand fonts, and every discovered source file. |\n\nVideos and components use the same canvas and the same playback engine.\nSwitching from a full composition to one component does not change the\nauthoring model.\n\n## Video preview\n\nA discovered video is immediately playable without encoding an MP4. Studio\nreads its metadata and schema to provide:\n\n- a searchable library and a gallery of animated thumbnails\n- timeline playback, scrubbing, frame stepping, and scene navigation\n- generated input controls from `schema.ts`\n- resolved format, brand, duration, and source file\n- a safe-area overlay\n- diagnostics for duration drift, duplicate scene IDs, and unusably short scenes\n- one explicit export action, defaulting to a distributable MP4\n\n## Component preview\n\nA discovered component preview provides:\n\n- a gallery grouped by narrative role\n- generated prop controls from the preview contract\n- named examples and variants\n- brand token switching without changing component source\n- 16:9, 9:16, and 1:1 canvases for layout stress tests\n\n## Chrome and theme\n\nStudio follows your system theme and can be pinned to light or dark from the top\nbar. The choice persists per browser. A composition never follows it: a video\nalways renders its brand's own colors, because the video is the product and the\nworkspace is only the frame around it.\n\nThe status bar links to the Odori documentation at `https://odori.dev/docs`.\nPoint it somewhere else when your team keeps its own:\n\n```ts title=\"odori.config.ts\"\nexport default defineConfig({docsUrl: \"https://docs.internal.example.com/video\"});\n```\n\n## Keyboard\n\n| Key | Action |\n| --- | --- |\n| `space` | Play or pause |\n| `←` `→` | Step one frame, or one second with `shift` |\n| `[` `]` | Jump to the previous or next scene boundary |\n| `home` `end` | Jump to the first or last frame |\n| `s` | Toggle the safe-area overlay |\n| `l` | Toggle looping |\n| `m` | Mute or unmute audio |\n| `g` | Toggle the gallery |\n| `⌘K` | Open the command palette |\n| `⌘1` to `⌘4` | Switch views |\n\nSelections are routeable as real paths: `/videos/launch` and\n`/components/title-reveal` open directly, and the browser's back button works.\nThe wordmark opens `/`, an overview of everything the\nproject contains, where each row opens the view that owns it.\n\n## Export from Studio\n\nThe export panel posts to the dev server, which runs the same render worker the\nCLI uses. It is a single action with a menu, so the common case is one click and\nthe alternatives stay one step away:\n\n| Choice | Result |\n| --- | --- |\n| MP4 video | Queues a job, reports progress, and writes an MP4 to the export directory |\n| PNG frame | Writes the current frame to the export directory |\n| Frame to clipboard | Copies the current frame as an image, writing no file to the export directory |\n\nThe menu selection becomes the button's default for the session. Jobs are listed\nby the file they wrote, with the full path on hover.\n\nSize is the resolution control: half, full, and double the authored format,\nnamed by their output height. What the panel deliberately does not offer is\naspect ratio. A vertical cut is not a crop of a widescreen composition, it is\na different composition: type is resized, layouts reflow, and safe areas\nmove, all of which is authorship. Give the vertical cut its own video with a\n9:16 layout, reusing the same scenes and components; component previews\nalready flip between 16:9, 9:16, and 1:1 in the catalog, which is where a\ncomponent proves it can survive the change.\n\n## Production separation\n\nPreview modules are development-only inputs. They never enter a production\nvideo bundle or change an export. The renderer compiles `videos/**/video.tsx`\nand its imported runtime dependencies. Studio separately loads `*.preview.tsx`\nfiles to build development controls and fixtures."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"slug": "index",
|
|
82
|
+
"title": "Docs",
|
|
83
|
+
"description": "Odori is the application framework for React video, with conventions, components, preview, and production rendering around a first-class videos folder.",
|
|
84
|
+
"section": "",
|
|
85
|
+
"body": "# Build videos like applications\n\nOdori is an independent React video framework. It owns the deterministic frame\nruntime, timeline compiler, browser player, and export pipeline, plus the\nconventions a production project otherwise has to invent:\n\n- a first-class `videos/` source root\n- layouts, schemas, prepared data, and inherited brand policy\n- beautiful source-owned components installed like shadcn\n- instant live preview without encoding an MP4\n- deterministic export jobs that render the exact previewed inputs\n\n<CardGroup cols={2}>\n Create a project and preview your first video.\n See the JSX-first `video.tsx` authoring model.\n Install polished motion primitives as editable source.\n Score a cut with cues the encoder mixes for you.\n</CardGroup>\n\n## The mental model\n\nOdori uses React as its authoring model, but does not depend on another video\nframework. It provides project structure, deterministic frame state, timeline\ncompilation, data boundaries, component distribution, preview discovery,\nvalidation, caching, and render operations.\n\n```text\nReact components + Odori frame state\n ↓\n Odori timeline runtime\n ↓\nStudio · embedded Viewer · stills · MP4 exports\n```\n\n## The packages\n\n| Package | Role |\n| --- | --- |\n| `odori` | Runtime, timeline, player, hooks, schema, manifest |\n| `@odori/cli` | Discovery, the Studio workspace and its dev server, stills, tests, export jobs |\n| `@odori/registry` | Source-owned components installed by `odori add` |\n| `create-odori` | Project scaffolder |\n\n## A complete video entry\n\n```tsx title=\"videos/launch/video.tsx\" lineNumbers\nimport {Scene, Video, defineVideoMetadata} from \"odori\";\nimport {CodeProof} from \"../components/code-proof/code-proof\";\nimport {EndCard} from \"../components/end-card/end-card\";\nimport {TitleReveal} from \"../components/title-reveal/title-reveal\";\nimport {productLayout} from \"../layout\";\n\nexport const metadata = defineVideoMetadata({\n title: \"Introducing Odori\",\n layout: productLayout,\n duration: \"12s\",\n});\n\nexport default function ProductLaunch() {\n return (\n <Video>\n <Scene id=\"opening\" duration=\"4s\">\n <TitleReveal title=\"Build videos like applications.\" />\n </Scene>\n <Scene id=\"proof\" duration=\"5s\">\n <CodeProof code={'pnpm odori dev'} language=\"shell\" />\n </Scene>\n <Scene id=\"end\" duration=\"3s\">\n <EndCard title=\"Author. Preview. Ship.\" />\n </Scene>\n </Video>\n );\n}\n```\n\nThe module exports static metadata for discovery and a normal React component\nfor the timeline. JSX is the default authoring surface; source files under\n`scenes/` are an organizational choice, not a framework requirement.\n\n## What Odori owns\n\n| Odori owns | You own |\n| --- | --- |\n| Discovery and generated manifests | Story and product truth |\n| Layout inheritance and input schemas | React components and scene composition |\n| Preview, still, test, and export commands | Content, pacing, and visual decisions |\n| Asset readiness and frozen render inputs | Your source repository |\n| Render jobs, progress, retries, and storage | When an MP4 should be created |\n\n<CardGroup cols={2}>\n Learn every special file and folder.\n Understand the development and production lifecycle.\n</CardGroup>"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"slug": "quickstart",
|
|
89
|
+
"title": "Quickstart",
|
|
90
|
+
"description": "Create a Odori project, author a JSX timeline, preview it instantly, and export it when the cut is ready.",
|
|
91
|
+
"section": "",
|
|
92
|
+
"body": "## Give your agent one prompt\n\n```text\nFollow https://odori.dev/start.md: set up Odori video authoring in this\nfolder and make a polished launch video from real product evidence and\nsource-owned components. When you're done, start Studio so I can watch the\nvideo while we work.\n```\n\n[start.md](https://odori.dev/start.md) is the whole quickstart in one\nagent-readable document: the initialization decision, component starter set,\nfirst-cut structure, visual defaults, and verification loop. You do not need a\nseparate studio or an MP4 render while authoring. To keep the instructions\navailable across sessions instead of fetching them once, install them as a\nskill:\n\n```bash\nnpx skills add allenzhou101/odori --skill odori-quickstart --yes\n```\n\nIts first question is whether to import a design system. Point it at a\nrepository, a token file, or a brand kit and it maps the colors, type, fonts,\nlogos, and motion into `videos/brands/`, which every video then inherits. Say\nskip and it starts from Odori's defaults rather than inventing a palette.\n\n## Create a project manually\n\n```bash\npnpm create odori@latest product-stories\ncd product-stories\npnpm install\n```\n\nUse the manual commands when you do not want an agent to initialize the project.\n\n Run `pnpm odori dev`. Odori starts [Studio](/docs/guides/studio) on\n `http://127.0.0.1:4300`. Studio discovers every `videos/**/video.tsx`\n composition and every `*.preview.tsx` component fixture.\n Run `pnpm odori new launch`. The generator creates a typed React entry\n under `videos/launch/`.\n Browse `pnpm odori registry`, then run\n `pnpm odori add title-reveal code-proof end-card`.\n Source is copied into `videos/components/` and belongs to your repository.\n Run `pnpm odori test`. Odori mounts every video, samples representative\n frames, and reports blank frames, content that escapes the canvas, and text\n too small to read.\n Run `pnpm odori export launch`, or start an export from Studio. Preview\n never requires an MP4 render.\n\n## Generated project\n\n<FileTree>\n\n- videos/\n - layout.tsx\n - brands/\n - paper.ts\n - components/\n - title-reveal/\n - title-reveal.tsx\n - title-reveal.preview.tsx\n - end-card/\n - end-card.tsx\n - end-card.preview.tsx\n - launch/\n - video.tsx\n- public/\n - fonts/\n- odori.config.ts\n- package.json\n\n</FileTree>\n\nIn a standalone video project, Odori creates only the video-facing source. In a\nNext.js project, `app/` and `videos/` remain siblings: routes are web entry\npoints; videos are render entry points.\n\n## Your first video\n\n```tsx title=\"videos/launch/video.tsx\" lineNumbers\nimport {Scene, Video, defineVideoMetadata} from \"odori\";\nimport {EndCard} from \"../components/end-card/end-card\";\nimport {TitleReveal} from \"../components/title-reveal/title-reveal\";\nimport {productLayout} from \"../layout\";\n\nexport const metadata = defineVideoMetadata({\n title: \"Hello, odori\",\n layout: productLayout,\n duration: \"8s\",\n});\n\nexport default function LaunchVideo() {\n return (\n <Video>\n <Scene duration=\"5s\">\n <TitleReveal title=\"Video projects deserve a framework.\" />\n </Scene>\n <Scene duration=\"3s\">\n <EndCard title=\"Built with odori\" />\n </Scene>\n </Video>\n );\n}\n```\n\n## Core commands\n\n| Command | Purpose |\n| --- | --- |\n| `odori dev` | Discover project resources and start Studio |\n| `odori doctor` | Check Node, React, Chrome, FFmpeg, and the generated cache |\n| `odori install` | Download the pinned Chrome and FFmpeg into the shared cache |\n| `odori init` | Add `videos/` and `odori.config.ts` to an existing project |\n| `odori new <name>` | Generate a `video.tsx` entry |\n| `odori add <components>` | Install editable component source |\n| `odori registry` | List available components and their temporal contracts |\n| `odori list` | Print discovered video IDs and formats |\n| `odori inspect <id>` | Show resolved layout, inputs, scenes, and the frozen manifest |\n| `odori frame <id> --at 4s` | Render one deterministic frame to a PNG |\n| `odori test [id]` | Validate contracts and representative frames |\n| `odori export <id>` | Render, mix audio, and encode a distributable file |\n| `odori export <id> --format webm` | mp4, webm, prores, gif, or png |\n| `odori jobs` | List export jobs, their attempts, and their output |\n| `odori diff` / `odori update` | Compare and apply upstream component changes |\n\nEvery command accepts `--input '{\"headline\":\"...\"}'` to supply schema-valid\ninputs, and `frame` and `export` accept `--output <path>`. Run\n`odori <command> --help` for one command's usage, and `odori --version` when\nreporting a bug.\n\n## Videos in CI\n\n`odori doctor` answers the question a runner usually fails on — Chrome and\nFFmpeg — before anything renders, and `odori test --json` emits one object per\ncheck for a step to read.\n\n```yaml title=\".github/workflows/video.yml\"\n- run: pnpm install --frozen-lockfile\n- uses: actions/cache@v4\n with:\n path: ~/.cache/odori\n key: odori-binaries-${{ runner.os }}\n- run: pnpm odori install\n- run: pnpm odori doctor\n- run: pnpm odori test --json\n- run: pnpm odori export launch --output out/launch.mp4\n- uses: actions/upload-artifact@v4\n with:\n name: launch\n path: out/launch.mp4\n```\n\n:::tip\nUse live preview throughout authoring. Export only when you need a distributable\nartifact, final codec verification, or production delivery.\n:::"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"slug": "reference/alternatives",
|
|
96
|
+
"title": "Alternative structures",
|
|
97
|
+
"description": "Other project and authoring models Odori could support, and why videos plus JSX is the default.",
|
|
98
|
+
"section": "reference",
|
|
99
|
+
"body": "## Filesystem alternatives\n\n| Structure | Strength | Cost | Decision |\n| --- | --- | --- | --- |\n| `videos/` at repository root | Clear first-class convention; works with or without Next.js | Adds another root source folder | Default |\n| `src/videos/` | Familiar to libraries using `src/` | Weakens the visible convention | Configurable |\n| `app/videos/` | Colocates with Next.js routes | Confuses URLs with compositions | Not recommended |\n| `packages/videos/` | Strong monorepo isolation | Heavy for small projects | Supported as a configured root |\n| One package per video | Independent deployments and ownership | Excessive setup and poor sharing | Large-studio option |\n\n## Authoring alternatives\n\n `video.tsx` exports static metadata and a React timeline. This is Odori's\n default because components compose naturally and React remains visible.\n A `video.ts` scene array is easier for machines to inspect but creates a\n parallel configuration language and makes composition less natural.\n Direct Odori frame hooks and ordinary React provide total freedom. Authors\n can bypass structured `<Scene>` boundaries without leaving the Odori\n runtime.\n A JSON timeline is portable and editor-friendly, but it limits React\n expressiveness and makes source ownership less direct.\n\n## Registration alternatives\n\nRuntime filesystem globbing is concise but bundler-specific. Manual registration\nis portable but repetitive and easy to drift. Odori generates a static import\nmanifest, retaining portability and code splitting while keeping registration\nout of user source.\n\n## Rendering alternatives\n\n- **Local Chromium and FFmpeg** are ideal for development and CI.\n- **Dedicated workers** provide predictable hosted rendering and isolation.\n- **Serverless rendering** scales well for bursty workloads but needs chunking,\n artifact assembly, and provider-specific operations.\n- **Client-side encoding** can help with lightweight cuts but is not the default\n for consistent production codecs, fonts, and large assets.\n\nThe authoring contract remains deployment-independent. Preview and export share\nthe same frozen manifest regardless of renderer."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"slug": "reference/api",
|
|
103
|
+
"title": "Authoring API",
|
|
104
|
+
"description": "The public React and TypeScript surface for Odori videos, layouts, inputs, preparation, and assets.",
|
|
105
|
+
"section": "reference",
|
|
106
|
+
"body": "## `defineVideoMetadata()`\n\nDeclares discovery-time metadata separately from the React component. The id\ndefaults to the entry's directory path under `videos/` and is validated when the\nmodule loads. Pass one only to override the path.\n\n```ts\ndefineVideoMetadata({\n id: \"launch\", // optional: defaults to the directory path\n title: \"Product launch\",\n description: \"A concise introduction.\",\n duration: \"12s\",\n layout: productLayout,\n schema: launchInput,\n defaultProps: {headline: \"Ship the story.\"},\n tags: [\"product\", \"launch\"],\n thumbnailFrame: 45,\n});\n```\n\n## `<Video>`\n\nProvides the resolved layout, input, prepared data, asset registry, and\ntimeline context. It adds no duration of its own. Scene children are laid out\nin order; any other child renders for the whole video.\n\n## `<Scene>`\n\n```tsx\n<Scene id=\"demo\" duration=\"8s\" name=\"Product demo\">\n <ProductDemo />\n</Scene>\n```\n\nEach scene receives a local frame clock starting at zero and is mounted only\nwhile it is on screen. Durations accept seconds (`8`), `\"8s\"`, `\"500ms\"`, or\n`\"45f\"`.\n\n## `<Clip>`\n\nContent placed at a time rather than after the thing before it. A clip names\nits own start, takes no space in the sequence, and moves nothing when its\nlength changes.\n\n```tsx\n<Clip from=\"2s\" duration=\"3s\">\n <LowerThird name=\"Ada Lovelace\" />\n</Clip>\n```\n\nIt reads its clock from whatever contains it: a child of `<Video>` is placed\nagainst the whole cut, the same clip inside a `<Scene>` against that scene.\nChildren see a clock starting at zero either way. Without `duration` it runs\nto the end of its container.\n\n## `<Stagger>`\n\nAn offset window inside a scene, for staggered layers.\n\n```tsx\n<Stagger from=\"1s\" duration=\"2s\">\n <Caption text=\"Preview needs no encode.\" />\n</Stagger>\n```\n\n## `<Audio>`\n\nPlaces a sound on the timeline. Cues declared inside a scene are offset by that\nscene's start.\n\n```tsx\n<Audio src=\"/audio/bed.m4a\" gain={0.75} fadeIn=\"1s\" fadeOut=\"1.5s\" duckUnder />\n```\n\nSee [Audio](/docs/guides/audio) for placement, ducking, preview, and mixing.\n\n## `<Fill>` and `<SafeArea>`\n\nLayout primitives. `SafeArea` insets its children by the inherited layout safe\narea.\n\n## `defineVideoLayout()` and `defineBrand()`\n\nDefine inherited format and presentation policy. Layouts cannot perform async\nwork or add frames. See [Layouts and brands](/docs/guides/layouts).\n\n## `defineInputSchema()`\n\nA serializable input contract with `parse()`, `safeParse()`, `defaults()`, and\n`describe()`. Studio generates controls from the description. Any zod-compatible\nschema also works.\n\n## `definePrepare()`\n\nDefines the sole asynchronous data boundary. Its result must be serializable\nand is frozen into the render manifest.\n\n## Frame hooks\n\n```tsx\nconst frame = useFrame();\nconst {fps, width, height, durationInFrames} = useVideo();\nconst scale = useDesignScale();\nconst brand = useBrand();\nconst layout = useLayout();\nconst scene = useScene();\nconst assets = useAssets();\n```\n\nMotion must derive from the current frame. Wall-clock timers, CSS keyframes,\nand `requestAnimationFrame` are not deterministic render primitives.\n\n## Motion helpers\n\n```tsx\ninterpolate(frame, [0, 20], [0, 1], {easing: Easing.standard});\nspring({frame, fps, from: 0.96, to: 1, damping: 20});\n```\n\n`interpolate` clamps by default, blends matching numeric segments inside\nstrings, and accepts `extrapolateLeft` and `extrapolateRight`. `spring` solves a\ndamped spring from the frame index alone.\n\n## Playback\n\n```tsx\nconst playback = usePlayback({fps: 30, durationInFrames: 360, autoPlay: true});\nplayback.toggle();\nplayback.step(1);\nplayback.seek(120);\n```\n\n`<Viewer>` wraps this hook with a canvas and controls. `usePlayback` is exported\nso a custom surface, such as Studio, can own its own transport.\n\n## Rendering surfaces\n\n| Export | Purpose |\n| --- | --- |\n| `OdoriRuntime` | Mount one video at one frame |\n| `Viewer` | Embeddable, seekable player with optional controls |\n| `RenderSurface` | The render worker target, with a frame setter and readiness handshake |\n| `createRenderManifest` | Freeze inputs, format, scenes, audio, assets, and fonts |\n| `useAudioPlayback` | Drive cue playback from a frame clock |"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"slug": "reference/cli",
|
|
110
|
+
"title": "CLI",
|
|
111
|
+
"description": "Every odori command, its flags, and what it writes.",
|
|
112
|
+
"section": "reference",
|
|
113
|
+
"body": "The CLI ships as `@odori/cli` and installs a `odori` binary.\n\n```bash\npnpm add -D @odori/cli\npnpm odori --help\n```\n\n## `odori dev`\n\nDiscovers the project, regenerates `.odori/`, and starts Studio.\n\n```bash\nodori dev --port 4300\n```\n\nStudio watches `videos/` and reloads when a `video.tsx`, `*.preview.tsx`, or\nbrand module appears or disappears. The dev server also exposes the endpoints\nStudio uses to request a still or an export.\n\nStudio opens in the default browser when the shell is interactive. Pass\n`--no-open`, set `open: false` in `odori.config.ts`, or set `ODORI_OPEN=0` to\nstart without it. CI and piped output never open a browser.\n\n```bash\nodori dev --no-open\n```\n\n`docsUrl` in `odori.config.ts` sets where Studio's documentation link points,\nand defaults to the hosted docs site at `https://odori.dev/docs`.\n\n## `odori init`\n\nAdds `videos/layout.tsx`, a first `videos/launch/video.tsx`, and\n`odori.config.ts` to an existing project. Existing files are never overwritten.\n\n## `odori new <name>`\n\nGenerates `videos/<name>/video.tsx` with static metadata and a JSX timeline. It\nwires the root layout when `videos/layout.tsx` exists.\n\n## `odori add <components...>`\n\nCopies registry component source and its preview fixture into\n`videos/components/`, resolves registry dependencies, and records provenance in\n`odori.lock.json`, which belongs in your repository.\n\n```bash\nodori add title-reveal end-card\nodori add terminal --force\n```\n\nA component you have edited locally is kept, with a warning, unless `--force`\nis passed.\n\n## `odori registry`\n\nPrints the catalog grouped by family, with aspect ratios, minimum duration, and\nreduced-motion behavior for each component.\n\n## `odori diff [components...]`\n\nCompares installed component source with the version that was installed and\nwith the version the registry ships today. Each component is reported as\n`up to date`, `modified locally`, `update available`, or `modified locally and\nupdated upstream`.\n\n```bash\nodori diff\nodori diff terminal --full\n```\n\n## `odori update [components...]`\n\nApplies upstream changes to components you have not edited. A component that is\nboth edited locally and changed upstream is left alone until you review it with\n`odori diff` and pass `--force`.\n\n## `odori list`\n\nPrints discovered video IDs, formats, durations, source files, and the number\nof component previews.\n\n## `odori graph`\n\nCompiles the project into `.odori/graph.json`: every video with its resolved\nformat, duration, brand, tags, audio variants, and the components it uses,\nbeside the component catalog, the audio library, and a structure report. It\nalso refreshes the generated imports and `catalog.json`, so everything under\n`.odori/` describes the same tree.\n\nThe structure report names every place the filesystem contract is almost met:\na `video.ts` that discovery will never see, a fixture named after something\nother than its directory, a literal `/audio/...` path with no file behind it,\nan audio variant that overrides a cue the brand never defines. Errors exit\nnon-zero, so the command doubles as a CI gate; `odori doctor` prints the same\nfindings as one check, and `odori test` fails on the errors before a browser\nstarts.\n\n```bash\nodori graph\nodori graph --json\n```\n\n## `odori inspect <id>`\n\nResolves the layout, compiles the timeline in a browser, runs `prepare.ts`, and\nfreezes a manifest.\n\n```bash\nodori inspect launch\nodori inspect launch --json --input '{\"headline\":\"Ship it.\"}'\n```\n\n## `odori frame <id>`\n\nRenders one deterministic frame to a PNG, through the same pipeline an export\nuses, so the image is the frame the video would show at that moment.\n\n`--at` is a duration like every other time in Odori: a bare number is seconds,\nand `120f` names frame 120.\n\n```bash\nodori frame launch --at 4s --output out/hero.png\nodori frame launch --at 120f\n```\n\n## `odori test [id]`\n\nValidates contracts and representative frames for one video or all of them.\nIt fails when default props do not satisfy the schema, when a declared duration\ndisagrees with the compiled scene total, when a frame is blank, when content\nsits entirely outside the canvas, or when text is smaller than 20px at a 1080p\nreference.\n\nThe readability floor measures what the glyphs come out as on screen, not what\nthe stylesheet asked for, so a scene that pushes in on a surface is judged at\nthe size the viewer sees.\n\nMark furniture with `data-odori-chrome` to exempt a subtree from that floor:\n\n```tsx\n<div data-odori-chrome>{/* the app's own sidebar, badges, timestamps */}</div>\n```\n\nIt exists for components that recreate somebody else's interface. Slack's\nchannel rail really is 16px, and growing it until this check is satisfied draws\na Slack nobody recognises. That text says \"this is Slack\"; it is not there to\nbe read. Use it for chrome and never for the content the video is about, which\nis the thing the floor is protecting.\n\nCrossing the frame edge is not a fault, so only content with no part on screen\nat all is reported. Film bleeds: a surface runs past the corner, a push-in takes\na headline wider than the shot, a full-frame image is cropped rather than\nletterboxed. When a shot means to put something fully outside the frame, say so\nwith `data-odori-bleed`:\n\n```tsx\n<div data-odori-bleed>{/* a word set larger than the frame */}</div>\n```\n\n## `odori export <id>`\n\nFreezes a manifest, records a job under `.odori/builds/`, renders every frame\nthrough the readiness handshake, mixes the audio track, and encodes an MP4 with\nFFmpeg. Frames are captured by several browser workers in parallel.\n\n```bash\nodori export launch --output out/launch.mp4\nodori export launch --concurrency 8 --preset slow\nodori export launch --no-audio\nodori export --retry job-88e09129a0-msw17lz9\n```\n\n`--no-audio` writes the picture with no audio track, for a silent loop on a\nlanding page or a clip going into an editor that brings its own sound. The\nchoice is frozen with the job, so a retry produces the same file.\n\n`--audio-variant <name>` exports one of the video's declared audio variants,\nfor a film that exists in more than one voice. The variant is applied when\nthe cues compile, frozen into the manifest like everything else, and the file\nis named `<id>-<name>` so two voices cannot overwrite each other. An unknown\nname fails before anything renders.\n\nA retry replays the frozen manifest, so it never re-resolves inputs or reruns\n`prepare.ts`.\n\n## `odori jobs`\n\nLists recorded export jobs with status, attempts, and output path.\n\n```text\njob-88e09129a0-msw17lz9 launch ready attempts 2 out/launch.mp4\n```\n\n## Shared flags\n\n| Flag | Purpose |\n| --- | --- |\n| `--input '<json>'` | Serializable input validated by the video schema |\n| `--output <path>` | Output path for `still` and `export` |\n| `--force` | Replace locally modified component source |\n| `--json` | Machine-readable output for `inspect` |\n| `--concurrency <n>` | Parallel render workers for `export` |\n| `--preset <name>` | x264 preset for `export`, default `medium` |\n| `--retry <job id>` | Re-run a recorded job from its frozen manifest |\n| `--full` | Print the diff body in `diff` |\n\n## Requirements\n\nRendering needs Chrome or Chromium and FFmpeg. Odori looks for a browser at the\nusual macOS and Linux locations, at `chromePath` in `odori.config.ts`, or at\n`ODORI_CHROME`.\n\n## odori docs\n\nThe documentation ships inside the CLI, so it answers with no network and no\nbrowser.\n\n```bash\nodori docs # every page, grouped by section\nodori docs guides/audio # print one page\nodori docs audio # the tail of a slug is enough\nodori docs search \"safe area\" # the lines that say it, with page and number\nodori docs --json # the same, for a program to read\n```\n\nPages are snapshotted from this site at release, which makes them exact for\nthe version installed and stale between releases — the same trade the\ncomponent snapshot makes."
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"slug": "reference/comparison",
|
|
117
|
+
"title": "Odori vs Remotion vs HyperFrames",
|
|
118
|
+
"description": "How the three authoring models in this repository differ in timeline, discovery, components, preview, and export.",
|
|
119
|
+
"section": "reference",
|
|
120
|
+
"body": "All three tools render frame-accurate video with Chrome and FFmpeg, and all\nthree are deterministic: a frame number in, the same pixels out. They differ in\n**what they own**. HyperFrames owns a document and a capture loop. Remotion owns\na React frame runtime. Odori owns the frame runtime *and* the conventions above\nit: discovery, layout inheritance, a component registry, contract tests, and an\nexport protocol.\n\nThe comparisons below are drawn from building the same product video three ways.\n\n## At a glance\n\n| | HyperFrames | Remotion | Odori |\n| --- | --- | --- | --- |\n| Authoring surface | HTML, CSS, GSAP | React and frame arithmetic | React with `<Video>` and `<Scene>` |\n| Composition exists because | A `.html` file is in the project | You registered it in `Root.tsx` | A `videos/**/video.tsx` file exists |\n| Timeline | `data-start` / `data-duration` attributes | `<Sequence from durationInFrames>` | `<Scene duration=\"4s\">`, offsets derived |\n| Timing unit | Seconds | Frames | Durations (`\"4s\"`) resolved to frames |\n| Format and fps | Attributes on the composition element | Props on each `<Composition>` | Inherited from `videos/layout.tsx` |\n| Brand tokens | CSS custom properties you maintain | Whatever you build | `defineBrand`, read with `useBrand` |\n| Component library | Registry blocks copied in | npm packages you assemble | `odori add`, source copied to `videos/components/` |\n| Typed inputs | None | Zod schema per composition | `schema` on `defineVideoMetadata`, `--input` on every command |\n| Preview | CLI preview server | Remotion Studio | Studio, with component fixtures alongside videos |\n| Automated checks | `hyperframes check` / `lint` | Your own tests | `odori test`: blank frames, overflow, unreadable text, contracts |\n| Export | `hyperframes render` | `remotion render` | `odori export`, jobs and a frozen manifest |\n| Build step | None | Bundler | Bundler |\n\n## HyperFrames: the raw-web option\n\nThe timeline lives in the document. Sections carry their own timing, and a\npaused GSAP timeline is handed to the renderer to seek:\n\n```html title=\"index.html, HyperFrames\"\n<section id=\"opening\" class=\"clip scene\" data-start=\"0\" data-duration=\"3\">…</section>\n<section id=\"proof\" class=\"clip scene\" data-start=\"3\" data-duration=\"3.5\">…</section>\n```\n\n```js\nconst tl = gsap.timeline({ paused: true });\ntl.fromTo(\"#opening h1\", { opacity: 0, y: 42 }, { opacity: 1, y: 0, duration: 0.7 }, 0.15);\nwindow.__timelines.main = tl;\n```\n\n**Strengths.** No build step, no framework, and any web technique is available\nimmediately. A designer who knows CSS can ship a cut.\n\n**Costs.** Timing is duplicated between markup attributes and the GSAP\ntimeline, so a scene can drift out of sync with its own animation. There is no\ntype checking across scenes, no component contract, and reuse is copy-paste.\n\n## Remotion: React on a frame clock\n\nRemotion gives you `useCurrentFrame`, `interpolate`, and `<Sequence>`, and you\nbuild the rest. Composition metadata is registration, not a file convention:\n\n```tsx title=\"src/Root.tsx, Remotion\"\n<Composition id=\"DirectRemotion\" component={DirectRemotion} durationInFrames={270} fps={30} width={1920} height={1080} />\n```\n\n```tsx title=\"src/Composition.tsx, Remotion\"\n<Sequence durationInFrames={90} name=\"Opening\">…</Sequence>\n<Sequence from={90} durationInFrames={105} name=\"Proof\">…</Sequence>\n<Sequence from={195} durationInFrames={75} name=\"Resolution\">…</Sequence>\n```\n\n**Strengths.** A mature, well-documented runtime with a large ecosystem, a\ncapable studio, and hosted rendering options.\n\n**Costs.** Absolute frame offsets are computed by hand, so inserting a scene\nmeans renumbering the ones after it. Format, fps, and brand are per-composition\nprops rather than an inherited layout, and there is no built-in component\nsystem or authored-frame linting. You assemble those yourself.\n\n## Odori: the framework layer\n\nA video exists because its file exists. Metadata is static, so discovery never\nruns your component:\n\n```tsx title=\"videos/launch/video.tsx\"\nexport const metadata = defineVideoMetadata({\n id: \"launch\",\n duration: \"12s\",\n layout: productLayout,\n schema: launchInput,\n});\n\nexport default function LaunchVideo({headline}: {headline: string}) {\n return (\n <Video>\n <Audio src=\"/audio/product-cinematic.m4a\" gain={0.75} duckUnder />\n <Scene id=\"opening\" duration=\"4s\">\n <Stage>\n <TitleReveal title={headline} />\n </Stage>\n </Scene>\n <Scene id=\"proof\" duration=\"5s\">…</Scene>\n </Video>\n );\n}\n```\n\nWhat the framework adds on top of a frame clock:\n\n- **Derived offsets.** Scenes are ordered, not numbered. Reordering or\n retiming a scene never touches its neighbours.\n- **Inherited layout.** Format, fps, and brand come from `videos/layout.tsx`,\n so one change re-formats every video.\n- **Source-owned components.** `odori add title-reveal` copies real\n source into `videos/components/`. You edit it; `odori diff` and\n `odori update` compare it with upstream later.\n- **Temporal contracts.** Registry components declare aspect ratios, minimum\n and recommended durations, entrance and exit frames, and content limits, and\n `odori test` fails a cut that violates them.\n- **Authored-frame checks.** `odori test` samples representative frames and\n reports blank frames, content escaping the canvas, and text too small to read\n at delivery size.\n- **Typed inputs.** Every command takes `--input`, validated against the\n video's schema, so one composition renders many variants.\n- **One frozen manifest.** Preview and export resolve the same manifest, so the\n approved cut is the rendered cut.\n\n## Choosing\n\n- Choose **HyperFrames** for a one-off cut, when the team is CSS-first and no\n build step is worth more than reuse.\n- Choose **Remotion** when you want an established ecosystem and are happy to\n own the conventions above the runtime yourself.\n- Choose **Odori** when video is ongoing work: many cuts, several formats, a\n brand to hold, components to share, and agents or teammates who need the\n structure to be discoverable and checkable.\n\nThe three are not mutually exclusive ideas. Odori's position is that the\nruntime was never the hard part; the conventions around it are. See [Alternative structures](/docs/reference/alternatives) for the models Odori\nconsidered before settling on `videos/` plus JSX."
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"slug": "skills",
|
|
124
|
+
"title": "Skills",
|
|
125
|
+
"description": "Workflows an agent installs once and follows every time it works on your videos.",
|
|
126
|
+
"section": "",
|
|
127
|
+
"body": "Components are what an agent builds with; skills are how it works. Each one is\na Markdown workflow installed into the agent from this repository, so the way\nyour videos get made is versioned alongside the code that makes them. Edit them\nthe way you edit code: a team that wants a different quality bar changes the\nfile, and every agent that installs from the repo follows it.\n\nThey live at [`skills/`](https://github.com/allenzhou101/odori/tree/main/skills),\nand each ships a reference file the agent reads while working.\n\n## odori-quickstart\n\nSets up Odori in a new or existing React project, imports a design system when\none exists, and makes a first video that already feels authored rather than\nmerely valid.\n\n```bash\nnpx skills add allenzhou101/odori --skill odori-quickstart --yes\n```\n\n## initialize-design-system\n\nDerives a brand from the repository's real tokens, fonts, and interface, and\nwrites it as the project's `videos/layout.tsx`. Evidence is gathered in a\nstated order, contested values keep their source as a comment, and the report\nsays which values were guessed.\n\n```bash\nnpx skills add allenzhou101/odori --skill initialize-design-system --yes\n```\n\n## create-video\n\nDesigns, writes, validates, and exports a video from repository evidence. It\ndemands a beat sheet before code and rendered frames before an export, and it\nroutes through the review workflow rather than shipping the first cut that\ntypechecks.\n\n```bash\nnpx skills add allenzhou101/odori --skill create-video --yes\n```\n\n## review-repair-video\n\nCritiques a cut as an editor, a motion designer, and a product expert, from\nrendered frames rather than from source. Findings are ranked as blockers,\nimprovements, and polish, each tied to a scene and a concrete edit. Its\nreference file is the quality gate: the promise understood in three seconds,\none visual idea per scene, truthful product detail, and an ending that\nresolves with one action.\n\n```bash\nnpx skills add allenzhou101/odori --skill review-repair-video --yes\n```\n\n## manage-components\n\nThe registry lifecycle: discovering what exists before building, installing as\nsource, adapting freely, tracking upstream with `odori diff` and\n`odori update`, and deciding where a new component should live. Includes the\nconventions a component satisfies before it is worth sharing.\n\n```bash\nnpx skills add allenzhou101/odori --skill manage-components --yes\n```\n\n## narrate-video\n\nScripts, generates, times, and mixes voiceover with a TTS provider such as\nElevenLabs: one file per line, each cue placed inside its scene so it moves\nwith the cut, the bed ducking under every spoken word. Its reference file\ncarries the line rules, starting with the one that matters: the narrator says\nwhat the screen cannot show.\n\n```bash\nnpx skills add allenzhou101/odori --skill narrate-video --yes\n```\n\n## reproduce-video\n\nReproduces an existing video from a reference file: measurement before\nauthorship, cut detection into an exact shot table, the film's motion\ngrammar named pattern by pattern in an anatomy document, and a\nmatched-frame comparison loop instead of eyeballing. Its reference file is\nthe residue of reproductions that went wrong first, including the ownership\nlines: matched labels, own paragraphs, real marks verbatim or absent.\n\n```bash\nnpx skills add allenzhou101/odori --skill reproduce-video --yes\n```"
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
}
|
package/src/docs.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import snapshot from "./docs-snapshot.json" with {type: "json"};
|
|
2
|
+
|
|
3
|
+
export type DocPage = {
|
|
4
|
+
slug: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
section: string;
|
|
8
|
+
body: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type DocHit = {page: DocPage; line: number; text: string};
|
|
12
|
+
|
|
13
|
+
/** Every page, in the order the snapshot fixed at build time. */
|
|
14
|
+
export const docPages = (): DocPage[] => (snapshot as {pages: DocPage[]}).pages;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The page a reader asked for, by slug or by the tail of one.
|
|
18
|
+
*
|
|
19
|
+
* `odori docs audio` should find `guides/audio` without the reader knowing
|
|
20
|
+
* how the site files things: the section is the site's organisation, not
|
|
21
|
+
* theirs. An exact slug still wins, so a page named after a section is never
|
|
22
|
+
* shadowed by one inside it.
|
|
23
|
+
*/
|
|
24
|
+
export const findDoc = (query: string): DocPage | undefined => {
|
|
25
|
+
const wanted = query.replace(/^\/+|\/+$|\.mdx$/g, "").toLowerCase();
|
|
26
|
+
const pages = docPages();
|
|
27
|
+
return (
|
|
28
|
+
pages.find((page) => page.slug.toLowerCase() === wanted) ??
|
|
29
|
+
pages.find((page) => page.slug.toLowerCase().endsWith(`/${wanted}`)) ??
|
|
30
|
+
pages.find((page) => page.title.toLowerCase() === wanted)
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lines that mention the query, with the page they came from.
|
|
36
|
+
*
|
|
37
|
+
* Line-level rather than page-level, because "which page mentions ducking"
|
|
38
|
+
* is a worse answer than the sentence that does: an agent can act on the
|
|
39
|
+
* line and open the page only if it needs the rest.
|
|
40
|
+
*/
|
|
41
|
+
export const searchDocs = (query: string, limit = 20): DocHit[] => {
|
|
42
|
+
const needle = query.toLowerCase();
|
|
43
|
+
if (!needle) return [];
|
|
44
|
+
const hits: DocHit[] = [];
|
|
45
|
+
for (const page of docPages()) {
|
|
46
|
+
const lines = page.body.split("\n");
|
|
47
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
48
|
+
if (!lines[index].toLowerCase().includes(needle)) continue;
|
|
49
|
+
hits.push({page, line: index + 1, text: lines[index].trim()});
|
|
50
|
+
if (hits.length >= limit) return hits;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return hits;
|
|
54
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,8 @@ export {
|
|
|
30
30
|
} from "./jobs";
|
|
31
31
|
export {buildAudioFilter, resolveCueFile, type MixInput} from "./audio-mix";
|
|
32
32
|
export {checkDeterminism, type DeterminismFinding} from "./determinism";
|
|
33
|
+
export {checkStructure, type StructureFinding} from "./structure";
|
|
34
|
+
export {buildGraphArtifact, type GraphArtifact} from "./commands/graph";
|
|
33
35
|
export {FORMATS, alphaWarning, formatNames, resolveFormat, type VideoFormat} from "./formats";
|
|
34
36
|
export {
|
|
35
37
|
CHROME_BUILD,
|