@neta-art/cohub-cli 10.3.0 → 10.4.2

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";