@odori/cli 0.0.7 → 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.
@@ -9,7 +9,7 @@ import type {ResolvedConfig} from "../config";
9
9
 
10
10
  type Failure = {video: string; message: string};
11
11
 
12
- type FrameReport = {overflow: string[]; small: string[]; empty: boolean; painted: number};
12
+ type FrameReport = {overflow: string[]; small: string[]; empty: boolean; painted: number; faded: number};
13
13
 
14
14
  /**
15
15
  * A fingerprint of every canvas on the frame.
@@ -92,12 +92,13 @@ type CanvasReport = Array<{index: number; hash: string; blank: boolean; gl?: boo
92
92
  */
93
93
  const FRAME_SCRIPT = `(() => {
94
94
  var root = document.querySelector("[data-odori-video]");
95
- if (!root) return {overflow: [], small: [], empty: true, painted: 0};
95
+ if (!root) return {overflow: [], small: [], empty: true, painted: 0, faded: 0};
96
96
 
97
97
  var bounds = root.getBoundingClientRect();
98
98
  var overflow = [];
99
99
  var small = [];
100
100
  var painted = 0;
101
+ var faded = 0;
101
102
  var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
102
103
 
103
104
  for (var index = 0; index < nodes.length; index += 1) {
@@ -110,7 +111,22 @@ const FRAME_SCRIPT = `(() => {
110
111
  var box = node.getBoundingClientRect();
111
112
  if (box.width === 0 || box.height === 0) continue;
112
113
  var style = getComputedStyle(node);
113
- if (style.visibility === "hidden" || Number(style.opacity) < 0.02) continue;
114
+ if (style.visibility === "hidden") continue;
115
+ /* Opacity inherits down the tree in effect even though it does not
116
+ inherit as a property: a parent faded to nothing takes its children
117
+ with it, while each child still computes its own opacity as 1. Reading
118
+ one node's value therefore judges text nobody can see. A scene that has
119
+ faded out is the common case, and it was reporting its hidden dialogue
120
+ as unreadable. */
121
+ var effective = 1;
122
+ for (var up = node; up && up !== root.parentElement; up = up.parentElement) {
123
+ effective *= Number(getComputedStyle(up).opacity);
124
+ if (effective < 0.02) break;
125
+ }
126
+ if (effective < 0.02) {
127
+ faded += 1;
128
+ continue;
129
+ }
114
130
  painted += 1;
115
131
 
116
132
  var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
@@ -135,12 +151,27 @@ const FRAME_SCRIPT = `(() => {
135
151
  * content the video is actually about, which is what stays checked.
136
152
  */
137
153
  var chrome = node.closest("[data-odori-chrome]") !== null;
138
- if (
139
- box.right > bounds.right + 1 ||
140
- box.left < bounds.left - 1 ||
141
- box.bottom > bounds.bottom + 1 ||
142
- box.top < bounds.top - 1
143
- ) {
154
+
155
+ /*
156
+ * Crossing the frame edge is not a fault. Film bleeds: a surface runs past
157
+ * the corner, a push-in takes a headline wider than the shot, a full-frame
158
+ * image is cropped rather than letterboxed. The old rule flagged any box
159
+ * that crossed by a pixel, which is a rule about a document, not about a
160
+ * cut, and the only reason the recreations passed it is that five sampled
161
+ * frames happened to miss their own bleeds.
162
+ *
163
+ * What is worth reporting is content with no intersection at all: nothing
164
+ * of it is on screen at the frame that was sampled, which is what a layout
165
+ * mistake looks like. Deliberate overscan does that too, and says so with
166
+ * data-odori-bleed.
167
+ */
168
+ var bleed = node.closest("[data-odori-bleed]") !== null;
169
+ var offCanvas =
170
+ box.right <= bounds.left ||
171
+ box.left >= bounds.right ||
172
+ box.bottom <= bounds.top ||
173
+ box.top >= bounds.bottom;
174
+ if (offCanvas && !bleed) {
144
175
  if (overflow.indexOf(label) < 0) overflow.push(label);
145
176
  }
146
177
 
@@ -164,7 +195,7 @@ const FRAME_SCRIPT = `(() => {
164
195
  }
165
196
  }
166
197
 
167
- return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted};
198
+ return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted, faded: faded};
168
199
  })()`;
169
200
 
170
201
  /**
@@ -186,7 +217,7 @@ const testVideo = async (
186
217
  if (!result.success) failures.push({video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}`});
187
218
  }
188
219
 
