@neta-art/cohub-cli 3.0.0 → 3.1.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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Board image export for the CLI.
3
+ *
4
+ * Rendering runs through the same PixiJS card renderers the web editor uses (see
5
+ * `@neta-art/cohub-board`), on a Canvas2D backend. Everything platform-specific
6
+ * lives here: fetching the document over HTTP, pulling image bytes out of the
7
+ * space, and locating fonts on disk.
8
+ */
9
+ import { type BoardDocument, type BoardItem } from "@neta-art/cohub-board";
10
+ import { type BoardExportRegion } from "@neta-art/cohub-board/export";
11
+ import { type BoardHeadlessExportFormat, type BoardHeadlessFont, type BoardHeadlessRenderer, type BoardHeadlessTexture } from "@neta-art/cohub-board/headless";
12
+ export declare const BOARD_EXPORT_FORMATS: BoardHeadlessExportFormat[];
13
+ /** Infer the output format from the file extension, defaulting to PNG. */
14
+ export declare function formatFromPath(path: string): BoardHeadlessExportFormat;
15
+ /**
16
+ * Geist, as shipped to the browser.
17
+ *
18
+ * The board asks for the "Geist" family; registering the exact same font files
19
+ * the web app loads is what makes CLI output match the editor rather than
20
+ * substituting whatever sans-serif the host happens to have. Resolution is
21
+ * best-effort: if the font package is not installed the export still succeeds,
22
+ * falling back through the font stack.
23
+ */
24
+ export declare function resolveBundledFonts(): BoardHeadlessFont[];
25
+ export type BoardExportSource = {
26
+ document: BoardDocument;
27
+ boardId: string;
28
+ title: string | null;
29
+ };
30
+ /**
31
+ * Load a board document by board id or by the path of its `.board` file.
32
+ *
33
+ * A `.board` file is a manifest holding a board id, so both forms converge on
34
+ * the same inspect call — which is also what the web client does.
35
+ */
36
+ export declare function loadBoardDocument(spaceId: string, target: string): Promise<BoardExportSource>;
37
+ /**
38
+ * Fetch every image in `items`, keyed the way the renderers ask for it.
39
+ *
40
+ * Takes the planned items rather than the whole document so a partial export
41
+ * does not download the rest of the board. Failures are collected rather than
42
+ * thrown: one unreadable image should cost a placeholder and a warning, not the
43
+ * whole export. Downloads run concurrently but bounded, so a board with hundreds
44
+ * of images does not open hundreds of sockets.
45
+ */
46
+ export declare function loadBoardTextures(headless: BoardHeadlessRenderer, spaceId: string, items: BoardItem[], options?: {
47
+ concurrency?: number;
48
+ }): Promise<{
49
+ textures: Map<string, BoardHeadlessTexture>;
50
+ failed: string[];
51
+ omitted: string[];
52
+ }>;
53
+ export type BoardExportRunOptions = {
54
+ spaceId: string;
55
+ target: string;
56
+ region: BoardExportRegion;
57
+ scale: number;
58
+ padding?: number;
59
+ colorScheme: "dark" | "light";
60
+ background: "paper" | "transparent";
61
+ format: BoardHeadlessExportFormat;
62
+ quality?: number;
63
+ withImages: boolean;
64
+ };
65
+ export type BoardExportRunResult = {
66
+ bytes: Uint8Array;
67
+ width: number;
68
+ height: number;
69
+ scale: number;
70
+ itemCount: number;
71
+ format: BoardHeadlessExportFormat;
72
+ warnings: string[];
73
+ };
74
+ /** Render a board to image bytes. Returns null when the region is empty. */
75
+ export declare function runBoardExport(options: BoardExportRunOptions): Promise<BoardExportRunResult | null>;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Board image export for the CLI.
3
+ *
4
+ * Rendering runs through the same PixiJS card renderers the web editor uses (see
5
+ * `@neta-art/cohub-board`), on a Canvas2D backend. Everything platform-specific
6
+ * lives here: fetching the document over HTTP, pulling image bytes out of the
7
+ * space, and locating fonts on disk.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join } from "node:path";
12
+ import { boardBootstrapToDocument, boardImageKeySource, imageAssetKey, isBoardPath, parseBoardManifest, } from "@neta-art/cohub-board";
13
+ import { planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub-board/export";
14
+ import { createBoardHeadlessRenderer, exportBoardImageBytes, } from "@neta-art/cohub-board/headless";
15
+ import { createClient } from "./client.js";
16
+ export const BOARD_EXPORT_FORMATS = ["png", "jpeg", "webp"];
17
+ /** Infer the output format from the file extension, defaulting to PNG. */
18
+ export function formatFromPath(path) {
19
+ const lower = path.toLowerCase();
20
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
21
+ return "jpeg";
22
+ if (lower.endsWith(".webp"))
23
+ return "webp";
24
+ return "png";
25
+ }
26
+ /**
27
+ * Geist, as shipped to the browser.
28
+ *
29
+ * The board asks for the "Geist" family; registering the exact same font files
30
+ * the web app loads is what makes CLI output match the editor rather than
31
+ * substituting whatever sans-serif the host happens to have. Resolution is
32
+ * best-effort: if the font package is not installed the export still succeeds,
33
+ * falling back through the font stack.
34
+ */
35
+ export function resolveBundledFonts() {
36
+ const require = createRequire(import.meta.url);
37
+ const fonts = [];
38
+ const candidates = [
39
+ { pkg: "@fontsource/geist", file: "geist-latin-500-normal.woff2", family: "Geist" },
40
+ { pkg: "@fontsource/geist", file: "geist-latin-400-normal.woff2", family: "Geist" },
41
+ { pkg: "@fontsource/geist", file: "geist-latin-600-normal.woff2", family: "Geist" },
42
+ { pkg: "@fontsource/geist-mono", file: "geist-mono-latin-400-normal.woff2", family: "Geist Mono" },
43
+ ];
44
+ for (const candidate of candidates) {
45
+ try {
46
+ const root = dirname(require.resolve(`${candidate.pkg}/package.json`));
47
+ const path = join(root, "files", candidate.file);
48
+ if (existsSync(path))
49
+ fonts.push({ path, family: candidate.family });
50
+ }
51
+ catch {
52
+ // Font package absent; the stack's system fallbacks cover it.
53
+ }
54
+ }
55
+ return fonts;
56
+ }
57
+ /**
58
+ * Load a board document by board id or by the path of its `.board` file.
59
+ *
60
+ * A `.board` file is a manifest holding a board id, so both forms converge on
61
+ * the same inspect call — which is also what the web client does.
62
+ */
63
+ export async function loadBoardDocument(spaceId, target) {
64
+ const client = createClient();
65
+ const boardId = isBoardPath(target)
66
+ ? await resolveManifestBoardId(spaceId, target)
67
+ : target;
68
+ const bootstrap = await client.space(spaceId).board(boardId).inspect({ include: ["nodes"] });
69
+ return {
70
+ document: boardBootstrapToDocument(bootstrap),
71
+ boardId: bootstrap.board.id,
72
+ title: bootstrap.board.title ?? null,
73
+ };
74
+ }
75
+ async function resolveManifestBoardId(spaceId, path) {
76
+ const file = await createClient().space(spaceId).files.read(path);
77
+ if (!("content" in file) || typeof file.content !== "string") {
78
+ throw new Error(`${path} is not a readable board manifest.`);
79
+ }
80
+ return parseBoardManifest(file.content).boardId;
81
+ }
82
+ /**
83
+ * Fetch every image in `items`, keyed the way the renderers ask for it.
84
+ *
85
+ * Takes the planned items rather than the whole document so a partial export
86
+ * does not download the rest of the board. Failures are collected rather than
87
+ * thrown: one unreadable image should cost a placeholder and a warning, not the
88
+ * whole export. Downloads run concurrently but bounded, so a board with hundreds
89
+ * of images does not open hundreds of sockets.
90
+ */
91
+ export async function loadBoardTextures(headless, spaceId, items, options = {}) {
92
+ const selection = selectBoardExportAssets(items, imageAssetKey);
93
+ const textures = new Map();
94
+ const failed = [];
95
+ const pending = [...selection.keys];
96
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? 6, 16));
97
+ const client = createClient();
98
+ async function worker() {
99
+ for (;;) {
100
+ const key = pending.shift();
101
+ if (!key)
102
+ return;
103
+ const source = boardImageKeySource(key);
104
+ if (!source) {
105
+ failed.push(key);
106
+ continue;
107
+ }
108
+ try {
109
+ const { bytes, mimeType } = source.kind === "file"
110
+ ? await readSpaceFileBytes(client, spaceId, source.value)
111
+ : await readUrlBytes(source.value);
112
+ const texture = await headless.decodeImage(bytes, mimeType);
113
+ textures.set(key, texture);
114
+ }
115
+ catch {
116
+ failed.push(key);
117
+ }
118
+ }
119
+ }
120
+ await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
121
+ return { textures, failed, omitted: selection.omittedKeys };
122
+ }
123
+ async function readSpaceFileBytes(client, spaceId, path) {
124
+ const { blob, mimeType } = await client.space(spaceId).files.download(path);
125
+ return { bytes: new Uint8Array(await blob.arrayBuffer()), mimeType };
126
+ }
127
+ async function readUrlBytes(url) {
128
+ const response = await fetch(url);
129
+ if (!response.ok)
130
+ throw new Error(`HTTP ${response.status}`);
131
+ return {
132
+ bytes: new Uint8Array(await response.arrayBuffer()),
133
+ mimeType: response.headers.get("content-type") ?? "image/png",
134
+ };
135
+ }
136
+ /** Render a board to image bytes. Returns null when the region is empty. */
137
+ export async function runBoardExport(options) {
138
+ const { document } = await loadBoardDocument(options.spaceId, options.target);
139
+ // Plan before fetching: a --frame / --items / --rect export should only pull
140
+ // the images it will actually draw, and an empty region should pull none.
141
+ const plan = planBoardExport({
142
+ document,
143
+ region: options.region,
144
+ scale: options.scale,
145
+ ...(options.padding === undefined ? {} : { padding: options.padding }),
146
+ });
147
+ if (!plan)
148
+ return null;
149
+ const headless = await createBoardHeadlessRenderer({ fonts: resolveBundledFonts() });
150
+ try {
151
+ const warnings = [];
152
+ let textures;
153
+ let omittedKeys = new Set();
154
+ if (options.withImages) {
155
+ const loaded = await loadBoardTextures(headless, options.spaceId, plan.items);
156
+ textures = loaded.textures;
157
+ omittedKeys = new Set(loaded.omitted);
158
+ if (loaded.failed.length > 0) {
159
+ warnings.push(`${loaded.failed.length} image${loaded.failed.length === 1 ? "" : "s"} could not be loaded: ${loaded.failed.slice(0, 3).join(", ")}${loaded.failed.length > 3 ? ", …" : ""}`);
160
+ }
161
+ if (loaded.omitted.length > 0) {
162
+ warnings.push(`${loaded.omitted.length} previews were drawn as placeholders to stay within the export texture limit.`);
163
+ }
164
+ }
165
+ const videoCount = plan.items.filter((item) => item.type === "video").length;
166
+ if (videoCount > 0) {
167
+ warnings.push(`${videoCount} video preview${videoCount === 1 ? " was" : "s were"} drawn as placeholders; headless video decoding is unavailable.`);
168
+ }
169
+ const result = exportBoardImageBytes(headless, document, {
170
+ region: options.region,
171
+ scale: options.scale,
172
+ padding: options.padding,
173
+ colorScheme: options.colorScheme,
174
+ background: options.background,
175
+ textures,
176
+ ...(options.withImages
177
+ ? {
178
+ assetKey: (item) => {
179
+ const key = imageAssetKey(item);
180
+ return key && omittedKeys.has(key) ? null : key;
181
+ },
182
+ }
183
+ : {}),
184
+ format: options.format,
185
+ quality: options.quality,
186
+ });
187
+ if (!result)
188
+ return null;
189
+ for (const warning of result.warnings) {
190
+ // Missing-image warnings are already reported above with their paths.
191
+ if (warning.kind === "images-missing" && options.withImages)
192
+ continue;
193
+ warnings.push(describeWarning(warning));
194
+ }
195
+ return {
196
+ bytes: result.bytes,
197
+ width: result.plan.width,
198
+ height: result.plan.height,
199
+ scale: result.plan.scale,
200
+ itemCount: result.plan.items.length,
201
+ format: result.format,
202
+ warnings,
203
+ };
204
+ }
205
+ finally {
206
+ headless.destroy();
207
+ }
208
+ }
209
+ function describeWarning(warning) {
210
+ if (warning.kind === "scale-clamped") {
211
+ return `Scale reduced from ${warning.requested}x to ${Number(warning.applied).toFixed(2)}x to stay within the size limit.`;
212
+ }
213
+ if (warning.kind === "images-missing") {
214
+ const keys = warning.keys;
215
+ return `${keys.length} image${keys.length === 1 ? "" : "s"} drawn as placeholders (use --images to fetch them).`;
216
+ }
217
+ if (warning.kind === "many-items") {
218
+ return `${warning.count} items exported.`;
219
+ }
220
+ return warning.kind;
221
+ }
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { BOARD_EXPORT_FORMATS, formatFromPath, runBoardExport } from "../board-export.js";
3
4
  import { createClient, createRealtimeClient } from "../client.js";
