@neta-art/cohub-cli 10.3.0 → 10.4.1

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/README.md CHANGED
@@ -55,6 +55,36 @@ and, if needed, `COHUB_AUTH_RESOURCE` and `COHUB_AUTH_SCOPE`. Stored login sessi
55
55
  Home Space caches, and Runtime directory bindings are scoped to the API base URL.
56
56
  Sign in once for each custom server, or supply `COHUB_EXECUTION_TOKEN`.
57
57
 
58
+ ## Board export resources
59
+
60
+ Board exports keep the requested resolution and source image quality. Before rendering,
61
+ `boards export` prints the output dimensions and frame count (except with `--json`).
62
+ Decoded image textures, including background and script output images, share a 256 MiB
63
+ budget. Oversized images fail before native decoding rather than silently becoming
64
+ placeholders. Use `--image-memory <MiB>` to raise the budget when the environment has
65
+ sufficient memory; it is an image budget, not a limit on total process memory.
66
+ `--size` controls the viewport only with `--camera`.
67
+
68
+ ```bash
69
+ cohub boards export lesson.board --animation lecture --at 0:92s:40ms --camera --size 1600x900 -o lecture.mp4
70
+ NODE_DEBUG=cohub:board-export cohub boards export lesson.board --animation lecture --at 0:92s:40ms -o lecture.mp4
71
+ ```
72
+
73
+ Debug output reports render stages, RSS, heap, external buffers, reserved image bytes,
74
+ and available/constrained process memory. Video encoding is reported separately.
75
+ MP4 exports require even output dimensions and fail before rendering if the initial
76
+ export dimensions are odd. Video frame dimensions must remain constant.
77
+
78
+ For a repeatable 2,301-frame native export memory benchmark, run from the repository root:
79
+
80
+ ```bash
81
+ node --import tsx packages/cli/scripts/bench-board-export.ts
82
+ ```
83
+
84
+ Set `BOARD_EXPORT_BENCH_FRAMES`, `BOARD_EXPORT_BENCH_WIDTH`, or
85
+ `BOARD_EXPORT_BENCH_HEIGHT` to change the workload. The benchmark discards PNGs
86
+ as they are produced and stops if RSS exceeds 1 GiB.
87
+
58
88
  ## Usage
59
89
 
60
90
  Use `--json` when reading output for decisions, extracting IDs, or chaining commands.
@@ -0,0 +1,8 @@
1
+ import type { BoardNodeRenderer } from "@neta-art/cohub/board/export/node";
2
+ export declare const BOARD_EXPORT_IMAGE_MEMORY_MIB = 256;
3
+ export declare class BoardExportResourceError extends Error {
4
+ }
5
+ export declare function createBoardExportImageBudget(renderer: BoardNodeRenderer, maxBytes: number): {
6
+ decodeImage: (bytes: Uint8Array, mimeType?: string) => Promise<import("@neta-art/cohub/board/export/node").BoardNodeTexture>;
7
+ readonly usedBytes: number;
8
+ };
@@ -0,0 +1,39 @@
1
+ export const BOARD_EXPORT_IMAGE_MEMORY_MIB = 256;
2
+ export class BoardExportResourceError extends Error {
3
+ }
4
+ export function createBoardExportImageBudget(renderer, maxBytes) {
5
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
6
+ throw new Error("Image memory budget must be a positive integer.");
7
+ let usedBytes = 0;
8
+ const decodeImage = async (bytes, mimeType) => {
9
+ const { default: sharp } = await import("sharp");
10
+ const { width, height } = await sharp(bytes, { limitInputPixels: false }).metadata();
11
+ const required = (width ?? 0) * (height ?? 0) * 4;
12
+ if (!Number.isSafeInteger(required) || required <= 0)
13
+ throw new Error("Image dimensions could not be read.");
14
+ if (required > maxBytes - usedBytes) {
15
+ throw new BoardExportResourceError(`Image ${width}x${height} needs ${Math.ceil(required / 1024 ** 2)} MiB; ` +
16
+ `${Math.ceil(usedBytes / 1024 ** 2)} MiB is already reserved against the ${Math.floor(maxBytes / 1024 ** 2)} MiB image budget. ` +
17
+ "Use smaller source images, export a smaller region, or increase --image-memory if enough memory is available.");
18
+ }
19
+ usedBytes += required;
20
+ try {
21
+ const texture = await renderer.decodeImage(bytes, mimeType);
22
+ const destroy = texture.destroy.bind(texture);
23
+ let released = false;
24
+ texture.destroy = (destroySource) => {
25
+ if (destroySource && !released) {
26
+ released = true;
27
+ usedBytes -= required;
28
+ }
29
+ destroy(destroySource);
30
+ };
31
+ return texture;
32
+ }
33
+ catch (error) {
34
+ usedBytes -= required;
35
+ throw error;
36
+ }
37
+ };
38
+ return { decodeImage, get usedBytes() { return usedBytes; } };
39
+ }
@@ -38,6 +38,12 @@ export type BoardExportRunOptions = {
38
38
  };
