@odori/cli 0.0.10 → 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.
@@ -317,7 +317,7 @@ var toComponent = (item) => ({
317
317
  });
318
318
  var snapshotItems = async () => {
319
319
  try {
320
- const loaded = await import("./registry-snapshot-TKH2KAC3.js");
320
+ const loaded = await import("./registry-snapshot-TSTAA6NT.js");
321
321
  return loaded.default.items;
322
322
  } catch {
323
323
  throw new Error(
@@ -407,6 +407,76 @@ var verifyIntegrity = (item, origin = "network") => {
407
407
  Nothing was written. This is a truncated download, a stale proxy, or a tampered document.`
408
408
  );
409
409
  };
410
+ var blocksUnavailable = (url) => new Error(
411
+ `No blocks available: ${url}/blocks.json could not be fetched and nothing is cached on this machine. Blocks are published rather than bundled, so this needs a network once. Components still install offline.`
412
+ );
413
+ var resolveBlocks = async (config, options = {}) => {
414
+ const url = registryUrl(config);
415
+ const cache = resolve4(cacheDir(url), "blocks.json");
416
+ if (options.allowNetwork !== false) {
417
+ try {
418
+ const index = await fetchJson(`${url}/blocks.json`);
419
+ if (!Array.isArray(index.items)) throw new Error("the index has no items array");
420
+ await mkdir2(dirname2(cache), { recursive: true });
421
+ await writeFile3(cache, JSON.stringify(index), "utf8");
422
+ return { items: index.items, origin: "network", detail: url };
423
+ } catch {
424
+ }
425
+ }
426
+ if (existsSync4(cache)) {
427
+ try {
428
+ const index = JSON.parse(await readFile3(cache, "utf8"));
429
+ return { items: index.items, origin: "cache", detail: cache };
430
+ } catch {
431
+ }
432
+ }
433
+ throw blocksUnavailable(url);
434
+ };
435
+ var resolveBlock = async (config, id, options = {}) => {
436
+ assertSafeName(id);
437
+ const url = registryUrl(config);
438
+ const cache = resolve4(cacheDir(url), `block-${id}.json`);
439
+ if (options.allowNetwork !== false) {
440
+ try {
441
+ const block = await fetchJson(`${url}/blocks/${id}.json`);
442
+ if (block?.id !== id) throw new Error(`the document at ${url}/blocks/${id}.json is for "${block?.id}"`);
443
+ await mkdir2(dirname2(cache), { recursive: true });
444
+ await writeFile3(cache, JSON.stringify(block), "utf8");
445
+ return { block, origin: "network" };
446
+ } catch {
447
+ }
448
+ }
449
+ if (existsSync4(cache)) {
450
+ try {
451
+ return { block: JSON.parse(await readFile3(cache, "utf8")), origin: "cache" };
452
+ } catch {
453
+ }
454
+ }
455
+ throw blocksUnavailable(url);
456
+ };
457
+ var verifyBlockIntegrity = (block) => {
458
+ const expected = block.meta?.integrity;
459
+ if (!expected) {
460
+ throw new Error(
461
+ `The document for block "${block.id}" carries no integrity hash. A fetched block must be verifiable; refusing to write it. Nothing was written.`
462
+ );
463
+ }
464
+ const hash = createHash("sha256");
465
+ for (const file of [...block.files].sort((left, right) => left.path.localeCompare(right.path))) {
466
+ hash.update(file.path);
467
+ hash.update("\0");
468
+ hash.update(file.content);
469
+ hash.update("\0");
470
+ }
471
+ const actual = `sha256-${hash.digest("base64")}`;
472
+ if (actual === expected) return;
473
+ throw new Error(
474
+ `The files for block "${block.id}" do not match the hash the registry published.
475
+ expected ${expected}
476
+ received ${actual}
477
+ Nothing was written. This is a truncated download, a stale proxy, or a tampered document.`
478
+ );
479
+ };
410
480
 
411
481
  // src/assets.ts
412
482
  import { createHash as createHash2 } from "crypto";
@@ -1023,6 +1093,13 @@ var registerCues = (brands, fps) => {
1023
1093
  }
1024
1094
  return known.size;
1025
1095
  };
1096
+ var registerVariantCues = (variants, fps) => {
1097
+ for (const overrides of Object.values(variants ?? {})) {
1098
+ for (const value of Object.values(overrides)) {
1099
+ if (isCueDefinition(value)) known.set(cueUrl(value), { cue: value, fps });
1100
+ }
1101
+ }
1102
+ };
1026
1103
  var renderedCue = (url) => {
1027
1104
  const cached = rendered.get(url);
1028
1105
  if (cached) return cached;
@@ -1053,6 +1130,7 @@ var registerProjectCues = async (graph, load = importFresh) => {
1053
1130
  const entry = { component: module.default, metadata: module.metadata };
1054
1131
  const layout = resolveEntryLayout(entry);
1055
1132
  registerCues([layout.brand], layout.format.fps);
1133
+ registerVariantCues(entry.metadata.audio?.variants, layout.format.fps);
1056
1134
  } catch (error) {
1057
1135
  log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
1058
1136
  }
@@ -1365,6 +1443,7 @@ var renderUrl = (origin, target, frame) => {
1365
1443
  const params = new URLSearchParams({ render: "1", video: target.videoId, frame: String(frame) });
1366
1444
  if (target.input) params.set("input", encodeParam(target.input));
1367
1445
  if (target.prepared !== void 0) params.set("prepared", encodeParam(target.prepared));
1446
+ if (target.audioVariant) params.set("audio", target.audioVariant);
1368
1447
  return `${origin}/?${params.toString()}`;
1369
1448
  };
1370
1449
  var DEFAULT_GRAPHICS = "software";
@@ -2029,16 +2108,17 @@ import { existsSync as existsSync13 } from "fs";
2029
2108
  import { join as join7, relative as relative7, resolve as resolve14, sep as sep2 } from "path";
2030
2109
  import { hashString as hashString3 } from "odori";
2031
2110
  var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
2032
- var walk = async (directory2, files = []) => {
2111
+ var walkSource = async (directory2, files = []) => {
2033
2112
  const entries = await readdir4(directory2, { withFileTypes: true });
2034
2113
  for (const entry of entries) {
2035
2114
  if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
2036
2115
  const full = join7(directory2, entry.name);
2037
- if (entry.isDirectory()) await walk(full, files);
2116
+ if (entry.isDirectory()) await walkSource(full, files);
2038
2117
  else files.push(full);
2039
2118
  }
2040
2119
  return files;
2041
2120
  };
2121
+ var walk = walkSource;
2042
2122
  var toIdentifier = (value, prefix) => {
2043
2123
  const cleaned = value.replace(
2044
2124
  /[^a-zA-Z0-9]+(.)?/g,
@@ -2757,10 +2837,11 @@ var createContext = async (root = process.cwd()) => {
2757
2837
  const layout = resolveEntryLayout3(video.entry);
2758
2838
  brands.set(layout.brand.name, layout.brand);
2759
2839
  registerCues([layout.brand], layout.format.fps);
2840
+ registerVariantCues(video.entry.metadata.audio?.variants, layout.format.fps);
2760
2841
  }
2761
2842
  return { config, graph, videos };
2762
2843
  };
2763
- var targetFor = (video, input, prepared, audio, scenes) => {
2844
+ var targetFor = (video, input, prepared, audio, scenes, audioVariant) => {
2764
2845
  const layout = resolveEntryLayout3(video.entry);
2765
2846
  return {
2766
2847
  videoId: video.entry.metadata.id,
@@ -2772,6 +2853,7 @@ var targetFor = (video, input, prepared, audio, scenes) => {
2772
2853
  prepared,
2773
2854
  audio,
2774
2855
  scenes,
2856
+ audioVariant,
2775
2857
  targetLufs: layout.audio.targetLufs
2776
2858
  };
2777
2859
  };
@@ -2906,7 +2988,19 @@ var exportCommand = async (id, options = {}) => {
2906
2988
  return withServer(config, async (server) => {
2907
2989
  const record = options.retry ? await readJob(config, options.retry) : await (async () => {
2908
2990
  const video2 = findVideo(videos, id);
2909
- const compiled = await compileInBrowser(server.url, targetFor(video2, options.input), config);
2991
+ if (options.audioVariant) {
2992
+ const declared = Object.keys(video2.entry.metadata.audio?.variants ?? {});
2993
+ if (!declared.includes(options.audioVariant)) {
2994
+ throw new Error(
2995
+ declared.length ? `No audio variant named "${options.audioVariant}". Declared: ${declared.join(", ")}.` : `"${id}" declares no audio variants; drop --audio-variant or add metadata.audio.variants.`
2996
+ );
2997
+ }
2998
+ }
2999
+ const compiled = await compileInBrowser(
3000
+ server.url,
3001
+ targetFor(video2, options.input, void 0, void 0, void 0, options.audioVariant),
3002
+ config
3003
+ );
2910
3004
  const { manifest } = await freezeManifest(
2911
3005
  { ...video2, durationInFrames: compiled.durationInFrames },
2912
3006
  graph,
@@ -2914,9 +3008,10 @@ var exportCommand = async (id, options = {}) => {
2914
3008
  options.input ?? {},
2915
3009
  { scenes: compiled.scenes, audio: compiled.audio }
2916
3010
  );
3011
+ const suffix = options.audioVariant ? `-${options.audioVariant}` : "";
2917
3012
  const output = resolve19(
2918
3013
  config.root,
2919
- options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
3014
+ options.output ?? `${config.exportDir}/${outputName(id)}${suffix}${format.extension}`
2920
3015
  );
2921
3016
  return createJob(config, manifest, output, {
2922
3017
  format: format.name,
@@ -3078,7 +3173,12 @@ var devCommand = async (options = {}) => {
3078
3173
  const { graph, videos } = await context();
3079
3174
  const video = findVideo(videos, String(body.videoId));
3080
3175
  const input = body.input ?? {};
3081
- const compiled = await compileInBrowser(origin, targetFor(video, input), config);
3176
+ const audioVariant = typeof body.audioVariant === "string" ? body.audioVariant : void 0;
3177
+ const compiled = await compileInBrowser(
3178
+ origin,
3179
+ targetFor(video, input, void 0, void 0, void 0, audioVariant),
3180
+ config
3181
+ );
3082
3182
  const { manifest } = await freezeManifest(
3083
3183
  { ...video, durationInFrames: compiled.durationInFrames },
3084
3184
  graph,
@@ -3223,18 +3323,350 @@ var devCommand = async (options = {}) => {
3223
3323
  return server;
3224
3324
  };
3225
3325
 
3326
+ // src/docs-snapshot.json
3327
+ var docs_snapshot_default = {
3328
+ pages: [
3329
+ {
3330
+ slug: "concepts/lifecycle",
3331
+ title: "Preview and render lifecycle",
3332
+ description: "One prepared manifest drives live preview, deterministic stills, tests, and on-demand encoded exports.",
3333
+ section: "concepts",
3334
+ body: "Odori separates authoring from encoding:\n\n```text\nDiscover source\n \u2193\nResolve layout, props, data, fonts, and assets\n \u2193\nFreeze a render manifest\n \u2193\nLive React preview \u2500\u2500 stills and visual tests\n \u2193\nExplicit export request\n \u2193\nRender worker \u2192 encoded media \u2192 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:::"
3335
+ },
3336
+ {
3337
+ slug: "concepts/project-structure",
3338
+ title: "Project structure",
3339
+ description: "The videos source root and the special files Odori discovers, inherits, and compiles.",
3340
+ section: "concepts",
3341
+ 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 \u2014 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 \u2014 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.'
3342
+ },
3343
+ {
3344
+ slug: "concepts/video-files",
3345
+ title: "The video file",
3346
+ description: "Author timelines as normal JSX while exporting static metadata for discovery and production rendering.",
3347
+ section: "concepts",
3348
+ 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.'
3349
+ },
3350
+ {
3351
+ slug: "guides/audio",
3352
+ title: "Audio",
3353
+ description: "Place sound on the timeline declaratively, preview it against the frame clock, and mix it into the export.",
3354
+ section: "guides",
3355
+ 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` \u2014 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` \u2014 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` \u2014 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` \u2014 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` \u2014 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 \u2014 produced, purchased, or generated with a provider from the\n[integrations page](/docs/guides/integrations) \u2014 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` \u2014 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` \u2014 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:::'
3356
+ },
3357
+ {
3358
+ slug: "guides/components",
3359
+ title: "Video components",
3360
+ description: "Install polished, temporal React components as source and compose them directly inside video.tsx.",
3361
+ section: "guides",
3362
+ 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 \u2014 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`.'
3363
+ },
3364
+ {
3365
+ slug: "guides/data",
3366
+ title: "Data and assets",
3367
+ description: "Resolve async work before playback, freeze mutable inputs, and keep frame rendering deterministic.",
3368
+ section: "guides",
3369
+ 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.'
3370
+ },
3371
+ {
3372
+ slug: "guides/effects",
3373
+ title: "Effects",
3374
+ description: "Author a scene as React, capture it deterministically, and post-process it through shaders.",
3375
+ section: "guides",
3376
+ 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."
3377
+ },
3378
+ {
3379
+ slug: "guides/integrations",
3380
+ title: "Integrations",
3381
+ description: "Generate media with a provider at author time, and keep the render deterministic.",
3382
+ section: "guides",
3383
+ 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 \u2014 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 \u2014 `odori bed`, which measures, levels to\nthe stem target, and registers a role \u2014 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` \u2014\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 \u2014 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 \u2014\nthe choice, never the key \u2014 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 \u2014 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 \u2014 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 \u2014\n[ElevenLabs](https://elevenlabs.io) covers text-to-speech on the same key \u2014\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 \u2014 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.'
3384
+ },
3385
+ {
3386
+ slug: "guides/layouts",
3387
+ title: "Layouts and brands",
3388
+ description: "Inherit format, typography, motion, safe areas, audio policy, and design tokens through the videos tree.",
3389
+ section: "guides",
3390
+ 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.'
3391
+ },
3392
+ {
3393
+ slug: "guides/mcp",
3394
+ title: "MCP server",
3395
+ description: "Odori's registry, blocks, and setup guide, available to any agent that speaks MCP.",
3396
+ section: "guides",
3397
+ 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.'
3398
+ },
3399
+ {
3400
+ slug: "guides/studio",
3401
+ title: "Studio",
3402
+ description: "The filesystem-driven workspace for discovering, previewing, and stress-testing Odori videos and components.",
3403
+ section: "guides",
3404
+ 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 \u2192 complete exportable videos\nvideos/**/*.preview.tsx \u2192 development-only component previews\nvideos/**/brands/*.ts \u2192 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| `\u2190` `\u2192` | 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| `\u2318K` | Open the command palette |\n| `\u23181` to `\u23184` | 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.'
3405
+ },
3406
+ {
3407
+ slug: "index",
3408
+ title: "Docs",
3409
+ description: "Odori is the application framework for React video, with conventions, components, preview, and production rendering around a first-class videos folder.",
3410
+ section: "",
3411
+ 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 \u2193\n Odori timeline runtime\n \u2193\nStudio \xB7 embedded Viewer \xB7 stills \xB7 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>'
3412
+ },
3413
+ {
3414
+ slug: "quickstart",
3415
+ title: "Quickstart",
3416
+ description: "Create a Odori project, author a JSX timeline, preview it instantly, and export it when the cut is ready.",
3417
+ section: "",
3418
+ 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 \u2014 Chrome and\nFFmpeg \u2014 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:::'
3419
+ },
3420
+ {
3421
+ slug: "reference/alternatives",
3422
+ title: "Alternative structures",
3423
+ description: "Other project and authoring models Odori could support, and why videos plus JSX is the default.",
3424
+ section: "reference",
3425
+ 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."
3426
+ },
3427
+ {
3428
+ slug: "reference/api",
3429
+ title: "Authoring API",
3430
+ description: "The public React and TypeScript surface for Odori videos, layouts, inputs, preparation, and assets.",
3431
+ section: "reference",
3432
+ 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 |'
3433
+ },
3434
+ {
3435
+ slug: "reference/cli",
3436
+ title: "CLI",
3437
+ description: "Every odori command, its flags, and what it writes.",
3438
+ section: "reference",
3439
+ 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 \u2014 the same trade the\ncomponent snapshot makes."
3440
+ },
3441
+ {
3442
+ slug: "reference/comparison",
3443
+ title: "Odori vs Remotion vs HyperFrames",
3444
+ description: "How the three authoring models in this repository differ in timeline, discovery, components, preview, and export.",
3445
+ section: "reference",
3446
+ 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">\u2026</section>\n<section id="proof" class="clip scene" data-start="3" data-duration="3.5">\u2026</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">\u2026</Sequence>\n<Sequence from={90} durationInFrames={105} name="Proof">\u2026</Sequence>\n<Sequence from={195} durationInFrames={75} name="Resolution">\u2026</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">\u2026</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.'
3447
+ },
3448
+ {
3449
+ slug: "skills",
3450
+ title: "Skills",
3451
+ description: "Workflows an agent installs once and follows every time it works on your videos.",
3452
+ section: "",
3453
+ 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```"
3454
+ }
3455
+ ]
3456
+ };
3457
+
3458
+ // src/docs.ts
3459
+ var docPages = () => docs_snapshot_default.pages;
3460
+ var findDoc = (query) => {
3461
+ const wanted = query.replace(/^\/+|\/+$|\.mdx$/g, "").toLowerCase();
3462
+ const pages = docPages();
3463
+ return pages.find((page) => page.slug.toLowerCase() === wanted) ?? pages.find((page) => page.slug.toLowerCase().endsWith(`/${wanted}`)) ?? pages.find((page) => page.title.toLowerCase() === wanted);
3464
+ };
3465
+ var searchDocs = (query, limit = 20) => {
3466
+ const needle = query.toLowerCase();
3467
+ if (!needle) return [];
3468
+ const hits = [];
3469
+ for (const page of docPages()) {
3470
+ const lines = page.body.split("\n");
3471
+ for (let index = 0; index < lines.length; index += 1) {
3472
+ if (!lines[index].toLowerCase().includes(needle)) continue;
3473
+ hits.push({ page, line: index + 1, text: lines[index].trim() });
3474
+ if (hits.length >= limit) return hits;
3475
+ }
3476
+ }
3477
+ return hits;
3478
+ };
3479
+
3480
+ // src/commands/docs.ts
3481
+ var docsCommand = async (positionals, options = {}) => {
3482
+ const [first, ...rest] = positionals;
3483
+ if (first === "search") {
3484
+ const query = rest.join(" ").trim();
3485
+ if (!query) {
3486
+ log.warn("Usage: odori docs search <text>");
3487
+ return 1;
3488
+ }
3489
+ const hits = searchDocs(query);
3490
+ if (options.json) {
3491
+ log.info(JSON.stringify(hits.map(({ page: page2, line, text }) => ({ slug: page2.slug, line, text })), null, 2));
3492
+ return hits.length > 0 ? 0 : 1;
3493
+ }
3494
+ if (hits.length === 0) {
3495
+ log.warn(`No page mentions ${query}.`);
3496
+ return 1;
3497
+ }
3498
+ log.title(`${hits.length} line${hits.length === 1 ? "" : "s"} mentioning ${query}`);
3499
+ for (const hit of hits) {
3500
+ log.info(` ${hit.page.slug}:${hit.line}`);
3501
+ log.detail(` ${hit.text}`);
3502
+ }
3503
+ log.detail(`Read one with: odori docs ${hits[0].page.slug}`);
3504
+ return 0;
3505
+ }
3506
+ if (!first) {
3507
+ const pages = docPages();
3508
+ if (options.json) {
3509
+ log.info(
3510
+ JSON.stringify(
3511
+ pages.map(({ slug, title, description }) => ({ slug, title, description })),
3512
+ null,
3513
+ 2
3514
+ )
3515
+ );
3516
+ return 0;
3517
+ }
3518
+ log.title(`${pages.length} pages`);
3519
+ const sections = /* @__PURE__ */ new Map();
3520
+ for (const page2 of pages) {
3521
+ const key = page2.section || "";
3522
+ sections.set(key, [...sections.get(key) ?? [], page2]);
3523
+ }
3524
+ for (const [section, group] of sections) {
3525
+ if (section) log.info(` ${section}/`);
3526
+ for (const page2 of group) {
3527
+ const name = section ? page2.slug.slice(section.length + 1) : page2.slug;
3528
+ log.info(` ${section ? " " : ""}${name.padEnd(section ? 20 : 22)}${page2.title}`);
3529
+ }
3530
+ }
3531
+ log.detail("Read one with: odori docs <page> \xB7 search with: odori docs search <text>");
3532
+ return 0;
3533
+ }
3534
+ const page = findDoc(first);
3535
+ if (!page) {
3536
+ log.warn(`No page called ${first}.`);
3537
+ log.detail("List them with: odori docs");
3538
+ return 1;
3539
+ }
3540
+ if (options.json) {
3541
+ log.info(JSON.stringify(page, null, 2));
3542
+ return 0;
3543
+ }
3544
+ log.title(page.title);
3545
+ if (page.description) log.detail(page.description);
3546
+ log.info("");
3547
+ log.info(page.body);
3548
+ return 0;
3549
+ };
3550
+
3226
3551
  // src/commands/doctor.ts
3227
3552
  import { constants } from "fs";
3228
- import { access, mkdir as mkdir15, readFile as readFile15, rm as rm6, writeFile as writeFile16 } from "fs/promises";
3229
- import { existsSync as existsSync18 } from "fs";
3553
+ import { access, mkdir as mkdir15, readFile as readFile16, rm as rm6, writeFile as writeFile16 } from "fs/promises";
3554
+ import { existsSync as existsSync19 } from "fs";
3230
3555
  import { createRequire as createRequire3 } from "module";
3231
- import { relative as relative9, resolve as resolve21 } from "path";
3556
+ import { relative as relative10, resolve as resolve22 } from "path";
3557
+
3558
+ // src/structure.ts
3559
+ import { readFile as readFile15 } from "fs/promises";
3560
+ import { existsSync as existsSync18 } from "fs";
3561
+ import { relative as relative9, resolve as resolve21, sep as sep4 } from "path";
3562
+ import { resolveEntryLayout as resolveEntryLayout5 } from "odori";
3563
+ var NEAR_MISSES = [
3564
+ {
3565
+ test: (base) => base === "video.ts" || base === "video.jsx",
3566
+ message: "Only video.tsx is a video entry. This file is invisible to discovery: rename it to video.tsx."
3567
+ },
3568
+ {
3569
+ test: (base) => base.toLowerCase() === "video.tsx" && base !== "video.tsx",
3570
+ message: "Entry names are exact and lower case. This file is invisible to discovery: rename it to video.tsx."
3571
+ },
3572
+ {
3573
+ test: (base) => /\.preview\.(ts|jsx)$/.test(base),
3574
+ message: "Only *.preview.tsx is a component fixture. This file is invisible to discovery: rename it to end in .preview.tsx."
3575
+ },
3576
+ {
3577
+ test: (base) => base.toLowerCase().endsWith(".preview.tsx") && !base.endsWith(".preview.tsx"),
3578
+ message: "Fixture names are exact and lower case. This file is invisible to discovery: rename it to end in .preview.tsx."
3579
+ }
3580
+ ];
3581
+ var AUDIO_REFERENCE = /["'`](\/audio\/[^"'`\s]+\.[a-z0-9]{2,4})["'`]/gi;
3582
+ var checkStructure = async (config, graph, videos) => {
3583
+ const findings = [];
3584
+ const videosRoot = resolve21(config.root, config.videosDir);
3585
+ const componentsRoot = resolve21(config.root, config.componentsDir);
3586
+ const files = existsSync18(videosRoot) ? await walkSource(videosRoot) : [];
3587
+ for (const file of files) {
3588
+ const base = file.split(sep4).pop() ?? "";
3589
+ const miss = NEAR_MISSES.find((candidate) => candidate.test(base));
3590
+ if (miss) findings.push({ level: "warn", file: relative9(config.root, file), message: miss.message });
3591
+ }
3592
+ for (const preview of graph.previews) {
3593
+ if (!preview.file.startsWith(componentsRoot + sep4)) continue;
3594
+ const directory2 = resolve21(preview.file, "..").split(sep4).pop() ?? "";
3595
+ if (directory2 && preview.name !== directory2) {
3596
+ findings.push({
3597
+ level: "warn",
3598
+ file: preview.relativeFile,
3599
+ message: `Fixture "${preview.name}" sits in components/${directory2}/, so usage is never attributed to it. Name the fixture after its directory: ${directory2}.preview.tsx.`
3600
+ });
3601
+ }
3602
+ }
3603
+ const audioUrls = new Set(graph.audio.map((entry) => entry.url));
3604
+ for (const file of files) {
3605
+ if (!/\.(tsx|ts)$/.test(file)) continue;
3606
+ const contents = await readFile15(file, "utf8");
3607
+ const reported = /* @__PURE__ */ new Set();
3608
+ for (const match of contents.matchAll(AUDIO_REFERENCE)) {
3609
+ const url = match[1];
3610
+ if (url.includes("${") || audioUrls.has(url) || reported.has(url)) continue;
3611
+ reported.add(url);
3612
+ findings.push({
3613
+ level: "error",
3614
+ file: relative9(config.root, file),
3615
+ message: `References ${url}, and no file answers it under ${config.audioDir}/. It will play as silence.`
3616
+ });
3617
+ }
3618
+ }
3619
+ for (const video of videos) {
3620
+ const layout = resolveEntryLayout5(video.entry);
3621
+ const brandCues = new Set(Object.keys(layout.brand.audio.cues));
3622
+ for (const [variant, overrides] of Object.entries(video.entry.metadata.audio?.variants ?? {})) {
3623
+ for (const [cue, value] of Object.entries(overrides)) {
3624
+ if (!brandCues.has(cue)) {
3625
+ findings.push({
3626
+ level: "warn",
3627
+ file: video.relativeFile,
3628
+ message: `Variant "${variant}" overrides a cue named "${cue}" that the brand "${layout.brand.name}" does not define. Nothing plays that name, so the override is never heard.`
3629
+ });
3630
+ }
3631
+ if (typeof value === "string" && value.startsWith("/") && !audioUrls.has(value)) {
3632
+ findings.push({
3633
+ level: "error",
3634
+ file: video.relativeFile,
3635
+ message: `Variant "${variant}" points cue "${cue}" at ${value}, and no file answers it under ${config.audioDir}/.`
3636
+ });
3637
+ }
3638
+ }
3639
+ }
3640
+ }
3641
+ const catalogFile = resolve21(config.root, config.outDir, "catalog.json");
3642
+ if (existsSync18(catalogFile)) {
3643
+ try {
3644
+ const catalog = JSON.parse(await readFile15(catalogFile, "utf8"));
3645
+ if (catalog.sourceHash && catalog.sourceHash !== graph.sourceHash) {
3646
+ findings.push({
3647
+ level: "warn",
3648
+ file: relative9(config.root, catalogFile),
3649
+ message: "The compiled catalog is older than the source tree. Run odori graph (or odori dev) to refresh it."
3650
+ });
3651
+ }
3652
+ } catch {
3653
+ findings.push({
3654
+ level: "warn",
3655
+ file: relative9(config.root, catalogFile),
3656
+ message: "The compiled catalog is not valid JSON. Run odori graph (or odori dev) to rewrite it."
3657
+ });
3658
+ }
3659
+ }
3660
+ return findings;
3661
+ };
3662
+
3663
+ // src/commands/doctor.ts
3232
3664
  var MINIMUM_NODE = 20;
3233
3665
  var version = (value) => value.replace(/^v/, "").split(".").map(Number);
3234
3666
  var runChecks = async (root) => {
3235
3667
  const checks = [];
3236
3668
  const config = await loadConfig(root);
3237
- const require2 = createRequire3(resolve21(root, "package.json"));
3669
+ const require2 = createRequire3(resolve22(root, "package.json"));
3238
3670
  const [major] = version(process.version);
3239
3671
  checks.push({
3240
3672
  name: "Node",
@@ -3245,7 +3677,7 @@ var runChecks = async (root) => {
3245
3677
  let react2 = "not found";
3246
3678
  let reactOk = false;
3247
3679
  try {
3248
- const manifest = JSON.parse(await readFile15(require2.resolve("react/package.json"), "utf8"));
3680
+ const manifest = JSON.parse(await readFile16(require2.resolve("react/package.json"), "utf8"));
3249
3681
  react2 = manifest.version;
3250
3682
  reactOk = version(react2)[0] >= 19;
3251
3683
  } catch {
@@ -3257,16 +3689,16 @@ var runChecks = async (root) => {
3257
3689
  ok: reactOk,
3258
3690
  fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
3259
3691
  });
3260
- const videosDir = resolve21(config.root, config.videosDir);
3692
+ const videosDir = resolve22(config.root, config.videosDir);
3261
3693
  checks.push({
3262
3694
  name: "Source root",
3263
- detail: existsSync18(videosDir) ? relative9(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
3264
- ok: existsSync18(videosDir),
3695
+ detail: existsSync19(videosDir) ? relative10(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
3696
+ ok: existsSync19(videosDir),
3265
3697
  fix: 'Run "odori init" to add the videos source root.'
3266
3698
  });
3267
3699
  checks.push({
3268
3700
  name: "Config",
3269
- detail: config.configPath ? relative9(config.root, config.configPath) : "defaults (no odori.config.ts)",
3701
+ detail: config.configPath ? relative10(config.root, config.configPath) : "defaults (no odori.config.ts)",
3270
3702
  // Loading got this far, so a config that exists also parsed.
3271
3703
  ok: true
3272
3704
  });
@@ -3292,11 +3724,11 @@ var runChecks = async (root) => {
3292
3724
  detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
3293
3725
  ok: true
3294
3726
  });
3295
- const generated = resolve21(config.root, ".odori");
3727
+ const generated = resolve22(config.root, ".odori");
3296
3728
  let writable = false;
3297
3729
  try {
3298
3730
  await mkdir15(generated, { recursive: true });
3299
- const probe = resolve21(generated, ".doctor");
3731
+ const probe = resolve22(generated, ".doctor");
3300
3732
  await writeFile16(probe, "", "utf8");
3301
3733
  await access(probe, constants.W_OK);
3302
3734
  await rm6(probe, { force: true });
@@ -3304,13 +3736,13 @@ var runChecks = async (root) => {
3304
3736
  } catch {
3305
3737
  writable = false;
3306
3738
  }
3307
- const componentsRoot = resolve21(root, config.componentsDir);
3739
+ const componentsRoot = resolve22(root, config.componentsDir);
3308
3740
  const orphans = [];
3309
- if (existsSync18(componentsRoot)) {
3741
+ if (existsSync19(componentsRoot)) {
3310
3742
  const { readdir: readdir9 } = await import("fs/promises");
3311
3743
  for (const entry of await readdir9(componentsRoot, { withFileTypes: true })) {
3312
3744
  if (!entry.isDirectory()) continue;
3313
- const files = await readdir9(resolve21(componentsRoot, entry.name));
3745
+ const files = await readdir9(resolve22(componentsRoot, entry.name));
3314
3746
  const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
3315
3747
  const fixture = files.some((file) => file.endsWith(".preview.tsx"));
3316
3748
  if (source && !fixture) orphans.push(entry.name);
@@ -3323,6 +3755,29 @@ var runChecks = async (root) => {
3323
3755
  warn: orphans.length > 0,
3324
3756
  fix: `Add a sibling <name>.preview.tsx with defineComponentPreview so Studio can play it on its own. A component with no fixture only ever renders inside a video.`
3325
3757
  });
3758
+ if (existsSync19(resolve22(config.root, config.videosDir))) {
3759
+ try {
3760
+ const graph = await discoverProject(config);
3761
+ const videos = await loadVideos(graph);
3762
+ const findings = await checkStructure(config, graph, videos);
3763
+ const errors = findings.filter((finding) => finding.level === "error");
3764
+ const first = errors[0] ?? findings[0];
3765
+ checks.push({
3766
+ name: "Structure",
3767
+ detail: findings.length === 0 ? `${videos.length} video${videos.length === 1 ? "" : "s"}, shape is sound` : `${first.file}: ${first.message}${findings.length > 1 ? ` (and ${findings.length - 1} more)` : ""}`,
3768
+ ok: errors.length === 0,
3769
+ warn: errors.length === 0 && findings.length > 0,
3770
+ fix: 'Run "odori graph" for the full report.'
3771
+ });
3772
+ } catch (error) {
3773
+ checks.push({
3774
+ name: "Structure",
3775
+ detail: error instanceof Error ? error.message : String(error),
3776
+ ok: false,
3777
+ fix: "A video entry failed to load. Open the file the message names."
3778
+ });
3779
+ }
3780
+ }
3326
3781
  for (const provider of Object.values(musicProviders)) {
3327
3782
  const source = await keySource(provider.keyVariable);
3328
3783
  checks.push({
@@ -3337,7 +3792,7 @@ var runChecks = async (root) => {
3337
3792
  name: "Generated cache",
3338
3793
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
3339
3794
  ok: writable,
3340
- fix: `Odori writes its import graph and render cache to ${relative9(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
3795
+ fix: `Odori writes its import graph and render cache to ${relative10(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
3341
3796
  });
3342
3797
  return checks;
3343
3798
  };
@@ -3364,14 +3819,14 @@ var doctorCommand = async (root = process.cwd()) => {
3364
3819
  };
3365
3820
 
3366
3821
  // src/commands/init.ts
3367
- import { mkdir as mkdir17, readFile as readFile16, writeFile as writeFile18 } from "fs/promises";
3368
- import { existsSync as existsSync20 } from "fs";
3369
- import { relative as relative11, resolve as resolve23 } from "path";
3822
+ import { mkdir as mkdir17, readFile as readFile17, writeFile as writeFile18 } from "fs/promises";
3823
+ import { existsSync as existsSync21 } from "fs";
3824
+ import { relative as relative12, resolve as resolve24 } from "path";
3370
3825
 
3371
3826
  // src/commands/new.ts
3372
3827
  import { mkdir as mkdir16, readdir as readdir6, writeFile as writeFile17 } from "fs/promises";
3373
- import { existsSync as existsSync19 } from "fs";
3374
- import { relative as relative10, resolve as resolve22 } from "path";
3828
+ import { existsSync as existsSync20 } from "fs";
3829
+ import { relative as relative11, resolve as resolve23 } from "path";
3375
3830
  var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
3376
3831
  var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
3377
3832
  var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
@@ -3429,18 +3884,18 @@ ${closing}
3429
3884
  `;
3430
3885
  };
3431
3886
  var installedParts = async (config) => {
3432
- const componentsDir = resolve22(config.root, config.componentsDir);
3433
- if (!existsSync19(componentsDir)) return { title: false, end: false };
3887
+ const componentsDir = resolve23(config.root, config.componentsDir);
3888
+ if (!existsSync20(componentsDir)) return { title: false, end: false };
3434
3889
  const entries = (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
3435
3890
  return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
3436
3891
  };
3437
3892
  var newCommand = async (name, options = {}) => {
3438
3893
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
3439
3894
  const config = await loadConfig(process.cwd());
3440
- const directory2 = resolve22(config.root, config.videosDir, name);
3441
- const file = resolve22(directory2, "video.tsx");
3442
- if (existsSync19(file)) throw new Error(`${relative10(config.root, file)} already exists.`);
3443
- const hasLayout = existsSync19(resolve22(config.root, config.videosDir, "layout.tsx"));
3895
+ const directory2 = resolve23(config.root, config.videosDir, name);
3896
+ const file = resolve23(directory2, "video.tsx");
3897
+ if (existsSync20(file)) throw new Error(`${relative11(config.root, file)} already exists.`);
3898
+ const hasLayout = existsSync20(resolve23(config.root, config.videosDir, "layout.tsx"));
3444
3899
  const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
3445
3900
  const composed = parts.title || parts.end;
3446
3901
  await mkdir16(directory2, { recursive: true });
@@ -3449,7 +3904,7 @@ var newCommand = async (name, options = {}) => {
3449
3904
  composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
3450
3905
  "utf8"
3451
3906
  );
3452
- log.success(`Created ${relative10(config.root, file)}`);
3907
+ log.success(`Created ${relative11(config.root, file)}`);
3453
3908
  if (composed) log.detail("Composed from the components this project has installed.");
3454
3909
  else if (options.blank !== true) {
3455
3910
  log.detail("No registry components installed yet: odori add title-reveal end-card");
@@ -3483,34 +3938,34 @@ export const productLayout = defineVideoLayout({
3483
3938
  });
3484
3939
  `;
3485
3940
  var initCommand = async (root = process.cwd()) => {
3486
- const videosDir = resolve23(root, defaultConfig.videosDir);
3487
- await mkdir17(resolve23(videosDir, "components"), { recursive: true });
3941
+ const videosDir = resolve24(root, defaultConfig.videosDir);
3942
+ await mkdir17(resolve24(videosDir, "components"), { recursive: true });
3488
3943
  const files = [
3489
- [resolve23(root, "odori.config.ts"), CONFIG_TEMPLATE],
3490
- [resolve23(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
3491
- [resolve23(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
3944
+ [resolve24(root, "odori.config.ts"), CONFIG_TEMPLATE],
3945
+ [resolve24(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
3946
+ [resolve24(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
3492
3947
  ];
3493
3948
  for (const [file, contents] of files) {
3494
- if (existsSync20(file)) {
3495
- log.detail(`Kept existing ${relative11(root, file)}`);
3949
+ if (existsSync21(file)) {
3950
+ log.detail(`Kept existing ${relative12(root, file)}`);
3496
3951
  continue;
3497
3952
  }
3498
- await mkdir17(resolve23(file, ".."), { recursive: true });
3953
+ await mkdir17(resolve24(file, ".."), { recursive: true });
3499
3954
  await writeFile18(file, contents, "utf8");
3500
- log.success(`Created ${relative11(root, file)}`);
3955
+ log.success(`Created ${relative12(root, file)}`);
3501
3956
  }
3502
3957
  await ensureModuleType(root);
3503
3958
  log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
3504
3959
  };
3505
3960
  var ensureModuleType = async (root) => {
3506
- const file = resolve23(root, "package.json");
3507
- if (!existsSync20(file)) {
3961
+ const file = resolve24(root, "package.json");
3962
+ if (!existsSync21(file)) {
3508
3963
  log.warn('No package.json here. Odori needs an ESM package: run npm init, then add "type": "module".');
3509
3964
  return;
3510
3965
  }
3511
3966
  let manifest;
3512
3967
  try {
3513
- manifest = JSON.parse(await readFile16(file, "utf8"));
3968
+ manifest = JSON.parse(await readFile17(file, "utf8"));
3514
3969
  } catch {
3515
3970
  log.warn('package.json is not readable JSON, so "type": "module" was not set. Odori needs it.');
3516
3971
  return;
@@ -3536,11 +3991,11 @@ var integrationsCommand = async () => {
3536
3991
  };
3537
3992
 
3538
3993
  // src/commands/inspect.ts
3539
- import { isOdoriSchema, resolveEntryLayout as resolveEntryLayout5 } from "odori";
3994
+ import { isOdoriSchema, resolveEntryLayout as resolveEntryLayout6 } from "odori";
3540
3995
  var inspectCommand = async (id, options = {}) => {
3541
3996
  const { config, graph, videos } = await createContext();
3542
3997
  const video = findVideo(videos, id);
3543
- const layout = resolveEntryLayout5(video.entry);
3998
+ const layout = resolveEntryLayout6(video.entry);
3544
3999
  const { durationInFrames, scenes, audio } = await withServer(
3545
4000
  config,
3546
4001
  (server) => compileInBrowser(server.url, targetFor(video, options.input), config)
@@ -3603,12 +4058,12 @@ var inspectCommand = async (id, options = {}) => {
3603
4058
  };
3604
4059
 
3605
4060
  // src/commands/list.ts
3606
- import { resolveEntryLayout as resolveEntryLayout6 } from "odori";
4061
+ import { resolveEntryLayout as resolveEntryLayout7 } from "odori";
3607
4062
  var listCommand = async () => {
3608
4063
  const { videos, graph, config } = await createContext();
3609
4064
  log.title(`${videos.length} video${videos.length === 1 ? "" : "s"} in ${config.videosDir}/`);
3610
4065
  for (const video of videos) {
3611
- const layout = resolveEntryLayout6(video.entry);
4066
+ const layout = resolveEntryLayout7(video.entry);
3612
4067
  const seconds = video.durationInFrames / layout.format.fps;
3613
4068
  log.info(
3614
4069
  ` ${video.entry.metadata.id} ${layout.format.width}x${layout.format.height} ${layout.format.fps}fps ${video.durationInFrames ? `${seconds.toFixed(1)}s` : "duration from scenes"}`
@@ -3621,9 +4076,134 @@ var listCommand = async () => {
3621
4076
  }
3622
4077
  };
3623
4078
 
3624
- // src/commands/narrate.ts
4079
+ // src/commands/blocks.ts
4080
+ import { existsSync as existsSync22 } from "fs";
3625
4081
  import { mkdir as mkdir18, writeFile as writeFile19 } from "fs/promises";
3626
- import { basename as basename3, dirname as dirname9, extname as extname2, join as join9, resolve as resolve24 } from "path";
4082
+ import { dirname as dirname9 } from "path";
4083
+ var rebase = (block, target, out) => {
4084
+ if (!out) return target;
4085
+ const home = block.entry.slice(0, block.entry.lastIndexOf("/"));
4086
+ if (!target.startsWith(`${home}/`)) return target;
4087
+ return `${out.replace(/\/$/, "")}/${target.slice(home.length + 1)}`;
4088
+ };
4089
+ var findBlock = async (config, id) => {
4090
+ const index = await resolveBlocks(config);
4091
+ const summary = index.items.find((item) => item.id === id);
4092
+ if (!summary) {
4093
+ const names = index.items.map((item) => item.id).join(", ");
4094
+ throw new Error(`No block named ${JSON.stringify(id)}. Available: ${names}`);
4095
+ }
4096
+ return { summary, index };
4097
+ };
4098
+ var blocksListCommand = async (options = {}) => {
4099
+ const config = await loadConfig(process.cwd());
4100
+ const { items, origin, detail } = await resolveBlocks(config);
4101
+ if (options.json) {
4102
+ log.info(JSON.stringify({ origin, items }, null, 2));
4103
+ return;
4104
+ }
4105
+ if (origin === "cache") log.detail(`blocks: cached copy (offline)`);
4106
+ else log.detail(`blocks: ${detail}`);
4107
+ log.title(`${items.length} block${items.length === 1 ? "" : "s"}`);
4108
+ for (const item of items) {
4109
+ log.info(` ${item.id} ${item.title}`);
4110
+ log.detail(` ${item.format} \xB7 ${item.tags.join(", ")}`);
4111
+ }
4112
+ log.detail(`
4113
+ odori blocks show <id> for its files, odori blocks add <id> to install one.`);
4114
+ };
4115
+ var blocksShowCommand = async (id, options = {}) => {
4116
+ const config = await loadConfig(process.cwd());
4117
+ const { summary } = await findBlock(config, id);
4118
+ if (options.json) {
4119
+ log.info(JSON.stringify(summary, null, 2));
4120
+ return;
4121
+ }
4122
+ log.title(`${summary.title} (${summary.id})`);
4123
+ log.info(` ${summary.description}`);
4124
+ log.detail(` ${summary.format} \xB7 ${summary.tags.join(", ")}`);
4125
+ log.title("files");
4126
+ for (const file of summary.files) log.info(` ${file.target}${file.target === summary.entry ? " (entry)" : ""}`);
4127
+ log.title("components");
4128
+ for (const component of summary.components) log.info(` ${component}`);
4129
+ log.detail(`
4130
+ odori blocks add ${summary.id}`);
4131
+ };
4132
+ var blocksCatCommand = async (id, file) => {
4133
+ const config = await loadConfig(process.cwd());
4134
+ await findBlock(config, id);
4135
+ const { block } = await resolveBlock(config, id);
4136
+ const found = block.files.find((item) => item.target === file) ?? block.files.find((item) => item.target.endsWith(`/${file}`));
4137
+ if (!found) {
4138
+ throw new Error(
4139
+ `Block "${id}" has no file ${JSON.stringify(file)}. It ships: ${block.files.map((item) => item.target).join(", ")}`
4140
+ );
4141
+ }
4142
+ log.info(found.content);
4143
+ };
4144
+ var blocksAddCommand = async (id, options = {}) => {
4145
+ const config = await loadConfig(process.cwd());
4146
+ await findBlock(config, id);
4147
+ const { block, origin } = await resolveBlock(config, id);
4148
+ if (origin === "cache") log.detail("blocks: cached copy (offline)");
4149
+ verifyBlockIntegrity(block);
4150
+ const writes = block.files.map((file) => {
4151
+ const target = rebase(block, file.target, options.out);
4152
+ return { target, destination: resolveWithinRoot(config.root, target), content: file.content };
4153
+ });
4154
+ const clashes = writes.filter((write) => existsSync22(write.destination));
4155
+ if (clashes.length > 0 && !options.force) {
4156
+ throw new Error(
4157
+ `These files already exist:
4158
+ ${clashes.map((write) => ` ${write.target}`).join("\n")}
4159
+ Nothing was written. Pass --out to install beside them, or --force to replace them.`
4160
+ );
4161
+ }
4162
+ if (options.dryRun) {
4163
+ log.title(`${block.title} would write ${writes.length} file${writes.length === 1 ? "" : "s"}`);
4164
+ for (const write of writes) log.info(` ${write.target}`);
4165
+ if (block.components.length > 0) log.detail(` and install ${block.components.join(", ")}`);
4166
+ return;
4167
+ }
4168
+ if (block.components.length > 0) {
4169
+ await addCommand(block.components.map(normalizeComponentName), { force: options.force });
4170
+ }
4171
+ for (const write of writes) {
4172
+ await mkdir18(dirname9(write.destination), { recursive: true });
4173
+ await writeFile19(write.destination, write.content, "utf8");
4174
+ log.info(` ${write.target}`);
4175
+ }
4176
+ const entry = rebase(block, block.entry, options.out);
4177
+ log.title(`${block.title} installed`);
4178
+ log.detail(` entry: ${entry}`);
4179
+ if (options.out) log.detail(` the video id is set in the entry; change it if this project already has "${block.id}".`);
4180
+ log.detail(` odori dev, then open the video to watch it.`);
4181
+ };
4182
+ var blocksCommand = async (positionals, flags = {}) => {
4183
+ const [action = "ls", ...rest] = positionals;
4184
+ switch (action) {
4185
+ case "ls":
4186
+ case "list":
4187
+ return blocksListCommand({ json: flags.json });
4188
+ case "show":
4189
+ if (!rest[0]) throw new Error("Name a block, for example: odori blocks show launch");
4190
+ return blocksShowCommand(rest[0], { json: flags.json });
4191
+ case "cat":
4192
+ if (!rest[0] || !rest[1]) {
4193
+ throw new Error("Name a block and a file, for example: odori blocks cat launch video.tsx");
4194
+ }
4195
+ return blocksCatCommand(rest[0], rest[1]);
4196
+ case "add":
4197
+ if (!rest[0]) throw new Error("Name a block, for example: odori blocks add launch");
4198
+ return blocksAddCommand(rest[0], { out: flags.out, force: flags.force, dryRun: flags.dryRun });
4199
+ default:
4200
+ throw new Error(`Unknown blocks command ${JSON.stringify(action)}. Try: ls, show, cat, add.`);
4201
+ }
4202
+ };
4203
+
4204
+ // src/commands/narrate.ts
4205
+ import { mkdir as mkdir19, writeFile as writeFile20 } from "fs/promises";
4206
+ import { basename as basename3, dirname as dirname10, extname as extname2, join as join9, resolve as resolve25 } from "path";
3627
4207
  import { wordsFromCharacters } from "odori";
3628
4208
  var narrateCommand = async (script, options = {}) => {
3629
4209
  if (!script.trim()) throw new Error('Give the script to read, for example: odori narrate "One definition. Every render."');
@@ -3643,15 +4223,15 @@ Either way it is sent only to the provider and never stored in the project.`
3643
4223
  const words = wordsFromCharacters(alignment);
3644
4224
  if (words.length === 0) throw new Error("The provider returned no word timings, so captions cannot be derived. Nothing was written.");
3645
4225
  const stem = options.output ? basename3(options.output, extname2(options.output)) : script.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "narration";
3646
- const directory2 = options.output ? dirname9(resolve24(config.root, options.output)) : join9(config.root, "public", "audio");
3647
- await mkdir18(directory2, { recursive: true });
4226
+ const directory2 = options.output ? dirname10(resolve25(config.root, options.output)) : join9(config.root, "public", "audio");
4227
+ await mkdir19(directory2, { recursive: true });
3648
4228
  const audioFile = join9(directory2, `${stem}.${extension}`);
3649
- await writeFile19(audioFile, bytes);
4229
+ await writeFile20(audioFile, bytes);
3650
4230
  const role = options.role ?? "voice.narration";
3651
4231
  const url = `/audio/${basename3(audioFile)}`;
3652
4232
  const narration = { script, provider: provider.name, voice, audio: role, words };
3653
4233
  const timingFile = join9(directory2, `${stem}.narration.json`);
3654
- await writeFile19(timingFile, `${JSON.stringify(narration, null, 2)}
4234
+ await writeFile20(timingFile, `${JSON.stringify(narration, null, 2)}
3655
4235
  `, "utf8");
3656
4236
  const registered = await registerCueInBrand(config, { name: role, url }, stem);
3657
4237
  const seconds = words[words.length - 1].endSeconds;
@@ -3664,14 +4244,14 @@ Either way it is sent only to the provider and never stored in the project.`
3664
4244
  };
3665
4245
 
3666
4246
  // src/commands/frame.ts
3667
- import { resolve as resolve25 } from "path";
3668
- import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
4247
+ import { resolve as resolve26 } from "path";
4248
+ import { framesFromOffset, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3669
4249
  var frameCommand = async (id, options = {}) => {
3670
4250
  const at = options.at ?? 0;
3671
4251
  framesFromOffset(at, 30);
3672
4252
  const { config, graph, videos } = await createContext();
3673
4253
  const video = findVideo(videos, id);
3674
- const fps = resolveEntryLayout7(video.entry).format.fps;
4254
+ const fps = resolveEntryLayout8(video.entry).format.fps;
3675
4255
  const frame = framesFromOffset(at, fps);
3676
4256
  const output = await withServer(config, async (server) => {
3677
4257
  const { durationInFrames, scenes, audio } = await compileInBrowser(server.url, targetFor(video, options.input), config);
@@ -3686,21 +4266,108 @@ var frameCommand = async (id, options = {}) => {
3686
4266
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
3687
4267
  }
3688
4268
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
3689
- const file = resolve25(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
4269
+ const file = resolve26(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3690
4270
  return renderStill(server.url, target, frame, file, config);
3691
4271
  });
3692
4272
  log.success(`Frame ${frame} written to ${output}`);
3693
4273
  return output;
3694
4274
  };
3695
4275
 
4276
+ // src/commands/graph.ts
4277
+ import { existsSync as existsSync23 } from "fs";
4278
+ import { mkdir as mkdir20, writeFile as writeFile21 } from "fs/promises";
4279
+ import { join as join10, resolve as resolve27 } from "path";
4280
+ import { resolveEntryLayout as resolveEntryLayout9 } from "odori";
4281
+ var buildGraphArtifact = async (root = process.cwd()) => {
4282
+ const { config, graph, videos } = await createContext(root);
4283
+ await writeGenerated(config, graph);
4284
+ const findings = await checkStructure(config, graph, videos);
4285
+ const slugOf = new Map(graph.videos.map((video) => [video.file, video.slug]));
4286
+ const artifact = {
4287
+ sourceHash: graph.sourceHash,
4288
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4289
+ videos: videos.map((video) => {
4290
+ const layout = resolveEntryLayout9(video.entry);
4291
+ const slug = slugOf.get(video.file) ?? video.entry.metadata.id;
4292
+ return {
4293
+ id: video.entry.metadata.id,
4294
+ title: video.entry.metadata.title,
4295
+ ...video.entry.metadata.description ? { description: video.entry.metadata.description } : {},
4296
+ file: video.relativeFile,
4297
+ format: { width: layout.format.width, height: layout.format.height, fps: layout.format.fps },
4298
+ durationInFrames: video.durationInFrames,
4299
+ seconds: video.durationInFrames ? Number((video.durationInFrames / layout.format.fps).toFixed(2)) : null,
4300
+ brand: layout.brand.name,
4301
+ tags: video.entry.metadata.tags ?? [],
4302
+ audioVariants: Object.keys(video.entry.metadata.audio?.variants ?? {}),
4303
+ components: graph.previews.filter((preview) => preview.usedBy?.includes(slug)).map((preview) => preview.name).sort(),
4304
+ prepare: existsSync23(resolve27(video.file, "..", "prepare.ts"))
4305
+ };
4306
+ }),
4307
+ components: graph.previews.map((preview) => ({
4308
+ name: preview.name,
4309
+ file: preview.relativeFile,
4310
+ usedBy: preview.usedBy ?? []
4311
+ })),
4312
+ brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
4313
+ audio: graph.audio.map((entry) => ({
4314
+ name: entry.name,
4315
+ url: entry.url,
4316
+ file: entry.relativeFile,
4317
+ bytes: entry.bytes
4318
+ })),
4319
+ categories: graph.categories,
4320
+ findings
4321
+ };
4322
+ const outDir = resolve27(config.root, config.outDir);
4323
+ await mkdir20(outDir, { recursive: true });
4324
+ await writeFile21(join10(outDir, "graph.json"), `${JSON.stringify(artifact, null, 2)}
4325
+ `, "utf8");
4326
+ return { artifact, outDir };
4327
+ };
4328
+ var graphCommand = async (options = {}) => {
4329
+ const { artifact } = await buildGraphArtifact();
4330
+ const errors = artifact.findings.filter((finding) => finding.level === "error");
4331
+ if (options.json === true) {
4332
+ log.info(JSON.stringify(artifact, null, 2));
4333
+ return errors.length === 0 ? 0 : 1;
4334
+ }
4335
+ log.title(`${artifact.videos.length} video${artifact.videos.length === 1 ? "" : "s"}`);
4336
+ for (const video of artifact.videos) {
4337
+ const duration = video.seconds === null ? "duration from scenes" : `${video.seconds}s`;
4338
+ const extras = [
4339
+ video.audioVariants.length > 0 ? `audio: ${video.audioVariants.join(", ")}` : "",
4340
+ video.components.length > 0 ? `components: ${video.components.join(", ")}` : "",
4341
+ video.prepare ? "prepare.ts" : ""
4342
+ ].filter(Boolean);
4343
+ log.info(` ${video.id} ${video.format.width}x${video.format.height} ${duration} ${video.brand}`);
4344
+ if (extras.length > 0) log.detail(` ${extras.join(" \xB7 ")}`);
4345
+ }
4346
+ if (artifact.components.length > 0) {
4347
+ log.title(`${artifact.components.length} component${artifact.components.length === 1 ? "" : "s"}`);
4348
+ for (const component of artifact.components) {
4349
+ log.info(` ${component.name}${component.usedBy.length > 0 ? ` used by ${component.usedBy.join(", ")}` : ""}`);
4350
+ }
4351
+ }
4352
+ if (artifact.audio.length > 0) log.title(`${artifact.audio.length} audio file${artifact.audio.length === 1 ? "" : "s"}`);
4353
+ for (const entry of artifact.audio) log.info(` ${entry.url} ${(entry.bytes / 1024).toFixed(0)} KB`);
4354
+ if (artifact.findings.length > 0) log.title("Structure");
4355
+ for (const finding of artifact.findings) {
4356
+ const say = finding.level === "error" ? log.error : log.warn;
4357
+ say(`${finding.file}: ${finding.message}`);
4358
+ }
4359
+ log.detail(`Wrote .odori/graph.json (source ${artifact.sourceHash.slice(0, 12)})`);
4360
+ return errors.length === 0 ? 0 : 1;
4361
+ };
4362
+
3696
4363
  // src/commands/test.ts
3697
- import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout9 } from "odori";
4364
+ import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout11 } from "odori";
3698
4365
 
3699
4366
  // src/contracts.ts
3700
- import { existsSync as existsSync21 } from "fs";
4367
+ import { existsSync as existsSync24 } from "fs";
3701
4368
  import { readdir as readdir7 } from "fs/promises";
3702
- import { resolve as resolve26 } from "path";
3703
- import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
4369
+ import { resolve as resolve28 } from "path";
4370
+ import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout10 } from "odori";
3704
4371
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
3705
4372
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
3706
4373
  "system-ui",
@@ -3766,8 +4433,8 @@ var checkAudioWindows = (cues, brand, videoId) => {
3766
4433
  return failures;
3767
4434
  };
3768
4435
  var checkInstalledContracts = async (config, videos) => {
3769
- const componentsDir = resolve26(config.root, config.componentsDir);
3770
- const onDisk = existsSync21(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
4436
+ const componentsDir = resolve28(config.root, config.componentsDir);
4437
+ const onDisk = existsSync24(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3771
4438
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
3772
4439
  if (names.size === 0) return [];
3773
4440
  const { items } = await resolveRegistry(config, { allowNetwork: false });
@@ -3776,7 +4443,7 @@ var checkInstalledContracts = async (config, videos) => {
3776
4443
  const seen = /* @__PURE__ */ new Set();
3777
4444
  const failures = [];
3778
4445
  for (const video of videos) {
3779
- const { brand } = resolveEntryLayout8(video.entry);
4446
+ const { brand } = resolveEntryLayout10(video.entry);
3780
4447
  for (const failure of checkComponentRequirements(installed, brand, video.entry.metadata.id)) {
3781
4448
  const key = `${brand.name}:${failure.message}`;
3782
4449
  if (seen.has(key)) continue;
@@ -3788,9 +4455,9 @@ var checkInstalledContracts = async (config, videos) => {
3788
4455
  };
3789
4456
 
3790
4457
  // src/determinism.ts
3791
- import { readdir as readdir8, readFile as readFile17 } from "fs/promises";
3792
- import { existsSync as existsSync22 } from "fs";
3793
- import { join as join10, relative as relative12, resolve as resolve27 } from "path";
4458
+ import { readdir as readdir8, readFile as readFile18 } from "fs/promises";
4459
+ import { existsSync as existsSync25 } from "fs";
4460
+ import { join as join11, relative as relative13, resolve as resolve29 } from "path";
3794
4461
  var FORBIDDEN = [
3795
4462
  {
3796
4463
  pattern: /\bMath\.random\s*\(/,
@@ -3824,18 +4491,18 @@ var scanSource = (source, file) => {
3824
4491
  var walk2 = async (directory2, files = []) => {
3825
4492
  for (const entry of await readdir8(directory2, { withFileTypes: true })) {
3826
4493
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3827
- const full = join10(directory2, entry.name);
4494
+ const full = join11(directory2, entry.name);
3828
4495
  if (entry.isDirectory()) await walk2(full, files);
3829
4496
  else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
3830
4497
  }
3831
4498
  return files;
3832
4499
  };
3833
4500
  var checkDeterminism = async (config) => {
3834
- const root = resolve27(config.root, config.videosDir);
3835
- if (!existsSync22(root)) return [];
4501
+ const root = resolve29(config.root, config.videosDir);
4502
+ if (!existsSync25(root)) return [];
3836
4503
  const files = await walk2(root);
3837
4504
  const findings = await Promise.all(
3838
- files.map(async (file) => scanSource(await readFile17(file, "utf8"), relative12(config.root, file)))
4505
+ files.map(async (file) => scanSource(await readFile18(file, "utf8"), relative13(config.root, file)))
3839
4506
  );
3840
4507
  return findings.flat();
3841
4508
  };
@@ -4013,7 +4680,7 @@ var FRAME_SCRIPT = `(() => {
4013
4680
  })()`;
4014
4681
  var testVideo = async (origin, video, config, failures, quiet = false) => {
4015
4682
  const id = video.entry.metadata.id;
4016
- const layout = resolveEntryLayout9(video.entry);
4683
+ const layout = resolveEntryLayout11(video.entry);
4017
4684
  if (isOdoriSchema2(video.entry.metadata.schema)) {
4018
4685
  const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
4019
4686
  if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
@@ -4086,11 +4753,14 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
4086
4753
  }
4087
4754
  };
4088
4755
  var testCommand = async (id, options = {}) => {
4089
- const { config, videos } = await createContext();
4756
+ const { config, graph, videos } = await createContext();
4090
4757
  const selected = id ? videos.filter((video) => video.entry.metadata.id === id) : videos;
4091
4758
  if (selected.length === 0) throw new Error(id ? `Unknown video "${id}".` : "No videos discovered.");
4092
4759
  const failures = [];
4093
4760
  failures.push(...await checkInstalledContracts(config, selected));
4761
+ for (const finding of await checkStructure(config, graph, videos)) {
4762
+ if (finding.level === "error") failures.push({ video: finding.file, message: finding.message });
4763
+ }
4094
4764
  for (const finding of await checkDeterminism(config)) {
4095
4765
  failures.push({
4096
4766
  video: `${finding.file}:${finding.line}`,
@@ -4183,12 +4853,13 @@ var COMMAND_FLAGS = {
4183
4853
  diff: ["full"],
4184
4854
  update: ["force"],
4185
4855
  list: [],
4856
+ graph: ["json"],
4186
4857
  inspect: ["json", "input"],
4187
4858
  frame: ["at", "output", "input"],
4188
4859
  bed: ["role", "output", "target", "generate", "provider", "seconds"],
4189
4860
  narrate: ["output", "voice", "role", "provider"],
4190
4861
  test: ["json"],
4191
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
4862
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "audio-variant", "fast", "no-frame-skip", "retry"],
4192
4863
  jobs: [],
4193
4864
  help: []
4194
4865
  };
@@ -4254,6 +4925,11 @@ var USAGE = {
4254
4925
  add: `odori add <components...> [--force] [--dry-run]
4255
4926
  Install editable component source, fetched from the registry and cached.
4256
4927
  --force replaces local edits. --dry-run lists the files and writes nothing.`,
4928
+ blocks: `odori blocks <ls|show|cat|add> [id] [file] [--out <dir>] [--force] [--dry-run] [--json]
4929
+ Whole videos, published like components. ls lists them, show names the files
4930
+ and components one is built from, cat prints one of those files, and add
4931
+ installs the block and everything it imports. --out installs into another
4932
+ directory rather than the one the block was authored in.`,
4257
4933
  registry: `odori registry
4258
4934
  List available registry components and cues.`,
4259
4935
  diff: `odori diff [components] [--full]
@@ -4262,6 +4938,11 @@ var USAGE = {
4262
4938
  Apply upstream component changes.`,
4263
4939
  list: `odori list
4264
4940
  Print discovered video ids and formats.`,
4941
+ graph: `odori graph [--json]
4942
+ Compile the project into .odori/graph.json: every video with its format,
4943
+ duration, brand, tags, audio variants, and components, plus the component
4944
+ catalog, the audio library, and structure findings. --json prints the same
4945
+ document to stdout. Exits non-zero when the structure has errors.`,
4265
4946
  inspect: `odori inspect <id> [--json] [--input <json>]
4266
4947
  Show resolved layout, inputs, scenes, and assets.`,
4267
4948
  frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
@@ -4272,7 +4953,7 @@ var USAGE = {
4272
4953
  check, for CI.`,
4273
4954
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
4274
4955
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
4275
- [--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
4956
+ [--no-audio] [--audio-variant <name>] [--fast] [--no-frame-skip] [--retry <job>]
4276
4957
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
4277
4958
  or png; without it the output's extension decides, and mp4 is the default.
4278
4959
  --quality is studio, social, or web. --scale multiplies the output size,
@@ -4290,14 +4971,17 @@ var HELP = `odori - build videos like applications
4290
4971
  Usage
4291
4972
  odori dev [--port 4300] Discover project resources and start Studio
4292
4973
  odori init Add videos/ and odori.config.ts to a project
4974
+ odori docs [page|search <text>] Read the documentation offline
4293
4975
  odori doctor Check everything a render and an encode need
4294
4976
  odori install Download the pinned Chrome and FFmpeg
4295
4977
  odori new <name> Generate a video.tsx entry
4296
4978
  odori add <components...> Install editable component source
4979
+ odori blocks <ls|show|add> Browse and install whole videos
4297
4980
  odori registry List available registry components
4298
4981
  odori diff [components] Compare installed components with upstream
4299
4982
  odori update [components] Apply upstream component changes
4300
4983
  odori list Print discovered video ids and formats
4984
+ odori graph [--json] Compile the project graph and check its shape
4301
4985
  odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
4302
4986
  odori frame <id> --at 4s Render one deterministic frame to a PNG
4303
4987
  odori test [id] [--json] Validate contracts and representative frames
@@ -4362,6 +5046,8 @@ var run3 = async (argv) => {
4362
5046
  case "integrations":
4363
5047
  await integrationsCommand();
4364
5048
  return 0;
5049
+ case "docs":
5050
+ return await docsCommand(positionals, { json: flags.json === true });
4365
5051
  case "doctor":
4366
5052
  return await doctorCommand();
4367
5053
  case "install":
@@ -4372,6 +5058,14 @@ var run3 = async (argv) => {
4372
5058
  case "add":
4373
5059
  await addCommand(positionals, { force: flags.force === true, dryRun: flags["dry-run"] === true });
4374
5060
  return 0;
5061
+ case "blocks":
5062
+ await blocksCommand(positionals, {
5063
+ out: typeof flags.out === "string" ? flags.out : void 0,
5064
+ force: flags.force === true,
5065
+ dryRun: flags["dry-run"] === true,
5066
+ json: flags.json === true
5067
+ });
5068
+ return 0;
4375
5069
  case "registry":
4376
5070
  await registryCommand();
4377
5071
  return 0;
@@ -4384,6 +5078,8 @@ var run3 = async (argv) => {
4384
5078
  case "list":
4385
5079
  await listCommand();
4386
5080
  return 0;
5081
+ case "graph":
5082
+ return await graphCommand({ json: flags.json === true });
4387
5083
  case "inspect":
4388
5084
  await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
4389
5085
  return 0;
@@ -4428,6 +5124,7 @@ var run3 = async (argv) => {
4428
5124
  scale: numberFlag(flags, "scale"),
4429
5125
  format: typeof flags.format === "string" ? flags.format : void 0,
4430
5126
  audio: flags["no-audio"] === true ? false : void 0,
5127
+ audioVariant: typeof flags["audio-variant"] === "string" ? flags["audio-variant"] : void 0,
4431
5128
  fast: flags.fast === true,
4432
5129
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
4433
5130
  retry: typeof flags.retry === "string" ? flags.retry : void 0
@@ -4516,6 +5213,8 @@ export {
4516
5213
  listJobs,
4517
5214
  JobQueue,
4518
5215
  checkDeterminism,
5216
+ checkStructure,
5217
+ buildGraphArtifact,
4519
5218
  exportQueue,
4520
5219
  cancelJob,
4521
5220
  runJob,