@neta-art/cohub-cli 10.8.3 → 10.11.0

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
@@ -57,12 +57,15 @@ Sign in once for each custom server, or supply `COHUB_EXECUTION_TOKEN`.
57
57
 
58
58
  ## Board export resources
59
59
 
60
- Board exports keep the requested resolution and source image quality. Before rendering,
60
+ Board exports keep the requested resolution. Previews are fetched and decoded at the size
61
+ the export needs: Cohub-hosted media (Space files included) provide resized images and
62
+ video first frames, and larger sources are scaled down before decoding. Before rendering,
61
63
  `boards export` prints the output dimensions and frame count (except with `--json`).
62
64
  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.
65
+ budget. Previews beyond it are drawn as placeholders with a warning; a background image
66
+ that does not fit fails the export before native decoding. Use `--image-memory <MiB>` to raise the budget
67
+ when the environment has sufficient memory; it is an image budget, not a limit on total
68
+ process memory.
66
69
  `--size` controls the viewport only with `--camera`.
67
70
 
68
71
  ```bash
@@ -602,6 +605,17 @@ cohub cron-jobs delete <cronJobId>
602
605
 
603
606
  Confirm before enabling, disabling, or deleting recurring scheduled prompts.
604
607
 
608
+ ## Feedback
609
+
610
+ ```bash
611
+ cohub feedback "runtime up hangs after sign-in"
612
+ cohub feedback "Board export is blurry" -f board.png -f export.log
613
+ cohub feedback --runtime-logs "Codex chats stopped syncing"
614
+ cat report.md | cohub feedback
615
+ ```
616
+
617
+ Sends feedback to the Cohub team. CLI version, OS, environment, account, and `COHUB_*` Space/Chat context are attached; the working directory is not. `--runtime-logs` attaches recent local Runtime diagnostics for the target Space.
618
+
605
619
  ## Safety
606
620
 
607
621
  Confirm before:
@@ -3,5 +3,4 @@ export declare const BOARD_CREATE_INPUT_MAX_BYTES: number;
3
3
  export declare const BOARD_DOMAIN_INPUT_MAX_BYTES: number;
4
4
  export declare function parseBoardJsonObject(text: string, source?: string): Record<string, unknown>;
5
5
  export declare function readBoardJsonObject(source: string, maxBytes?: number): Promise<Record<string, unknown>>;
6
- export declare function resolveBoardId(spaceId: string, target: string): Promise<string>;
7
6
  export declare function writeBoardOutput(path: string, bytes: Uint8Array, force?: boolean): Promise<void>;
@@ -2,8 +2,6 @@ import { randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { access, link, open, rename, unlink } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
- import { isBoardPath, parseBoardManifest, } from "@neta-art/cohub/board";
6
- import { createClient } from "./client.js";
7
5
  export const BOARD_TRANSACTION_INPUT_MAX_BYTES = 16 * 1024 * 1024;
8
6
  export const BOARD_CREATE_INPUT_MAX_BYTES = 32 * 1024 * 1024;
9
7
  export const BOARD_DOMAIN_INPUT_MAX_BYTES = 1024 * 1024;
@@ -62,15 +60,6 @@ export async function readBoardJsonObject(source, maxBytes = BOARD_DOMAIN_INPUT_
62
60
  await handle.close();
63
61
  }
64
62
  }
65
- export async function resolveBoardId(spaceId, target) {
66
- if (!isBoardPath(target))
67
- return target;
68
- const file = await createClient().space(spaceId).files.read(target);
69
- if (!("content" in file) || typeof file.content !== "string") {
70
- throw new Error(`${target} is not a readable Board manifest`);
71
- }
72
- return parseBoardManifest(file.content).boardId;
73
- }
74
63
  export async function writeBoardOutput(path, bytes, force = false) {
75
64
  if (!force) {
76
65
  await access(path).then(() => { throw new Error(`Output already exists: ${path}; use --force to replace it`); }, () => undefined);
@@ -2,7 +2,9 @@ import type { BoardNodeRenderer } from "@neta-art/cohub/board/export/node";
2
2
  export declare const BOARD_EXPORT_IMAGE_MEMORY_MIB = 256;
3
3
  export declare class BoardExportResourceError extends Error {
4
4
  }
5
+ export type BoardExportDecodeImage = (bytes: Uint8Array, mimeType?: string, maxEdge?: number) => ReturnType<BoardNodeRenderer["decodeImage"]>;
6
+ /** Decodes within `maxBytes`, first scaling images down to `maxEdge` on their long edge. */
5
7
  export declare function createBoardExportImageBudget(renderer: BoardNodeRenderer, maxBytes: number): {
6
- decodeImage: (bytes: Uint8Array, mimeType?: string) => Promise<import("@neta-art/cohub/board/export/node").BoardNodeTexture>;
8
+ decodeImage: BoardExportDecodeImage;
7
9
  readonly usedBytes: number;
8
10
  };
@@ -1,13 +1,24 @@
1
1
  export const BOARD_EXPORT_IMAGE_MEMORY_MIB = 256;
2
2
  export class BoardExportResourceError extends Error {
3
3
  }
4
+ /** Decodes within `maxBytes`, first scaling images down to `maxEdge` on their long edge. */
4
5
  export function createBoardExportImageBudget(renderer, maxBytes) {
5
6
  if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)
6
7
  throw new Error("Image memory budget must be a positive integer.");
7
8
  let usedBytes = 0;
8
- const decodeImage = async (bytes, mimeType) => {
9
+ const decodeImage = async (input, mimeType, maxEdge = Number.POSITIVE_INFINITY) => {
9
10
  const { default: sharp } = await import("sharp");
10
- const { width, height } = await sharp(bytes, { limitInputPixels: false }).metadata();
11
+ let bytes = input;
12
+ let { width, height } = await sharp(bytes, { limitInputPixels: false }).metadata();
13
+ if (Math.max(width ?? 0, height ?? 0) > maxEdge) {
14
+ const resized = await sharp(bytes, { limitInputPixels: false })
15
+ .resize({ width: maxEdge, height: maxEdge, fit: "inside" })
16
+ .png({ compressionLevel: 0 })
17
+ .toBuffer({ resolveWithObject: true });
18
+ ({ width, height } = resized.info);
19
+ bytes = new Uint8Array(resized.data);
20
+ mimeType = "image/png";
21
+ }
11
22
  const required = (width ?? 0) * (height ?? 0) * 4;
12
23
  if (!Number.isSafeInteger(required) || required <= 0)
13
24
  throw new Error("Image dimensions could not be read.");
@@ -1,5 +1,6 @@
1
- import type { BoardDocument, BoardExportRegion, BoardSceneItem } from "@neta-art/cohub/board";
2
- import type { BoardNodeExportFormat, BoardNodeFont, BoardNodeRenderer, BoardNodeTexture } from "@neta-art/cohub/board/export/node";
1
+ import type { BoardDocument, BoardExportRegion, BoardSceneItem, BoardThemeId } from "@neta-art/cohub/board";
2
+ import type { BoardNodeExportFormat, BoardNodeFont, BoardNodeTexture } from "@neta-art/cohub/board/export/node";
3
+ import { type BoardExportDecodeImage } from "./board-export-resources.js";
3
4
  export declare function resolveBundledFonts(): BoardNodeFont[];
4
5
  export type BoardExportSource = {
5
6
  document: BoardDocument;
@@ -7,18 +8,31 @@ export type BoardExportSource = {
7
8
  title: string | null;
8
9
  };
9
10
  export declare function loadBoardDocument(spaceId: string, target: string): Promise<BoardExportSource>;
11
+ type TextureMiss = "error" | "unavailable" | "omitted";
10
12
  export type BoardExportTextureCache = {
11
13
  textures: Map<string, BoardNodeTexture>;
12
- failed: Set<string>;
14
+ /** Long edge each texture was decoded for; Infinity once it is the whole source. */
15
+ edges: Map<string, number>;
16
+ /** Keys without a texture: failed to load, no picture Node can draw, or over the image budget. */
17
+ missed: Map<string, TextureMiss>;
13
18
  };
14
- export declare function loadBoardTextures(renderer: BoardNodeRenderer, spaceId: string, items: readonly BoardSceneItem[], options?: {
19
+ /** Texture keys Node can draw; 3D model stills need a browser. */
20
+ export declare function boardExportAssetKey(item: BoardSceneItem): string | null;
21
+ /**
22
+ * Previews for `items` within what the image budget leaves after other images,
23
+ * such as the background. A preview that still does not fit is omitted.
24
+ */
25
+ export declare function loadBoardTextures(images: {
26
+ decodeImage: BoardExportDecodeImage;
27
+ usedBytes: number;
28
+ }, spaceId: string, items: readonly BoardSceneItem[], options: {
29
+ scale: number;
30
+ maxBytes: number;
15
31
  concurrency?: number;
16
32
  cache?: BoardExportTextureCache;
17
33
  }): Promise<{
18
34
  textures: Map<string, BoardNodeTexture>;
19
- failed: string[];
20
- omitted: string[];
21
- }>;
35
+ } & Record<"failed" | "unavailable" | "omitted", string[]>>;
22
36
  export type BoardExportRunOptions = {
23
37
  spaceId: string;
24
38
  target: string;
@@ -27,7 +41,7 @@ export type BoardExportRunOptions = {
27
41
  animation?: string;
28
42
  scale: number;
29
43
  padding?: number;
30
- colorScheme: "dark" | "light";
44
+ theme: BoardThemeId;
31
45
  background: "paper" | "transparent";
32
46
  format: BoardNodeExportFormat;
33
47
  quality?: number;
@@ -74,3 +88,4 @@ export declare function runBoardVideoExport(options: BoardVideoExportOptions): P
74
88
  warnings: string[];
75
89
  }>;
76
90
  export declare function runBoardExport(options: BoardExportRunOptions): Promise<BoardExportRunResult | null>;
91
+ export {};
@@ -10,12 +10,13 @@ import { fileURLToPath } from "node:url";
10
10
  import { tmpdir } from "node:os";
11
11
  const execFile = promisify(execFileCallback);
12
12
  import { parseBoardMediaSource } from "@neta-art/cohub/board";
13
- import { hasBoardScripts, boardDocumentAt, createBoardRenderSampler, boardImageKeySource, buildBoardScene, createNodeBoardRenderer, createNodeBoardScriptHost, exportBoardImageBytes, imageAssetKey, parseBoardDocument, planBoardExport, prepareBoardExport, selectBoardExportAssets, } from "./board-kernel.js";
14
- import { resolveBoardId } from "./board-command-support.js";
13
+ import { imageVariantUrl, MEDIA_VARIANT_MAX, mediaVariantSize, videoFrameUrl } from "@neta-art/cohub/media";
14
+ import { hasBoardScripts, boardAssetKey, boardAssetKeySource, boardDocumentAt, boardRenderTheme, boardTextureEdge, boardTexturePixels, createBoardRenderSampler, buildBoardScene, createNodeBoardRenderer, createNodeBoardScriptHost, exportBoardImageBytes, parseBoardDocument, planBoardExport, prepareBoardExport, selectBoardExportAssets, } from "./board-kernel.js";
15
+ import { settleBoardDocument } from "./board-media.js";
15
16
  import { BOARD_EXPORT_FONTS } from "./board-fonts.js";
16
17
  import { createClient } from "./client.js";
17
18
  import { downloadPublicAudio, downloadPublicImage } from "./safe-remote-image.js";
18
- import { BOARD_EXPORT_IMAGE_MEMORY_MIB, BoardExportResourceError, createBoardExportImageBudget } from "./board-export-resources.js";
19
+ import { BOARD_EXPORT_IMAGE_MEMORY_MIB, BoardExportResourceError, createBoardExportImageBudget, } from "./board-export-resources.js";
19
20
  const debugExport = debuglog("cohub:board-export");
20
21
  export function resolveBundledFonts() {
21
22
  const require = createRequire(import.meta.url);
@@ -38,7 +39,7 @@ export function resolveBundledFonts() {
38
39
  });
39
40
  }
40
41
  export async function loadBoardDocument(spaceId, target) {
41
- const board = createClient().space(spaceId).board(await resolveBoardId(spaceId, target));
42
+ const board = await createClient().space(spaceId).boards.open(target);
42
43
  let result = await board.get();
43
44
  const snapshot = result;
44
45
  const items = { ...result.items };
@@ -53,55 +54,105 @@ export async function loadBoardDocument(spaceId, target) {
53
54
  throw new Error(`Board ${result.id} could not be read: ${parsed.diagnostics[0]?.path}: ${parsed.diagnostics[0]?.message}`);
54
55
  return { document: parsed.document, boardId: result.id, title: result.title };
55
56
  }
56
- export async function loadBoardTextures(renderer, spaceId, items, options = {}) {
57
- const selection = selectBoardExportAssets([...items], imageAssetKey);
58
- const cache = options.cache ?? { textures: new Map(), failed: new Set() };
59
- const { textures, failed } = cache;
57
+ /** Texture keys Node can draw; 3D model stills need a browser. */
58
+ export function boardExportAssetKey(item) {
59
+ const key = boardAssetKey(item);
60
+ return key && boardAssetKeySource(key)?.media !== "model" ? key : null;
61
+ }
62
+ function textureEdge(item, scale) {
63
+ const edge = boardTextureEdge(item, scale);
64
+ return Number.isFinite(edge) ? edge : Math.ceil(boardTexturePixels(item, scale));
65
+ }
66
+ const textureBytes = (texture) => texture.width * texture.height * 4;
67
+ /**
68
+ * Previews for `items` within what the image budget leaves after other images,
69
+ * such as the background. A preview that still does not fit is omitted.
70
+ */
71
+ export async function loadBoardTextures(images, spaceId, items, options) {
72
+ const cache = options.cache ?? { textures: new Map(), edges: new Map(), missed: new Map() };
73
+ const { textures, edges, missed } = cache;
74
+ let previewBytes = 0;
75
+ for (const texture of textures.values())
76
+ previewBytes += textureBytes(texture);
77
+ const maxBytes = options.maxBytes - (images.usedBytes - previewBytes);
78
+ const selection = selectBoardExportAssets([...items], boardExportAssetKey, { scale: options.scale, maxBytes });
60
79
  const wanted = new Set(selection.keys);
61
80
  for (const [key, texture] of textures) {
62
81
  if (wanted.has(key))
63
82
  continue;
64
83
  texture.destroy(true);
65
84
  textures.delete(key);
85
+ edges.delete(key);
66
86
  }
67
- for (const key of failed)
87
+ for (const key of missed.keys())
68
88
  if (!wanted.has(key))
69
- failed.delete(key);
70
- const pending = selection.keys.filter((key) => !textures.has(key) && !failed.has(key));
89
+ missed.delete(key);
90
+ const pending = selection.items.flatMap((item, index) => {
91
+ const key = selection.keys[index];
92
+ const edge = textureEdge(item, options.scale);
93
+ return missed.has(key) || (edges.get(key) ?? 0) >= edge ? [] : [{ key, item, edge }];
94
+ });
71
95
  const concurrency = Math.max(1, Math.min(options.concurrency ?? 2, 16));
72
96
  const client = createClient();
73
- let resourceError;
74
97
  async function worker() {
75
- for (;;) {
76
- if (resourceError)
77
- return;
78
- const key = pending.shift();
79
- if (!key)
80
- return;
81
- const source = boardImageKeySource(key);
82
- if (!source) {
83
- failed.add(key);
84
- continue;
85
- }
98
+ for (let task = pending.shift(); task; task = pending.shift()) {
99
+ const { key, item, edge } = task;
100
+ let miss = "unavailable";
86
101
  try {
87
- const { bytes, mimeType } = source.kind === "file"
88
- ? await readSpaceFileBytes(client, spaceId, source.value)
89
- : await downloadPublicImage(source.value);
90
- const texture = await renderer.decodeImage(bytes, mimeType);
91
- textures.set(key, texture);
102
+ const source = boardAssetKeySource(key);
103
+ const picture = source && await fetchBoardPicture(client, spaceId, source, item, edge);
104
+ if (picture) {
105
+ const texture = await images.decodeImage(picture.bytes, picture.mimeType, edge);
106
+ textures.get(key)?.destroy(true);
107
+ textures.set(key, texture);
108
+ edges.set(key, Math.max(texture.width, texture.height) < edge ? Number.POSITIVE_INFINITY : edge);
109
+ miss = null;
110
+ }
92
111
  }
93
112
  catch (error) {
94
- if (error instanceof BoardExportResourceError)
95
- resourceError = error;
96
- else
97
- failed.add(key);
113
+ miss = error instanceof BoardExportResourceError ? "omitted" : "error";
98
114
  }
115
+ // A preview already shown stays, at the sharpness it has.
116
+ if (miss && textures.has(key))
117
+ edges.set(key, Number.POSITIVE_INFINITY);
118
+ else if (miss)
119
+ missed.set(key, miss);
99
120
  }
100
121
  }
101
122
  await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
102
- if (resourceError)
103
- throw resourceError;
104
- return { textures, failed: [...failed], omitted: selection.omittedKeys };
123
+ const keys = (reason) => [...missed].flatMap(([key, value]) => (value === reason ? [key] : []));
124
+ return { textures, failed: keys("error"), unavailable: keys("unavailable"), omitted: [...selection.omittedKeys, ...keys("omitted")] };
125
+ }
126
+ /**
127
+ * The picture for a texture, `edge` pixels on its long edge where the host can
128
+ * resize: a CDN variant of an image, or the first frame of a video. Null when
129
+ * there is none, as for videos outside Cohub's media hosts.
130
+ */
131
+ async function fetchBoardPicture(client, spaceId, source, item, edge) {
132
+ // Thumbnail edges below the smallest variant fetch that variant; decoding scales it down.
133
+ const variantEdge = Number.isFinite(edge) && edge <= MEDIA_VARIANT_MAX ? mediaVariantSize(edge) : null;
134
+ const video = source.media === "video";
135
+ const url = source.kind === "url"
136
+ ? source.value
137
+ : video || variantEdge
138
+ ? await client.space(spaceId).files.resolveUrl(source.value, { purpose: "playback" }).catch(() => null)
139
+ : null;
140
+ if (video) {
141
+ const { naturalWidth: w, naturalHeight: h } = (item.type === "video" && item.props.snapshot) || {};
142
+ const width = w && h ? Math.min(w, (edge * w) / Math.max(w, h)) : edge;
143
+ const frame = url && videoFrameUrl(url, mediaVariantSize(width));
144
+ return frame ? downloadPublicImage(frame) : null;
145
+ }
146
+ const variant = url && variantEdge && imageVariantUrl(url, variantEdge);
147
+ if (variant) {
148
+ try {
149
+ return await downloadPublicImage(variant);
150
+ }
151
+ catch {
152
+ // Fall back to the original below.
153
+ }
154
+ }
155
+ return source.kind === "url" ? downloadPublicImage(source.value) : readSpaceFileBytes(client, spaceId, source.value);
105
156
  }
106
157
  async function readSpaceFileBytes(client, spaceId, path) {
107
158
  const { blob, mimeType } = await client.space(spaceId).files.download(path);
@@ -122,7 +173,7 @@ export async function runBoardVideoExport(options) {
122
173
  if (times.some((time, index) => index > 0 && Math.abs(time - times[index - 1] - step) > 0.001))
123
174
  throw new Error("Video export needs evenly spaced times.");
124
175
  await execFile("ffmpeg", ["-version"], { signal: options.signal }).catch(() => { throw new Error("Video export needs ffmpeg. Install ffmpeg and try again."); });
125
- const document = options.document ?? (await loadBoardDocument(options.spaceId, options.target)).document;
176
+ const document = options.document ?? await settleBoardDocument(options.spaceId, (await loadBoardDocument(options.spaceId, options.target)).document);
126
177
  const tempDir = await mkdtemp(join(tmpdir(), "cohub-board-export-"));
127
178
  try {
128
179
  let count = 0;
@@ -208,7 +259,7 @@ export async function runBoardVideoExport(options) {
208
259
  }
209
260
  export async function runBoardExport(options) {
210
261
  options.signal?.throwIfAborted();
211
- const document = options.document ?? (await loadBoardDocument(options.spaceId, options.target)).document;
262
+ const document = options.document ?? await settleBoardDocument(options.spaceId, (await loadBoardDocument(options.spaceId, options.target)).document);
212
263
  if (options.animation && !document.animations[options.animation]) {
213
264
  throw new Error(`Animation ${options.animation} does not exist.`);
214
265
  }
@@ -230,10 +281,11 @@ export async function runBoardExport(options) {
230
281
  });
231
282
  };
232
283
  let scriptHost = null;
233
- const textureCache = { textures: new Map(), failed: new Set() };
284
+ const textureCache = { textures: new Map(), edges: new Map(), missed: new Map() };
234
285
  let backgroundTexture;
235
286
  try {
236
- const sampler = createBoardRenderSampler(document, options.animation ?? null, { colorScheme: options.colorScheme });
287
+ const theme = boardRenderTheme(options.theme);
288
+ const sampler = createBoardRenderSampler(document, options.animation ?? null, theme);
237
289
  const documentAt = (time) => time === null ? document : sampler.at(time).document;
238
290
  const plan = planBoardExport({
239
291
  scene: buildBoardScene(documentAt(times[0] ?? null)),
@@ -272,7 +324,6 @@ export async function runBoardExport(options) {
272
324
  warnings.push("The board background image could not be loaded; the fallback color was exported.");
273
325
  }
274
326
  }
275
- const videoCount = plan.items.filter((item) => item.type === "video").length;
276
327
  if (hasBoardScripts(document))
277
328
  scriptHost = createNodeBoardScriptHost(renderer, {
278
329
  concurrency: 2,
@@ -282,18 +333,13 @@ export async function runBoardExport(options) {
282
333
  },
283
334
  });
284
335
  const host = scriptHost;
285
- if (videoCount > 0) {
286
- warnings.push(`${videoCount} video preview${videoCount === 1 ? " was" : "s were"} drawn as placeholders; video decoding is unavailable in Node.`);
287
- }
288
336
  const backgroundImage = backgroundTexture
289
337
  ? { texture: backgroundTexture, fit: declaredBackground.fit ?? "cover", position: "center", opacity: declaredBackground.opacity ?? 1 }
290
338
  : undefined;
291
- const assetKey = options.withImages
292
- ? (item) => {
293
- const key = imageAssetKey(item);
294
- return key && omittedKeys.has(key) ? null : key;
295
- }
296
- : undefined;
339
+ const assetKey = (item) => {
340
+ const key = boardExportAssetKey(item);
341
+ return key && omittedKeys.has(key) ? null : key;
342
+ };
297
343
  const frames = [];
298
344
  const reported = new Set();
299
345
  let completed = 0;
@@ -320,11 +366,15 @@ export async function runBoardExport(options) {
320
366
  }
321
367
  trace("images", completed, prepared.plan);
322
368
  if (options.withImages) {
323
- const loaded = await loadBoardTextures(renderer, options.spaceId, prepared.plan.items.filter((item) => prepared.scene.opacity(item.id) > 0), { cache: textureCache });
369
+ const visible = prepared.plan.items.filter((item) => prepared.scene.opacity(item.id) > 0);
370
+ const loaded = await loadBoardTextures(images, options.spaceId, visible, { scale: prepared.plan.scale, maxBytes: imageMemoryBytes, cache: textureCache });
324
371
  omittedKeys = new Set(loaded.omitted);
372
+ const sources = (keys) => keys.slice(0, 3).map((key) => boardAssetKeySource(key)?.value ?? key).join(", ");
373
+ const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
325
374
  const assetWarnings = [
326
- loaded.failed.length ? `${loaded.failed.length} image(s) could not be loaded: ${loaded.failed.slice(0, 3).join(", ")}` : null,
327
- loaded.omitted.length ? `${loaded.omitted.length} previews were drawn as placeholders to stay within the export texture limit.` : null,
375
+ loaded.failed.length ? `${plural(loaded.failed.length, "preview")} could not be loaded: ${sources(loaded.failed)}` : null,
376
+ loaded.unavailable.length ? `${plural(loaded.unavailable.length, "video")} drawn without a cover; Node exports take covers from Cohub-hosted media only.` : null,
377
+ loaded.omitted.length ? `${plural(loaded.omitted.length, "preview")} drawn as placeholders to stay within the ${imageMemoryBytes / 1024 ** 2} MiB image budget; raise --image-memory to include them.` : null,
328
378
  ];
329
379
  for (const warning of assetWarnings)
330
380
  if (warning && !reported.has(warning)) {
@@ -335,12 +385,12 @@ export async function runBoardExport(options) {
335
385
  trace("render", completed, prepared.plan);
336
386
  const result = exportBoardImageBytes(renderer, document, {
337
387
  ...frameOptions,
338
- colorScheme: options.colorScheme,
388
+ ...theme,
339
389
  background: options.background,
340
390
  textures,
341
391
  scripts: scriptHost ?? undefined,
342
392
  backgroundImage,
343
- ...(assetKey ? { assetKey } : {}),
393
+ assetKey,
344
394
  format: options.format,
345
395
  quality: options.quality,
346
396
  });
@@ -387,7 +437,7 @@ function describeWarning(warning) {
387
437
  }
388
438
  if (warning.kind === "images-missing") {
389
439
  const keys = warning.keys;
390
- return `${keys.length} image${keys.length === 1 ? "" : "s"} drawn as placeholders (use --images to fetch them).`;
440
+ return `${keys.length} preview${keys.length === 1 ? "" : "s"} drawn as placeholders; omit --no-images to fetch them.`;
391
441
  }
392
442
  if (warning.kind === "many-items") {
393
443
  return `${warning.count} items exported.`;
@@ -1,4 +1,8 @@
1
- /** Geist files `boards export` registers, copied into `dist/fonts` at build time. */
1
+ /**
2
+ * Fonts `boards export` registers, copied into `dist/fonts` at build time. The
3
+ * variable files and family names are the ones the Web loads, so every weight
4
+ * draws with the same outlines; Figtree is the Neta Pixel theme's font.
5
+ */
2
6
  export type BoardExportFont = {
3
7
  pkg: string;
4
8
  file: string;
@@ -1,10 +1,10 @@
1
- /** Geist files `boards export` registers, copied into `dist/fonts` at build time. */
2
- const subset = (pkg, family, weights) => weights.map((weight) => ({
3
- pkg,
4
- family,
5
- file: `${pkg.split("/").pop()}-latin-${weight}-normal.woff2`,
6
- }));
1
+ /**
2
+ * Fonts `boards export` registers, copied into `dist/fonts` at build time. The
3
+ * variable files and family names are the ones the Web loads, so every weight
4
+ * draws with the same outlines; Figtree is the Neta Pixel theme's font.
5
+ */
7
6
  export const BOARD_EXPORT_FONTS = [
8
- ...subset("@fontsource/geist", "Geist", [400, 500, 600, 700]),
9
- ...subset("@fontsource/geist-mono", "Geist Mono", [500, 600, 700]),
7
+ { pkg: "@fontsource-variable/geist", file: "geist-latin-wght-normal.woff2", family: "Geist Variable" },
8
+ { pkg: "@fontsource-variable/geist-mono", file: "geist-mono-latin-wght-normal.woff2", family: "Geist Mono Variable" },
9
+ { pkg: "@fontsource-variable/figtree", file: "figtree-latin-wght-normal.woff2", family: "Figtree Variable" },
10
10
  ];
@@ -1,4 +1,4 @@
1
- export { BOARD_SCRIPT_DRAWING_EXAMPLE, hasBoardScripts, boardDocumentAt, createBoardAnimationSampler, boardSequence, boardSequencePatch, boardPathTrack, boardImageKeySource, buildBoardScene, imageAssetKey, parseBoardDocument, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
1
+ export { BOARD_SCRIPT_DRAWING_EXAMPLE, hasBoardScripts, boardAssetKey, boardAssetKeySource, boardDocumentAt, createBoardAnimationSampler, boardSequence, boardSequencePatch, boardPathTrack, boardTextureEdge, boardTexturePixels, buildBoardScene, parseBoardDocument, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
2
2
  export { prepareBoardExport } from "@neta-art/cohub/board/export";
3
- export { createBoardRenderSampler, inspectBoardScene, inspectBoardItems, measureBoardText } from "@neta-art/cohub/board/render";
3
+ export { boardRenderTheme, createBoardRenderSampler, inspectBoardScene, inspectBoardItems, measureBoardText } from "@neta-art/cohub/board/render";
4
4
  export { createNodeBoardRenderer, createNodeBoardScriptHost, exportBoardImageBytes } from "@neta-art/cohub/board/export/node";