@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.
@@ -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.
@@ -35,15 +35,36 @@ const CANVAS_SCRIPT = `(() => {
35
35
  });
36
36
 
37
37
  return canvases.map(function (canvas, index) {
38
- var context = canvas.getContext("2d");
39
- if (!context || canvas.width === 0 || canvas.height === 0) {
38
+ if (canvas.width === 0 || canvas.height === 0) {
40
39
  return {index: index, hash: "no-context", blank: true};
41
40
  }
42
41
  var data;
43
- try {
44
- data = context.getImageData(0, 0, canvas.width, canvas.height).data;
45
- } catch (error) {
46
- return {index: index, hash: "tainted", blank: false};
42
+ var isGl = false;
43
+ var context = canvas.getContext("2d");
44
+ if (context) {
45
+ try {
46
+ data = context.getImageData(0, 0, canvas.width, canvas.height).data;
47
+ } catch (error) {
48
+ return {index: index, hash: "tainted", blank: false};
49
+ }
50
+ } else {
51
+ /*
52
+ * A canvas that already holds a GL context returns null for "2d", and
53
+ * reading that as an empty canvas reported every shader video as having
54
+ * drawn nothing while it was drawing correctly. Pixels come back through
55
+ * readPixels instead, which is the only way to see a GL surface.
56
+ *
57
+ * Worth knowing when this comes back empty: a drawing buffer is cleared
58
+ * once it has been composited unless the context was asked for with
59
+ * preserveDrawingBuffer, so an author who has not set that gets a real
60
+ * frame on screen and nothing here.
61
+ */
62
+ var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
63
+ if (!gl) return {index: index, hash: "no-context", blank: true};
64
+ var pixels4 = new Uint8Array(canvas.width * canvas.height * 4);
65
+ gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels4);
66
+ data = pixels4;
67
+ isGl = true;
47
68
  }
48
69
 
49
70
  // A prime stride over the whole buffer rather than a coarse grid: a grid
@@ -59,11 +80,11 @@ const CANVAS_SCRIPT = `(() => {
59
80
  hash =
60
81
  ((hash << 5) + hash + data[offset] + data[offset + 1] * 3 + data[offset + 2] * 7 + data[offset + 3] * 11) | 0;
61
82
  }
62
- return {index: index, hash: String(hash), blank: opaque === 0};
83
+ return {index: index, hash: String(hash), blank: opaque === 0, gl: isGl};
63
84
  });
64
85
  })()`;
65
86
 
66
- type CanvasReport = Array<{index: number; hash: string; blank: boolean}>;
87
+ type CanvasReport = Array<{index: number; hash: string; blank: boolean; gl?: boolean}>;
67
88
 
68
89
  /**
69
90
  * Evaluated as source in the page so no bundler helpers leak into the browser.
@@ -71,20 +92,41 @@ type CanvasReport = Array<{index: number; hash: string; blank: boolean}>;
71
92
  */
72
93
  const FRAME_SCRIPT = `(() => {
73
94
  var root = document.querySelector("[data-odori-video]");
74
- if (!root) return {overflow: [], small: [], empty: true, painted: 0};
95
+ if (!root) return {overflow: [], small: [], empty: true, painted: 0, faded: 0};
75
96
 
76
97
  var bounds = root.getBoundingClientRect();
77
98
  var overflow = [];
78
99
  var small = [];
79
100
  var painted = 0;
101
+ var faded = 0;
80
102
  var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
81
103
 
82
104
  for (var index = 0; index < nodes.length; index += 1) {
83
105
  var node = nodes[index];
106
+ /* The subtree an effect surface is photographing is deliberately parked
107
+ outside the frame. It is the input to a canvas, not something a viewer
108
+ ever sees, so judging its position or its type size is judging the
109
+ wrong thing. */
110
+ if (node.closest("[data-odori-capture]")) continue;
84
111
  var box = node.getBoundingClientRect();
85
112
  if (box.width === 0 || box.height === 0) continue;
86
113
  var style = getComputedStyle(node);
87
- 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
+ }
88
130
  painted += 1;
89
131
 
90
132
  var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
@@ -109,12 +151,27 @@ const FRAME_SCRIPT = `(() => {
109
151
  * content the video is actually about, which is what stays checked.
110
152
  */
111
153
  var chrome = node.closest("[data-odori-chrome]") !== null;
112
- if (
113
- box.right > bounds.right + 1 ||
114
- box.left < bounds.left - 1 ||
115
- box.bottom > bounds.bottom + 1 ||
116
- box.top < bounds.top - 1
117
- ) {
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) {
118
175
  if (overflow.indexOf(label) < 0) overflow.push(label);
119
176
  }
120
177
 
@@ -138,7 +195,7 @@ const FRAME_SCRIPT = `(() => {
138
195
  }
139
196
  }
140
197
 
141
- 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};
142
199
  })()`;
143
200
 
144
201
  /**
@@ -160,7 +217,7 @@ const testVideo = async (
160
217
  if (!result.success) failures.push({video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}`});
161
218
  }
162
219
 
163
- const {browser, page} = await openRenderPage(origin, targetFor(video), config);
220
+ const {browser, page, errors} = await openRenderPage(origin, targetFor(video), config);
164
221
  try {
165
222
  const timeline = await readTimeline(page);
166
223
  // The track is compiled by the same pass the player and the encoder read,
@@ -180,16 +237,24 @@ const testVideo = async (
180
237
 
181
238
  const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor((total * 3) / 4), total - 1];
182
239
  for (const frame of [...new Set(samples)]) {
183
- await seekTo(page, frame);
240
+ await seekTo(page, frame, errors);
184
241
  const result = (await page.evaluate(FRAME_SCRIPT)) as FrameReport;
185
242
  if (result.empty) failures.push({video: id, message: `Frame ${frame} rendered no video root.`});
186
- 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) {
187
252
  failures.push({video: id, message: `Frame ${frame} is blank.`});
188
253
  }
189
254
  for (const item of result.overflow) {
190
255
  failures.push({
191
256
  video: id,
192
- 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.`,
193
258
  });
194
259
  }
195
260
  for (const item of result.small) {
@@ -204,14 +269,19 @@ const testVideo = async (
204
269
  if (canvases.length > 0) {
205
270
  // Seek away and back, so the second read is a real re-render rather
206
271
  // than a second look at the same paint.
207
- await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
208
- 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);
209
274
  const again = (await page.evaluate(CANVAS_SCRIPT)) as CanvasReport;
210
275
 
211
276
  for (const canvas of canvases) {
212
277
  const second = again.find((item) => item.index === canvas.index);
213
278
  if (canvas.blank) {
214
- failures.push({video: id, message: `Frame ${frame}: canvas ${canvas.index} drew nothing.`});
279
+ failures.push({
280
+ video: id,
281
+ message: canvas.gl
282
+ ? `Frame ${frame}: canvas ${canvas.index} read back empty. A WebGL drawing buffer is cleared once it has been composited, so ask for the context with {preserveDrawingBuffer: true}.`
283
+ : `Frame ${frame}: canvas ${canvas.index} drew nothing.`,
284
+ });
215
285
  continue;
216
286
  }
217
287
  if (canvas.hash === "tainted") {
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));