@odori/cli 0.0.6 → 0.0.7

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.
@@ -1757,9 +1757,10 @@ var renderUrl = (origin, target, frame) => {
1757
1757
  if (target.prepared !== void 0) params.set("prepared", encodeParam(target.prepared));
1758
1758
  return `${origin}/?${params.toString()}`;
1759
1759
  };
1760
+ var BROWSER_ARGS = ["--enable-unsafe-swiftshader"];
1760
1761
  var openRenderPage = async (origin, target, config) => {
1761
1762
  const executablePath = await browserExecutable(config);
1762
- const browser = await chromium.launch({ executablePath, headless: true });
1763
+ const browser = await chromium.launch({ executablePath, headless: true, args: BROWSER_ARGS });
1763
1764
  const page = await browser.newPage({
1764
1765
  viewport: { width: target.width, height: target.height },
1765
1766
  deviceScaleFactor: 1
@@ -3427,15 +3428,36 @@ var CANVAS_SCRIPT = `(() => {
3427
3428
  });
3428
3429
 
3429
3430
  return canvases.map(function (canvas, index) {
3430
- var context = canvas.getContext("2d");
3431
- if (!context || canvas.width === 0 || canvas.height === 0) {
3431
+ if (canvas.width === 0 || canvas.height === 0) {
3432
3432
  return {index: index, hash: "no-context", blank: true};
3433
3433
  }
3434
3434
  var data;
3435
- try {
3436
- data = context.getImageData(0, 0, canvas.width, canvas.height).data;
3437
- } catch (error) {
3438
- return {index: index, hash: "tainted", blank: false};
3435
+ var isGl = false;
3436
+ var context = canvas.getContext("2d");
3437
+ if (context) {
3438
+ try {
3439
+ data = context.getImageData(0, 0, canvas.width, canvas.height).data;
3440
+ } catch (error) {
3441
+ return {index: index, hash: "tainted", blank: false};
3442
+ }
3443
+ } else {
3444
+ /*
3445
+ * A canvas that already holds a GL context returns null for "2d", and
3446
+ * reading that as an empty canvas reported every shader video as having
3447
+ * drawn nothing while it was drawing correctly. Pixels come back through
3448
+ * readPixels instead, which is the only way to see a GL surface.
3449
+ *
3450
+ * Worth knowing when this comes back empty: a drawing buffer is cleared
3451
+ * once it has been composited unless the context was asked for with
3452
+ * preserveDrawingBuffer, so an author who has not set that gets a real
3453
+ * frame on screen and nothing here.
3454
+ */
3455
+ var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
3456
+ if (!gl) return {index: index, hash: "no-context", blank: true};
3457
+ var pixels4 = new Uint8Array(canvas.width * canvas.height * 4);
3458
+ gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels4);
3459
+ data = pixels4;
3460
+ isGl = true;
3439
3461
  }
3440
3462
 
3441
3463
  // A prime stride over the whole buffer rather than a coarse grid: a grid
@@ -3451,7 +3473,7 @@ var CANVAS_SCRIPT = `(() => {
3451
3473
  hash =
3452
3474
  ((hash << 5) + hash + data[offset] + data[offset + 1] * 3 + data[offset + 2] * 7 + data[offset + 3] * 11) | 0;
3453
3475
  }
3454
- return {index: index, hash: String(hash), blank: opaque === 0};
3476
+ return {index: index, hash: String(hash), blank: opaque === 0, gl: isGl};
3455
3477
  });
3456
3478
  })()`;
3457
3479
  var FRAME_SCRIPT = `(() => {
@@ -3466,6 +3488,11 @@ var FRAME_SCRIPT = `(() => {
3466
3488
 
3467
3489
  for (var index = 0; index < nodes.length; index += 1) {
3468
3490
  var node = nodes[index];
3491
+ /* The subtree an effect surface is photographing is deliberately parked
3492
+ outside the frame. It is the input to a canvas, not something a viewer
3493
+ ever sees, so judging its position or its type size is judging the
3494
+ wrong thing. */
3495
+ if (node.closest("[data-odori-capture]")) continue;
3469
3496
  var box = node.getBoundingClientRect();
3470
3497
  if (box.width === 0 || box.height === 0) continue;
3471
3498
  var style = getComputedStyle(node);
@@ -3572,7 +3599,10 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3572
3599
  for (const canvas of canvases) {
3573
3600
  const second = again.find((item) => item.index === canvas.index);
3574
3601
  if (canvas.blank) {
3575
- failures.push({ video: id, message: `Frame ${frame}: canvas ${canvas.index} drew nothing.` });
3602
+ failures.push({
3603
+ video: id,
3604
+ message: canvas.gl ? `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}.` : `Frame ${frame}: canvas ${canvas.index} drew nothing.`
3605
+ });
3576
3606
  continue;
3577
3607
  }
3578
3608
  if (canvas.hash === "tainted") {
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  checkFlags,
3
3
  parseArgs,
4
4
  run
5
- } from "./chunk-6CE7U5KB.js";
5
+ } from "./chunk-STVORYOF.js";
6
6
  export {
7
7
  checkFlags,
8
8
  parseArgs,
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  withServer,
76
76
  writeGenerated,
77
77
  writePrepareCache
78
- } from "./chunk-6CE7U5KB.js";
78
+ } from "./chunk-STVORYOF.js";
79
79
  export {
80
80
  CHROME_BUILD,
81
81
  FORMATS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
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.6"
31
+ "odori": "0.0.7"
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.6"
39
+ "@odori/registry": "0.0.7"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
@@ -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.
@@ -81,6 +102,11 @@ const FRAME_SCRIPT = `(() => {
81
102
 
82
103
  for (var index = 0; index < nodes.length; index += 1) {
83
104
  var node = nodes[index];
105
+ /* The subtree an effect surface is photographing is deliberately parked
106
+ outside the frame. It is the input to a canvas, not something a viewer
107
+ ever sees, so judging its position or its type size is judging the
108
+ wrong thing. */
109
+ if (node.closest("[data-odori-capture]")) continue;
84
110
  var box = node.getBoundingClientRect();
85
111
  if (box.width === 0 || box.height === 0) continue;
86
112
  var style = getComputedStyle(node);
@@ -211,7 +237,12 @@ const testVideo = async (
211
237
  for (const canvas of canvases) {
212
238
  const second = again.find((item) => item.index === canvas.index);
213
239
  if (canvas.blank) {
214
- failures.push({video: id, message: `Frame ${frame}: canvas ${canvas.index} drew nothing.`});
240
+ failures.push({
241
+ video: id,
242
+ message: canvas.gl
243
+ ? `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}.`
244
+ : `Frame ${frame}: canvas ${canvas.index} drew nothing.`,
245
+ });
215
246
  continue;
216
247
  }
217
248
  if (canvas.hash === "tainted") {
package/src/render.ts CHANGED
@@ -35,6 +35,23 @@ const renderUrl = (origin: string, target: RenderTarget, frame: number) => {
35
35
  return `${origin}/?${params.toString()}`;
36
36
  };
37
37
 
38
+ /**
39
+ * Flags the render browser is launched with.
40
+ *
41
+ * Headless Chrome has no GPU, so a WebGL context is simply refused and
42
+ * `getContext("webgl2")` returns null: a video that post-processes through a
43
+ * shader renders black in the export and nowhere else, which is the worst
44
+ * shape a bug can take. This turns on ANGLE's software backend, which draws
45
+ * the same picture on any machine and is therefore the right one for a
46
+ * renderer that has to agree with itself across parallel workers.
47
+ *
48
+ * Measured against the pinned build rather than guessed. The combination that
49
+ * reads as obvious, `--use-gl=angle --use-angle=swiftshader`, leaves WebGL2
50
+ * off; this flag alone turns it on. Nothing changes for a video that never
51
+ * asks for a GL context.
52
+ */
53
+ export const BROWSER_ARGS = ["--enable-unsafe-swiftshader"];
54
+
38
55
  export type RenderPage = {browser: Browser; page: Page; errors: string[]};
39
56
 
40
57
  /** Open one video in render mode and wait for its first frame to mount. */
@@ -44,7 +61,7 @@ export const openRenderPage = async (
44
61
  config: ResolvedConfig,
45
62
  ): Promise<RenderPage> => {
46
63
  const executablePath = await browserExecutable(config);
47
- const browser = await chromium.launch({executablePath, headless: true});
64
+ const browser = await chromium.launch({executablePath, headless: true, args: BROWSER_ARGS});
48
65
  const page = await browser.newPage({
49
66
  viewport: {width: target.width, height: target.height},
50
67
  deviceScaleFactor: 1,