@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.7",
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.7"
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.7"
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
- `[mixed]loudnorm=I=${targetLufs}:TP=-1.5:LRA=11,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`,
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. A retry keeps the
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
+ };
@@ -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;
@@ -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
+ };