@odori/cli 0.0.8 → 0.0.10

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(
@@ -870,7 +870,11 @@ var elevenlabs = {
870
870
  signal: AbortSignal.timeout(1e4)
871
871
  });
872
872
  if (response.ok) return true;
873
- if (response.status === 401 || response.status === 403) return false;
873
+ if (response.status === 401 || response.status === 403) {
874
+ const detail = await response.text().catch(() => "");
875
+ if (/missing_permissions|permission/i.test(detail)) return true;
876
+ return false;
877
+ }
874
878
  throw new Error(`ElevenLabs answered ${response.status} to a key check.`);
875
879
  },
876
880
  async generate({ prompt, seconds, apiKey }) {
@@ -893,6 +897,54 @@ Is ${elevenlabs.keyVariable} a current key?` : "")
893
897
  return { bytes: new Uint8Array(await response.arrayBuffer()), extension };
894
898
  }
895
899
  };
900
+ var elevenlabsVoice = {
901
+ name: "elevenlabs",
902
+ title: "ElevenLabs Speech",
903
+ docsUrl: "https://elevenlabs.io/docs/api-reference/text-to-speech",
904
+ keyVariable: "ELEVENLABS_API_KEY",
905
+ // Rachel, the provider's most neutral narrator. --voice overrides.
906
+ defaultVoice: "21m00Tcm4TlvDq8ikWAM",
907
+ verifyKey: (apiKey) => elevenlabs.verifyKey(apiKey),
908
+ async speak({ script, voice, apiKey }) {
909
+ const response = await fetch(
910
+ `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}/with-timestamps?output_format=mp3_44100_128`,
911
+ {
912
+ method: "POST",
913
+ headers: { "xi-api-key": apiKey, "content-type": "application/json" },
914
+ body: JSON.stringify({ text: script, model_id: "eleven_multilingual_v2" }),
915
+ signal: AbortSignal.timeout(3e5)
916
+ }
917
+ );
918
+ if (!response.ok) {
919
+ const detail = await response.text().catch(() => "");
920
+ throw new Error(
921
+ `ElevenLabs returned ${response.status} ${response.statusText}.` + (detail ? `
922
+ ${detail.slice(0, 400)}` : "") + (response.status === 401 ? `
923
+ Is ${elevenlabsVoice.keyVariable} a current key?` : "")
924
+ );
925
+ }
926
+ const payload = await response.json();
927
+ return {
928
+ bytes: Uint8Array.from(Buffer.from(payload.audio_base64, "base64")),
929
+ extension: "mp3",
930
+ alignment: {
931
+ characters: payload.alignment.characters,
932
+ startSeconds: payload.alignment.character_start_times_seconds,
933
+ endSeconds: payload.alignment.character_end_times_seconds
934
+ }
935
+ };
936
+ }
937
+ };
938
+ var voiceProviders = { elevenlabs: elevenlabsVoice };
939
+ var resolveVoiceProvider = (name) => {
940
+ const provider = voiceProviders[name];
941
+ if (!provider) {
942
+ throw new Error(
943
+ `No voice provider named "${name}". Available: ${Object.keys(voiceProviders).join(", ")}.`
944
+ );
945
+ }
946
+ return provider;
947
+ };
896
948
  var musicProviders = { elevenlabs };
897
949
  var resolveMusicProvider = (name) => {
898
950
  const provider = musicProviders[name];
@@ -2445,6 +2497,13 @@ var studioEntry = resolve18(studioRoot, "index.html");
2445
2497
  var installRoot = resolve18(cliRoot, "..", "..");
2446
2498
  var VIRTUAL_ID = "virtual:odori-project";
2447
2499
  var RESOLVED_ID = `\0${VIRTUAL_ID}`;
2500
+ var cliVersion = () => {
2501
+ try {
2502
+ return createRequire2(import.meta.url)("../package.json").version;
2503
+ } catch {
2504
+ return "dev";
2505
+ }
2506
+ };
2448
2507
  var runtimeSource = (root) => {
2449
2508
  for (const from of [resolve18(root, "package.json"), import.meta.url]) {
2450
2509
  try {
@@ -2495,6 +2554,7 @@ var odoriProjectPlugin = (config, getGraph) => ({
2495
2554
  audioDir: config.audioDir,
2496
2555
  docsUrl: config.docsUrl,
2497
2556
  audio: graph.audio,
2557
+ version: cliVersion(),
2498
2558
  sourceHash: graph.sourceHash,
2499
2559
  assets: config.assets ?? [],
2500
2560
  files: {
@@ -3561,8 +3621,50 @@ var listCommand = async () => {
3561
3621
  }
3562
3622
  };
3563
3623
 
3624
+ // src/commands/narrate.ts
3625
+ 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";
3627
+ import { wordsFromCharacters } from "odori";
3628
+ var narrateCommand = async (script, options = {}) => {
3629
+ if (!script.trim()) throw new Error('Give the script to read, for example: odori narrate "One definition. Every render."');
3630
+ const config = await loadConfig(process.cwd());
3631
+ const provider = resolveVoiceProvider(options.provider ?? "elevenlabs");
3632
+ const apiKey = await resolveKey(provider);
3633
+ if (!apiKey) {
3634
+ throw new Error(
3635
+ `Narrating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,
3636
+ or paste one once into Studio\u2019s integrations page ("odori dev", then Integrations).
3637
+ Either way it is sent only to the provider and never stored in the project.`
3638
+ );
3639
+ }
3640
+ const voice = options.voice ?? provider.defaultVoice;
3641
+ log.detail(`Recording ${script.split(/\s+/).length} words with ${provider.title}`);
3642
+ const { bytes, extension, alignment } = await provider.speak({ script, voice, apiKey });
3643
+ const words = wordsFromCharacters(alignment);
3644
+ if (words.length === 0) throw new Error("The provider returned no word timings, so captions cannot be derived. Nothing was written.");
3645
+ 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 });
3648
+ const audioFile = join9(directory2, `${stem}.${extension}`);
3649
+ await writeFile19(audioFile, bytes);
3650
+ const role = options.role ?? "voice.narration";
3651
+ const url = `/audio/${basename3(audioFile)}`;
3652
+ const narration = { script, provider: provider.name, voice, audio: role, words };
3653
+ const timingFile = join9(directory2, `${stem}.narration.json`);
3654
+ await writeFile19(timingFile, `${JSON.stringify(narration, null, 2)}
3655
+ `, "utf8");
3656
+ const registered = await registerCueInBrand(config, { name: role, url }, stem);
3657
+ const seconds = words[words.length - 1].endSeconds;
3658
+ log.success(`Recorded ${seconds.toFixed(1)}s to ${basename3(audioFile)} (${(bytes.length / 1024).toFixed(0)} KB)`);
3659
+ log.success(`Word timings in ${basename3(timingFile)}`);
3660
+ if (registered) log.detail(`Registered "${role}" in the brand`);
3661
+ log.detail("In a video:");
3662
+ log.detail(` <Audio src="${role}" />`);
3663
+ log.detail(` <Captions cues={captionCues(narration, fps)} /> // import narration from the json`);
3664
+ };
3665
+
3564
3666
  // src/commands/frame.ts
