@odori/cli 0.0.8 → 0.0.9

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-GV6FFZXV.js");
320
+ const loaded = await import("./registry-snapshot-TKH2KAC3.js");
321
321
  return loaded.default.items;
322
322
  } catch {
323
323
  throw new Error(
@@ -893,6 +893,54 @@ Is ${elevenlabs.keyVariable} a current key?` : "")
893
893
  return { bytes: new Uint8Array(await response.arrayBuffer()), extension };
894
894
  }
895
895
  };
896
+ var elevenlabsVoice = {
897
+ name: "elevenlabs",
898
+ title: "ElevenLabs Speech",
899
+ docsUrl: "https://elevenlabs.io/docs/api-reference/text-to-speech",
900
+ keyVariable: "ELEVENLABS_API_KEY",
901
+ // Rachel, the provider's most neutral narrator. --voice overrides.
902
+ defaultVoice: "21m00Tcm4TlvDq8ikWAM",
903
+ verifyKey: (apiKey) => elevenlabs.verifyKey(apiKey),
904
+ async speak({ script, voice, apiKey }) {
905
+ const response = await fetch(
906
+ `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}/with-timestamps?output_format=mp3_44100_128`,
907
+ {
908
+ method: "POST",
909
+ headers: { "xi-api-key": apiKey, "content-type": "application/json" },
910
+ body: JSON.stringify({ text: script, model_id: "eleven_multilingual_v2" }),
911
+ signal: AbortSignal.timeout(3e5)
912
+ }
913
+ );
914
+ if (!response.ok) {
915
+ const detail = await response.text().catch(() => "");
916
+ throw new Error(
917
+ `ElevenLabs returned ${response.status} ${response.statusText}.` + (detail ? `
918
+ ${detail.slice(0, 400)}` : "") + (response.status === 401 ? `
919
+ Is ${elevenlabsVoice.keyVariable} a current key?` : "")
920
+ );
921
+ }
922
+ const payload = await response.json();
923
+ return {
924
+ bytes: Uint8Array.from(Buffer.from(payload.audio_base64, "base64")),
925
+ extension: "mp3",
926
+ alignment: {
927
+ characters: payload.alignment.characters,
928
+ startSeconds: payload.alignment.character_start_times_seconds,
929
+ endSeconds: payload.alignment.character_end_times_seconds
930
+ }
931
+ };
932
+ }
933
+ };
934
+ var voiceProviders = { elevenlabs: elevenlabsVoice };
935
+ var resolveVoiceProvider = (name) => {
936
+ const provider = voiceProviders[name];
937
+ if (!provider) {
938
+ throw new Error(
939
+ `No voice provider named "${name}". Available: ${Object.keys(voiceProviders).join(", ")}.`
940
+ );
941
+ }
942
+ return provider;
943
+ };
896
944
  var musicProviders = { elevenlabs };
897
945
  var resolveMusicProvider = (name) => {
898
946
  const provider = musicProviders[name];
@@ -2445,6 +2493,13 @@ var studioEntry = resolve18(studioRoot, "index.html");
2445
2493
  var installRoot = resolve18(cliRoot, "..", "..");
2446
2494
  var VIRTUAL_ID = "virtual:odori-project";
2447
2495
  var RESOLVED_ID = `\0${VIRTUAL_ID}`;
2496
+ var cliVersion = () => {
2497
+ try {
2498
+ return createRequire2(import.meta.url)("../package.json").version;
2499
+ } catch {
2500
+ return "dev";
2501
+ }
2502
+ };
2448
2503
  var runtimeSource = (root) => {
2449
2504
  for (const from of [resolve18(root, "package.json"), import.meta.url]) {
2450
2505
  try {
@@ -2495,6 +2550,7 @@ var odoriProjectPlugin = (config, getGraph) => ({
2495
2550
  audioDir: config.audioDir,
2496
2551
  docsUrl: config.docsUrl,
2497
2552
  audio: graph.audio,
2553
+ version: cliVersion(),
2498
2554
  sourceHash: graph.sourceHash,
2499
2555
  assets: config.assets ?? [],
2500
2556
  files: {
@@ -3561,8 +3617,50 @@ var listCommand = async () => {
3561
3617
  }
3562
3618
  };
3563
3619
 
3620
+ // src/commands/narrate.ts
3621
+ import { mkdir as mkdir18, writeFile as writeFile19 } from "fs/promises";
3622
+ import { basename as basename3, dirname as dirname9, extname as extname2, join as join9, resolve as resolve24 } from "path";
3623
+ import { wordsFromCharacters } from "odori";
3624
+ var narrateCommand = async (script, options = {}) => {
3625
+ if (!script.trim()) throw new Error('Give the script to read, for example: odori narrate "One definition. Every render."');
3626
+ const config = await loadConfig(process.cwd());
3627
+ const provider = resolveVoiceProvider(options.provider ?? "elevenlabs");
3628
+ const apiKey = await resolveKey(provider);
3629
+ if (!apiKey) {
3630
+ throw new Error(
3631
+ `Narrating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,
3632
+ or paste one once into Studio\u2019s integrations page ("odori dev", then Integrations).
3633
+ Either way it is sent only to the provider and never stored in the project.`
3634
+ );
3635
+ }
3636
+ const voice = options.voice ?? provider.defaultVoice;
3637
+ log.detail(`Recording ${script.split(/\s+/).length} words with ${provider.title}`);
3638
+ const { bytes, extension, alignment } = await provider.speak({ script, voice, apiKey });
3639
+ const words = wordsFromCharacters(alignment);
3640
+ if (words.length === 0) throw new Error("The provider returned no word timings, so captions cannot be derived. Nothing was written.");
3641
+ const stem = options.output ? basename3(options.output, extname2(options.output)) : script.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "narration";
3642
+ const directory2 = options.output ? dirname9(resolve24(config.root, options.output)) : join9(config.root, "public", "audio");
3643
+ await mkdir18(directory2, { recursive: true });
3644
+ const audioFile = join9(directory2, `${stem}.${extension}`);
3645
+ await writeFile19(audioFile, bytes);
3646
+ const role = options.role ?? "voice.narration";
3647
+ const url = `/audio/${basename3(audioFile)}`;
3648
+ const narration = { script, provider: provider.name, voice, audio: role, words };
3649
+ const timingFile = join9(directory2, `${stem}.narration.json`);
3650
+ await writeFile19(timingFile, `${JSON.stringify(narration, null, 2)}
3651
+ `, "utf8");
3652
+ const registered = await registerCueInBrand(config, { name: role, url }, stem);
3653
+ const seconds = words[words.length - 1].endSeconds;
3654
+ log.success(`Recorded ${seconds.toFixed(1)}s to ${basename3(audioFile)} (${(bytes.length / 1024).toFixed(0)} KB)`);
3655
+ log.success(`Word timings in ${basename3(timingFile)}`);
3656
+ if (registered) log.detail(`Registered "${role}" in the brand`);
3657
+ log.detail("In a video:");
3658
+ log.detail(` <Audio src="${role}" />`);
3659
+ log.detail(` <Captions cues={captionCues(narration, fps)} /> // import narration from the json`);
3660
+ };
3661
+
3564
3662
  // src/commands/frame.ts