4
- import { handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
5
+ import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
5
6
  import { resolveSpace } from "../space.js";
6
7
  const INSPECT_SECTIONS = ["nodes", "effects", "sequences", "clips", "playback"];
7
8
  function isObject(value) {
@@ -170,6 +171,118 @@ function registerTransactionCommand(boards, name) {
170
171
  function commandId(options) {
171
172
  return options.commandId?.trim() || randomUUID();
172
173
  }
174
+ /**
175
+ * Resolve the mutually exclusive region flags.
176
+ *
177
+ * Selecting more than one is rejected rather than silently ranked: "why did
178
+ * --items win over --frame" is a worse experience than being told to pick one.
179
+ */
180
+ function parseExportRegion(options) {
181
+ const chosen = [
182
+ options.frame ? "--frame" : null,
183
+ options.items ? "--items" : null,
184
+ options.rect ? "--rect" : null,
185
+ ].filter(Boolean);
186
+ if (chosen.length > 1) {
187
+ throw new Error(`Pick one region: ${chosen.join(", ")} cannot be combined`);
188
+ }
189
+ if (options.frame)
190
+ return { kind: "frame", id: options.frame };
191
+ if (options.items) {
192
+ const ids = options.items.split(",").map((id) => id.trim()).filter(Boolean);
193
+ if (ids.length === 0)
194
+ throw new Error("--items needs at least one node id");
195
+ return { kind: "items", ids };
196
+ }
197
+ if (options.rect) {
198
+ const rect = parseViewport(options.rect);
199
+ if (!rect)
200
+ throw new Error("--rect must be x,y,width,height");
201
+ return { kind: "rect", rect };
202
+ }
203
+ return { kind: "all" };
204
+ }
205
+ function parseExportFormat(options, outPath) {
206
+ if (!options.format)
207
+ return formatFromPath(outPath);
208
+ const format = options.format.toLowerCase();
209
+ if (!BOARD_EXPORT_FORMATS.includes(format)) {
210
+ throw new Error(`Unknown format "${options.format}"; use ${BOARD_EXPORT_FORMATS.join(", ")}`);
211
+ }
212
+ return format;
213
+ }
214
+ function parseColorMode(value) {
215
+ if (!value || value === "dark")
216
+ return "dark";
217
+ if (value === "light")
218
+ return "light";
219
+ throw new Error('--theme must be "dark" or "light"');
220
+ }
221
+ function parseBackground(value) {
222
+ if (!value || value === "paper")
223
+ return "paper";
224
+ if (value === "transparent")
225
+ return "transparent";
226
+ throw new Error('--background must be "paper" or "transparent"');
227
+ }
228
+ function registerExportCommand(boards) {
229
+ withJson(boards.command("export <board>")
230
+ .description("Render a Board to an image (board id or .board path)")
231
+ .requiredOption("-o, --out <file>", "Output file; extension selects the format")
232
+ .option("--scale <factor>", "Output pixels per world unit", "2")
233
+ .option("--padding <units>", "World-space padding around the content")
234
+ .option("--frame <node-id>", "Export a single frame as a page")
235
+ .option("--items <ids>", "Comma-separated node ids to export")
236
+ .option("--rect <rect>", "World rect as x,y,width,height")
237
+ .option("--theme <mode>", "dark or light", "dark")
238
+ .option("--background <mode>", "paper or transparent", "paper")
239
+ .option("--format <format>", `Override format (${BOARD_EXPORT_FORMATS.join(", ")})`)
240
+ .option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
241
+ .option("--no-images", "Skip image downloads and draw placeholders"))
242
+ .action(async (board, options) => {
243
+ try {
244
+ const out = options.out;
245
+ if (!out)
246
+ throw new Error("--out is required");
247
+ const result = await runBoardExport({
248
+ spaceId: resolveSpace(boards),
249
+ target: board,
250
+ region: parseExportRegion(options),
251
+ scale: parseNumber(options.scale ?? "2", "scale", { min: 0.01, max: 16 }),
252
+ ...(options.padding === undefined
253
+ ? {}
254
+ : { padding: parseNumber(options.padding, "padding", { min: 0 }) }),
255
+ colorScheme: parseColorMode(options.theme),
256
+ background: parseBackground(options.background),
257
+ format: parseExportFormat(options, out),
258
+ quality: parseNumber(options.quality ?? "0.92", "quality", { min: 0, max: 1 }),
259
+ withImages: options.images !== false,
260
+ });
261
+ if (!result) {
262
+ return error("Nothing to export", "The selected region contains no items.");
263
+ }
264
+ await writeFile(out, result.bytes);
265
+ if (jsonRequested(options)) {
266
+ return outJson({
267
+ path: out,
268
+ width: result.width,
269
+ height: result.height,
270
+ scale: result.scale,
271
+ items: result.itemCount,
272
+ format: result.format,
273
+ bytes: result.bytes.length,
274
+ warnings: result.warnings,
275
+ });
276
+ }
277
+ ok(`Exported ${result.width}×${result.height} ${result.format.toUpperCase()} to ${out}`);
278
+ for (const warning of result.warnings)
279
+ console.log(` ! ${warning}`);
280
+ }
281
+ catch (cause) {
282
+ handleHttp(cause);
283
+ }
284
+ });
285
+ }
173
286
  export function registerBoards(program) {
174
287
  const boards = program
175
288
  .command("boards")
@@ -239,6 +352,7 @@ export function registerBoards(program) {
239
352
  });
240
353
  registerTransactionCommand(boards, "validate");
241
354
  registerTransactionCommand(boards, "apply");
355
+ registerExportCommand(boards);
242
356
  withJson(boards.command("play <board-id> <sequence-id>")
243
357
  .description("Start shared playback")
244
358
  .option("--position <time>", "Initial position in milliseconds")
@@ -322,9 +436,12 @@ export function registerBoards(program) {
322
436
  if (event.type === "board.transaction.applied") {
323
437
  process.stdout.write(`version ${event.payload.version} transaction ${event.payload.txId} operations ${event.payload.operations.length}\n`);
324
438
  }
325
- else {
439
+ else if (event.type === "board.playback.changed") {
326
440
  process.stdout.write(`${event.payload.status} sequence ${event.payload.sequenceId} position ${event.payload.position}\n`);
327
441
  }
442
+ else {
443
+ process.stdout.write(`awareness ${event.payload.actorName} ${event.payload.update.type}\n`);
444
+ }
328
445
  },
329
446
  });
330
447
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -18,7 +18,8 @@
18
18
  "@neta-art/generation": "^0.1.16",
19
19
  "commander": "^15.0.0",
20
20
  "sharp": "^0.35.3",
21
- "@neta-art/cohub": "3.0.0"
21
+ "@neta-art/cohub": "3.1.0",
22
+ "@neta-art/cohub-board": "0.2.0"
22
23
  },
23
24
  "publishConfig": {
24
25
  "access": "public"
@@ -27,6 +28,9 @@
27
28
  "@types/node": "^26.1.1",
28
29
  "typescript": "^7.0.2"
29
30
  },
31
+ "optionalDependencies": {
32
+ "@napi-rs/canvas": "^1.0.2"
33
+ },
30
34
  "scripts": {
31
35
  "build": "tsc -p tsconfig.build.json",
32
36
  "typecheck": "tsc -p tsconfig.json --noEmit"