3565
- import { resolve as resolve24 } from "path";
3667
+ import { resolve as resolve25 } from "path";
3566
3668
  import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
3567
3669
  var frameCommand = async (id, options = {}) => {
3568
3670
  const at = options.at ?? 0;
@@ -3584,7 +3686,7 @@ var frameCommand = async (id, options = {}) => {
3584
3686
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
3585
3687
  }
3586
3688
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
3587
- const file = resolve24(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3689
+ const file = resolve25(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3588
3690
  return renderStill(server.url, target, frame, file, config);
3589
3691
  });
3590
3692
  log.success(`Frame ${frame} written to ${output}`);
@@ -3597,7 +3699,7 @@ import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayo
3597
3699
  // src/contracts.ts
3598
3700
  import { existsSync as existsSync21 } from "fs";
3599
3701
  import { readdir as readdir7 } from "fs/promises";
3600
- import { resolve as resolve25 } from "path";
3702
+ import { resolve as resolve26 } from "path";
3601
3703
  import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3602
3704
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
3603
3705
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
@@ -3664,7 +3766,7 @@ var checkAudioWindows = (cues, brand, videoId) => {
3664
3766
  return failures;
3665
3767
  };
3666
3768
  var checkInstalledContracts = async (config, videos) => {
3667
- const componentsDir = resolve25(config.root, config.componentsDir);
3769
+ const componentsDir = resolve26(config.root, config.componentsDir);
3668
3770
  const onDisk = existsSync21(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3669
3771
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
3670
3772
  if (names.size === 0) return [];
@@ -3688,7 +3790,7 @@ var checkInstalledContracts = async (config, videos) => {
3688
3790
  // src/determinism.ts
3689
3791
  import { readdir as readdir8, readFile as readFile17 } from "fs/promises";
3690
3792
  import { existsSync as existsSync22 } from "fs";
3691
- import { join as join9, relative as relative12, resolve as resolve26 } from "path";
3793
+ import { join as join10, relative as relative12, resolve as resolve27 } from "path";
3692
3794
  var FORBIDDEN = [
3693
3795
  {
3694
3796
  pattern: /\bMath\.random\s*\(/,
@@ -3722,14 +3824,14 @@ var scanSource = (source, file) => {
3722
3824
  var walk2 = async (directory2, files = []) => {
3723
3825
  for (const entry of await readdir8(directory2, { withFileTypes: true })) {
3724
3826
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3725
- const full = join9(directory2, entry.name);
3827
+ const full = join10(directory2, entry.name);
3726
3828
  if (entry.isDirectory()) await walk2(full, files);
3727
3829
  else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
3728
3830
  }
3729
3831
  return files;
3730
3832
  };
3731
3833
  var checkDeterminism = async (config) => {
3732
- const root = resolve26(config.root, config.videosDir);
3834
+ const root = resolve27(config.root, config.videosDir);
3733
3835
  if (!existsSync22(root)) return [];
3734
3836
  const files = await walk2(root);
3735
3837
  const findings = await Promise.all(
@@ -4084,6 +4186,7 @@ var COMMAND_FLAGS = {
4084
4186
  inspect: ["json", "input"],
4085
4187
  frame: ["at", "output", "input"],
4086
4188
  bed: ["role", "output", "target", "generate", "provider", "seconds"],
4189
+ narrate: ["output", "voice", "role", "provider"],
4087
4190
  test: ["json"],
4088
4191
  export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
4089
4192
  jobs: [],
@@ -4130,6 +4233,11 @@ var USAGE = {
4130
4233
  With --generate the positional is a prompt instead of a path: the track is
4131
4234
  generated with a provider (--provider, default elevenlabs, key from
4132
4235
  ELEVENLABS_API_KEY), then prepared identically. --seconds sets its length.`,
4236
+ narrate: `odori narrate <script> [--output <path>] [--voice <id>] [--role <cue>]
4237
+ Record the script as narration. Writes the audio and a .narration.json with
4238
+ the time every word starts and ends, and registers the cue role (default
4239
+ voice.narration) in the brand. Captions derive from the timings at compose
4240
+ time, so they cannot drift from the voice.`,
4133
4241
  integrations: `odori integrations
4134
4242
  List generation providers and whether each is connected. Configuration
4135
4243
  lives in the environment or Studio's integrations page; generation happens
@@ -4194,6 +4302,7 @@ Usage
4194
4302
  odori frame <id> --at 4s Render one deterministic frame to a PNG
4195
4303
  odori test [id] [--json] Validate contracts and representative frames
4196
4304
  odori export <id> [--output f] Render and encode a distributable file
4305
+ odori narrate <script> Record narration with word timings
4197
4306
  odori jobs List export jobs and their status
4198
4307
 
4199
4308
  Options
@@ -4212,7 +4321,7 @@ Options
4212
4321
 
4213
4322
  Run "odori <command> --help" for one command, or "odori doctor" to check setup.
4214
4323
  `;
4215
- var cliVersion = () => {
4324
+ var cliVersion2 = () => {
4216
4325
  try {
4217
4326
  const require2 = createRequire4(import.meta.url);
4218
4327
  return require2("../package.json").version;
@@ -4224,7 +4333,7 @@ var run3 = async (argv) => {
4224
4333
  const { command: command2, positionals, flags } = parseArgs(argv);
4225
4334
  try {
4226
4335
  if (command2 === "--version" || command2 === "-v" || command2 === "version") {
4227
- log.info(`odori ${cliVersion()} (node ${process.version})`);
4336
+ log.info(`odori ${cliVersion2()} (node ${process.version})`);
4228
4337
  return 0;
4229
4338
  }
4230
4339
  if (flags.help === true && USAGE[command2]) {
@@ -4289,6 +4398,14 @@ var run3 = async (argv) => {
4289
4398
  seconds: numberFlag(flags, "seconds")
4290
4399
  });
4291
4400
  return 0;
4401
+ case "narrate":
4402
+ await narrateCommand(positionals.join(" "), {
4403
+ output: typeof flags.output === "string" ? flags.output : void 0,
4404
+ voice: typeof flags.voice === "string" ? flags.voice : void 0,
4405
+ role: typeof flags.role === "string" ? flags.role : void 0,
4406
+ provider: typeof flags.provider === "string" ? flags.provider : void 0
4407
+ });
4408
+ return 0;
4292
4409
  case "frame":
4293
4410
  await frameCommand(positionals[0] ?? "", {
4294
4411
  // 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-AJBK4XRB.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-AJBK4XRB.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.10",
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.10"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "22.19.0",
@@ -36,7 +36,7 @@
36
36
  "@types/react-dom": "19.2.3",
37
37
  "tsup": "^8.5.1",
38
38
  "typescript": "5.9.3",
39
- "@odori/registry": "0.0.8"
39
+ "@odori/registry": "0.0.9"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
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
@@ -309,6 +309,8 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
309
309
  verified = null;
310
310
  }
311
311
  if (verified === false) {
312
+ // The provider's check accepts a valid key regardless of its
313
+ // scoping, so a rejection here means the key itself.
312
314
  json(response, 400, {error: `${provider.title} rejected that key.`});
313
315
  return;
314
316
  }
@@ -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
@@ -45,7 +45,20 @@ const elevenlabs: MusicProvider = {
45
45
  signal: AbortSignal.timeout(10_000),
46
46
  });
47
47
  if (response.ok) return true;
48
- if (response.status === 401 || response.status === 403) return false;
48
+ if (response.status === 401 || response.status === 403) {
49
+ /*
50
+ * A restricted key opts in to endpoints one by one, and this probe's
51
+ * endpoint is one of them: a real key scoped to something else answers
52
+ * 401 here exactly like a wrong key does. The error body tells them
53
+ * apart, an invalid key names itself invalid, a valid one names the
54
+ * permission it lacks, and a key that is real but scoped elsewhere is
55
+ * a pass. Whether it can reach the integration's own endpoint is the
56
+ * generate call's problem, which reports it properly.
57
+ */
58
+ const detail = await response.text().catch(() => "");
59
+ if (/missing_permissions|permission/i.test(detail)) return true;
60
+ return false;
61
+ }
49
62
  throw new Error(`ElevenLabs answered ${response.status} to a key check.`);
50
63
  },
51
64
  async generate({prompt, seconds, apiKey}) {
@@ -69,6 +82,89 @@ const elevenlabs: MusicProvider = {
69
82
  },
70
83
  };
71
84
 
85
+ export type GeneratedSpeech = {
86
+ bytes: Uint8Array;
87
+ extension: string;
88
+ /** Character-level timing, parallel arrays over the spoken text. */
89
+ alignment: {characters: string[]; startSeconds: number[]; endSeconds: number[]};
90
+ };
91
+
92
+ export type VoiceProvider = {
93
+ name: string;
94
+ title: string;
95
+ docsUrl: string;
96
+ keyVariable: string;
97
+ defaultVoice: string;
98
+ speak(options: {script: string; voice: string; apiKey: string}): Promise<GeneratedSpeech>;
99
+ verifyKey(apiKey: string): Promise<boolean>;
100
+ };
101
+
102
+ /*
103
+ * Speech comes back with its own word timing, in the same call as the audio.
104
+ * That is the whole design: the recording and its alignment are one artifact,
105
+ * so captions derived from it cannot drift from the voice, and nothing ever
106
+ * has to transcribe audio after the fact to find out when a word happened.
107
+ */
108
+ const elevenlabsVoice: VoiceProvider = {
109
+ name: "elevenlabs",
110
+ title: "ElevenLabs Speech",
111
+ docsUrl: "https://elevenlabs.io/docs/api-reference/text-to-speech",
112
+ keyVariable: "ELEVENLABS_API_KEY",
113
+ // Rachel, the provider's most neutral narrator. --voice overrides.
114
+ defaultVoice: "21m00Tcm4TlvDq8ikWAM",
115
+ verifyKey: (apiKey) => elevenlabs.verifyKey(apiKey),
116
+ async speak({script, voice, apiKey}) {
117
+ const response = await fetch(
118
+ `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}/with-timestamps?output_format=mp3_44100_128`,
119
+ {
120
+ method: "POST",
121
+ headers: {"xi-api-key": apiKey, "content-type": "application/json"},
122
+ body: JSON.stringify({text: script, model_id: "eleven_multilingual_v2"}),
123
+ signal: AbortSignal.timeout(300_000),
124
+ },
125
+ );
126
+ if (!response.ok) {
127
+ const detail = await response.text().catch(() => "");
128
+ throw new Error(
129
+ `ElevenLabs returned ${response.status} ${response.statusText}.` +
130
+ (detail ? `
131
+ ${detail.slice(0, 400)}` : "") +
132
+ (response.status === 401 ? `
133
+ Is ${elevenlabsVoice.keyVariable} a current key?` : ""),
134
+ );
135
+ }
136
+ const payload = (await response.json()) as {
137
+ audio_base64: string;
138
+ alignment: {
139
+ characters: string[];
140
+ character_start_times_seconds: number[];
141
+ character_end_times_seconds: number[];
142
+ };
143
+ };
144
+ return {
145
+ bytes: Uint8Array.from(Buffer.from(payload.audio_base64, "base64")),
146
+ extension: "mp3",
147
+ alignment: {
148
+ characters: payload.alignment.characters,
149
+ startSeconds: payload.alignment.character_start_times_seconds,
150
+ endSeconds: payload.alignment.character_end_times_seconds,
151
+ },
152
+ };
153
+ },
154
+ };
155
+
156
+ export const voiceProviders: Record<string, VoiceProvider> = {elevenlabs: elevenlabsVoice};
157
+
158
+ export const resolveVoiceProvider = (name: string): VoiceProvider => {
159
+ const provider = voiceProviders[name];
160
+ if (!provider) {
161
+ throw new Error(
162
+ `No voice provider named "${name}". Available: ${Object.keys(voiceProviders).join(", ")}.`,
163
+ );
164
+ }
165
+ return provider;
166
+ };
167
+
72
168
  export const musicProviders: Record<string, MusicProvider> = {elevenlabs};
73
169
 
74
170
  export const resolveMusicProvider = (name: string): MusicProvider => {
@@ -86,5 +182,5 @@ export const resolveMusicProvider = (name: string): MusicProvider => {
86
182
  * Studio integrations page writes. Both are read here and nowhere else, so
87
183
  * "where do keys come from" has one answer.
88
184
  */
89
- export const resolveKey = async (provider: MusicProvider): Promise<string | undefined> =>
185
+ export const resolveKey = async (provider: {keyVariable: string}): Promise<string | undefined> =>
90
186
  process.env[provider.keyVariable] ?? (await storedKey(provider.keyVariable));