@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/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. A retry keeps the
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
+ };
@@ -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
+ };