@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.
- package/dist/{chunk-UPYHTDXP.js → chunk-AJBK4XRB.js} +128 -11
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-GV6FFZXV.js → registry-snapshot-TKH2KAC3.js} +1 -1
- package/package.json +3 -3
- package/src/cli.ts +16 -0
- package/src/commands/dev.ts +2 -0
- package/src/commands/narrate.ts +74 -0
- package/src/providers.ts +98 -2
- package/src/registry-snapshot.json +1 -1
- package/src/server.ts +10 -0
- package/studio/src/Studio.tsx +2 -4
- package/studio/src/components/GenerateBed.tsx +148 -0
- package/studio/src/components/Settings.tsx +266 -50
- package/studio/src/components/ui.tsx +11 -1
- package/studio/src/integrations.ts +29 -0
- package/studio/src/studio.css +170 -3
- package/studio/src/views/AssetsView.tsx +6 -0
- package/studio/src/virtual.d.ts +1 -0
- package/studio/src/views/IntegrationsView.tsx +0 -270
|
@@ -317,7 +317,7 @@ var toComponent = (item) => ({
|
|
|
317
317
|
});
|
|
318
318
|
var snapshotItems = async () => {
|
|
319
319
|
try {
|
|
320
|
-
const loaded = await import("./registry-snapshot-
|
|
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)
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 ${
|
|
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
package/dist/index.js
CHANGED
|
@@ -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 ?
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/src/commands/dev.ts
CHANGED
|
@@ -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)
|
|
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:
|
|
185
|
+
export const resolveKey = async (provider: {keyVariable: string}): Promise<string | undefined> =>
|
|
90
186
|
process.env[provider.keyVariable] ?? (await storedKey(provider.keyVariable));
|