@odori/cli 0.0.7 → 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.
- package/dist/{chunk-STVORYOF.js → chunk-TDM65HRW.js} +1575 -1074
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +52 -4
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-ADKSTGDR.js → registry-snapshot-TKH2KAC3.js} +362 -25
- package/package.json +3 -3
- package/src/audio-mix.ts +6 -1
- package/src/cli.ts +53 -4
- package/src/commands/bed.ts +224 -0
- package/src/commands/dev.ts +80 -0
- package/src/commands/doctor.ts +20 -0
- package/src/commands/exportVideo.ts +15 -0
- package/src/commands/integrations.ts +26 -0
- package/src/commands/narrate.ts +74 -0
- package/src/commands/test.ts +55 -16
- package/src/config.ts +5 -0
- package/src/jobs.ts +1 -1
- package/src/keystore.ts +76 -0
- package/src/providers.ts +173 -0
- package/src/registry-snapshot.json +362 -25
- package/src/render.ts +69 -22
- package/src/server.ts +10 -0
- package/studio/src/Studio.tsx +1 -1
- 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 +276 -3
- package/studio/src/views/AssetsView.tsx +6 -0
- package/studio/src/virtual.d.ts +1 -0
|
@@ -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/commands/test.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {ResolvedConfig} from "../config";
|
|
|
9
9
|
|
|
10
10
|
type Failure = {video: string; message: string};
|
|
11
11
|
|
|
12
|
-
type FrameReport = {overflow: string[]; small: string[]; empty: boolean; painted: number};
|
|
12
|
+
type FrameReport = {overflow: string[]; small: string[]; empty: boolean; painted: number; faded: number};
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* A fingerprint of every canvas on the frame.
|
|
@@ -92,12 +92,13 @@ type CanvasReport = Array<{index: number; hash: string; blank: boolean; gl?: boo
|
|
|
92
92
|
*/
|
|
93
93
|
const FRAME_SCRIPT = `(() => {
|
|
94
94
|
var root = document.querySelector("[data-odori-video]");
|
|
95
|
-
if (!root) return {overflow: [], small: [], empty: true, painted: 0};
|
|
95
|
+
if (!root) return {overflow: [], small: [], empty: true, painted: 0, faded: 0};
|
|
96
96
|
|
|
97
97
|
var bounds = root.getBoundingClientRect();
|
|
98
98
|
var overflow = [];
|
|
99
99
|
var small = [];
|
|
100
100
|
var painted = 0;
|
|
101
|
+
var faded = 0;
|
|
101
102
|
var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
|
|
102
103
|
|
|
103
104
|
for (var index = 0; index < nodes.length; index += 1) {
|
|
@@ -110,7 +111,22 @@ const FRAME_SCRIPT = `(() => {
|
|
|
110
111
|
var box = node.getBoundingClientRect();
|
|
111
112
|
if (box.width === 0 || box.height === 0) continue;
|
|
112
113
|
var style = getComputedStyle(node);
|
|
113
|
-
if (style.visibility === "hidden"
|
|
114
|
+
if (style.visibility === "hidden") continue;
|
|
115
|
+
/* Opacity inherits down the tree in effect even though it does not
|
|
116
|
+
inherit as a property: a parent faded to nothing takes its children
|
|
117
|
+
with it, while each child still computes its own opacity as 1. Reading
|
|
118
|
+
one node's value therefore judges text nobody can see. A scene that has
|
|
119
|
+
faded out is the common case, and it was reporting its hidden dialogue
|
|
120
|
+
as unreadable. */
|
|
121
|
+
var effective = 1;
|
|
122
|
+
for (var up = node; up && up !== root.parentElement; up = up.parentElement) {
|
|
123
|
+
effective *= Number(getComputedStyle(up).opacity);
|
|
124
|
+
if (effective < 0.02) break;
|
|
125
|
+
}
|
|
126
|
+
if (effective < 0.02) {
|
|
127
|
+
faded += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
114
130
|
painted += 1;
|
|
115
131
|
|
|
116
132
|
var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
|
|
@@ -135,12 +151,27 @@ const FRAME_SCRIPT = `(() => {
|
|
|
135
151
|
* content the video is actually about, which is what stays checked.
|
|
136
152
|
*/
|
|
137
153
|
var chrome = node.closest("[data-odori-chrome]") !== null;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
154
|
+
|
|
155
|
+
/*
|
|
156
|
+
* Crossing the frame edge is not a fault. Film bleeds: a surface runs past
|
|
157
|
+
* the corner, a push-in takes a headline wider than the shot, a full-frame
|
|
158
|
+
* image is cropped rather than letterboxed. The old rule flagged any box
|
|
159
|
+
* that crossed by a pixel, which is a rule about a document, not about a
|
|
160
|
+
* cut, and the only reason the recreations passed it is that five sampled
|
|
161
|
+
* frames happened to miss their own bleeds.
|
|
162
|
+
*
|
|
163
|
+
* What is worth reporting is content with no intersection at all: nothing
|
|
164
|
+
* of it is on screen at the frame that was sampled, which is what a layout
|
|
165
|
+
* mistake looks like. Deliberate overscan does that too, and says so with
|
|
166
|
+
* data-odori-bleed.
|
|
167
|
+
*/
|
|
168
|
+
var bleed = node.closest("[data-odori-bleed]") !== null;
|
|
169
|
+
var offCanvas =
|
|
170
|
+
box.right <= bounds.left ||
|
|
171
|
+
box.left >= bounds.right ||
|
|
172
|
+
box.bottom <= bounds.top ||
|
|
173
|
+
box.top >= bounds.bottom;
|
|
174
|
+
if (offCanvas && !bleed) {
|
|
144
175
|
if (overflow.indexOf(label) < 0) overflow.push(label);
|
|
145
176
|
}
|
|
146
177
|
|
|
@@ -164,7 +195,7 @@ const FRAME_SCRIPT = `(() => {
|
|
|
164
195
|
}
|
|
165
196
|
}
|
|
166
197
|
|
|
167
|
-
return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted};
|
|
198
|
+
return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted, faded: faded};
|
|
168
199
|
})()`;
|
|
169
200
|
|
|
170
201
|
/**
|
|
@@ -186,7 +217,7 @@ const testVideo = async (
|
|
|
186
217
|
if (!result.success) failures.push({video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}`});
|
|
187
218
|
}
|
|
188
219
|
|
|
189
|
-
const {browser, page} = await openRenderPage(origin, targetFor(video), config);
|
|
220
|
+
const {browser, page, errors} = await openRenderPage(origin, targetFor(video), config);
|
|
190
221
|
try {
|
|
191
222
|
const timeline = await readTimeline(page);
|
|
192
223
|
// The track is compiled by the same pass the player and the encoder read,
|
|
@@ -206,16 +237,24 @@ const testVideo = async (
|
|
|
206
237
|
|
|
207
238
|
const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor((total * 3) / 4), total - 1];
|
|
208
239
|
for (const frame of [...new Set(samples)]) {
|
|
209
|
-
await seekTo(page, frame);
|
|
240
|
+
await seekTo(page, frame, errors);
|
|
210
241
|
const result = (await page.evaluate(FRAME_SCRIPT)) as FrameReport;
|
|
211
242
|
if (result.empty) failures.push({video: id, message: `Frame ${frame} rendered no video root.`});
|
|
212
|
-
|
|
243
|
+
/*
|
|
244
|
+
* A scene's fade envelope drives its first and last frame to zero
|
|
245
|
+
* opacity, so those two frames are blank on purpose in every video that
|
|
246
|
+
* does not opt out with transition="cut". What separates that from the
|
|
247
|
+
* failure worth reporting is whether anything was there to fade: a frame
|
|
248
|
+
* holding a faded-out tree drew its picture and dimmed it, while a frame
|
|
249
|
+
* holding nothing at all never built one.
|
|
250
|
+
*/
|
|
251
|
+
if (!result.empty && result.painted < 2 && result.faded < 2) {
|
|
213
252
|
failures.push({video: id, message: `Frame ${frame} is blank.`});
|
|
214
253
|
}
|
|
215
254
|
for (const item of result.overflow) {
|
|
216
255
|
failures.push({
|
|
217
256
|
video: id,
|
|
218
|
-
message: `Frame ${frame}: ${item}
|
|
257
|
+
message: `Frame ${frame}: ${item} is entirely outside the ${layout.format.width}x${layout.format.height} canvas.`,
|
|
219
258
|
});
|
|
220
259
|
}
|
|
221
260
|
for (const item of result.small) {
|
|
@@ -230,8 +269,8 @@ const testVideo = async (
|
|
|
230
269
|
if (canvases.length > 0) {
|
|
231
270
|
// Seek away and back, so the second read is a real re-render rather
|
|
232
271
|
// than a second look at the same paint.
|
|
233
|
-
await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
|
|
234
|
-
await seekTo(page, frame);
|
|
272
|
+
await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1, errors);
|
|
273
|
+
await seekTo(page, frame, errors);
|
|
235
274
|
const again = (await page.evaluate(CANVAS_SCRIPT)) as CanvasReport;
|
|
236
275
|
|
|
237
276
|
for (const canvas of canvases) {
|
package/src/config.ts
CHANGED
|
@@ -44,6 +44,11 @@ export type OdoriConfig = {
|
|
|
44
44
|
skipUnchangedFrames?: boolean;
|
|
45
45
|
/** Reuse encoded chunks whose frames still look identical. Defaults to true. */
|
|
46
46
|
cacheChunks?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Default providers for `--generate`, by kind. The choice is committed like
|
|
49
|
+
* every other decision; the keys never are.
|
|
50
|
+
*/
|
|
51
|
+
generation?: {music?: string};
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
export const defaultConfig: OdoriConfig = {
|
package/src/jobs.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {ExportJob, RenderManifest} from "odori";
|
|
|
5
5
|
import type {ResolvedConfig} from "./config";
|
|
6
6
|
|
|
7
7
|
/** How a job should be encoded, frozen with it so a retry cannot drift. */
|
|
8
|
-
export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string; audio?: boolean};
|
|
8
|
+
export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string; audio?: boolean; graphics?: string};
|
|
9
9
|
|
|
10
10
|
export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string; render?: JobRender};
|
|
11
11
|
|
package/src/keystore.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {existsSync} from "node:fs";
|
|
2
|
+
import {chmod, mkdir, readFile, rm, writeFile} from "node:fs/promises";
|
|
3
|
+
import {homedir} from "node:os";
|
|
4
|
+
import {dirname, join} from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provider keys, stored per machine.
|
|
8
|
+
*
|
|
9
|
+
* A key is personal, not project property: it belongs next to the login in
|
|
10
|
+
* `~/.config`, never inside a repository where one .gitignore mistake ships
|
|
11
|
+
* it. The environment always wins over this file, so nothing changes for a
|
|
12
|
+
* shell or a CI runner that already sets the variable — the store is the
|
|
13
|
+
* fallback for the key typed once into Studio.
|
|
14
|
+
*
|
|
15
|
+
* The file is written 0600 and read whole. There is no listing of values
|
|
16
|
+
* anywhere: callers ask for one variable and get one answer, and the Studio
|
|
17
|
+
* endpoint that fronts this reports only whether a key exists, never what it
|
|
18
|
+
* is.
|
|
19
|
+
*/
|
|
20
|
+
const storePath = (): string =>
|
|
21
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "odori", "keys.json");
|
|
22
|
+
|
|
23
|
+
const readStore = async (): Promise<Record<string, string>> => {
|
|
24
|
+
const path = storePath();
|
|
25
|
+
if (!existsSync(path)) return {};
|
|
26
|
+
try {
|
|
27
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
28
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
29
|
+
return Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string")) as Record<
|
|
30
|
+
string,
|
|
31
|
+
string
|
|
32
|
+
>;
|
|
33
|
+
} catch {
|
|
34
|
+
// A corrupt store reads as empty rather than throwing: the recovery in
|
|
35
|
+
// either case is typing the key again, and a parse error should not take
|
|
36
|
+
// Studio down with it.
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const writeStore = async (store: Record<string, string>): Promise<void> => {
|
|
42
|
+
const path = storePath();
|
|
43
|
+
if (Object.keys(store).length === 0) {
|
|
44
|
+
await rm(path, {force: true});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
await mkdir(dirname(path), {recursive: true, mode: 0o700});
|
|
48
|
+
await writeFile(path, JSON.stringify(store, null, 2) + "\n", {mode: 0o600});
|
|
49
|
+
// writeFile's mode only applies on creation; an existing file keeps its
|
|
50
|
+
// permissions, so they are asserted rather than assumed.
|
|
51
|
+
await chmod(path, 0o600);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The stored value for one variable, or nothing. Environment is the caller's job. */
|
|
55
|
+
export const storedKey = async (variable: string): Promise<string | undefined> => (await readStore())[variable];
|
|
56
|
+
|
|
57
|
+
export const setStoredKey = async (variable: string, value: string): Promise<void> => {
|
|
58
|
+
const store = await readStore();
|
|
59
|
+
store[variable] = value;
|
|
60
|
+
await writeStore(store);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const clearStoredKey = async (variable: string): Promise<void> => {
|
|
64
|
+
const store = await readStore();
|
|
65
|
+
delete store[variable];
|
|
66
|
+
await writeStore(store);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where a key would come from, for status displays. Never the key itself.
|
|
71
|
+
*/
|
|
72
|
+
export const keySource = async (variable: string): Promise<"environment" | "stored" | null> => {
|
|
73
|
+
if (process.env[variable]) return "environment";
|
|
74
|
+
if ((await readStore())[variable]) return "stored";
|
|
75
|
+
return null;
|
|
76
|
+
};
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation providers.
|
|
3
|
+
*
|
|
4
|
+
* Generation is an authoring step, not a render step: a provider is called
|
|
5
|
+
* once, at the keyboard, and what it returns goes through the same prepare
|
|
6
|
+
* pass as a file from disk. The render never talks to any of this, which is
|
|
7
|
+
* how it stays deterministic while the way tracks are made keeps changing.
|
|
8
|
+
*
|
|
9
|
+
* Keys come from the environment and are never written to config: the user's
|
|
10
|
+
* key, the user's account, the user's output. Odori redistributes nothing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {storedKey} from "./keystore";
|
|
14
|
+
|
|
15
|
+
export type GeneratedAudio = {
|
|
16
|
+
bytes: Uint8Array;
|
|
17
|
+
/** File extension for the bytes, without the dot. */
|
|
18
|
+
extension: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type MusicProvider = {
|
|
22
|
+
name: string;
|
|
23
|
+
/** Shown in Studio and `odori integrations`. */
|
|
24
|
+
title: string;
|
|
25
|
+
docsUrl: string;
|
|
26
|
+
/** The environment variable the key is read from. */
|
|
27
|
+
keyVariable: string;
|
|
28
|
+
generate(options: {prompt: string; seconds: number; apiKey: string}): Promise<GeneratedAudio>;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a key is currently accepted, asked cheaply. Distinguishes a wrong
|
|
31
|
+
* key (false) from an unreachable provider (thrown), because Studio should
|
|
32
|
+
* refuse to store the first and shrug at the second.
|
|
33
|
+
*/
|
|
34
|
+
verifyKey(apiKey: string): Promise<boolean>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const elevenlabs: MusicProvider = {
|
|
38
|
+
name: "elevenlabs",
|
|
39
|
+
title: "ElevenLabs Music",
|
|
40
|
+
docsUrl: "https://elevenlabs.io/docs/api-reference/music",
|
|
41
|
+
keyVariable: "ELEVENLABS_API_KEY",
|
|
42
|
+
async verifyKey(apiKey) {
|
|
43
|
+
const response = await fetch("https://api.elevenlabs.io/v1/user", {
|
|
44
|
+
headers: {"xi-api-key": apiKey},
|
|
45
|
+
signal: AbortSignal.timeout(10_000),
|
|
46
|
+
});
|
|
47
|
+
if (response.ok) return true;
|
|
48
|
+
if (response.status === 401 || response.status === 403) return false;
|
|
49
|
+
throw new Error(`ElevenLabs answered ${response.status} to a key check.`);
|
|
50
|
+
},
|
|
51
|
+
async generate({prompt, seconds, apiKey}) {
|
|
52
|
+
const response = await fetch("https://api.elevenlabs.io/v1/music", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {"xi-api-key": apiKey, "content-type": "application/json"},
|
|
55
|
+
body: JSON.stringify({prompt, music_length_ms: Math.round(seconds * 1000)}),
|
|
56
|
+
signal: AbortSignal.timeout(300_000),
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const detail = await response.text().catch(() => "");
|
|
60
|
+
throw new Error(
|
|
61
|
+
`ElevenLabs returned ${response.status} ${response.statusText}.` +
|
|
62
|
+
(detail ? `\n${detail.slice(0, 400)}` : "") +
|
|
63
|
+
(response.status === 401 ? `\nIs ${elevenlabs.keyVariable} a current key?` : ""),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const type = response.headers.get("content-type") ?? "";
|
|
67
|
+
const extension = type.includes("wav") ? "wav" : type.includes("mp4") ? "m4a" : "mp3";
|
|
68
|
+
return {bytes: new Uint8Array(await response.arrayBuffer()), extension};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
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
|
+
|
|
155
|
+
export const musicProviders: Record<string, MusicProvider> = {elevenlabs};
|
|
156
|
+
|
|
157
|
+
export const resolveMusicProvider = (name: string): MusicProvider => {
|
|
158
|
+
const provider = musicProviders[name];
|
|
159
|
+
if (!provider) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`No music provider named "${name}". Available: ${Object.keys(musicProviders).join(", ")}.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return provider;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The key for a provider: the environment first, then the machine store the
|
|
169
|
+
* Studio integrations page writes. Both are read here and nowhere else, so
|
|
170
|
+
* "where do keys come from" has one answer.
|
|
171
|
+
*/
|
|
172
|
+
export const resolveKey = async (provider: {keyVariable: string}): Promise<string | undefined> =>
|
|
173
|
+
process.env[provider.keyVariable] ?? (await storedKey(provider.keyVariable));
|