3565
- import { resolve as resolve24 } from "path";
3663
+ import { resolve as resolve25 } from "path";
3566
3664
  import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
3567
3665
  var frameCommand = async (id, options = {}) => {
3568
3666
  const at = options.at ?? 0;
@@ -3584,7 +3682,7 @@ var frameCommand = async (id, options = {}) => {
3584
3682
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
3585
3683
  }
3586
3684
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
3587
- const file = resolve24(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3685
+ const file = resolve25(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3588
3686
  return renderStill(server.url, target, frame, file, config);
3589
3687
  });
3590
3688
  log.success(`Frame ${frame} written to ${output}`);
@@ -3597,7 +3695,7 @@ import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayo
3597
3695
  // src/contracts.ts
3598
3696
  import { existsSync as existsSync21 } from "fs";
3599
3697
  import { readdir as readdir7 } from "fs/promises";
3600
- import { resolve as resolve25 } from "path";
3698
+ import { resolve as resolve26 } from "path";
3601
3699
  import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3602
3700
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
3603
3701
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
@@ -3664,7 +3762,7 @@ var checkAudioWindows = (cues, brand, videoId) => {
3664
3762
  return failures;
3665
3763
  };
3666
3764
  var checkInstalledContracts = async (config, videos) => {
3667
- const componentsDir = resolve25(config.root, config.componentsDir);
3765
+ const componentsDir = resolve26(config.root, config.componentsDir);
3668
3766
  const onDisk = existsSync21(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3669
3767
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
3670
3768
  if (names.size === 0) return [];
@@ -3688,7 +3786,7 @@ var checkInstalledContracts = async (config, videos) => {
3688
3786
  // src/determinism.ts
3689
3787
  import { readdir as readdir8, readFile as readFile17 } from "fs/promises";
3690
3788
  import { existsSync as existsSync22 } from "fs";
3691
- import { join as join9, relative as relative12, resolve as resolve26 } from "path";
3789
+ import { join as join10, relative as relative12, resolve as resolve27 } from "path";
3692
3790
  var FORBIDDEN = [
3693
3791
  {
3694
3792
  pattern: /\bMath\.random\s*\(/,
@@ -3722,14 +3820,14 @@ var scanSource = (source, file) => {
3722
3820
  var walk2 = async (directory2, files = []) => {
3723
3821
  for (const entry of await readdir8(directory2, { withFileTypes: true })) {
3724
3822
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3725
- const full = join9(directory2, entry.name);
3823
+ const full = join10(directory2, entry.name);
3726
3824
  if (entry.isDirectory()) await walk2(full, files);
3727
3825
  else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
3728
3826
  }
3729
3827
  return files;
3730
3828
  };
3731
3829
  var checkDeterminism = async (config) => {
3732
- const root = resolve26(config.root, config.videosDir);
3830
+ const root = resolve27(config.root, config.videosDir);
3733
3831
  if (!existsSync22(root)) return [];
3734
3832
  const files = await walk2(root);
3735
3833
  const findings = await Promise.all(
@@ -4084,6 +4182,7 @@ var COMMAND_FLAGS = {
4084
4182
  inspect: ["json", "input"],
4085
4183
  frame: ["at", "output", "input"],
4086
4184
  bed: ["role", "output", "target", "generate", "provider", "seconds"],
4185
+ narrate: ["output", "voice", "role", "provider"],
4087
4186
  test: ["json"],
4088
4187
  export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
4089
4188
  jobs: [],
@@ -4130,6 +4229,11 @@ var USAGE = {
4130
4229
  With --generate the positional is a prompt instead of a path: the track is
4131
4230
  generated with a provider (--provider, default elevenlabs, key from
4132
4231
  ELEVENLABS_API_KEY), then prepared identically. --seconds sets its length.`,
4232
+ narrate: `odori narrate <script> [--output <path>] [--voice <id>] [--role <cue>]
4233
+ Record the script as narration. Writes the audio and a .narration.json with
4234
+ the time every word starts and ends, and registers the cue role (default
4235
+ voice.narration) in the brand. Captions derive from the timings at compose
4236
+ time, so they cannot drift from the voice.`,
4133
4237
  integrations: `odori integrations
4134
4238
  List generation providers and whether each is connected. Configuration
4135
4239
  lives in the environment or Studio's integrations page; generation happens
@@ -4194,6 +4298,7 @@ Usage
4194
4298
  odori frame <id> --at 4s Render one deterministic frame to a PNG
4195
4299
  odori test [id] [--json] Validate contracts and representative frames
4196
4300
  odori export <id> [--output f] Render and encode a distributable file
4301
+ odori narrate <script> Record narration with word timings
4197
4302
  odori jobs List export jobs and their status
4198
4303
 
4199
4304
  Options
@@ -4212,7 +4317,7 @@ Options
4212
4317
 
4213
4318
  Run "odori <command> --help" for one command, or "odori doctor" to check setup.
4214
4319
  `;
4215
- var cliVersion = () => {
4320
+ var cliVersion2 = () => {
4216
4321
  try {
4217
4322
  const require2 = createRequire4(import.meta.url);
4218
4323
  return require2("../package.json").version;
@@ -4224,7 +4329,7 @@ var run3 = async (argv) => {
4224
4329
  const { command: command2, positionals, flags } = parseArgs(argv);
4225
4330
  try {
4226
4331
  if (command2 === "--version" || command2 === "-v" || command2 === "version") {
4227
- log.info(`odori ${cliVersion()} (node ${process.version})`);
4332
+ log.info(`odori ${cliVersion2()} (node ${process.version})`);
4228
4333
  return 0;
4229
4334
  }
4230
4335
  if (flags.help === true && USAGE[command2]) {
@@ -4289,6 +4394,14 @@ var run3 = async (argv) => {
4289
4394
  seconds: numberFlag(flags, "seconds")
4290
4395
  });
4291
4396
  return 0;
4397
+ case "narrate":
4398
+ await narrateCommand(positionals.join(" "), {
4399
+ output: typeof flags.output === "string" ? flags.output : void 0,
4400
+ voice: typeof flags.voice === "string" ? flags.voice : void 0,
4401
+ role: typeof flags.role === "string" ? flags.role : void 0,
4402
+ provider: typeof flags.provider === "string" ? flags.provider : void 0
4403
+ });
4404
+ return 0;
4292
4405
  case "frame":
4293
4406
  await frameCommand(positionals[0] ?? "", {
4294
4407
  // A duration, so "4s" and "120f" both work; a bare number is
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  checkFlags,
3
3
  parseArgs,
4
4
  run
5
- } from "./chunk-UPYHTDXP.js";
5
+ } from "./chunk-TDM65HRW.js";
6
6
  export {
7
7
  checkFlags,
8
8
  parseArgs,
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  withServer,
76
76
  writeGenerated,
77
77
  writePrepareCache
78
- } from "./chunk-UPYHTDXP.js";
78
+ } from "./chunk-TDM65HRW.js";
79
79
  export {
80
80
  CHROME_BUILD,
81
81
  FORMATS,
@@ -4267,7 +4267,7 @@ export default defineComponentPreview({
4267
4267
  files: [
4268
4268
  {
4269
4269
  path: "components/statement/statement.tsx",
4270
- content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type StatementChip = {\n /** The chip\'s text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: "neutral" | "dark" | "accent";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: "characters" | "words" | "none";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: "rise" | "cut";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: "left" | "center";\n /** Light page or dark page. Chips and text follow it. */\n theme?: "light" | "dark";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== "string";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = "words",\n motion = "rise",\n stagger,\n align = "left",\n theme = "light",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === "dark";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === "characters" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === "characters" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === "none") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === "cut") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === "cut"\n ? {display: "inline-block", opacity: progress}\n : {\n display: "inline-block",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip["tone"]) => {\n if (tone === "accent") return {background: brand.colors.accent, color: paper, border: "transparent"};\n if (tone === "dark") return {background: ink, color: paper, border: "transparent"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: "center",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: "inline-flex",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: "nowrap",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === "center" ? "center" : "flex-start",\n background: dark ? ink : "transparent",\n justifyContent: "center",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: "center",\n color: ink,\n columnGap: 16 * scale,\n display: "flex",\n flexWrap: "wrap",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === "center" ? "center" : "flex-start",\n letterSpacing: "-0.03em",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === "cut" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === "characters") {\n return (\n <span key={`${part}-${index}`} style={{display: "inline-block", whiteSpace: "pre"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: "inline-block", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === "cut"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: "inline-grid"}}>\n <span\n style={{\n gridArea: "1 / 1",\n opacity: 1 - swapped,\n transform: motion === "cut" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: "1 / 1",\n opacity: swapped,\n transform: motion === "cut" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
4270
+ content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type StatementChip = {\n /** The chip\'s text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: "neutral" | "dark" | "accent";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: "characters" | "words" | "none";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: "rise" | "cut";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: "left" | "center";\n /** Light page or dark page. Chips and text follow it. */\n theme?: "light" | "dark";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== "string";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = "words",\n motion = "rise",\n stagger,\n align = "left",\n theme = "light",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === "dark";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === "characters" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === "characters" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === "none") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === "cut") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === "cut"\n ? {display: "inline-block", opacity: progress}\n : {\n display: "inline-block",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip["tone"]) => {\n if (tone === "accent") return {background: brand.colors.accent, color: paper, border: "transparent"};\n if (tone === "dark") return {background: ink, color: paper, border: "transparent"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: "center",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: "inline-flex",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: "nowrap",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === "center" ? "center" : "flex-start",\n // paper, not ink. ink is the type colour, and painting the ground\n // with it put light text on a light ground: the dark theme rendered\n // the sentence invisible, including in this component\'s own fixture.\n background: dark ? paper : "transparent",\n justifyContent: "center",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: "center",\n color: ink,\n columnGap: 16 * scale,\n display: "flex",\n flexWrap: "wrap",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === "center" ? "center" : "flex-start",\n letterSpacing: "-0.03em",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === "cut" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === "characters") {\n return (\n <span key={`${part}-${index}`} style={{display: "inline-block", whiteSpace: "pre"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: "inline-block", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === "cut"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: "inline-grid"}}>\n <span\n style={{\n gridArea: "1 / 1",\n opacity: 1 - swapped,\n transform: motion === "cut" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: "1 / 1",\n opacity: swapped,\n transform: motion === "cut" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
4271
4271
  target: "videos/components/statement/statement.tsx"
4272
4272
  },
4273
4273
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,7 @@
28
28
  "playwright-core": "1.55.0",
29
29
  "tsx": "4.20.5",
30
30
  "vite": "7.3.0",
31
- "odori": "0.0.8"
31
+ "odori": "0.0.9"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "22.19.0",
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import {inspectCommand} from "./commands/inspect";
12
12
  import {listCommand} from "./commands/list";
13
13
  import {newCommand} from "./commands/new";
14
14
  import {bedCommand} from "./commands/bed";
15
+ import {narrateCommand} from "./commands/narrate";
15
16
  import {frameCommand} from "./commands/frame";
16
17
  import {testCommand} from "./commands/test";
17
18
 
@@ -102,6 +103,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
102
103
  inspect: ["json", "input"],
103
104
  frame: ["at", "output", "input"],
104
105
  bed: ["role", "output", "target", "generate", "provider", "seconds"],
106
+ narrate: ["output", "voice", "role", "provider"],
105
107
  test: ["json"],
106
108
  export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
107
109
  jobs: [],
@@ -161,6 +163,11 @@ const USAGE: Record<string, string> = {
161
163
  With --generate the positional is a prompt instead of a path: the track is
162
164
  generated with a provider (--provider, default elevenlabs, key from
163
165
  ELEVENLABS_API_KEY), then prepared identically. --seconds sets its length.`,
166
+ narrate: `odori narrate <script> [--output <path>] [--voice <id>] [--role <cue>]
167
+ Record the script as narration. Writes the audio and a .narration.json with
168
+ the time every word starts and ends, and registers the cue role (default
169
+ voice.narration) in the brand. Captions derive from the timings at compose
170
+ time, so they cannot drift from the voice.`,
164
171
  integrations: `odori integrations
165
172
  List generation providers and whether each is connected. Configuration
166
173
  lives in the environment or Studio's integrations page; generation happens
@@ -226,6 +233,7 @@ Usage
226
233
  odori frame <id> --at 4s Render one deterministic frame to a PNG
227
234
  odori test [id] [--json] Validate contracts and representative frames
228
235
  odori export <id> [--output f] Render and encode a distributable file
236
+ odori narrate <script> Record narration with word timings
229
237
  odori jobs List export jobs and their status
230
238
 
231
239
  Options
@@ -327,6 +335,14 @@ export const run = async (argv: string[]): Promise<number> => {
327
335
  seconds: numberFlag(flags, "seconds"),
328
336
  });
329
337
  return 0;
338
+ case "narrate":
339
+ await narrateCommand(positionals.join(" "), {
340
+ output: typeof flags.output === "string" ? flags.output : undefined,
341
+ voice: typeof flags.voice === "string" ? flags.voice : undefined,
342
+ role: typeof flags.role === "string" ? flags.role : undefined,
343
+ provider: typeof flags.provider === "string" ? flags.provider : undefined,
344
+ });
345
+ return 0;
330
346
  case "frame":
331
347
  await frameCommand(positionals[0] ?? "", {
332
348
  // A duration, so "4s" and "120f" both work; a bare number is
@@ -0,0 +1,74 @@
1
+ import {mkdir, writeFile} from "node:fs/promises";
2
+ import {basename, dirname, extname, join, resolve} from "node:path";
3
+ import {wordsFromCharacters, type Narration} from "odori";
4
+ import {registerCueInBrand} from "../brand-file";
5
+ import {loadConfig} from "../config";
6
+ import {log} from "../log";
7
+ import {resolveKey, resolveVoiceProvider} from "../providers";
8
+
9
+ export type NarrateOptions = {
10
+ output?: string;
11
+ voice?: string;
12
+ provider?: string;
13
+ /** The brand cue role the audio registers under. */
14
+ role?: string;
15
+ };
16
+
17
+ /**
18
+ * Record a narration and keep it as source.
19
+ *
20
+ * One call produces the audio and the time every word starts and ends, and
21
+ * both land in the project: the file under public/audio, the timings beside
22
+ * it as `<name>.narration.json`. Captions derive from the timings at compose
23
+ * time, so they cannot drift from the voice, and a scene can be sized to a
24
+ * sentence because the sentence's end is data.
25
+ *
26
+ * Like every generation in Odori this is an authoring step. The provider is
27
+ * called once, here, and the render never knows a network was involved.
28
+ */
29
+ export const narrateCommand = async (script: string, options: NarrateOptions = {}) => {
30
+ if (!script.trim()) throw new Error('Give the script to read, for example: odori narrate "One definition. Every render."');
31
+ const config = await loadConfig(process.cwd());
32
+ const provider = resolveVoiceProvider(options.provider ?? "elevenlabs");
33
+ const apiKey = await resolveKey(provider);
34
+ if (!apiKey) {
35
+ throw new Error(
36
+ `Narrating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,\n` +
37
+ 'or paste one once into Studio’s integrations page ("odori dev", then Integrations).\n' +
38
+ "Either way it is sent only to the provider and never stored in the project.",
39
+ );
40
+ }
41
+
42
+ const voice = options.voice ?? provider.defaultVoice;
43
+ log.detail(`Recording ${script.split(/\s+/).length} words with ${provider.title}`);
44
+ const {bytes, extension, alignment} = await provider.speak({script, voice, apiKey});
45
+ const words = wordsFromCharacters(alignment);
46
+ if (words.length === 0) throw new Error("The provider returned no word timings, so captions cannot be derived. Nothing was written.");
47
+
48
+ const stem = options.output
49
+ ? basename(options.output, extname(options.output))
50
+ : script.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "narration";
51
+ const directory = options.output
52
+ ? dirname(resolve(config.root, options.output))
53
+ : join(config.root, "public", "audio");
54
+ await mkdir(directory, {recursive: true});
55
+
56
+ const audioFile = join(directory, `${stem}.${extension}`);
57
+ await writeFile(audioFile, bytes);
58
+
59
+ const role = options.role ?? "voice.narration";
60
+ const url = `/audio/${basename(audioFile)}`;
61
+ const narration: Narration = {script, provider: provider.name, voice, audio: role, words};
62
+ const timingFile = join(directory, `${stem}.narration.json`);
63
+ await writeFile(timingFile, `${JSON.stringify(narration, null, 2)}\n`, "utf8");
64
+
65
+ const registered = await registerCueInBrand(config, {name: role, url}, stem);
66
+ const seconds = words[words.length - 1].endSeconds;
67
+
68
+ log.success(`Recorded ${seconds.toFixed(1)}s to ${basename(audioFile)} (${(bytes.length / 1024).toFixed(0)} KB)`);
69
+ log.success(`Word timings in ${basename(timingFile)}`);
70
+ if (registered) log.detail(`Registered "${role}" in the brand`);
71
+ log.detail("In a video:");
72
+ log.detail(` <Audio src="${role}" />`);
73
+ log.detail(` <Captions cues={captionCues(narration, fps)} /> // import narration from the json`);
74
+ };
package/src/providers.ts CHANGED
@@ -69,6 +69,89 @@ const elevenlabs: MusicProvider = {
69
69
  },
70
70
  };
71
71
 
72
+ export type GeneratedSpeech = {
73
+ bytes: Uint8Array;
74
+ extension: string;
75
+ /** Character-level timing, parallel arrays over the spoken text. */
76
+ alignment: {characters: string[]; startSeconds: number[]; endSeconds: number[]};
77
+ };
78
+
79
+ export type VoiceProvider = {
80
+ name: string;
81
+ title: string;
82
+ docsUrl: string;
83
+ keyVariable: string;
84
+ defaultVoice: string;
85
+ speak(options: {script: string; voice: string; apiKey: string}): Promise<GeneratedSpeech>;
86
+ verifyKey(apiKey: string): Promise<boolean>;
87
+ };
88
+
89
+ /*
90
+ * Speech comes back with its own word timing, in the same call as the audio.
91
+ * That is the whole design: the recording and its alignment are one artifact,
92
+ * so captions derived from it cannot drift from the voice, and nothing ever
93
+ * has to transcribe audio after the fact to find out when a word happened.
94
+ */
95
+ const elevenlabsVoice: VoiceProvider = {
96
+ name: "elevenlabs",
97
+ title: "ElevenLabs Speech",
98
+ docsUrl: "https://elevenlabs.io/docs/api-reference/text-to-speech",
99
+ keyVariable: "ELEVENLABS_API_KEY",
100
+ // Rachel, the provider's most neutral narrator. --voice overrides.
101
+ defaultVoice: "21m00Tcm4TlvDq8ikWAM",
102
+ verifyKey: (apiKey) => elevenlabs.verifyKey(apiKey),
103
+ async speak({script, voice, apiKey}) {
104
+ const response = await fetch(
105
+ `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}/with-timestamps?output_format=mp3_44100_128`,
106
+ {
107
+ method: "POST",
108
+ headers: {"xi-api-key": apiKey, "content-type": "application/json"},
109
+ body: JSON.stringify({text: script, model_id: "eleven_multilingual_v2"}),
110
+ signal: AbortSignal.timeout(300_000),
111
+ },
112
+ );
113
+ if (!response.ok) {
114
+ const detail = await response.text().catch(() => "");
115
+ throw new Error(
116
+ `ElevenLabs returned ${response.status} ${response.statusText}.` +
117
+ (detail ? `
118
+ ${detail.slice(0, 400)}` : "") +
119
+ (response.status === 401 ? `
120
+ Is ${elevenlabsVoice.keyVariable} a current key?` : ""),
121
+ );
122
+ }
123
+ const payload = (await response.json()) as {
124
+ audio_base64: string;
125
+ alignment: {
126
+ characters: string[];
127
+ character_start_times_seconds: number[];
128
+ character_end_times_seconds: number[];
129
+ };
130
+ };
131
+ return {
132
+ bytes: Uint8Array.from(Buffer.from(payload.audio_base64, "base64")),
133
+ extension: "mp3",
134
+ alignment: {
135
+ characters: payload.alignment.characters,
136
+ startSeconds: payload.alignment.character_start_times_seconds,
137
+ endSeconds: payload.alignment.character_end_times_seconds,
138
+ },
139
+ };
140
+ },
141
+ };
142
+
143
+ export const voiceProviders: Record<string, VoiceProvider> = {elevenlabs: elevenlabsVoice};
144
+
145
+ export const resolveVoiceProvider = (name: string): VoiceProvider => {
146
+ const provider = voiceProviders[name];
147
+ if (!provider) {
148
+ throw new Error(
149
+ `No voice provider named "${name}". Available: ${Object.keys(voiceProviders).join(", ")}.`,
150
+ );
151
+ }
152
+ return provider;
153
+ };
154
+
72
155
  export const musicProviders: Record<string, MusicProvider> = {elevenlabs};
73
156
 
74
157
  export const resolveMusicProvider = (name: string): MusicProvider => {
@@ -86,5 +169,5 @@ export const resolveMusicProvider = (name: string): MusicProvider => {
86
169
  * Studio integrations page writes. Both are read here and nowhere else, so
87
170
  * "where do keys come from" has one answer.
88
171
  */
89
- export const resolveKey = async (provider: MusicProvider): Promise<string | undefined> =>
172
+ export const resolveKey = async (provider: {keyVariable: string}): Promise<string | undefined> =>
90
173
  process.env[provider.keyVariable] ?? (await storedKey(provider.keyVariable));
@@ -4035,7 +4035,7 @@
4035
4035
  "files": [
4036
4036
  {
4037
4037
  "path": "components/statement/statement.tsx",
4038
- "content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n background: dark ? ink : \"transparent\",\n justifyContent: \"center\",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n color: ink,\n columnGap: 16 * scale,\n display: \"flex\",\n flexWrap: \"wrap\",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === \"center\" ? \"center\" : \"flex-start\",\n letterSpacing: \"-0.03em\",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === \"cut\" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === \"characters\") {\n return (\n <span key={`${part}-${index}`} style={{display: \"inline-block\", whiteSpace: \"pre\"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: \"inline-block\", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === \"cut\"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: \"inline-grid\"}}>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: 1 - swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
4038
+ "content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n // paper, not ink. ink is the type colour, and painting the ground\n // with it put light text on a light ground: the dark theme rendered\n // the sentence invisible, including in this component's own fixture.\n background: dark ? paper : \"transparent\",\n justifyContent: \"center\",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n color: ink,\n columnGap: 16 * scale,\n display: \"flex\",\n flexWrap: \"wrap\",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === \"center\" ? \"center\" : \"flex-start\",\n letterSpacing: \"-0.03em\",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === \"cut\" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === \"characters\") {\n return (\n <span key={`${part}-${index}`} style={{display: \"inline-block\", whiteSpace: \"pre\"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: \"inline-block\", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === \"cut\"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: \"inline-grid\"}}>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: 1 - swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
4039
4039
  "target": "videos/components/statement/statement.tsx"
4040
4040
  },
4041
4041
  {
package/src/server.ts CHANGED
@@ -42,6 +42,15 @@ const RESOLVED_ID = `\0${VIRTUAL_ID}`;
42
42
  * the aliases below are simply not installed — Vite then resolves `odori`
43
43
  * through its own exports map, which is what should happen.
44
44
  */
45
+ /** The CLI's own published version, for the status bar. */
46
+ const cliVersion = (): string => {
47
+ try {
48
+ return (createRequire(import.meta.url)("../package.json") as {version: string}).version;
49
+ } catch {
50
+ return "dev";
51
+ }
52
+ };
53
+
45
54
  const runtimeSource = (root: string): string | null => {
46
55
  for (const from of [resolve(root, "package.json"), import.meta.url]) {
47
56
  try {
@@ -99,6 +108,7 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
99
108
  audioDir: config.audioDir,
100
109
  docsUrl: config.docsUrl,
101
110
  audio: graph.audio,
111
+ version: cliVersion(),
102
112
  sourceHash: graph.sourceHash,
103
113
  assets: config.assets ?? [],
104
114
  files: {