@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odori/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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.9"
|
|
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.8"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|
package/src/audio-mix.ts
CHANGED
|
@@ -126,7 +126,12 @@ export const buildAudioFilter = (
|
|
|
126
126
|
// loudnorm resamples to its own rate and can drop the layout on the way
|
|
127
127
|
// out, so the last link states the output format rather than negotiating
|
|
128
128
|
// it with whatever encoder is downstream.
|
|
129
|
-
|
|
129
|
+
/* No LRA target: a mix cannot be given a loudness range it does not have,
|
|
130
|
+
so asking for one states an intent the filter cannot honour. Measured
|
|
131
|
+
against the beds here it changes nothing either way, which is the point
|
|
132
|
+
— the range comes from the material, and asking for eleven from a bed
|
|
133
|
+
mastered to two only hides that. */
|
|
134
|
+
`[mixed]loudnorm=I=${targetLufs}:TP=-1.5,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`,
|
|
130
135
|
);
|
|
131
136
|
|
|
132
137
|
return {filter: parts.join(";"), label: "[audio]"};
|
package/src/cli.ts
CHANGED
|
@@ -7,9 +7,12 @@ 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";
|
|
15
|
+
import {narrateCommand} from "./commands/narrate";
|
|
13
16
|
import {frameCommand} from "./commands/frame";
|
|
14
17
|
import {testCommand} from "./commands/test";
|
|
15
18
|
|
|
@@ -23,7 +26,7 @@ type Flags = Record<string, string | boolean>;
|
|
|
23
26
|
* vanishes from the list, which is two failures for the price of one and
|
|
24
27
|
* neither of them says anything. A switch has no value to take.
|
|
25
28
|
*/
|
|
26
|
-
const BOOLEAN_FLAGS = new Set(["force", "dry-run", "json", "no-audio", "no-frame-skip", "no-open", "open", "help", "version"]);
|
|
29
|
+
const BOOLEAN_FLAGS = new Set(["force", "dry-run", "json", "no-audio", "fast", "no-frame-skip", "no-open", "open", "help", "version"]);
|
|
27
30
|
|
|
28
31
|
/** Commands that used to exist under another name. */
|
|
29
32
|
const RENAMED: Record<string, string> = {still: "frame"};
|
|
@@ -93,13 +96,16 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
93
96
|
new: ["blank"],
|
|
94
97
|
add: ["force", "dry-run"],
|
|
95
98
|
registry: [],
|
|
99
|
+
integrations: [],
|
|
96
100
|
diff: ["full"],
|
|
97
101
|
update: ["force"],
|
|
98
102
|
list: [],
|
|
99
103
|
inspect: ["json", "input"],
|
|
100
104
|
frame: ["at", "output", "input"],
|
|
105
|
+
bed: ["role", "output", "target", "generate", "provider", "seconds"],
|
|
106
|
+
narrate: ["output", "voice", "role", "provider"],
|
|
101
107
|
test: ["json"],
|
|
102
|
-
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "no-frame-skip", "retry"],
|
|
108
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
|
|
103
109
|
jobs: [],
|
|
104
110
|
help: [],
|
|
105
111
|
};
|
|
@@ -151,6 +157,21 @@ const USAGE: Record<string, string> = {
|
|
|
151
157
|
Discover project resources and start Studio.`,
|
|
152
158
|
init: `odori init
|
|
153
159
|
Add videos/ and odori.config.ts to a project.`,
|
|
160
|
+
bed: `odori bed <file> [--role <name>] [--target <lufs>] [--output <path>]
|
|
161
|
+
Prepare an audio file to sit under a video: measure it, level it to the stem
|
|
162
|
+
target every other bed is prepared to, and write it where audio is served.
|
|
163
|
+
With --generate the positional is a prompt instead of a path: the track is
|
|
164
|
+
generated with a provider (--provider, default elevenlabs, key from
|
|
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.`,
|
|
171
|
+
integrations: `odori integrations
|
|
172
|
+
List generation providers and whether each is connected. Configuration
|
|
173
|
+
lives in the environment or Studio's integrations page; generation happens
|
|
174
|
+
in task commands like "odori bed --generate".`,
|
|
154
175
|
doctor: `odori doctor
|
|
155
176
|
Check Node, React, the source root, Chrome, FFmpeg, and the generated cache.`,
|
|
156
177
|
install: `odori install
|
|
@@ -181,11 +202,15 @@ const USAGE: Record<string, string> = {
|
|
|
181
202
|
check, for CI.`,
|
|
182
203
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
183
204
|
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
184
|
-
[--no-audio] [--no-frame-skip] [--retry <job>]
|
|
205
|
+
[--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
|
|
185
206
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
186
207
|
or png; without it the output's extension decides, and mp4 is the default.
|
|
187
208
|
--quality is studio, social, or web. --scale multiplies the output size,
|
|
188
|
-
0.25 to 2. --no-audio writes the picture with no sound.
|
|
209
|
+
0.25 to 2. --no-audio writes the picture with no sound.
|
|
210
|
+
--fast draws on this machine's GPU rather than the reproducible software
|
|
211
|
+
backend. Measured here: about five percent on ordinary post-processing, and
|
|
212
|
+
fourteen times on a shader that is genuinely per-pixel expensive. The pixels
|
|
213
|
+
it makes belong to this machine, so keep it for iterating. A retry keeps the
|
|
189
214
|
settings its job was created with.`,
|
|
190
215
|
jobs: `odori jobs
|
|
191
216
|
List export jobs and their status.`,
|
|
@@ -208,6 +233,7 @@ Usage
|
|
|
208
233
|
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
209
234
|
odori test [id] [--json] Validate contracts and representative frames
|
|
210
235
|
odori export <id> [--output f] Render and encode a distributable file
|
|
236
|
+
odori narrate <script> Record narration with word timings
|
|
211
237
|
odori jobs List export jobs and their status
|
|
212
238
|
|
|
213
239
|
Options
|
|
@@ -270,6 +296,9 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
270
296
|
case "init":
|
|
271
297
|
await initCommand();
|
|
272
298
|
return 0;
|
|
299
|
+
case "integrations":
|
|
300
|
+
await integrationsCommand();
|
|
301
|
+
return 0;
|
|
273
302
|
case "doctor":
|
|
274
303
|
return await doctorCommand();
|
|
275
304
|
case "install":
|
|
@@ -295,6 +324,25 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
295
324
|
case "inspect":
|
|
296
325
|
await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
|
|
297
326
|
return 0;
|
|
327
|
+
case "bed":
|
|
328
|
+
if (!positionals[0]) throw new Error('Which file? "odori bed ./track.mp3".');
|
|
329
|
+
await bedCommand(positionals[0], {
|
|
330
|
+
role: typeof flags.role === "string" ? flags.role : undefined,
|
|
331
|
+
output: typeof flags.output === "string" ? flags.output : undefined,
|
|
332
|
+
target: numberFlag(flags, "target"),
|
|
333
|
+
generate: flags.generate === true,
|
|
334
|
+
provider: typeof flags.provider === "string" ? flags.provider : undefined,
|
|
335
|
+
seconds: numberFlag(flags, "seconds"),
|
|
336
|
+
});
|
|
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;
|
|
298
346
|
case "frame":
|
|
299
347
|
await frameCommand(positionals[0] ?? "", {
|
|
300
348
|
// A duration, so "4s" and "120f" both work; a bare number is
|
|
@@ -317,6 +365,7 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
317
365
|
scale: numberFlag(flags, "scale"),
|
|
318
366
|
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
319
367
|
audio: flags["no-audio"] === true ? false : undefined,
|
|
368
|
+
fast: flags.fast === true,
|
|
320
369
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
|
|
321
370
|
retry: typeof flags.retry === "string" ? flags.retry : undefined,
|
|
322
371
|
});
|
|
@@ -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
|
+
};
|