@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.
- package/dist/board-export.d.ts +75 -0
- package/dist/board-export.js +221 -0
- package/dist/commands/boards.js +120 -3
- package/package.json +6 -2
|
@@ -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
|
+
}
|
package/dist/commands/boards.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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"
|