39
39
  document?: BoardDocument;
40
40
  signal?: AbortSignal;
41
+ imageMemoryMiB?: number;
42
+ onPlan?: (plan: {
43
+ width: number;
44
+ height: number;
45
+ frames: number;
46
+ }) => void;
41
47
  onFrame?: (frame: BoardExportFrame, index: number) => Promise<void>;
42
48
  onProgress?: (completed: number, total: number) => void;
43
49
  };
@@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile, copyFile, link, rename, unlink } from "node:fs/
3
3
  import { constants } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { execFile as execFileCallback } from "node:child_process";
6
- import { promisify } from "node:util";
6
+ import { debuglog, promisify } from "node:util";
7
7
  import { createRequire } from "node:module";
8
8
  import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -15,6 +15,8 @@ import { resolveBoardId } from "./board-command-support.js";
15
15
  import { BOARD_EXPORT_FONTS } from "./board-fonts.js";
16
16
  import { createClient } from "./client.js";
17
17
  import { downloadPublicAudio, downloadPublicImage } from "./safe-remote-image.js";
18
+ import { BOARD_EXPORT_IMAGE_MEMORY_MIB, BoardExportResourceError, createBoardExportImageBudget } from "./board-export-resources.js";
19
+ const debugExport = debuglog("cohub:board-export");
18
20
  export function resolveBundledFonts() {
19
21
  const require = createRequire(import.meta.url);
20
22
  const bundled = fileURLToPath(new URL("./fonts/", import.meta.url));
@@ -66,10 +68,13 @@ export async function loadBoardTextures(renderer, spaceId, items, options = {})
66
68
  if (!wanted.has(key))
67
69
  failed.delete(key);
68
70
  const pending = selection.keys.filter((key) => !textures.has(key) && !failed.has(key));
69
- const concurrency = Math.max(1, Math.min(options.concurrency ?? 4, 16));
71
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? 2, 16));
70
72
  const client = createClient();
73
+ let resourceError;
71
74
  async function worker() {
72
75
  for (;;) {
76
+ if (resourceError)
77
+ return;
73
78
  const key = pending.shift();
74
79
  if (!key)
75
80
  return;
@@ -85,12 +90,17 @@ export async function loadBoardTextures(renderer, spaceId, items, options = {})
85
90
  const texture = await renderer.decodeImage(bytes, mimeType);
86
91
  textures.set(key, texture);
87
92
  }
88
- catch {
89
- failed.add(key);
93
+ catch (error) {
94
+ if (error instanceof BoardExportResourceError)
95
+ resourceError = error;
96
+ else
97
+ failed.add(key);
90
98
  }
91
99
  }
92
100
  }
93
101
  await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
102
+ if (resourceError)
103
+ throw resourceError;
94
104
  return { textures, failed: [...failed], omitted: selection.omittedKeys };
95
105
  }
