@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/cli.ts
CHANGED
|
@@ -7,9 +7,11 @@ import {doctorCommand} from "./commands/doctor";
|
|
|
7
7
|
import {installCommand} from "./binaries";
|
|
8
8
|
import {exportCommand, jobsCommand} from "./commands/exportVideo";
|
|
9
9
|
import {initCommand} from "./commands/init";
|
|
10
|
+
import {integrationsCommand} from "./commands/integrations";
|
|
10
11
|
import {inspectCommand} from "./commands/inspect";
|
|
11
12
|
import {listCommand} from "./commands/list";
|
|
12
13
|
import {newCommand} from "./commands/new";
|
|
14
|
+
import {bedCommand} from "./commands/bed";
|
|
13
15
|
import {frameCommand} from "./commands/frame";
|
|
14
16
|
import {testCommand} from "./commands/test";
|
|
15
17
|
|
|
@@ -23,7 +25,7 @@ type Flags = Record<string, string | boolean>;
|
|
|
23
25
|
* vanishes from the list, which is two failures for the price of one and
|
|
24
26
|
* neither of them says anything. A switch has no value to take.
|
|
25
27
|
*/
|
|
26
|
-
const BOOLEAN_FLAGS = new Set(["force", "dry-run", "json", "no-audio", "no-frame-skip", "no-open", "open", "help", "version"]);
|
|
28
|
+
const BOOLEAN_FLAGS = new Set(["force", "dry-run", "json", "no-audio", "fast", "no-frame-skip", "no-open", "open", "help", "version"]);
|
|
27
29
|
|
|
28
30
|
/** Commands that used to exist under another name. */
|
|
29
31
|
const RENAMED: Record<string, string> = {still: "frame"};
|
|
@@ -93,13 +95,15 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
93
95
|
new: ["blank"],
|
|
94
96
|
add: ["force", "dry-run"],
|
|
95
97
|
registry: [],
|
|
98
|
+
integrations: [],
|
|
96
99
|
diff: ["full"],
|
|
97
100
|
update: ["force"],
|
|
98
101
|
list: [],
|
|
99
102
|
inspect: ["json", "input"],
|
|
100
103
|
frame: ["at", "output", "input"],
|
|
104
|
+
bed: ["role", "output", "target", "generate", "provider", "seconds"],
|
|
101
105
|
test: ["json"],
|
|
102
|
-
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "no-frame-skip", "retry"],
|
|
106
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
|
|
103
107
|
jobs: [],
|
|
104
108
|
help: [],
|
|
105
109
|
};
|
|
@@ -151,6 +155,16 @@ const USAGE: Record<string, string> = {
|
|
|
151
155
|
Discover project resources and start Studio.`,
|
|
152
156
|
init: `odori init
|
|
153
157
|
Add videos/ and odori.config.ts to a project.`,
|
|
158
|
+
bed: `odori bed <file> [--role <name>] [--target <lufs>] [--output <path>]
|
|
159
|
+
Prepare an audio file to sit under a video: measure it, level it to the stem
|
|
160
|
+
target every other bed is prepared to, and write it where audio is served.
|
|
161
|
+
With --generate the positional is a prompt instead of a path: the track is
|
|
162
|
+
generated with a provider (--provider, default elevenlabs, key from
|
|
163
|
+
ELEVENLABS_API_KEY), then prepared identically. --seconds sets its length.`,
|
|
164
|
+
integrations: `odori integrations
|
|
165
|
+
List generation providers and whether each is connected. Configuration
|
|
166
|
+
lives in the environment or Studio's integrations page; generation happens
|
|
167
|
+
in task commands like "odori bed --generate".`,
|
|
154
168
|
doctor: `odori doctor
|
|
155
169
|
Check Node, React, the source root, Chrome, FFmpeg, and the generated cache.`,
|
|
156
170
|
install: `odori install
|
|
@@ -181,11 +195,15 @@ const USAGE: Record<string, string> = {
|
|
|
181
195
|
check, for CI.`,
|
|
182
196
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
183
197
|
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
184
|
-
[--no-audio] [--no-frame-skip] [--retry <job>]
|
|
198
|
+
[--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
|
|
185
199
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
186
200
|
or png; without it the output's extension decides, and mp4 is the default.
|
|
187
201
|
--quality is studio, social, or web. --scale multiplies the output size,
|
|
188
|
-
0.25 to 2. --no-audio writes the picture with no sound.
|
|
202
|
+
0.25 to 2. --no-audio writes the picture with no sound.
|
|
203
|
+
--fast draws on this machine's GPU rather than the reproducible software
|
|
204
|
+
backend. Measured here: about five percent on ordinary post-processing, and
|
|
205
|
+
fourteen times on a shader that is genuinely per-pixel expensive. The pixels
|
|
206
|
+
it makes belong to this machine, so keep it for iterating. A retry keeps the
|
|
189
207
|
settings its job was created with.`,
|
|
190
208
|
jobs: `odori jobs
|
|
191
209
|
List export jobs and their status.`,
|
|
@@ -270,6 +288,9 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
270
288
|
case "init":
|
|
271
289
|
await initCommand();
|
|
272
290
|
return 0;
|
|
291
|
+
case "integrations":
|
|
292
|
+
await integrationsCommand();
|
|
293
|
+
return 0;
|
|
273
294
|
case "doctor":
|
|
274
295
|
return await doctorCommand();
|
|
275
296
|
case "install":
|
|
@@ -295,6 +316,17 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
295
316
|
case "inspect":
|
|
296
317
|
await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
|
|
297
318
|
return 0;
|
|
319
|
+
case "bed":
|
|
320
|
+
if (!positionals[0]) throw new Error('Which file? "odori bed ./track.mp3".');
|
|
321
|
+
await bedCommand(positionals[0], {
|
|
322
|
+
role: typeof flags.role === "string" ? flags.role : undefined,
|
|
323
|
+
output: typeof flags.output === "string" ? flags.output : undefined,
|
|
324
|
+
target: numberFlag(flags, "target"),
|
|
325
|
+
generate: flags.generate === true,
|
|
326
|
+
provider: typeof flags.provider === "string" ? flags.provider : undefined,
|
|
327
|
+
seconds: numberFlag(flags, "seconds"),
|
|
328
|
+
});
|
|
329
|
+
return 0;
|
|
298
330
|
case "frame":
|
|
299
331
|
await frameCommand(positionals[0] ?? "", {
|
|
300
332
|
// A duration, so "4s" and "120f" both work; a bare number is
|
|
@@ -317,6 +349,7 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
317
349
|
scale: numberFlag(flags, "scale"),
|
|
318
350
|
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
319
351
|
audio: flags["no-audio"] === true ? false : undefined,
|
|
352
|
+
fast: flags.fast === true,
|
|
320
353
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
|
|
321
354
|
retry: typeof flags.retry === "string" ? flags.retry : undefined,
|
|
322
355
|
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {spawn} from "node:child_process";
|
|
2
|
+
import {mkdir, stat, writeFile} from "node:fs/promises";
|
|
3
|
+
import {basename, dirname, extname, join, relative, resolve} from "node:path";
|
|
4
|
+
import {log} from "../log";
|
|
5
|
+
import {loadConfig, type ResolvedConfig} from "../config";
|
|
6
|
+
import {ffmpegExecutable} from "../render";
|
|
7
|
+
import {registerCueInBrand} from "../brand-file";
|
|
8
|
+
import {resolveKey, resolveMusicProvider} from "../providers";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The level a bed is prepared to.
|
|
12
|
+
*
|
|
13
|
+
* Not the level it is delivered at: a mix is normalised to the brand's target
|
|
14
|
+
* on the way out, around -14 LUFS. A bed sits under that, and the six decibels
|
|
15
|
+
* between the two are where the cues and any narration live. Preparing every
|
|
16
|
+
* bed to the same number is what makes them interchangeable — swap one for
|
|
17
|
+
* another and the video does not re-mix itself.
|
|
18
|
+
*/
|
|
19
|
+
const STEM_LUFS = -20;
|
|
20
|
+
|
|
21
|
+
/** Below full scale, so nothing clips after the encoder resamples it. */
|
|
22
|
+
const STEM_PEAK = -1.5;
|
|
23
|
+
|
|
24
|
+
type Measured = {input_i: string; input_tp: string; input_lra: string; input_thresh: string};
|
|
25
|
+
|
|
26
|
+
export type BedOptions = {
|
|
27
|
+
role?: string;
|
|
28
|
+
output?: string;
|
|
29
|
+
target?: number;
|
|
30
|
+
generate?: boolean;
|
|
31
|
+
provider?: string;
|
|
32
|
+
seconds?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** What preparing a bed did, for the CLI to print and Studio to render. */
|
|
36
|
+
export type BedReport = {
|
|
37
|
+
source: string;
|
|
38
|
+
destination: string;
|
|
39
|
+
role: string;
|
|
40
|
+
/** Servable path when the file landed under public/, or nothing. */
|
|
41
|
+
url: string | null;
|
|
42
|
+
registered: {file: string; already: boolean} | null;
|
|
43
|
+
before: {lufs: number; peak: number; range: number};
|
|
44
|
+
after: {lufs: number; peak: number};
|
|
45
|
+
bytes: number;
|
|
46
|
+
warnings: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const run = (command: string, args: string[]) =>
|
|
50
|
+
new Promise<string>((resolveRun, rejectRun) => {
|
|
51
|
+
const child = spawn(command, args, {stdio: ["ignore", "ignore", "pipe"]});
|
|
52
|
+
let stderr = "";
|
|
53
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
54
|
+
stderr += chunk.toString();
|
|
55
|
+
});
|
|
56
|
+
child.on("error", rejectRun);
|
|
57
|
+
child.on("exit", (code) => {
|
|
58
|
+
if (code === 0) resolveRun(stderr);
|
|
59
|
+
else rejectRun(new Error(`ffmpeg exited with ${code}: ${stderr.slice(-800)}`));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What the file measures now.
|
|
65
|
+
*
|
|
66
|
+
* loudnorm reports its analysis as JSON on stderr, and the second pass needs
|
|
67
|
+
* those exact numbers: given them it corrects linearly, applying one gain to
|
|
68
|
+
* the whole file. Without them it works a window at a time and moves quiet
|
|
69
|
+
* passages relative to loud ones, which is a mastering decision nobody asked
|
|
70
|
+
* it to make.
|
|
71
|
+
*/
|
|
72
|
+
const measure = async (ffmpeg: string, file: string): Promise<Measured> => {
|
|
73
|
+
const stderr = await run(ffmpeg, [
|
|
74
|
+
"-i", file,
|
|
75
|
+
"-af", `loudnorm=I=${STEM_LUFS}:TP=${STEM_PEAK}:print_format=json`,
|
|
76
|
+
"-f", "null", "-",
|
|
77
|
+
]);
|
|
78
|
+
const start = stderr.lastIndexOf("{");
|
|
79
|
+
const end = stderr.lastIndexOf("}");
|
|
80
|
+
if (start === -1 || end === -1) throw new Error(`Could not read loudness from ${basename(file)}.`);
|
|
81
|
+
return JSON.parse(stderr.slice(start, end + 1)) as Measured;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const round = (value: string | number) => Number(value).toFixed(1);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask a provider for a track and land it next to the file it becomes.
|
|
88
|
+
*
|
|
89
|
+
* The raw answer is kept beside the prepared output, extension and all,
|
|
90
|
+
* because it is the one copy of the generation that exists: the provider will
|
|
91
|
+
* not produce it again, and re-preparing to a different target later needs
|
|
92
|
+
* the original, not the levelled encode.
|
|
93
|
+
*/
|
|
94
|
+
const generateSource = async (config: ResolvedConfig, prompt: string, options: BedOptions): Promise<string> => {
|
|
95
|
+
const provider = resolveMusicProvider(options.provider ?? config.generation?.music ?? "elevenlabs");
|
|
96
|
+
const apiKey = await resolveKey(provider);
|
|
97
|
+
if (!apiKey) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Generating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,\n` +
|
|
100
|
+
'or paste one once into Studio\u2019s integrations page ("odori dev", then Integrations).\n' +
|
|
101
|
+
"Either way it is sent only to the provider and never stored in the project.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const seconds = options.seconds ?? 60;
|
|
106
|
+
log.detail(`Generating ${seconds}s with ${provider.name}`);
|
|
107
|
+
const {bytes, extension} = await provider.generate({prompt, seconds, apiKey});
|
|
108
|
+
|
|
109
|
+
const stem = options.output
|
|
110
|
+
? basename(options.output, extname(options.output))
|
|
111
|
+
: prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "generated";
|
|
112
|
+
const directory = options.output ? dirname(resolve(config.root, options.output)) : join(config.root, "public", "audio");
|
|
113
|
+
await mkdir(directory, {recursive: true});
|
|
114
|
+
const source = join(directory, `${stem}-source.${extension}`);
|
|
115
|
+
await writeFile(source, bytes);
|
|
116
|
+
log.detail(` kept the original at ${basename(source)} (${(bytes.length / 1024).toFixed(0)} KB)`);
|
|
117
|
+
return source;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Prepare an audio file to sit under a video, and wire it to its role.
|
|
122
|
+
*
|
|
123
|
+
* The one job is levelling, because that is what was wrong: a library whose
|
|
124
|
+
* beds ranged over eight decibels meant choosing a bed and choosing a mix were
|
|
125
|
+
* the same act. Everything here is measurement and one correction, so the file
|
|
126
|
+
* that comes out is the file that went in at a known level. When the result
|
|
127
|
+
* lands under public/ the role is registered in the brand too, the same way
|
|
128
|
+
* `odori add` registers an installed asset, so the name works immediately.
|
|
129
|
+
*/
|
|
130
|
+
export const prepareBed = async (config: ResolvedConfig, input: string, options: BedOptions = {}): Promise<BedReport> => {
|
|
131
|
+
// With --generate the positional is a prompt, not a path: a provider is
|
|
132
|
+
// called once, here at author time, and its answer becomes the source file
|
|
133
|
+
// the rest of this command has always started from. The render never knows.
|
|
134
|
+
const source = options.generate ? await generateSource(config, input, options) : resolve(config.root, input);
|
|
135
|
+
if (!options.generate) {
|
|
136
|
+
await stat(source).catch(() => {
|
|
137
|
+
throw new Error(`No file at ${input}.`);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ffmpeg = await ffmpegExecutable(config);
|
|
142
|
+
const target = options.target ?? STEM_LUFS;
|
|
143
|
+
|
|
144
|
+
// A generated original keeps a -source suffix; the prepared file drops it.
|
|
145
|
+
const name = basename(source, extname(source)).replace(/-source$/, "");
|
|
146
|
+
const destination = options.output
|
|
147
|
+
? resolve(config.root, options.output)
|
|
148
|
+
: join(config.root, "public", "audio", `${name}.m4a`);
|
|
149
|
+
await mkdir(dirname(destination), {recursive: true});
|
|
150
|
+
|
|
151
|
+
log.detail(`Measuring ${basename(source)}`);
|
|
152
|
+
const measured = await measure(ffmpeg, source);
|
|
153
|
+
|
|
154
|
+
await run(ffmpeg, [
|
|
155
|
+
"-y", "-i", source,
|
|
156
|
+
"-af",
|
|
157
|
+
`loudnorm=I=${target}:TP=${STEM_PEAK}` +
|
|
158
|
+
`:measured_I=${measured.input_i}:measured_TP=${measured.input_tp}` +
|
|
159
|
+
`:measured_LRA=${measured.input_lra}:measured_thresh=${measured.input_thresh}` +
|
|
160
|
+
`:linear=true`,
|
|
161
|
+
"-c:a", "aac", "-b:a", "192k",
|
|
162
|
+
destination,
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
const after = await measure(ffmpeg, destination);
|
|
166
|
+
const bytes = (await stat(destination)).size;
|
|
167
|
+
|
|
168
|
+
const before = {lufs: Number(measured.input_i), peak: Number(measured.input_tp), range: Number(measured.input_lra)};
|
|
169
|
+
const warnings: string[] = [];
|
|
170
|
+
/* The range is the one number this cannot fix. It is a property of the
|
|
171
|
+
recording, and a bed with none of it has nowhere to go under a cut. */
|
|
172
|
+
if (before.range < 4) {
|
|
173
|
+
warnings.push(
|
|
174
|
+
`A range of ${round(before.range)} LU is very compressed. Mastered music sits around 5 to 8, ` +
|
|
175
|
+
"and film score higher. Levelling cannot restore range a recording does not have; " +
|
|
176
|
+
"this bed will sit flat under the cut.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (before.peak > 0) {
|
|
180
|
+
warnings.push(`The source peaked at ${round(before.peak)} dBTP, which is above full scale.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const role = options.role ?? `bed.${name.replace(/^bed[-.]?/, "") || "main"}`;
|
|
184
|
+
|
|
185
|
+
// Registration needs a URL, and a URL needs the file to be servable: only a
|
|
186
|
+
// destination under public/ has one. Anywhere else the caller chose a path
|
|
187
|
+
// on purpose and gets the report instead.
|
|
188
|
+
const publicDir = join(config.root, "public");
|
|
189
|
+
const relativeToPublic = relative(publicDir, destination);
|
|
190
|
+
const url = relativeToPublic.startsWith("..") ? null : "/" + relativeToPublic.split("\\").join("/");
|
|
191
|
+
const registered = url ? await registerCueInBrand(config, {name: role, url}, name) : null;
|
|
192
|
+
|
|
193
|
+
return {source, destination, role, url, registered, before, after: {lufs: Number(after.input_i), peak: Number(after.input_tp)}, bytes, warnings};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const bedCommand = async (input: string, options: BedOptions = {}) => {
|
|
197
|
+
const config = await loadConfig(process.cwd());
|
|
198
|
+
const report = await prepareBed(config, input, options);
|
|
199
|
+
|
|
200
|
+
log.info(`Prepared ${basename(report.destination)}`);
|
|
201
|
+
log.detail(` loudness ${round(report.before.lufs)} → ${round(report.after.lufs)} LUFS`);
|
|
202
|
+
log.detail(` peak ${round(report.before.peak)} → ${round(report.after.peak)} dBTP`);
|
|
203
|
+
log.detail(` range ${round(report.before.range)} LU`);
|
|
204
|
+
log.detail(` size ${(report.bytes / 1024).toFixed(0)} KB`);
|
|
205
|
+
|
|
206
|
+
for (const warning of report.warnings) {
|
|
207
|
+
log.detail("");
|
|
208
|
+
log.detail(` ${warning}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
log.detail("");
|
|
212
|
+
if (report.registered?.already) {
|
|
213
|
+
log.detail(` "${report.role}" is already registered in ${report.registered.file}`);
|
|
214
|
+
} else if (report.registered) {
|
|
215
|
+
log.detail(` registered "${report.role}" in ${report.registered.file}`);
|
|
216
|
+
} else if (report.url) {
|
|
217
|
+
log.detail(" No brand with an audio cues block was found. Register it by hand:");
|
|
218
|
+
log.detail(` audio: {cues: {"${report.role}": "${report.url}"}}`);
|
|
219
|
+
} else {
|
|
220
|
+
log.detail(" The output is outside public/, so it cannot be registered or served.");
|
|
221
|
+
}
|
|
222
|
+
log.detail(" Place it in a video:");
|
|
223
|
+
log.detail(` <Audio src="${report.role}" fadeIn="1s" fadeOut="1.5s" duckUnder />`);
|
|
224
|
+
};
|
package/src/commands/dev.ts
CHANGED
|
@@ -4,6 +4,9 @@ import {existsSync} from "node:fs";
|
|
|
4
4
|
import {readFile} from "node:fs/promises";
|
|
5
5
|
import type {IncomingMessage, ServerResponse} from "node:http";
|
|
6
6
|
import {loadConfig} from "../config";
|
|
7
|
+
import {clearStoredKey, keySource, setStoredKey} from "../keystore";
|
|
8
|
+
import {musicProviders} from "../providers";
|
|
9
|
+
import {prepareBed} from "./bed";
|
|
7
10
|
import {log} from "../log";
|
|
8
11
|
import {createJob, listJobs, readJob} from "../jobs";
|
|
9
12
|
import {discoverProject} from "../discovery";
|
|
@@ -238,6 +241,83 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
238
241
|
return;
|
|
239
242
|
}
|
|
240
243
|
|
|
244
|
+
if (request.method === "POST" && url.startsWith("/generate")) {
|
|
245
|
+
const body = await readBody(request);
|
|
246
|
+
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
|
247
|
+
if (!prompt) {
|
|
248
|
+
json(response, 400, {error: "A prompt is required."});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// The same path the CLI walks: provider, prepare, register. The
|
|
252
|
+
// report comes back whole so Studio can show the numbers the
|
|
253
|
+
// terminal would have printed.
|
|
254
|
+
const report = await prepareBed(config, prompt, {
|
|
255
|
+
generate: true,
|
|
256
|
+
seconds: typeof body.seconds === "number" ? Math.min(300, Math.max(5, body.seconds)) : undefined,
|
|
257
|
+
role: typeof body.role === "string" && body.role.trim() ? body.role.trim() : undefined,
|
|
258
|
+
provider: typeof body.provider === "string" ? body.provider : undefined,
|
|
259
|
+
});
|
|
260
|
+
json(response, 200, {
|
|
261
|
+
...report,
|
|
262
|
+
source: relative(config.root, report.source),
|
|
263
|
+
destination: relative(config.root, report.destination),
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (url === "/integrations" || url === "/integrations/") {
|
|
269
|
+
// Status only, and only booleans: which providers exist, whether
|
|
270
|
+
// each has a key, and where it came from. The value itself has
|
|
271
|
+
// no read path over HTTP, in either direction.
|
|
272
|
+
if (request.method === "GET") {
|
|
273
|
+
const providers = await Promise.all(
|
|
274
|
+
Object.values(musicProviders).map(async (provider) => ({
|
|
275
|
+
name: provider.name,
|
|
276
|
+
title: provider.title,
|
|
277
|
+
kind: "music",
|
|
278
|
+
keyVariable: provider.keyVariable,
|
|
279
|
+
docsUrl: provider.docsUrl,
|
|
280
|
+
source: await keySource(provider.keyVariable),
|
|
281
|
+
})),
|
|
282
|
+
);
|
|
283
|
+
json(response, 200, {providers});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (request.method === "POST") {
|
|
287
|
+
const body = await readBody(request);
|
|
288
|
+
const provider = musicProviders[String(body.provider ?? "")];
|
|
289
|
+
if (!provider) {
|
|
290
|
+
json(response, 400, {error: `No provider named ${JSON.stringify(body.provider)}.`});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
294
|
+
if (!key) {
|
|
295
|
+
// An empty key is the clear action, so removing a key is the
|
|
296
|
+
// same gesture as setting one.
|
|
297
|
+
await clearStoredKey(provider.keyVariable);
|
|
298
|
+
json(response, 200, {source: await keySource(provider.keyVariable), verified: null});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// A key that the provider rejects outright is not stored: a
|
|
302
|
+
// stored bad key fails later, at generation, where the person
|
|
303
|
+
// who typed it may be long gone. An unreachable provider is
|
|
304
|
+
// not the same thing, so that key is kept and said so.
|
|
305
|
+
let verified: boolean | null = null;
|
|
306
|
+
try {
|
|
307
|
+
verified = await provider.verifyKey(key);
|
|
308
|
+
} catch {
|
|
309
|
+
verified = null;
|
|
310
|
+
}
|
|
311
|
+
if (verified === false) {
|
|
312
|
+
json(response, 400, {error: `${provider.title} rejected that key.`});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await setStoredKey(provider.keyVariable, key);
|
|
316
|
+
json(response, 200, {source: await keySource(provider.keyVariable), verified});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
241
321
|
if (request.method === "GET" && url.startsWith("/jobs")) {
|
|
242
322
|
json(response, 200, await listJobs(config));
|
|
243
323
|
return;
|
package/src/commands/doctor.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {existsSync} from "node:fs";
|
|
|
4
4
|
import {createRequire} from "node:module";
|
|
5
5
|
import {relative, resolve} from "node:path";
|
|
6
6
|
import {loadConfig} from "../config";
|
|
7
|
+
import {keySource} from "../keystore";
|
|
8
|
+
import {musicProviders} from "../providers";
|
|
7
9
|
import {CHROME_BUILD, cacheRoot, resolveBrowser, resolveFfmpeg} from "../binaries";
|
|
8
10
|
import {log} from "../log";
|
|
9
11
|
|
|
@@ -165,6 +167,24 @@ export const runChecks = async (root: string): Promise<Check[]> => {
|
|
|
165
167
|
fix: `Add a sibling <name>.preview.tsx with defineComponentPreview so Studio can play it on its own. A component with no fixture only ever renders inside a video.`,
|
|
166
168
|
});
|
|
167
169
|
|
|
170
|
+
// A missing provider key is not a broken project — generation is optional —
|
|
171
|
+
// but doctor is exactly where "why does --generate fail" gets answered.
|
|
172
|
+
for (const provider of Object.values(musicProviders)) {
|
|
173
|
+
const source = await keySource(provider.keyVariable);
|
|
174
|
+
checks.push({
|
|
175
|
+
name: `Provider: ${provider.name}`,
|
|
176
|
+
detail:
|
|
177
|
+
source === "environment"
|
|
178
|
+
? `connected (${provider.keyVariable})`
|
|
179
|
+
: source === "stored"
|
|
180
|
+
? "connected (key stored on this machine)"
|
|
181
|
+
: "not configured",
|
|
182
|
+
ok: true,
|
|
183
|
+
warn: source === null,
|
|
184
|
+
fix: `Optional. To generate with ${provider.title}: set ${provider.keyVariable}, or paste a key in Studio's integrations page.`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
168
188
|
checks.push({
|
|
169
189
|
name: "Generated cache",
|
|
170
190
|
detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
|
|
@@ -2,6 +2,7 @@ import {resolve} from "node:path";
|
|
|
2
2
|
import {resolveEntryLayout, type ExportJob} from "odori";
|
|
3
3
|
import type {ResolvedConfig} from "../config";
|
|
4
4
|
import {log} from "../log";
|
|
5
|
+
import type {Graphics} from "../render";
|
|
5
6
|
import {JobQueue, appendJobLog, createJob, listJobs, readJob, updateJob, type JobRecord} from "../jobs";
|
|
6
7
|
import {findVideo, freezeManifest, outputName, type LoadedVideo} from "../project";
|
|
7
8
|
import {resolveFormat, QUALITIES, type Quality, type VideoFormat} from "../formats";
|
|
@@ -37,6 +38,7 @@ export const runJob = async (
|
|
|
37
38
|
scale?: number;
|
|
38
39
|
format?: VideoFormat;
|
|
39
40
|
audio?: boolean;
|
|
41
|
+
graphics?: Graphics;
|
|
40
42
|
skipUnchangedFrames?: boolean;
|
|
41
43
|
signal?: AbortSignal;
|
|
42
44
|
onProgress?: (job: ExportJob) => void;
|
|
@@ -96,6 +98,7 @@ export const runJob = async (
|
|
|
96
98
|
scale: options.scale ?? record.render?.scale,
|
|
97
99
|
format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : undefined),
|
|
98
100
|
audio: options.audio ?? record.render?.audio,
|
|
101
|
+
graphics: options.graphics ?? (record.render?.graphics as Graphics | undefined),
|
|
99
102
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
100
103
|
signal: controller.signal,
|
|
101
104
|
onTimings: (timings) => {
|
|
@@ -155,6 +158,8 @@ export const exportCommand = async (
|
|
|
155
158
|
format?: string;
|
|
156
159
|
/** False writes the picture with no audio track. */
|
|
157
160
|
audio?: boolean;
|
|
161
|
+
/** True draws on the machine's GPU instead of the reproducible backend. */
|
|
162
|
+
fast?: boolean;
|
|
158
163
|
skipUnchangedFrames?: boolean;
|
|
159
164
|
retry?: string;
|
|
160
165
|
} = {},
|
|
@@ -188,12 +193,21 @@ export const exportCommand = async (
|
|
|
188
193
|
quality,
|
|
189
194
|
scale,
|
|
190
195
|
audio: options.audio !== false,
|
|
196
|
+
graphics: options.fast ? "gpu" : "software",
|
|
191
197
|
...(options.preset ? {preset: options.preset} : {}),
|
|
192
198
|
});
|
|
193
199
|
})();
|
|
194
200
|
|
|
195
201
|
const video = findVideo(videos, record.manifest.videoId);
|
|
196
202
|
log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
|
|
203
|
+
/* Said every time, not only when it is unusual. A file that came out
|
|
204
|
+
different should never leave you guessing which of the two drew it. */
|
|
205
|
+
const backend = options.fast ? "gpu" : (record.render?.graphics ?? "software");
|
|
206
|
+
log.detail(
|
|
207
|
+
backend === "gpu"
|
|
208
|
+
? "graphics: this machine's GPU. Faster, and the pixels are specific to it."
|
|
209
|
+
: "graphics: software, reproducible on any machine",
|
|
210
|
+
);
|
|
197
211
|
if (options.retry) log.detail(`retrying attempt ${record.job.attempts + 1} from the frozen manifest`);
|
|
198
212
|
if (record.manifest.audio.length > 0) {
|
|
199
213
|
log.detail(`${record.manifest.audio.length} audio cue(s) at ${record.manifest.format.durationInFrames} frames`);
|
|
@@ -207,6 +221,7 @@ export const exportCommand = async (
|
|
|
207
221
|
scale: options.retry && options.scale === undefined ? undefined : scale,
|
|
208
222
|
format: options.retry ? undefined : format,
|
|
209
223
|
audio: options.audio,
|
|
224
|
+
graphics: options.fast ? "gpu" : undefined,
|
|
210
225
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
211
226
|
onProgress: (next) => {
|
|
212
227
|
if (next.status === "rendering" || next.status === "encoding") {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {log} from "../log";
|
|
2
|
+
import {keySource} from "../keystore";
|
|
3
|
+
import {musicProviders} from "../providers";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The directory of generation providers: what exists, what is connected, and
|
|
7
|
+
* where each key would come from. Read-only on purpose — the work happens in
|
|
8
|
+
* task commands like `odori bed --generate`, and configuration happens where
|
|
9
|
+
* keys live: the environment, or Studio's integrations page.
|
|
10
|
+
*/
|
|
11
|
+
export const integrationsCommand = async () => {
|
|
12
|
+
log.title("Integrations");
|
|
13
|
+
for (const provider of Object.values(musicProviders)) {
|
|
14
|
+
const source = await keySource(provider.keyVariable);
|
|
15
|
+
const status =
|
|
16
|
+
source === "environment"
|
|
17
|
+
? `connected (${provider.keyVariable})`
|
|
18
|
+
: source === "stored"
|
|
19
|
+
? "connected (stored on this machine)"
|
|
20
|
+
: `not configured — set ${provider.keyVariable}, or paste a key in Studio`;
|
|
21
|
+
log.info(` ${provider.name.padEnd(14)} music ${status}`);
|
|
22
|
+
}
|
|
23
|
+
log.detail("");
|
|
24
|
+
log.detail(' Generate through a task command: odori bed "warm ambient, no drums" --generate');
|
|
25
|
+
log.detail(" Keys are read from the environment first, then ~/.config/odori. Never the project.");
|
|
26
|
+
};
|