@odori/cli 0.0.6 → 0.0.8
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-6CE7U5KB.js → chunk-UPYHTDXP.js} +1498 -1080
- 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-GV6FFZXV.js} +361 -24
- package/package.json +3 -3
- package/src/audio-mix.ts +6 -1
- package/src/cli.ts +37 -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/test.ts +95 -25
- package/src/config.ts +5 -0
- package/src/jobs.ts +1 -1
- package/src/keystore.ts +76 -0
- package/src/providers.ts +90 -0
- package/src/registry-snapshot.json +361 -24
- package/src/render.ts +74 -10
- package/studio/src/Studio.tsx +3 -1
- package/studio/src/studio.css +106 -0
- package/studio/src/views/IntegrationsView.tsx +270 -0
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.
|
|
@@ -35,15 +35,36 @@ const CANVAS_SCRIPT = `(() => {
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
return canvases.map(function (canvas, index) {
|
|
38
|
-
|
|
39
|
-
if (!context || canvas.width === 0 || canvas.height === 0) {
|
|
38
|
+
if (canvas.width === 0 || canvas.height === 0) {
|
|
40
39
|
return {index: index, hash: "no-context", blank: true};
|
|
41
40
|
}
|
|
42
41
|
var data;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
var isGl = false;
|
|
43
|
+
var context = canvas.getContext("2d");
|
|
44
|
+
if (context) {
|
|
45
|
+
try {
|
|
46
|
+
data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return {index: index, hash: "tainted", blank: false};
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
/*
|
|
52
|
+
* A canvas that already holds a GL context returns null for "2d", and
|
|
53
|
+
* reading that as an empty canvas reported every shader video as having
|
|
54
|
+
* drawn nothing while it was drawing correctly. Pixels come back through
|
|
55
|
+
* readPixels instead, which is the only way to see a GL surface.
|
|
56
|
+
*
|
|
57
|
+
* Worth knowing when this comes back empty: a drawing buffer is cleared
|
|
58
|
+
* once it has been composited unless the context was asked for with
|
|
59
|
+
* preserveDrawingBuffer, so an author who has not set that gets a real
|
|
60
|
+
* frame on screen and nothing here.
|
|
61
|
+
*/
|
|
62
|
+
var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
|
|
63
|
+
if (!gl) return {index: index, hash: "no-context", blank: true};
|
|
64
|
+
var pixels4 = new Uint8Array(canvas.width * canvas.height * 4);
|
|
65
|
+
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels4);
|
|
66
|
+
data = pixels4;
|
|
67
|
+
isGl = true;
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
// A prime stride over the whole buffer rather than a coarse grid: a grid
|
|
@@ -59,11 +80,11 @@ const CANVAS_SCRIPT = `(() => {
|
|
|
59
80
|
hash =
|
|
60
81
|
((hash << 5) + hash + data[offset] + data[offset + 1] * 3 + data[offset + 2] * 7 + data[offset + 3] * 11) | 0;
|
|
61
82
|
}
|
|
62
|
-
return {index: index, hash: String(hash), blank: opaque === 0};
|
|
83
|
+
return {index: index, hash: String(hash), blank: opaque === 0, gl: isGl};
|
|
63
84
|
});
|
|
64
85
|
})()`;
|
|
65
86
|
|
|
66
|
-
type CanvasReport = Array<{index: number; hash: string; blank: boolean}>;
|
|
87
|
+
type CanvasReport = Array<{index: number; hash: string; blank: boolean; gl?: boolean}>;
|
|
67
88
|
|
|
68
89
|
/**
|
|
69
90
|
* Evaluated as source in the page so no bundler helpers leak into the browser.
|
|
@@ -71,20 +92,41 @@ type CanvasReport = Array<{index: number; hash: string; blank: boolean}>;
|
|
|
71
92
|
*/
|
|
72
93
|
const FRAME_SCRIPT = `(() => {
|
|
73
94
|
var root = document.querySelector("[data-odori-video]");
|
|
74
|
-
if (!root) return {overflow: [], small: [], empty: true, painted: 0};
|
|
95
|
+
if (!root) return {overflow: [], small: [], empty: true, painted: 0, faded: 0};
|
|
75
96
|
|
|
76
97
|
var bounds = root.getBoundingClientRect();
|
|
77
98
|
var overflow = [];
|
|
78
99
|
var small = [];
|
|
79
100
|
var painted = 0;
|
|
101
|
+
var faded = 0;
|
|
80
102
|
var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
|
|
81
103
|
|
|
82
104
|
for (var index = 0; index < nodes.length; index += 1) {
|
|
83
105
|
var node = nodes[index];
|
|
106
|
+
/* The subtree an effect surface is photographing is deliberately parked
|
|
107
|
+
outside the frame. It is the input to a canvas, not something a viewer
|
|
108
|
+
ever sees, so judging its position or its type size is judging the
|
|
109
|
+
wrong thing. */
|
|
110
|
+
if (node.closest("[data-odori-capture]")) continue;
|
|
84
111
|
var box = node.getBoundingClientRect();
|
|
85
112
|
if (box.width === 0 || box.height === 0) continue;
|
|
86
113
|
var style = getComputedStyle(node);
|
|
87
|
-
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
|
+
}
|
|
88
130
|
painted += 1;
|
|
89
131
|
|
|
90
132
|
var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
|
|
@@ -109,12 +151,27 @@ const FRAME_SCRIPT = `(() => {
|
|
|
109
151
|
* content the video is actually about, which is what stays checked.
|
|
110
152
|
*/
|
|
111
153
|
var chrome = node.closest("[data-odori-chrome]") !== null;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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) {
|
|
118
175
|
if (overflow.indexOf(label) < 0) overflow.push(label);
|
|
119
176
|
}
|
|
120
177
|
|
|
@@ -138,7 +195,7 @@ const FRAME_SCRIPT = `(() => {
|
|
|
138
195
|
}
|
|
139
196
|
}
|
|
140
197
|
|
|
141
|
-
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};
|
|
142
199
|
})()`;
|
|
143
200
|
|
|
144
201
|
/**
|
|
@@ -160,7 +217,7 @@ const testVideo = async (
|
|
|
160
217
|
if (!result.success) failures.push({video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}`});
|
|
161
218
|
}
|
|
162
219
|
|
|
163
|
-
const {browser, page} = await openRenderPage(origin, targetFor(video), config);
|
|
220
|
+
const {browser, page, errors} = await openRenderPage(origin, targetFor(video), config);
|
|
164
221
|
try {
|
|
165
222
|
const timeline = await readTimeline(page);
|
|
166
223
|
// The track is compiled by the same pass the player and the encoder read,
|
|
@@ -180,16 +237,24 @@ const testVideo = async (
|
|
|
180
237
|
|
|
181
238
|
const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor((total * 3) / 4), total - 1];
|
|
182
239
|
for (const frame of [...new Set(samples)]) {
|
|
183
|
-
await seekTo(page, frame);
|
|
240
|
+
await seekTo(page, frame, errors);
|
|
184
241
|
const result = (await page.evaluate(FRAME_SCRIPT)) as FrameReport;
|
|
185
242
|
if (result.empty) failures.push({video: id, message: `Frame ${frame} rendered no video root.`});
|
|
186
|
-
|
|
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) {
|
|
187
252
|
failures.push({video: id, message: `Frame ${frame} is blank.`});
|
|
188
253
|
}
|
|
189
254
|
for (const item of result.overflow) {
|
|
190
255
|
failures.push({
|
|
191
256
|
video: id,
|
|
192
|
-
message: `Frame ${frame}: ${item}
|
|
257
|
+
message: `Frame ${frame}: ${item} is entirely outside the ${layout.format.width}x${layout.format.height} canvas.`,
|
|
193
258
|
});
|
|
194
259
|
}
|
|
195
260
|
for (const item of result.small) {
|
|
@@ -204,14 +269,19 @@ const testVideo = async (
|
|
|
204
269
|
if (canvases.length > 0) {
|
|
205
270
|
// Seek away and back, so the second read is a real re-render rather
|
|
206
271
|
// than a second look at the same paint.
|
|
207
|
-
await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
|
|
208
|
-
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);
|
|
209
274
|
const again = (await page.evaluate(CANVAS_SCRIPT)) as CanvasReport;
|
|
210
275
|
|
|
211
276
|
for (const canvas of canvases) {
|
|
212
277
|
const second = again.find((item) => item.index === canvas.index);
|
|
213
278
|
if (canvas.blank) {
|
|
214
|
-
failures.push({
|
|
279
|
+
failures.push({
|
|
280
|
+
video: id,
|
|
281
|
+
message: canvas.gl
|
|
282
|
+
? `Frame ${frame}: canvas ${canvas.index} read back empty. A WebGL drawing buffer is cleared once it has been composited, so ask for the context with {preserveDrawingBuffer: true}.`
|
|
283
|
+
: `Frame ${frame}: canvas ${canvas.index} drew nothing.`,
|
|
284
|
+
});
|
|
215
285
|
continue;
|
|
216
286
|
}
|
|
217
287
|
if (canvas.hash === "tainted") {
|
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,90 @@
|
|
|
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 const musicProviders: Record<string, MusicProvider> = {elevenlabs};
|
|
73
|
+
|
|
74
|
+
export const resolveMusicProvider = (name: string): MusicProvider => {
|
|
75
|
+
const provider = musicProviders[name];
|
|
76
|
+
if (!provider) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`No music provider named "${name}". Available: ${Object.keys(musicProviders).join(", ")}.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return provider;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The key for a provider: the environment first, then the machine store the
|
|
86
|
+
* Studio integrations page writes. Both are read here and nowhere else, so
|
|
87
|
+
* "where do keys come from" has one answer.
|
|
88
|
+
*/
|
|
89
|
+
export const resolveKey = async (provider: MusicProvider): Promise<string | undefined> =>
|
|
90
|
+
process.env[provider.keyVariable] ?? (await storedKey(provider.keyVariable));
|