96
106
  async function readSpaceFileBytes(client, spaceId, path) {
@@ -117,7 +127,14 @@ export async function runBoardVideoExport(options) {
117
127
  try {
118
128
  let count = 0;
119
129
  let dimensions = { width: 0, height: 0 };
120
- const result = await runBoardExport({ ...options, document, format: "png", onFrame: async (frame, index) => {
130
+ const result = await runBoardExport({ ...options, document, format: "png", onPlan: (plan) => {
131
+ if (options.format === "mp4" && (plan.width % 2 || plan.height % 2)) {
132
+ throw new Error(`MP4 needs even dimensions, but the export is ${plan.width}x${plan.height}. Use --camera --size with even dimensions, or adjust the export region.`);
133
+ }
134
+ options.onPlan?.(plan);
135
+ }, onFrame: async (frame, index) => {
136
+ if (count && (frame.width !== dimensions.width || frame.height !== dimensions.height))
137
+ throw new Error("Video frame dimensions changed. Use a fixed export region or a smaller camera viewport.");
121
138
  await writeFile(join(tempDir, `${String(index + 1).padStart(6, "0")}.png`), frame.bytes, { signal: options.signal });
122
139
  dimensions = { width: frame.width, height: frame.height };
123
140
  count++;
@@ -160,6 +177,7 @@ export async function runBoardVideoExport(options) {
160
177
  args.push("-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", "-c:a", "libopus", "-f", "webm");
161
178
  const encoded = join(tempDir, `video.${options.format}`);
162
179
  args.push("-t", duration.toFixed(3), encoded);
180
+ debugExport("%j", { stage: "encode", width: dimensions.width, height: dimensions.height, frames: count, memory: process.memoryUsage() });
163
181
  try {
164
182
  await execFile("ffmpeg", args, { encoding: "utf8", maxBuffer: 2 * 1024 * 1024, signal: options.signal });
165
183
  }
@@ -195,7 +213,22 @@ export async function runBoardExport(options) {
195
213
  throw new Error(`Animation ${options.animation} does not exist.`);
196
214
  }
197
215
  const times = options.times?.length ? options.times : [null];
198
- const renderer = await createNodeBoardRenderer({ fonts: resolveBundledFonts() });
216
+ const imageMemoryBytes = (options.imageMemoryMiB ?? BOARD_EXPORT_IMAGE_MEMORY_MIB) * 1024 ** 2;
217
+ if (!Number.isSafeInteger(imageMemoryBytes) || imageMemoryBytes <= 0)
218
+ throw new Error("Image memory budget must be a positive integer.");
219
+ const nodeRenderer = await createNodeBoardRenderer({ fonts: resolveBundledFonts() });
220
+ const images = createBoardExportImageBudget(nodeRenderer, imageMemoryBytes);
221
+ const renderer = { ...nodeRenderer, decodeImage: images.decodeImage };
222
+ const trace = (stage, completed, dimensions) => {
223
+ if (!debugExport.enabled || completed % 30 !== 0)
224
+ return;
225
+ debugExport("%j", {
226
+ stage, completed, total: times.length,
227
+ ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
228
+ imageBytes: images.usedBytes, memory: process.memoryUsage(),
229
+ availableMemory: process.availableMemory(), constrainedMemory: process.constrainedMemory(),
230
+ });
231
+ };
199
232
  let scriptHost = null;
200
233
  const textureCache = { textures: new Map(), failed: new Set() };
201
234
  let backgroundTexture;
@@ -210,6 +243,17 @@ export async function runBoardExport(options) {
210
243
  });
211
244
  if (!plan)
212
245
  return null;
246
+ const region = times.length > 1 && !options.camera ? { kind: "rect", rect: plan.world } : options.region;
247
+ const firstTime = times[0] ?? null;
248
+ const first = prepareBoardExport(document, {
249
+ sampler, region, camera: options.camera, scale: options.scale,
250
+ padding: times.length > 1 ? 0 : options.padding,
251
+ ...(firstTime === null ? {} : { at: { animation: options.animation, time: firstTime } }),
252
+ });
253
+ if (!first)
254
+ return null;
255
+ options.onPlan?.({ width: first.plan.width, height: first.plan.height, frames: times.length });
256
+ trace("plan", 0, first.plan);
213
257
  const warnings = [];
214
258
  const textures = textureCache.textures;
215
259
  let omittedKeys = new Set();
@@ -222,19 +266,21 @@ export async function runBoardExport(options) {
222
266
  const { bytes, mimeType } = await downloadPublicImage(declaredBackground.imageUrl);
223
267
  backgroundTexture = await renderer.decodeImage(bytes, mimeType);
224
268
  }
225
- catch {
269
+ catch (error) {
270
+ if (error instanceof BoardExportResourceError)
271
+ throw error;
226
272
  warnings.push("The board background image could not be loaded; the fallback color was exported.");
227
273
  }
228
274
  }
229
275
  const videoCount = plan.items.filter((item) => item.type === "video").length;
230
276
  if (hasBoardScripts(document))
231
277
  scriptHost = createNodeBoardScriptHost(renderer, {
278
+ concurrency: 2,
232
279
  readModule: async (src) => {
233
280
  const { bytes } = await readSpaceFileBytes(createClient(), options.spaceId, src);
234
281
  return new TextDecoder().decode(bytes);
235
282
  },
236
283
  });
237
- const region = times.length > 1 && !options.camera ? { kind: "rect", rect: plan.world } : options.region;
238
284
  const host = scriptHost;
239
285
  if (videoCount > 0) {
240
286
  warnings.push(`${videoCount} video preview${videoCount === 1 ? " was" : "s were"} drawn as placeholders; video decoding is unavailable in Node.`);
@@ -259,6 +305,7 @@ export async function runBoardExport(options) {
259
305
  if (!initial)
260
306
  continue;
261
307
  let prepared = initial;
308
+ trace("prepare", completed, initial.plan);
262
309
  if (host) {
263
310
  await host.prepare(initial.scene.items, [time ?? 0], initial.plan.scale);
264
311
  for (const item of initial.scene.items) {
@@ -271,6 +318,7 @@ export async function runBoardExport(options) {
271
318
  continue;
272
319
  prepared = scripted;
273
320
  }
321
+ trace("images", completed, prepared.plan);
274
322
  if (options.withImages) {
275
323
  const loaded = await loadBoardTextures(renderer, options.spaceId, prepared.plan.items.filter((item) => prepared.scene.opacity(item.id) > 0), { cache: textureCache });
276
324
  omittedKeys = new Set(loaded.omitted);
@@ -284,6 +332,7 @@ export async function runBoardExport(options) {
284
332
  reported.add(warning);
285
333
  }
286
334
  }
335
+ trace("render", completed, prepared.plan);
287
336
  const result = exportBoardImageBytes(renderer, document, {
288
337
  ...frameOptions,
289
338
  colorScheme: options.colorScheme,
@@ -320,6 +369,7 @@ export async function runBoardExport(options) {
320
369
  frames.push(frame);
321
370
  completed++;
322
371
  options.onProgress?.(completed, times.length);
372
+ trace("rendered", completed, result.plan);
323
373
  }
324
374
  return completed ? { frames, warnings } : null;
325
375
  }
@@ -1,4 +1,4 @@
1
1
  export { BOARD_SCRIPT_DRAWING_EXAMPLE, hasBoardScripts, boardDocumentAt, createBoardAnimationSampler, boardSequence, boardSequencePatch, boardPathTrack, boardImageKeySource, buildBoardScene, imageAssetKey, parseBoardDocument, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
2
2
  export { prepareBoardExport } from "@neta-art/cohub/board/export";
3
- export { createBoardRenderSampler, inspectBoardScene, measureBoardText } from "@neta-art/cohub/board/render";
3
+ export { createBoardRenderSampler, inspectBoardScene, inspectBoardItems, measureBoardText } from "@neta-art/cohub/board/render";
4
4
  export { createNodeBoardRenderer, createNodeBoardScriptHost, exportBoardImageBytes } from "@neta-art/cohub/board/export/node";
@@ -1,4 +1,4 @@
1
- import { $ as groupD8, A as bgr2rgb, B as Bounds, C as Sprite, D as TexturePool, E as RenderGroup, G as TextureStyle, H as Texture, I as BigPool, J as deprecation, K as definedProps, L as GlobalResourceRegistry, M as multiplyHexColors, N as getGlobalBounds, O as InstructionSet, P as FilterEffect, Q as Rectangle, R as warn, S as DOMAdapter, T as Container, U as TextureMatrix, V as updateQuadBounds, W as TextureSource, X as v8_3_4, Y as v8_0_0, Z as uid$1, _ as Cache, a as Geometry, at as extensions, b as ImageSource, c as Filter, d as RendererType, et as Matrix, f as BindGroup, g as getTestContext, h as GlProgram, i as canUseNewCanvasBlendModes, it as ExtensionType, j as multiplyColors, k as getLocalBounds, l as State, m as GpuProgram, n as CanvasPool, nt as eventemitter3_default, o as Buffer$1, p as UniformGroup, q as nextPow2, r as canvasUtils, rt as ObservablePoint, s as BufferUsage, t as getPo2TextureFromSource, tt as Point, u as Shader, v as Ticker, w as ViewContainer, x as CanvasSource, z as Color } from "./board-kernel/getPo2TextureFromSource.js";
1
+ import { $ as groupD8, A as bgr2rgb, B as Bounds, C as Sprite, D as TexturePool, E as RenderGroup, G as TextureStyle, H as Texture, I as BigPool, J as deprecation, K as definedProps, L as GlobalResourceRegistry, M as multiplyHexColors, N as getGlobalBounds, O as InstructionSet, P as FilterEffect, Q as Rectangle, R as warn, S as DOMAdapter, T as Container, U as TextureMatrix, V as updateQuadBounds, W as TextureSource, X as v8_3_4, Y as v8_0_0, Z as uid$1, _ as Cache, a as Geometry, at as extensions, b as ImageSource, c as Filter, d as RendererType, et as Matrix, f as BindGroup, g as getTestContext, h as GlProgram, i as canUseNewCanvasBlendModes, it as ExtensionType, j as multiplyColors, k as getLocalBounds, l as State, m as GpuProgram, n as CanvasPool, nt as eventemitter3_default, o as Buffer, p as UniformGroup, q as nextPow2, r as canvasUtils, rt as ObservablePoint, s as BufferUsage, t as getPo2TextureFromSource, tt as Point, u as Shader, v as Ticker, w as ViewContainer, x as CanvasSource, z as Color } from "./board-kernel/getPo2TextureFromSource.js";
2
2
  import { Worker } from "node:worker_threads";
3
3
  import { fileURLToPath } from "node:url";
4
4
  //#region ../../node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/core/util.js
@@ -12305,13 +12305,13 @@ const placeHolderBufferData = /* @__PURE__ */ new Float32Array(1);
12305
12305
  const placeHolderIndexData = /* @__PURE__ */ new Uint32Array(1);
12306
12306
  var BatchGeometry = class extends Geometry {
12307
12307
  constructor() {
12308
- const attributeBuffer = new Buffer$1({
12308
+ const attributeBuffer = new Buffer({
12309
12309
  data: placeHolderBufferData,
12310
12310
  label: "attribute-batch-buffer",
12311
12311
  usage: BufferUsage.VERTEX | BufferUsage.COPY_DST,
12312
12312
  shrinkToFit: false
12313
12313
  });
12314
- const indexBuffer = new Buffer$1({
12314
+ const indexBuffer = new Buffer({
12315
12315
  data: placeHolderIndexData,
12316
12316
  label: "index-batch-buffer",
12317
12317
  usage: BufferUsage.INDEX | BufferUsage.COPY_DST,
@@ -18288,19 +18288,19 @@ const _MeshGeometry = class _MeshGeometry extends Geometry {
18288
18288
  3
18289
18289
  ]);
18290
18290
  const shrinkToFit = options.shrinkBuffersToFit;
18291
- const positionBuffer = new Buffer$1({
18291
+ const positionBuffer = new Buffer({
18292
18292
  data: positions,
18293
18293
  label: "attribute-mesh-positions",
18294
18294
  shrinkToFit,
18295
18295
  usage: BufferUsage.VERTEX | BufferUsage.COPY_DST
18296
18296
  });
18297
- const uvBuffer = new Buffer$1({
18297
+ const uvBuffer = new Buffer({
18298
18298
  data: uvs,
18299
18299
  label: "attribute-mesh-uvs",
18300
18300
  shrinkToFit,
18301
18301
  usage: BufferUsage.VERTEX | BufferUsage.COPY_DST
18302
18302
  });
18303
- const indexBuffer = new Buffer$1({
18303
+ const indexBuffer = new Buffer({
18304
18304
  data: indices,
18305
18305
  label: "index-mesh-buffer",
18306
18306
  shrinkToFit,
@@ -27161,12 +27161,24 @@ function measureBoardText(props) {
27161
27161
  missingCharacters: characters
27162
27162
  };
27163
27163
  }
27164
- function inspectBoardScene(scene, limit = 100) {
27164
+ function inspectBoardItems(scene, ids) {
27165
+ return {
27166
+ diagnostics: inspectBoardScene(scene, 100, ids),
27167
+ items: scene.items.filter((item) => !ids || ids.has(item.id)).map((item) => ({
27168
+ id: item.id,
27169
+ bounds: item.frame,
27170
+ worldBounds: itemBounds(item.frame),
27171
+ opacity: scene.opacity(item.id),
27172
+ ...item.type === "text" ? { text: measureBoardText(item.props) } : {}
27173
+ }))
27174
+ };
27175
+ }
27176
+ function inspectBoardScene(scene, limit = 100, ids) {
27165
27177
  ensureBoardTextMeasurement();
27166
27178
  const diagnostics = [];
27167
27179
  const texts = scene.items.filter((item) => item.type === "text" && scene.opacity(item.id) > .01);
27168
27180
  const push = (entry) => {
27169
- if (diagnostics.length < limit) diagnostics.push(entry);
27181
+ if (diagnostics.length < limit && (!ids || ids.has(entry.item) || Boolean(entry.related && ids.has(entry.related)))) diagnostics.push(entry);
27170
27182
  };
27171
27183
  for (const item of scene.items) {
27172
27184
  if (diagnostics.length >= limit) break;
@@ -27178,7 +27190,7 @@ function inspectBoardScene(scene, limit = 100) {
27178
27190
  let glyphBudget = 256;
27179
27191
  for (const item of texts) {
27180
27192
  if (diagnostics.length >= limit || item.type !== "text") break;
27181
- if (glyphBudget-- > 0) {
27193
+ if ((!ids || ids.has(item.id)) && glyphBudget-- > 0) {
27182
27194
  const measured = measureBoardText(item.props);
27183
27195
  if (measured.missingCharacters) push({
27184
27196
  code: "glyph-unavailable",
@@ -27217,6 +27229,7 @@ function inspectBoardScene(scene, limit = 100) {
27217
27229
  const b = sorted[j];
27218
27230
  if (!b) continue;
27219
27231
  if (b.rect.x >= a.rect.x + a.rect.width) break;
27232
+ if (ids && !ids.has(a.item.id) && !ids.has(b.item.id)) continue;
27220
27233
  if (++comparisons > 1e4) return diagnostics;
27221
27234
  if (a.item.parent === b.item.parent && rectsIntersect(a.rect, b.rect)) push({
27222
27235
  code: "text-overlap",
@@ -31338,13 +31351,24 @@ function renderBoardExport(renderer, input, options = {}) {
31338
31351
  backgroundImage: options.backgroundImage
31339
31352
  });
31340
31353
  try {
31341
- const canvas = renderer.extract.canvas({
31354
+ const texture = renderer.textureGenerator.generateTexture({
31342
31355
  target: exportScene.root,
31343
31356
  frame: new Rectangle(0, 0, plan.width, plan.height),
31344
31357
  resolution: 1,
31345
31358
  antialias: true,
31346
31359
  clearColor: options.background === "transparent" ? void 0 : palette.bg
31347
31360
  });
31361
+ let canvas;
31362
+ const surface = renderer.type === RendererType.CANVAS ? texture.source.resource : void 0;
31363
+ try {
31364
+ canvas = renderer.texture.generateCanvas(texture);
31365
+ } finally {
31366
+ texture.destroy(true);
31367
+ if (surface) {
31368
+ surface.width = 1;
31369
+ surface.height = 1;
31370
+ }
31371
+ }
31348
31372
  const warnings = [];
31349
31373
  if (plan.clamped) warnings.push({
31350
31374
  kind: "scale-clamped",
@@ -31369,11 +31393,55 @@ function renderBoardExport(renderer, input, options = {}) {
31369
31393
  }
31370
31394
  }
31371
31395
  //#endregion
31396
+ //#region ../sdk/src/board/export/node/fonts.ts
31397
+ function initializeBoardFonts(registry, fonts) {
31398
+ for (const font of fonts) registry.registerFromPath(font.path, font.family);
31399
+ if (!registry.setAlias) return;
31400
+ for (const [alias, candidates] of Object.entries({
31401
+ "system-ui": [
31402
+ "Noto Sans",
31403
+ "DejaVu Sans",
31404
+ "Liberation Sans",
31405
+ "FreeSans",
31406
+ "Helvetica Neue",
31407
+ "Arial",
31408
+ "Segoe UI"
31409
+ ],
31410
+ "sans-serif": [
31411
+ "Geist",
31412
+ "Noto Sans",
31413
+ "DejaVu Sans",
31414
+ "Liberation Sans",
31415
+ "FreeSans",
31416
+ "Arial"
31417
+ ],
31418
+ serif: [
31419
+ "Noto Serif",
31420
+ "DejaVu Serif",
31421
+ "Liberation Serif",
31422
+ "FreeSerif",
31423
+ "Times New Roman"
31424
+ ],
31425
+ monospace: [
31426
+ "Geist Mono",
31427
+ "DejaVu Sans Mono",
31428
+ "Liberation Mono",
31429
+ "FreeMono",
31430
+ "Courier New"
31431
+ ]
31432
+ })) {
31433
+ if (registry.has(alias)) continue;
31434
+ const family = candidates.find((name) => registry.has(name));
31435
+ if (family) registry.setAlias(family, alias);
31436
+ }
31437
+ }
31438
+ //#endregion
31372
31439
  //#region ../sdk/src/board/export/node/scripts.ts
31373
31440
  const WORKER = `
31374
31441
  const {parentPort, workerData} = require('node:worker_threads');
31375
31442
  const {createContext, Script, SourceTextModule} = require('node:vm');
31376
- const {createCanvas} = require(workerData.canvasModule);
31443
+ const {createCanvas, GlobalFonts} = require(workerData.canvasModule);
31444
+ (${initializeBoardFonts.toString()})(GlobalFonts, workerData.fonts);
31377
31445
  const instances = new Map();
31378
31446
  const graphs = new Map();
31379
31447
  const update = new Script('__runtime.update(JSON.parse(__inputJson)); __runtime.step(__time)');
@@ -31382,7 +31450,10 @@ const output = new Script('JSON.stringify(__runtime.output())');
31382
31450
  const dispose = new Script('__runtime.destroy()');
31383
31451
  function release(id) {
31384
31452
  const entry=instances.get(id);instances.delete(id);
31385
- if(entry) {try {dispose.runInContext(entry.context,{timeout:workerData.timeout});} catch {}}
31453
+ if(entry) {
31454
+ try {dispose.runInContext(entry.context,{timeout:workerData.timeout});} catch {}
31455
+ if(entry.canvas) {entry.canvas.width=1;entry.canvas.height=1;}
31456
+ }
31386
31457
  }
31387
31458
  async function create(data) {
31388
31459
  const context = createContext({console:{log(){},warn(){},error(){}},structuredClone},{codeGeneration:{strings:false,wasm:false}});
@@ -31432,22 +31503,29 @@ parentPort.on('message',async data=>{
31432
31503
  const width=Math.max(1,Math.round(data.input.width*data.scale));
31433
31504
  const height=Math.max(1,Math.round(data.input.height*data.scale));
31434
31505
  const canvas=entry.canvas??=createCanvas(width,height);
31435
- canvas.width=width;canvas.height=height;
31436
- const drawing=canvas.getContext('2d');drawing.scale(data.scale,data.scale);
31437
- context.__drawing=drawing;draw.runInContext(context,{timeout:workerData.timeout});
31438
- png=canvas.toBuffer('image/png');delete context.__drawing;
31506
+ if(canvas.width!==width || canvas.height!==height) {canvas.width=width;canvas.height=height;}
31507
+ const drawing=canvas.getContext('2d');drawing.reset();drawing.scale(data.scale,data.scale);
31508
+ context.__drawing=drawing;
31509
+ try {
31510
+ draw.runInContext(context,{timeout:workerData.timeout});
31511
+ png=canvas.toBuffer('image/png');
31512
+ } finally {
31513
+ delete context.__drawing;canvas.width=1;canvas.height=1;
31514
+ }
31439
31515
  }
31440
31516
  parentPort.postMessage({png,output:JSON.parse(output.runInContext(context,{timeout:workerData.timeout}))});
31441
- } catch(error) { instances.delete(data.input?.id);parentPort.postMessage({error:String(error.stack||error)}); }
31517
+ } catch(error) { release(data.input?.id);parentPort.postMessage({error:String(error.stack||error)}); }
31442
31518
  });
31443
31519
  `;
31444
31520
  function createNodeBoardScriptHost(renderer, options) {
31521
+ const concurrency = options.concurrency ?? 4;
31522
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error("Script concurrency must be an integer between 1 and 4.");
31445
31523
  const textures = /* @__PURE__ */ new Map();
31446
31524
  const outputs = /* @__PURE__ */ new Map();
31447
31525
  const errors = /* @__PURE__ */ new Map();
31448
31526
  const sources = /* @__PURE__ */ new Map();
31449
31527
  const modules = createBoardScriptModuleReader(options.readModule, options.readTimeoutMs);
31450
- const lanes = Array.from({ length: 4 }, () => ({
31528
+ const lanes = Array.from({ length: concurrency }, () => ({
31451
31529
  worker: null,
31452
31530
  tail: Promise.resolve(),
31453
31531
  graphs: /* @__PURE__ */ new Set()
@@ -31488,6 +31566,7 @@ function createNodeBoardScriptHost(renderer, options) {
31488
31566
  workerData: {
31489
31567
  runtime: createScriptRuntime.toString(),
31490
31568
  timeout,
31569
+ fonts: renderer.fonts ?? [],
31491
31570
  canvasModule: fileURLToPath(import.meta.resolve("@napi-rs/canvas"))
31492
31571
  }
31493
31572
  });
@@ -31621,20 +31700,6 @@ function createNodeBoardScriptHost(renderer, options) {
31621
31700
  }
31622
31701
  //#endregion
31623
31702
  //#region ../sdk/src/board/export/node/index.ts
31624
- const SYSTEM_UI_FAMILIES = [
31625
- "Noto Sans",
31626
- "DejaVu Sans",
31627
- "Liberation Sans",
31628
- "FreeSans",
31629
- "Helvetica Neue",
31630
- "Arial",
31631
- "Segoe UI"
31632
- ];
31633
- function aliasSystemUi(fonts) {
31634
- if (!fonts.setAlias || fonts.has("system-ui")) return;
31635
- const family = SYSTEM_UI_FAMILIES.find((name) => fonts.has(name));
31636
- if (family) fonts.setAlias(family, "system-ui");
31637
- }
31638
31703
  const WEBGL_PROBE_STUB = {
31639
31704
  getShaderPrecisionFormat: () => ({
31640
31705
  precision: 1,
@@ -31681,8 +31746,11 @@ function installAnimationFrameShim() {
31681
31746
  async function createNodeBoardRenderer(options = {}) {
31682
31747
  const canvasModule = options.canvasModule ?? await loadCanvasModule();
31683
31748
  extensions.add(CanvasGraphicsPipe, GraphicsPipe, CanvasGraphicsContextSystem, GraphicsContextSystem, CanvasRendererTextSystem, CanvasTextSystem, CanvasTextPipe);
31684
- for (const font of options.fonts ?? []) canvasModule.GlobalFonts.registerFromPath(font.path, font.family ?? "Geist");
31685
- aliasSystemUi(canvasModule.GlobalFonts);
31749
+ const fonts = (options.fonts ?? []).map((font) => ({
31750
+ ...font,
31751
+ family: font.family ?? "Geist"
31752
+ }));
31753
+ initializeBoardFonts(canvasModule.GlobalFonts, fonts);
31686
31754
  function createCanvas(width = 1, height = 1) {
31687
31755
  const canvas = canvasModule.createCanvas(Math.max(1, Math.ceil(width)), Math.max(1, Math.ceil(height)));
31688
31756
  const nativeGetContext = canvas.getContext.bind(canvas);
@@ -31719,14 +31787,16 @@ async function createNodeBoardRenderer(options = {}) {
31719
31787
  return {
31720
31788
  renderer,
31721
31789
  canvasModule,
31722
- decodeImage: async (bytes, mimeType = "image/png") => {
31790
+ fonts,
31791
+ decodeImage: async (bytes) => {
31723
31792
  const image = new canvasModule.Image();
31724
- const base64 = Buffer.from(bytes).toString("base64");
31725
31793
  await new Promise((resolve, reject) => {
31726
31794
  image.onload = () => resolve();
31727
31795
  image.onerror = (error) => reject(error);
31728
- image.src = `data:${mimeType};base64,${base64}`;
31796
+ image.src = bytes;
31729
31797
  });
31798
+ image.onload = void 0;
31799
+ image.onerror = void 0;
31730
31800
  return new Texture({ source: new ImageSource({
31731
31801
  resource: image,
31732
31802
  width: image.width,
@@ -31760,7 +31830,7 @@ function exportBoardImageBytes(renderer, document, options = {}) {
31760
31830
  try {
31761
31831
  const bytes = format === "png" ? canvas.toBuffer(MIME.png) : canvas.toBuffer(MIME[format], Math.round(quality * 100));
31762
31832
  return {
31763
- bytes: new Uint8Array(bytes),
31833
+ bytes: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength),
31764
31834
  plan: result.plan,
31765
31835
  warnings: result.warnings,
31766
31836
  format
@@ -31771,4 +31841,4 @@ function exportBoardImageBytes(renderer, document, options = {}) {
31771
31841
  }
31772
31842
  }
31773
31843
  //#endregion
31774
- export { BOARD_SCRIPT_DRAWING_EXAMPLE, boardDocumentAt, boardImageKeySource, boardPathTrack, boardSequence, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createBoardRenderSampler, createNodeBoardRenderer, createNodeBoardScriptHost, exportBoardImageBytes, hasBoardScripts, imageAssetKey, inspectBoardScene, measureBoardText, parseBoardDocument, planBoardExport, prepareBoardExport, selectBoardExportAssets };
31844
+ export { BOARD_SCRIPT_DRAWING_EXAMPLE, boardDocumentAt, boardImageKeySource, boardPathTrack, boardSequence, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createBoardRenderSampler, createNodeBoardRenderer, createNodeBoardScriptHost, exportBoardImageBytes, hasBoardScripts, imageAssetKey, inspectBoardItems, inspectBoardScene, measureBoardText, parseBoardDocument, planBoardExport, prepareBoardExport, selectBoardExportAssets };
@@ -1,6 +1,6 @@
1
1
  import { readBoardJsonObject } from "../../board-command-support.js";
2
2
  import { loadBoardDocument, resolveBundledFonts } from "../../board-export.js";
3
- import { boardPathTrack, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createNodeBoardRenderer, inspectBoardScene, measureBoardText, } from "../../board-kernel.js";
3
+ import { boardPathTrack, boardSequencePatch, buildBoardScene, createBoardAnimationSampler, createNodeBoardRenderer, inspectBoardItems, } from "../../board-kernel.js";
4
4
  import { parseBoardTimes } from "../../board-time.js";
5
5
  import { handleHttp, json as outJson } from "../../output.js";
6
6
  import { resolveSpace } from "../../space.js";
@@ -9,9 +9,14 @@ export function registerBoardAuthoringCommands(boards) {
9
9
  .command("inspect <board>")
10
10
  .description("Measure items and report visible layout hints without changing the Board")
11
11
  .option("--animation <id>", "Animation to sample")
12
- .option("--at <times>", "Time, comma-separated times, or start:end:step", "0")
12
+ .option("--at <times>", "Time, comma-separated times, or start:end:step (up to 100 samples)", "0")
13
13
  .option("--items <ids>", "Limit measurements to comma-separated item ids")
14
14
  .option("--json", "Output as JSON")
15
+ .addHelpText("after", `
16
+ Without --animation, inspects the static document, not playback.
17
+ Bounds use world units with x/y at the top left; worldBounds includes rotation.
18
+ Checks native layout only, not camera cropping or content drawn inside scripts.
19
+ Example: cohub boards inspect lesson.board --animation lecture --at 0:20s:1s --json`)
15
20
  .action(async (target, options) => {
16
21
  try {
17
22
  const { document } = await loadBoardDocument(await resolveSpace(boards), target);
@@ -21,20 +26,13 @@ export function registerBoardAuthoringCommands(boards) {
21
26
  try {
22
27
  const ids = options.items ? new Set(options.items.split(",")) : null;
23
28
  outJson({
24
- samples: times.map((time) => {
25
- const scene = sampler?.at(time).scene ?? buildBoardScene(document);
26
- return {
27
- time,
28
- diagnostics: inspectBoardScene(scene),
29
- items: scene.items
30
- .filter((item) => !ids || ids.has(item.id))
31
- .map((item) => ({
32
- id: item.id,
33
- bounds: item.frame,
34
- ...(item.type === "text" ? { text: measureBoardText(item.props) } : {}),
35
- })),
36
- };
37
- }),
29
+ mode: sampler ? "animation" : "static",
30
+ animation: options.animation ?? null,
31
+ coverage: "Native layout in world coordinates; excludes camera cropping and script-drawn content.",
32
+ samples: times.map((time) => ({
33
+ time,
34
+ ...inspectBoardItems(sampler?.at(time).scene ?? buildBoardScene(document), ids ?? undefined),
35
+ })),
38
36
  });
39
37
  }
40
38
  finally {
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { parseBoardJsonObject } from "../board-command-support.js";
3
3
  export declare const parseJsonObject: typeof parseBoardJsonObject;
4
+ export declare function boardSchemaField(schema: Record<string, unknown> | undefined, path: string): unknown;
4
5
  export declare function parseRect(value: string, name?: string): {
5
6
  x: number;
6
7
  y: number;