189
- const {browser, page} = await openRenderPage(origin, targetFor(video), config);
220
+ const {browser, page, errors} = await openRenderPage(origin, targetFor(video), config);
190
221
  try {
191
222
  const timeline = await readTimeline(page);
192
223
  // The track is compiled by the same pass the player and the encoder read,
@@ -206,16 +237,24 @@ const testVideo = async (
206
237
 
207
238
  const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor((total * 3) / 4), total - 1];
208
239
  for (const frame of [...new Set(samples)]) {
209
- await seekTo(page, frame);
240
+ await seekTo(page, frame, errors);
210
241
  const result = (await page.evaluate(FRAME_SCRIPT)) as FrameReport;
211
242
  if (result.empty) failures.push({video: id, message: `Frame ${frame} rendered no video root.`});
212
- if (!result.empty && result.painted < 2) {
243
+ /*
244
+ * A scene's fade envelope drives its first and last frame to zero
245
+ * opacity, so those two frames are blank on purpose in every video that
246
+ * does not opt out with transition="cut". What separates that from the
247
+ * failure worth reporting is whether anything was there to fade: a frame
248
+ * holding a faded-out tree drew its picture and dimmed it, while a frame
249
+ * holding nothing at all never built one.
250
+ */
251
+ if (!result.empty && result.painted < 2 && result.faded < 2) {
213
252
  failures.push({video: id, message: `Frame ${frame} is blank.`});
214
253
  }
215
254
  for (const item of result.overflow) {
216
255
  failures.push({
217
256
  video: id,
218
- message: `Frame ${frame}: ${item} escapes the ${layout.format.width}x${layout.format.height} canvas.`,
257
+ message: `Frame ${frame}: ${item} is entirely outside the ${layout.format.width}x${layout.format.height} canvas.`,
219
258
  });
220
259
  }
221
260
  for (const item of result.small) {
@@ -230,8 +269,8 @@ const testVideo = async (
230
269
  if (canvases.length > 0) {
231
270
  // Seek away and back, so the second read is a real re-render rather
232
271
  // than a second look at the same paint.
233
- await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
234
- await seekTo(page, frame);
272
+ await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1, errors);
273
+ await seekTo(page, frame, errors);
235
274
  const again = (await page.evaluate(CANVAS_SCRIPT)) as CanvasReport;
236
275
 
237
276
  for (const canvas of canvases) {
package/src/config.ts CHANGED
@@ -44,6 +44,11 @@ export type OdoriConfig = {
44
44
  skipUnchangedFrames?: boolean;
45
45
  /** Reuse encoded chunks whose frames still look identical. Defaults to true. */
46
46
  cacheChunks?: boolean;
47
+ /**
48
+ * Default providers for `--generate`, by kind. The choice is committed like
49
+ * every other decision; the keys never are.
50
+ */
51
+ generation?: {music?: string};
47
52
  };
48
53
 
49
54
  export const defaultConfig: OdoriConfig = {
package/src/jobs.ts CHANGED
@@ -5,7 +5,7 @@ import type {ExportJob, RenderManifest} from "odori";
5
5
  import type {ResolvedConfig} from "./config";
6
6
 
7
7
  /** How a job should be encoded, frozen with it so a retry cannot drift. */
8
- export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string; audio?: boolean};
8
+ export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string; audio?: boolean; graphics?: string};
9
9
 
10
10
  export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string; render?: JobRender};
11
11
 
@@ -0,0 +1,76 @@
1
+ import {existsSync} from "node:fs";
2
+ import {chmod, mkdir, readFile, rm, writeFile} from "node:fs/promises";
3
+ import {homedir} from "node:os";
4
+ import {dirname, join} from "node:path";
5
+
6
+ /**
7
+ * Provider keys, stored per machine.
8
+ *
9
+ * A key is personal, not project property: it belongs next to the login in
10
+ * `~/.config`, never inside a repository where one .gitignore mistake ships
11
+ * it. The environment always wins over this file, so nothing changes for a
12
+ * shell or a CI runner that already sets the variable — the store is the
13
+ * fallback for the key typed once into Studio.
14
+ *
15
+ * The file is written 0600 and read whole. There is no listing of values
16
+ * anywhere: callers ask for one variable and get one answer, and the Studio
17
+ * endpoint that fronts this reports only whether a key exists, never what it
18
+ * is.
19
+ */
20
+ const storePath = (): string =>
21
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "odori", "keys.json");
22
+
23
+ const readStore = async (): Promise<Record<string, string>> => {
24
+ const path = storePath();
25
+ if (!existsSync(path)) return {};
26
+ try {
27
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
28
+ if (typeof parsed !== "object" || parsed === null) return {};
29
+ return Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string")) as Record<
30
+ string,
31
+ string
32
+ >;
33
+ } catch {
34
+ // A corrupt store reads as empty rather than throwing: the recovery in
35
+ // either case is typing the key again, and a parse error should not take
36
+ // Studio down with it.
37
+ return {};
38
+ }
39
+ };
40
+
41
+ const writeStore = async (store: Record<string, string>): Promise<void> => {
42
+ const path = storePath();
43
+ if (Object.keys(store).length === 0) {
44
+ await rm(path, {force: true});
45
+ return;
46
+ }
47
+ await mkdir(dirname(path), {recursive: true, mode: 0o700});
48
+ await writeFile(path, JSON.stringify(store, null, 2) + "\n", {mode: 0o600});
49
+ // writeFile's mode only applies on creation; an existing file keeps its
50
+ // permissions, so they are asserted rather than assumed.
51
+ await chmod(path, 0o600);
52
+ };
53
+
54
+ /** The stored value for one variable, or nothing. Environment is the caller's job. */
55
+ export const storedKey = async (variable: string): Promise<string | undefined> => (await readStore())[variable];
56
+
57
+ export const setStoredKey = async (variable: string, value: string): Promise<void> => {
58
+ const store = await readStore();
59
+ store[variable] = value;
60
+ await writeStore(store);
61
+ };
62
+
63
+ export const clearStoredKey = async (variable: string): Promise<void> => {
64
+ const store = await readStore();
65
+ delete store[variable];
66
+ await writeStore(store);
67
+ };
68
+
69
+ /**
70
+ * Where a key would come from, for status displays. Never the key itself.
71
+ */
72
+ export const keySource = async (variable: string): Promise<"environment" | "stored" | null> => {
73
+ if (process.env[variable]) return "environment";
74
+ if ((await readStore())[variable]) return "stored";
75
+ return null;
76
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Generation providers.
3
+ *
4
+ * Generation is an authoring step, not a render step: a provider is called
5
+ * once, at the keyboard, and what it returns goes through the same prepare
6
+ * pass as a file from disk. The render never talks to any of this, which is
7
+ * how it stays deterministic while the way tracks are made keeps changing.
8
+ *
9
+ * Keys come from the environment and are never written to config: the user's
10
+ * key, the user's account, the user's output. Odori redistributes nothing.
11
+ */
12
+
13
+ import {storedKey} from "./keystore";
14
+
15
+ export type GeneratedAudio = {
16
+ bytes: Uint8Array;
17
+ /** File extension for the bytes, without the dot. */
18
+ extension: string;
19
+ };
20
+
21
+ export type MusicProvider = {
22
+ name: string;
23
+ /** Shown in Studio and `odori integrations`. */
24
+ title: string;
25
+ docsUrl: string;
26
+ /** The environment variable the key is read from. */
27
+ keyVariable: string;
28
+ generate(options: {prompt: string; seconds: number; apiKey: string}): Promise<GeneratedAudio>;
29
+ /**
30
+ * Whether a key is currently accepted, asked cheaply. Distinguishes a wrong
31
+ * key (false) from an unreachable provider (thrown), because Studio should
32
+ * refuse to store the first and shrug at the second.
33
+ */
34
+ verifyKey(apiKey: string): Promise<boolean>;
35
+ };
36
+
37
+ const elevenlabs: MusicProvider = {
38
+ name: "elevenlabs",
39
+ title: "ElevenLabs Music",
40
+ docsUrl: "https://elevenlabs.io/docs/api-reference/music",
41
+ keyVariable: "ELEVENLABS_API_KEY",
42
+ async verifyKey(apiKey) {
43
+ const response = await fetch("https://api.elevenlabs.io/v1/user", {
44
+ headers: {"xi-api-key": apiKey},
45
+ signal: AbortSignal.timeout(10_000),
46
+ });
47
+ if (response.ok) return true;
48
+ if (response.status === 401 || response.status === 403) return false;
49
+ throw new Error(`ElevenLabs answered ${response.status} to a key check.`);
50
+ },
51
+ async generate({prompt, seconds, apiKey}) {
52
+ const response = await fetch("https://api.elevenlabs.io/v1/music", {
53
+ method: "POST",
54
+ headers: {"xi-api-key": apiKey, "content-type": "application/json"},
55
+ body: JSON.stringify({prompt, music_length_ms: Math.round(seconds * 1000)}),
56
+ signal: AbortSignal.timeout(300_000),
57
+ });
58
+ if (!response.ok) {
59
+ const detail = await response.text().catch(() => "");
60
+ throw new Error(
61
+ `ElevenLabs returned ${response.status} ${response.statusText}.` +
62
+ (detail ? `\n${detail.slice(0, 400)}` : "") +
63
+ (response.status === 401 ? `\nIs ${elevenlabs.keyVariable} a current key?` : ""),
64
+ );
65
+ }
66
+ const type = response.headers.get("content-type") ?? "";
67
+ const extension = type.includes("wav") ? "wav" : type.includes("mp4") ? "m4a" : "mp3";
68
+ return {bytes: new Uint8Array(await response.arrayBuffer()), extension};
69
+ },
70
+ };
71
+
72
+ export const musicProviders: Record<string, MusicProvider> = {elevenlabs};
73
+
74
+ export const resolveMusicProvider = (name: string): MusicProvider => {
75
+ const provider = musicProviders[name];
76
+ if (!provider) {
77
+ throw new Error(
78
+ `No music provider named "${name}". Available: ${Object.keys(musicProviders).join(", ")}.`,
79
+ );
80
+ }
81
+ return provider;
82
+ };
83
+
84
+ /**
85
+ * The key for a provider: the environment first, then the machine store the
86
+ * Studio integrations page writes. Both are read here and nowhere else, so
87
+ * "where do keys come from" has one answer.
88
+ */
89
+ export const resolveKey = async (provider: MusicProvider): Promise<string | undefined> =>
90
+ process.env[provider.keyVariable] ?? (await storedKey(provider.keyVariable));