@neta-art/cohub-cli 3.11.0 → 3.12.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-command-support.d.ts +7 -0
- package/dist/board-command-support.js +99 -0
- package/dist/board-export.js +28 -22
- package/dist/commands/board-domain.d.ts +2 -0
- package/dist/commands/board-domain.js +8 -0
- package/dist/commands/boards/animation.d.ts +2 -0
- package/dist/commands/boards/animation.js +227 -0
- package/dist/commands/boards/appearance.d.ts +2 -0
- package/dist/commands/boards/appearance.js +93 -0
- package/dist/commands/boards/context.d.ts +12 -0
- package/dist/commands/boards/context.js +25 -0
- package/dist/commands/boards/nodes.d.ts +2 -0
- package/dist/commands/boards/nodes.js +110 -0
- package/dist/commands/boards.d.ts +3 -2
- package/dist/commands/boards.js +126 -57
- package/dist/safe-remote-image.d.ts +24 -0
- package/dist/safe-remote-image.js +160 -0
- package/package.json +2 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const BOARD_TRANSACTION_INPUT_MAX_BYTES: number;
|
|
2
|
+
export declare const BOARD_CREATE_INPUT_MAX_BYTES: number;
|
|
3
|
+
export declare const BOARD_DOMAIN_INPUT_MAX_BYTES: number;
|
|
4
|
+
export declare function parseBoardJsonObject(text: string, source?: string): Record<string, unknown>;
|
|
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
|
+
export declare function writeBoardOutput(path: string, bytes: Uint8Array, force?: boolean): Promise<void>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, link, open, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { isBoardPath, parseBoardManifest, } from "@neta-art/cohub/board";
|
|
6
|
+
import { createClient } from "./client.js";
|
|
7
|
+
export const BOARD_TRANSACTION_INPUT_MAX_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
export const BOARD_CREATE_INPUT_MAX_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
export const BOARD_DOMAIN_INPUT_MAX_BYTES = 1024 * 1024;
|
|
10
|
+
export function parseBoardJsonObject(text, source = "input") {
|
|
11
|
+
if (!text.trim())
|
|
12
|
+
throw new Error(`${source} is empty`);
|
|
13
|
+
let value;
|
|
14
|
+
try {
|
|
15
|
+
value = JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
catch (cause) {
|
|
18
|
+
throw new Error(`${source} must contain valid JSON`, { cause });
|
|
19
|
+
}
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
21
|
+
throw new Error(`${source} must contain a JSON object`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
async function readStdinBounded(maxBytes) {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let size = 0;
|
|
28
|
+
for await (const chunk of process.stdin) {
|
|
29
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
30
|
+
size += bytes.byteLength;
|
|
31
|
+
if (size > maxBytes)
|
|
32
|
+
throw new Error(`stdin exceeds the ${maxBytes} byte input limit`);
|
|
33
|
+
chunks.push(bytes);
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
36
|
+
}
|
|
37
|
+
export async function readBoardJsonObject(source, maxBytes = BOARD_DOMAIN_INPUT_MAX_BYTES) {
|
|
38
|
+
if (source === "-")
|
|
39
|
+
return parseBoardJsonObject(await readStdinBounded(maxBytes), "stdin");
|
|
40
|
+
const handle = await open(source, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
41
|
+
try {
|
|
42
|
+
const info = await handle.stat();
|
|
43
|
+
if (!info.isFile())
|
|
44
|
+
throw new Error(`${source} must be a regular file`);
|
|
45
|
+
if (info.size > maxBytes)
|
|
46
|
+
throw new Error(`${source} exceeds the ${maxBytes} byte input limit`);
|
|
47
|
+
const chunks = [];
|
|
48
|
+
const buffer = Buffer.allocUnsafe(Math.max(1, Math.min(64 * 1024, maxBytes + 1)));
|
|
49
|
+
let size = 0;
|
|
50
|
+
for (;;) {
|
|
51
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, null);
|
|
52
|
+
if (bytesRead === 0)
|
|
53
|
+
break;
|
|
54
|
+
size += bytesRead;
|
|
55
|
+
if (size > maxBytes)
|
|
56
|
+
throw new Error(`${source} exceeds the ${maxBytes} byte input limit`);
|
|
57
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
|
58
|
+
}
|
|
59
|
+
return parseBoardJsonObject(Buffer.concat(chunks).toString("utf8"), source);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
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
|
+
export async function writeBoardOutput(path, bytes, force = false) {
|
|
75
|
+
if (!force) {
|
|
76
|
+
await access(path).then(() => { throw new Error(`Output already exists: ${path}; use --force to replace it`); }, () => undefined);
|
|
77
|
+
}
|
|
78
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
79
|
+
try {
|
|
80
|
+
const handle = await open(temp, "wx");
|
|
81
|
+
try {
|
|
82
|
+
await handle.writeFile(bytes);
|
|
83
|
+
await handle.sync();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await handle.close();
|
|
87
|
+
}
|
|
88
|
+
if (force)
|
|
89
|
+
await rename(temp, path);
|
|
90
|
+
else {
|
|
91
|
+
await link(temp, path);
|
|
92
|
+
await unlink(temp);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (cause) {
|
|
96
|
+
await unlink(temp).catch(() => undefined);
|
|
97
|
+
throw cause;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/board-export.js
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
|
-
import { boardBootstrapToDocument, boardImageKeySource, imageAssetKey,
|
|
12
|
+
import { boardBootstrapToDocument, boardImageKeySource, imageAssetKey, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
|
|
13
13
|
import { createBoardHeadlessRenderer, exportBoardImageBytes, } from "@neta-art/cohub/board/headless";
|
|
14
|
+
import { resolveBoardId } from "./board-command-support.js";
|
|
14
15
|
import { createClient } from "./client.js";
|
|
16
|
+
import { downloadPublicImage } from "./safe-remote-image.js";
|
|
15
17
|
export const BOARD_EXPORT_FORMATS = ["png", "jpeg", "webp"];
|
|
16
18
|
/** Infer the output format from the file extension, defaulting to PNG. */
|
|
17
19
|
export function formatFromPath(path) {
|
|
@@ -61,9 +63,7 @@ export function resolveBundledFonts() {
|
|
|
61
63
|
*/
|
|
62
64
|
export async function loadBoardDocument(spaceId, target) {
|
|
63
65
|
const client = createClient();
|
|
64
|
-
const boardId =
|
|
65
|
-
? await resolveManifestBoardId(spaceId, target)
|
|
66
|
-
: target;
|
|
66
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
67
67
|
const bootstrap = await client.space(spaceId).board(boardId).inspect({ include: ["nodes"] });
|
|
68
68
|
return {
|
|
69
69
|
document: boardBootstrapToDocument(bootstrap),
|
|
@@ -71,13 +71,6 @@ export async function loadBoardDocument(spaceId, target) {
|
|
|
71
71
|
title: bootstrap.board.title ?? null,
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
-
async function resolveManifestBoardId(spaceId, path) {
|
|
75
|
-
const file = await createClient().space(spaceId).files.read(path);
|
|
76
|
-
if (!("content" in file) || typeof file.content !== "string") {
|
|
77
|
-
throw new Error(`${path} is not a readable board manifest.`);
|
|
78
|
-
}
|
|
79
|
-
return parseBoardManifest(file.content).boardId;
|
|
80
|
-
}
|
|
81
74
|
/**
|
|
82
75
|
* Fetch every image in `items`, keyed the way the renderers ask for it.
|
|
83
76
|
*
|
|
@@ -92,7 +85,7 @@ export async function loadBoardTextures(headless, spaceId, items, options = {})
|
|
|
92
85
|
const textures = new Map();
|
|
93
86
|
const failed = [];
|
|
94
87
|
const pending = [...selection.keys];
|
|
95
|
-
const concurrency = Math.max(1, Math.min(options.concurrency ??
|
|
88
|
+
const concurrency = Math.max(1, Math.min(options.concurrency ?? 4, 16));
|
|
96
89
|
const client = createClient();
|
|
97
90
|
async function worker() {
|
|
98
91
|
for (;;) {
|
|
@@ -107,7 +100,7 @@ export async function loadBoardTextures(headless, spaceId, items, options = {})
|
|
|
107
100
|
try {
|
|
108
101
|
const { bytes, mimeType } = source.kind === "file"
|
|
109
102
|
? await readSpaceFileBytes(client, spaceId, source.value)
|
|
110
|
-
: await
|
|
103
|
+
: await downloadPublicImage(source.value);
|
|
111
104
|
const texture = await headless.decodeImage(bytes, mimeType);
|
|
112
105
|
textures.set(key, texture);
|
|
113
106
|
}
|
|
@@ -123,15 +116,6 @@ async function readSpaceFileBytes(client, spaceId, path) {
|
|
|
123
116
|
const { blob, mimeType } = await client.space(spaceId).files.download(path);
|
|
124
117
|
return { bytes: new Uint8Array(await blob.arrayBuffer()), mimeType };
|
|
125
118
|
}
|
|
126
|
-
async function readUrlBytes(url) {
|
|
127
|
-
const response = await fetch(url);
|
|
128
|
-
if (!response.ok)
|
|
129
|
-
throw new Error(`HTTP ${response.status}`);
|
|
130
|
-
return {
|
|
131
|
-
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
132
|
-
mimeType: response.headers.get("content-type") ?? "image/png",
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
119
|
/** Render a board to image bytes. Returns null when the region is empty. */
|
|
136
120
|
export async function runBoardExport(options) {
|
|
137
121
|
const { document } = await loadBoardDocument(options.spaceId, options.target);
|
|
@@ -149,6 +133,7 @@ export async function runBoardExport(options) {
|
|
|
149
133
|
try {
|
|
150
134
|
const warnings = [];
|
|
151
135
|
let textures;
|
|
136
|
+
let backgroundTexture;
|
|
152
137
|
let omittedKeys = new Set();
|
|
153
138
|
if (options.withImages) {
|
|
154
139
|
const loaded = await loadBoardTextures(headless, options.spaceId, plan.items);
|
|
@@ -161,6 +146,19 @@ export async function runBoardExport(options) {
|
|
|
161
146
|
warnings.push(`${loaded.omitted.length} previews were drawn as placeholders to stay within the export texture limit.`);
|
|
162
147
|
}
|
|
163
148
|
}
|
|
149
|
+
const declaredBackground = document.appearance.background;
|
|
150
|
+
if (options.withImages &&
|
|
151
|
+
options.background === "paper" &&
|
|
152
|
+
declaredBackground.kind === "image" &&
|
|
153
|
+
declaredBackground.imageUrl) {
|
|
154
|
+
try {
|
|
155
|
+
const { bytes, mimeType } = await downloadPublicImage(declaredBackground.imageUrl);
|
|
156
|
+
backgroundTexture = await headless.decodeImage(bytes, mimeType);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
warnings.push("The board background image could not be loaded; the fallback color was exported.");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
164
162
|
const videoCount = plan.items.filter((item) => item.type === "video").length;
|
|
165
163
|
if (videoCount > 0) {
|
|
166
164
|
warnings.push(`${videoCount} video preview${videoCount === 1 ? " was" : "s were"} drawn as placeholders; headless video decoding is unavailable.`);
|
|
@@ -172,6 +170,14 @@ export async function runBoardExport(options) {
|
|
|
172
170
|
colorScheme: options.colorScheme,
|
|
173
171
|
background: options.background,
|
|
174
172
|
textures,
|
|
173
|
+
backgroundImage: backgroundTexture
|
|
174
|
+
? {
|
|
175
|
+
texture: backgroundTexture,
|
|
176
|
+
fit: declaredBackground.fit ?? "cover",
|
|
177
|
+
position: declaredBackground.position ?? "center",
|
|
178
|
+
opacity: declaredBackground.opacity ?? 1,
|
|
179
|
+
}
|
|
180
|
+
: undefined,
|
|
175
181
|
...(options.withImages
|
|
176
182
|
? {
|
|
177
183
|
assetKey: (item) => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { registerBoardAnimationCommands } from "./boards/animation.js";
|
|
2
|
+
import { registerBoardAppearanceCommands } from "./boards/appearance.js";
|
|
3
|
+
import { registerBoardNodeCommands } from "./boards/nodes.js";
|
|
4
|
+
export function registerBoardDomainCommands(boards) {
|
|
5
|
+
registerBoardAppearanceCommands(boards);
|
|
6
|
+
registerBoardNodeCommands(boards);
|
|
7
|
+
registerBoardAnimationCommands(boards);
|
|
8
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { BoardCameraFocusParamsSchema, boardEffectDeleteOperation, boardEffectUpsertOperation, boardSequenceDeleteOperation, boardSequenceUpsertOperation, } from "@neta-art/cohub/board";
|
|
3
|
+
import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
|
|
4
|
+
import { handleHttp, json, jsonRequested, table } from "../../output.js";
|
|
5
|
+
import { finite, resolvedBoard, showUpdated, withJson, } from "./context.js";
|
|
6
|
+
function rect(value) {
|
|
7
|
+
const parts = value.split(",").map(Number);
|
|
8
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
|
|
9
|
+
throw new Error("--rect must be x,y,width,height");
|
|
10
|
+
}
|
|
11
|
+
const [x, y, width, height] = parts;
|
|
12
|
+
if (width <= 0 || height <= 0)
|
|
13
|
+
throw new Error("--rect width and height must be positive");
|
|
14
|
+
return { x, y, width, height };
|
|
15
|
+
}
|
|
16
|
+
export function registerBoardAnimationCommands(boards) {
|
|
17
|
+
const effects = boards.command("effects").description("Manage Board effects");
|
|
18
|
+
withJson(effects.command("list <board>").alias("ls").description("List effects"))
|
|
19
|
+
.action(async (target, options) => {
|
|
20
|
+
try {
|
|
21
|
+
const board = await resolvedBoard(boards, target);
|
|
22
|
+
const result = await board.inspect({ include: ["effects"] });
|
|
23
|
+
if (jsonRequested(options))
|
|
24
|
+
return json(result.effects);
|
|
25
|
+
table(result.effects, [
|
|
26
|
+
{ key: "id", label: "ID" },
|
|
27
|
+
{ key: "kind", label: "KIND" },
|
|
28
|
+
{ key: "enabled", label: "ENABLED" },
|
|
29
|
+
{ key: "layer", label: "LAYER" },
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
handleHttp(cause);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
withJson(effects.command("upsert <board>")
|
|
37
|
+
.description("Create or replace an effect")
|
|
38
|
+
.requiredOption("-i, --input <file>", "Board effect JSON; use - for stdin")
|
|
39
|
+
.addHelpText("after", `
|
|
40
|
+
Minimal pulse effect:
|
|
41
|
+
{"id":"pulse-title","target":{"type":"node","nodeId":"title"},"kind":"effects.pulse","kindVersion":1,"lifecycle":"when-visible","timeOrigin":"visible","seed":"pulse-title"}
|
|
42
|
+
|
|
43
|
+
Run boards capabilities <board> to discover supported effect kinds.`))
|
|
44
|
+
.action(async (target, options) => {
|
|
45
|
+
try {
|
|
46
|
+
const effect = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
47
|
+
const board = await resolvedBoard(boards, target);
|
|
48
|
+
showUpdated(await board.mutate({ build: () => [boardEffectUpsertOperation(effect)] }), options);
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
handleHttp(cause);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
withJson(effects.command("delete <board> <effect-id>").alias("rm").description("Delete an effect"))
|
|
55
|
+
.action(async (target, effectId, options) => {
|
|
56
|
+
try {
|
|
57
|
+
const board = await resolvedBoard(boards, target);
|
|
58
|
+
showUpdated(await board.mutate({ build: () => [boardEffectDeleteOperation(effectId)] }), options);
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
handleHttp(cause);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const sequences = boards.command("sequences").description("Manage Board sequences");
|
|
65
|
+
withJson(sequences.command("list <board>").alias("ls").description("List sequences"))
|
|
66
|
+
.action(async (target, options) => {
|
|
67
|
+
try {
|
|
68
|
+
const board = await resolvedBoard(boards, target);
|
|
69
|
+
const result = await board.inspect({ include: ["sequences"] });
|
|
70
|
+
if (jsonRequested(options))
|
|
71
|
+
return json(result.sequences);
|
|
72
|
+
table(result.sequences, [
|
|
73
|
+
{ key: "id", label: "ID" },
|
|
74
|
+
{ key: "name", label: "NAME" },
|
|
75
|
+
{ key: "duration", label: "DURATION" },
|
|
76
|
+
{ key: "revision", label: "REVISION" },
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
catch (cause) {
|
|
80
|
+
handleHttp(cause);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
withJson(sequences.command("get <board> <sequence-id>").description("Get a sequence and its clips"))
|
|
84
|
+
.action(async (target, sequenceId, options) => {
|
|
85
|
+
try {
|
|
86
|
+
const board = await resolvedBoard(boards, target);
|
|
87
|
+
const result = await board.inspect({ include: ["sequences", "clips"] });
|
|
88
|
+
const sequence = result.sequences.find((item) => item.id === sequenceId);
|
|
89
|
+
if (!sequence)
|
|
90
|
+
throw new Error(`Sequence not found: ${sequenceId}`);
|
|
91
|
+
const output = {
|
|
92
|
+
sequence,
|
|
93
|
+
clips: result.clips.filter((clip) => clip.sequenceId === sequenceId),
|
|
94
|
+
};
|
|
95
|
+
if (jsonRequested(options))
|
|
96
|
+
return json(output);
|
|
97
|
+
table([sequence], [
|
|
98
|
+
{ key: "id", label: "ID" },
|
|
99
|
+
{ key: "name", label: "NAME" },
|
|
100
|
+
{ key: "duration", label: "DURATION" },
|
|
101
|
+
{ key: "revision", label: "REVISION" },
|
|
102
|
+
]);
|
|
103
|
+
table(output.clips, [
|
|
104
|
+
{ key: "id", label: "CLIP" },
|
|
105
|
+
{ key: "kind", label: "KIND" },
|
|
106
|
+
{ key: "start", label: "START" },
|
|
107
|
+
{ key: "duration", label: "DURATION" },
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
catch (cause) {
|
|
111
|
+
handleHttp(cause);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
withJson(sequences.command("upsert <board>")
|
|
115
|
+
.description("Create or replace a sequence and clips")
|
|
116
|
+
.requiredOption("-i, --input <file>", "{ sequence, clips } JSON; use - for stdin")
|
|
117
|
+
.addHelpText("after", `
|
|
118
|
+
Minimal sequence with one clip:
|
|
119
|
+
{"sequence":{"id":"intro","name":"Intro","duration":1200,"seed":"intro"},"clips":[{"id":"reveal-title","kind":"text.reveal","kindVersion":1,"target":{"type":"node","nodeId":"title"},"start":0,"duration":600,"seed":"reveal-title"}]}
|
|
120
|
+
|
|
121
|
+
Coordinate rules:
|
|
122
|
+
node motion x/y and path points are Board-world offsets; camera.pan x/y are screen-pixel offsets.
|
|
123
|
+
|
|
124
|
+
Edit an existing sequence:
|
|
125
|
+
cohub boards sequences get <board> intro --json > intro.json
|
|
126
|
+
cohub boards sequences upsert <board> -i intro.json`))
|
|
127
|
+
.action(async (target, options) => {
|
|
128
|
+
try {
|
|
129
|
+
const input = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
130
|
+
const board = await resolvedBoard(boards, target);
|
|
131
|
+
showUpdated(await board.mutate({ build: () => [boardSequenceUpsertOperation(input)] }), options);
|
|
132
|
+
}
|
|
133
|
+
catch (cause) {
|
|
134
|
+
handleHttp(cause);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
withJson(sequences.command("camera-focus <board> <sequence-id>")
|
|
138
|
+
.description("Add or replace a semantic camera focus clip")
|
|
139
|
+
.option("--id <id>", "Stable clip id")
|
|
140
|
+
.option("--node <id>", "Focus one node")
|
|
141
|
+
.option("--nodes <ids>", "Focus comma-separated nodes")
|
|
142
|
+
.option("--frame <id>", "Focus a frame")
|
|
143
|
+
.option("--rect <rect>", "Board world rect as x,y,width,height")
|
|
144
|
+
.requiredOption("--at <ms>", "Clip start time")
|
|
145
|
+
.option("--duration <ms>", "Transition duration", "700")
|
|
146
|
+
.option("--padding <px>", "Screen padding in CSS pixels", "32")
|
|
147
|
+
.option("--fit <mode>", "contain or cover", "contain")
|
|
148
|
+
.option("--min-zoom <zoom>", "Minimum zoom multiplier")
|
|
149
|
+
.option("--max-zoom <zoom>", "Maximum zoom multiplier")
|
|
150
|
+
.option("--easing <name>", "Easing", "ease-out-cubic")
|
|
151
|
+
.addHelpText("after", `
|
|
152
|
+
Examples:
|
|
153
|
+
cohub boards sequences camera-focus plan.board intro --node hero --at 1200
|
|
154
|
+
cohub boards sequences camera-focus plan.board intro --rect 120,80,640,360 --at 2000 --duration 800`))
|
|
155
|
+
.action(async (target, sequenceId, options) => {
|
|
156
|
+
try {
|
|
157
|
+
const selected = [options.node, options.nodes, options.frame, options.rect].filter(Boolean);
|
|
158
|
+
if (selected.length !== 1)
|
|
159
|
+
throw new Error("Choose one of --node, --nodes, --frame, or --rect");
|
|
160
|
+
const focus = options.node
|
|
161
|
+
? { type: "node", nodeId: options.node }
|
|
162
|
+
: options.nodes
|
|
163
|
+
? { type: "nodes", nodeIds: options.nodes.split(",").map((id) => id.trim()).filter(Boolean) }
|
|
164
|
+
: options.frame
|
|
165
|
+
? { type: "frame", frameId: options.frame }
|
|
166
|
+
: { type: "rect", rect: rect(options.rect) };
|
|
167
|
+
const params = BoardCameraFocusParamsSchema.parse({
|
|
168
|
+
focus,
|
|
169
|
+
fit: options.fit,
|
|
170
|
+
padding: finite(options.padding, "padding"),
|
|
171
|
+
...(options.minZoom === undefined ? {} : { minZoom: finite(options.minZoom, "min zoom") }),
|
|
172
|
+
...(options.maxZoom === undefined ? {} : { maxZoom: finite(options.maxZoom, "max zoom") }),
|
|
173
|
+
});
|
|
174
|
+
const start = finite(options.at, "start");
|
|
175
|
+
const duration = finite(options.duration, "duration");
|
|
176
|
+
if (start < 0 || duration <= 0)
|
|
177
|
+
throw new Error("start must be non-negative and duration must be positive");
|
|
178
|
+
const clipId = options.id ?? randomUUID();
|
|
179
|
+
const board = await resolvedBoard(boards, target);
|
|
180
|
+
showUpdated(await board.mutate({
|
|
181
|
+
include: ["sequences", "clips"],
|
|
182
|
+
build(current) {
|
|
183
|
+
const currentSequence = current.sequences.find((sequence) => sequence.id === sequenceId);
|
|
184
|
+
if (!currentSequence)
|
|
185
|
+
throw new Error(`Sequence not found: ${sequenceId}`);
|
|
186
|
+
const { boardId: _boardId, revision: _revision, ...sequence } = currentSequence;
|
|
187
|
+
const clips = current.clips
|
|
188
|
+
.filter((clip) => clip.sequenceId === sequenceId && clip.id !== clipId)
|
|
189
|
+
.map(({ sequenceId: _sequenceId, ...clip }) => clip);
|
|
190
|
+
clips.push({
|
|
191
|
+
id: clipId,
|
|
192
|
+
kind: "camera.focus",
|
|
193
|
+
kindVersion: 1,
|
|
194
|
+
target: { type: "camera" },
|
|
195
|
+
start,
|
|
196
|
+
duration,
|
|
197
|
+
layer: "screen",
|
|
198
|
+
fill: "forwards",
|
|
199
|
+
easing: options.easing,
|
|
200
|
+
params,
|
|
201
|
+
keyframes: [],
|
|
202
|
+
assetRefs: [],
|
|
203
|
+
seed: clipId,
|
|
204
|
+
metadata: {},
|
|
205
|
+
});
|
|
206
|
+
return [boardSequenceUpsertOperation({
|
|
207
|
+
sequence: { ...sequence, duration: Math.max(sequence.duration, start + duration) },
|
|
208
|
+
clips,
|
|
209
|
+
})];
|
|
210
|
+
},
|
|
211
|
+
}), options);
|
|
212
|
+
}
|
|
213
|
+
catch (cause) {
|
|
214
|
+
handleHttp(cause);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
withJson(sequences.command("delete <board> <sequence-id>").alias("rm").description("Delete a sequence and its clips"))
|
|
218
|
+
.action(async (target, sequenceId, options) => {
|
|
219
|
+
try {
|
|
220
|
+
const board = await resolvedBoard(boards, target);
|
|
221
|
+
showUpdated(await board.mutate({ build: () => [boardSequenceDeleteOperation(sequenceId)] }), options);
|
|
222
|
+
}
|
|
223
|
+
catch (cause) {
|
|
224
|
+
handleHttp(cause);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { BoardAppearanceSchema, DEFAULT_BOARD_APPEARANCE, boardAppearanceOperation, boardPlaybackPolicyOperation, boardTitleOperation, patchBoardAppearance, } from "@neta-art/cohub/board";
|
|
2
|
+
import { handleHttp } from "../../output.js";
|
|
3
|
+
import { finite, resolvedBoard, showUpdated, withJson, } from "./context.js";
|
|
4
|
+
function appearanceFrom(metadata) {
|
|
5
|
+
const parsed = BoardAppearanceSchema.safeParse(metadata.appearance);
|
|
6
|
+
return parsed.success ? parsed.data : DEFAULT_BOARD_APPEARANCE;
|
|
7
|
+
}
|
|
8
|
+
export function registerBoardAppearanceCommands(boards) {
|
|
9
|
+
withJson(boards.command("rename <board> <title>").description("Rename a Board"))
|
|
10
|
+
.action(async (target, title, options) => {
|
|
11
|
+
try {
|
|
12
|
+
const board = await resolvedBoard(boards, target);
|
|
13
|
+
showUpdated(await board.mutate({ build: () => [boardTitleOperation(title)] }), options);
|
|
14
|
+
}
|
|
15
|
+
catch (cause) {
|
|
16
|
+
handleHttp(cause);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
withJson(boards.command("background <board>")
|
|
20
|
+
.description("Configure the Board background")
|
|
21
|
+
.option("--color <color>", "Solid CSS color")
|
|
22
|
+
.option("--image <url>", "Public image URL")
|
|
23
|
+
.option("--fit <mode>", "cover, contain, or repeat", "cover")
|
|
24
|
+
.option("--position <position>", "center, top, bottom, left, or right", "center")
|
|
25
|
+
.option("--opacity <value>", "Image opacity from 0 to 1", "1")
|
|
26
|
+
.option("--reset", "Restore the default background")
|
|
27
|
+
.addHelpText("after", `
|
|
28
|
+
Examples:
|
|
29
|
+
cohub boards background plan.board --color "#123456"
|
|
30
|
+
cohub boards background plan.board --image https://example.com/bg.webp --fit cover --opacity 0.8`))
|
|
31
|
+
.action(async (target, options) => {
|
|
32
|
+
try {
|
|
33
|
+
const selected = [options.color, options.image, options.reset ? "reset" : undefined].filter(Boolean);
|
|
34
|
+
if (selected.length !== 1)
|
|
35
|
+
throw new Error("Choose one of --color, --image, or --reset");
|
|
36
|
+
if (!["cover", "contain", "repeat"].includes(options.fit))
|
|
37
|
+
throw new Error("--fit must be cover, contain, or repeat");
|
|
38
|
+
if (!["center", "top", "bottom", "left", "right"].includes(options.position))
|
|
39
|
+
throw new Error("--position must be center, top, bottom, left, or right");
|
|
40
|
+
const opacity = finite(options.opacity, "opacity");
|
|
41
|
+
if (opacity < 0 || opacity > 1)
|
|
42
|
+
throw new Error("opacity must be between 0 and 1");
|
|
43
|
+
const board = await resolvedBoard(boards, target);
|
|
44
|
+
const result = await board.mutate({
|
|
45
|
+
build(current) {
|
|
46
|
+
const appearance = appearanceFrom(current.board.metadata);
|
|
47
|
+
const background = options.reset
|
|
48
|
+
? { kind: "solid" }
|
|
49
|
+
: options.color
|
|
50
|
+
? { kind: "solid", color: options.color }
|
|
51
|
+
: {
|
|
52
|
+
kind: "image",
|
|
53
|
+
imageUrl: options.image,
|
|
54
|
+
fit: options.fit,
|
|
55
|
+
position: options.position,
|
|
56
|
+
opacity,
|
|
57
|
+
color: appearance.background.color,
|
|
58
|
+
};
|
|
59
|
+
return [boardAppearanceOperation(patchBoardAppearance(appearance, { background }))];
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
showUpdated(result, options);
|
|
63
|
+
}
|
|
64
|
+
catch (cause) {
|
|
65
|
+
handleHttp(cause);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
withJson(boards.command("playback-policy <board>")
|
|
69
|
+
.description("Configure automatic Board playback")
|
|
70
|
+
.option("--sequence <id>", "Sequence to play")
|
|
71
|
+
.option("--delay <ms>", "Delay before playback", "0")
|
|
72
|
+
.option("--loop", "Loop the sequence")
|
|
73
|
+
.option("--clear", "Remove the playback policy"))
|
|
74
|
+
.action(async (target, options) => {
|
|
75
|
+
try {
|
|
76
|
+
if (options.clear === Boolean(options.sequence))
|
|
77
|
+
throw new Error("Use --sequence or --clear");
|
|
78
|
+
const delayMs = finite(options.delay, "delay");
|
|
79
|
+
if (delayMs < 0)
|
|
80
|
+
throw new Error("delay must be non-negative");
|
|
81
|
+
const policy = options.clear
|
|
82
|
+
? null
|
|
83
|
+
: { sequenceId: options.sequence, delayMs, loop: Boolean(options.loop) };
|
|
84
|
+
const board = await resolvedBoard(boards, target);
|
|
85
|
+
showUpdated(await board.mutate({
|
|
86
|
+
build: (current) => [boardPlaybackPolicyOperation(current.board.metadata, policy)],
|
|
87
|
+
}), options);
|
|
88
|
+
}
|
|
89
|
+
catch (cause) {
|
|
90
|
+
handleHttp(cause);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
export type JsonOptions = {
|
|
3
|
+
json?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare function withJson(command: Command): Command;
|
|
6
|
+
export declare function finite(value: string | undefined, name: string, fallback?: number): number;
|
|
7
|
+
export declare function resolvedBoard(boards: Command, target: string): Promise<import("@neta-art/cohub").BoardClient>;
|
|
8
|
+
export declare function showUpdated(result: {
|
|
9
|
+
board: {
|
|
10
|
+
version: number;
|
|
11
|
+
};
|
|
12
|
+
}, options: JsonOptions): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createClient } from "../../client.js";
|
|
2
|
+
import { resolveBoardId } from "../../board-command-support.js";
|
|
3
|
+
import { json, jsonRequested, ok } from "../../output.js";
|
|
4
|
+
import { resolveSpace } from "../../space.js";
|
|
5
|
+
export function withJson(command) {
|
|
6
|
+
return command.option("--json", "Output as JSON");
|
|
7
|
+
}
|
|
8
|
+
export function finite(value, name, fallback) {
|
|
9
|
+
if (value === undefined && fallback !== undefined)
|
|
10
|
+
return fallback;
|
|
11
|
+
const result = Number(value);
|
|
12
|
+
if (!Number.isFinite(result))
|
|
13
|
+
throw new Error(`${name} must be a finite number`);
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
export async function resolvedBoard(boards, target) {
|
|
17
|
+
const spaceId = resolveSpace(boards);
|
|
18
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
19
|
+
return createClient().space(spaceId).board(boardId);
|
|
20
|
+
}
|
|
21
|
+
export function showUpdated(result, options) {
|
|
22
|
+
if (jsonRequested(options))
|
|
23
|
+
return json(result);
|
|
24
|
+
ok(`Board updated to version ${result.board.version}`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { boardNodeCreateOperation, boardNodeDeleteOperations, boardNodePatchOperation, createBoardConnection, createBoardNode, } from "@neta-art/cohub/board";
|
|
3
|
+
import { BOARD_DOMAIN_INPUT_MAX_BYTES, readBoardJsonObject, } from "../../board-command-support.js";
|
|
4
|
+
import { handleHttp } from "../../output.js";
|
|
5
|
+
import { resolvedBoard, showUpdated, withJson, } from "./context.js";
|
|
6
|
+
export function registerBoardNodeCommands(boards) {
|
|
7
|
+
const nodes = boards.command("nodes").description("Create and update Board nodes");
|
|
8
|
+
withJson(nodes.command("add <board>")
|
|
9
|
+
.description("Add a node")
|
|
10
|
+
.requiredOption("-i, --input <file>", "BoardNodeSpec JSON; use - for stdin")
|
|
11
|
+
.addHelpText("after", `
|
|
12
|
+
Frame x/y/width/height use Board world units. Draw points and arrow endpoints are also world input.
|
|
13
|
+
Minimal text node:
|
|
14
|
+
{"id":"title","type":"text","frame":{"x":120,"y":80,"width":320,"height":48},"text":"Launch plan"}`))
|
|
15
|
+
.action(async (target, options) => {
|
|
16
|
+
try {
|
|
17
|
+
const input = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
18
|
+
const node = createBoardNode(input);
|
|
19
|
+
const board = await resolvedBoard(boards, target);
|
|
20
|
+
showUpdated(await board.mutate({ build: () => [boardNodeCreateOperation(node)] }), options);
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
handleHttp(cause);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
withJson(nodes.command("patch <board> <node-id>")
|
|
27
|
+
.description("Patch a node")
|
|
28
|
+
.requiredOption("-i, --input <file>", "BoardNodeInput field patch; use - for stdin")
|
|
29
|
+
.addHelpText("after", `
|
|
30
|
+
x/y are absolute Board world coordinates.
|
|
31
|
+
Move a node without changing its content:
|
|
32
|
+
{"x":160,"y":120}
|
|
33
|
+
|
|
34
|
+
Nested view, style, and data fields replace their complete stored object.`))
|
|
35
|
+
.action(async (target, nodeId, options) => {
|
|
36
|
+
try {
|
|
37
|
+
const patch = await readBoardJsonObject(options.input, BOARD_DOMAIN_INPUT_MAX_BYTES);
|
|
38
|
+
if ("nodeId" in patch)
|
|
39
|
+
throw new Error("node patch must not contain nodeId");
|
|
40
|
+
const board = await resolvedBoard(boards, target);
|
|
41
|
+
showUpdated(await board.mutate({
|
|
42
|
+
build: () => [boardNodePatchOperation(nodeId, patch)],
|
|
43
|
+
}), options);
|
|
44
|
+
}
|
|
45
|
+
catch (cause) {
|
|
46
|
+
handleHttp(cause);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
withJson(nodes.command("remove <board> <node-id>")
|
|
50
|
+
.alias("rm")
|
|
51
|
+
.description("Remove a node and its connections"))
|
|
52
|
+
.action(async (target, nodeId, options) => {
|
|
53
|
+
try {
|
|
54
|
+
const board = await resolvedBoard(boards, target);
|
|
55
|
+
showUpdated(await board.mutate({
|
|
56
|
+
include: ["connections"],
|
|
57
|
+
build: (current) => boardNodeDeleteOperations(nodeId, current.connections),
|
|
58
|
+
}), options);
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
handleHttp(cause);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
withJson(boards.command("connect <board> <source> <target>")
|
|
65
|
+
.description("Connect two Board nodes")
|
|
66
|
+
.option("--id <id>", "Connection id")
|
|
67
|
+
.option("--relation <relation>", "Relation type")
|
|
68
|
+
.option("--direction <direction>", "none, forward, backward, or both", "forward")
|
|
69
|
+
.option("--label <label>", "Connection label")
|
|
70
|
+
.option("--source-port <id>", "Source port id")
|
|
71
|
+
.option("--target-port <id>", "Target port id"))
|
|
72
|
+
.action(async (target, source, destination, options) => {
|
|
73
|
+
try {
|
|
74
|
+
const direction = options.direction ?? "forward";
|
|
75
|
+
if (!["none", "forward", "backward", "both"].includes(direction)) {
|
|
76
|
+
throw new Error("--direction must be none, forward, backward, or both");
|
|
77
|
+
}
|
|
78
|
+
const board = await resolvedBoard(boards, target);
|
|
79
|
+
const connection = createBoardConnection({
|
|
80
|
+
id: options.id ?? randomUUID(),
|
|
81
|
+
sourceNodeId: source,
|
|
82
|
+
targetNodeId: destination,
|
|
83
|
+
relation: options.relation,
|
|
84
|
+
direction: direction,
|
|
85
|
+
label: options.label,
|
|
86
|
+
sourcePortId: options.sourcePort,
|
|
87
|
+
targetPortId: options.targetPort,
|
|
88
|
+
});
|
|
89
|
+
showUpdated(await board.mutate({
|
|
90
|
+
build: () => [{ type: "connection.create", payload: { connection } }],
|
|
91
|
+
}), options);
|
|
92
|
+
}
|
|
93
|
+
catch (cause) {
|
|
94
|
+
handleHttp(cause);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
withJson(boards.command("disconnect <board> <connection-id>")
|
|
98
|
+
.description("Remove a Board connection"))
|
|
99
|
+
.action(async (target, connectionId, options) => {
|
|
100
|
+
try {
|
|
101
|
+
const board = await resolvedBoard(boards, target);
|
|
102
|
+
showUpdated(await board.mutate({
|
|
103
|
+
build: () => [{ type: "connection.delete", payload: { connectionId } }],
|
|
104
|
+
}), options);
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
handleHttp(cause);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { BoardInspectInput, BoardTransactionInput } from "@neta-art/cohub";
|
|
2
2
|
import type { Command } from "commander";
|
|
3
|
+
import { parseBoardJsonObject } from "../board-command-support.js";
|
|
3
4
|
declare const INSPECT_SECTIONS: readonly ["nodes", "connections", "effects", "sequences", "clips", "playback"];
|
|
4
5
|
type InspectSection = (typeof INSPECT_SECTIONS)[number];
|
|
5
|
-
export declare
|
|
6
|
-
export declare function readJsonObject(source: string): Promise<Record<string, unknown>>;
|
|
6
|
+
export declare const parseJsonObject: typeof parseBoardJsonObject;
|
|
7
|
+
export declare function readJsonObject(source: string, maxBytes?: number): Promise<Record<string, unknown>>;
|
|
7
8
|
export declare function parseInspectSections(value?: string): InspectSection[] | undefined;
|
|
8
9
|
export declare function parseViewport(value?: string): BoardInspectInput["viewport"];
|
|
9
10
|
export declare function createTransactionInput(input: Record<string, unknown>, options: {
|
package/dist/commands/boards.js
CHANGED
|
@@ -1,36 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { BOARD_CREATE_INPUT_MAX_BYTES, BOARD_TRANSACTION_INPUT_MAX_BYTES, parseBoardJsonObject, readBoardJsonObject, resolveBoardId, writeBoardOutput, } from "../board-command-support.js";
|
|
3
3
|
import { BOARD_EXPORT_FORMATS, formatFromPath, runBoardExport } from "../board-export.js";
|
|
4
|
+
import { registerBoardDomainCommands } from "./board-domain.js";
|
|
4
5
|
import { createClient, createRealtimeClient } from "../client.js";
|
|
5
6
|
import { error, handleHttp, json as outJson, jsonRequested, ok, table } from "../output.js";
|
|
6
7
|
import { resolveSpace } from "../space.js";
|
|
7
8
|
const INSPECT_SECTIONS = ["nodes", "connections", "effects", "sequences", "clips", "playback"];
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export function parseJsonObject(text, source = "input") {
|
|
12
|
-
if (!text.trim())
|
|
13
|
-
throw new Error(`${source} is empty`);
|
|
14
|
-
let value;
|
|
15
|
-
try {
|
|
16
|
-
value = JSON.parse(text);
|
|
17
|
-
}
|
|
18
|
-
catch (cause) {
|
|
19
|
-
throw new Error(`${source} must contain valid JSON`, { cause });
|
|
20
|
-
}
|
|
21
|
-
if (!isObject(value))
|
|
22
|
-
throw new Error(`${source} must contain a JSON object`);
|
|
23
|
-
return value;
|
|
24
|
-
}
|
|
25
|
-
async function readStdin() {
|
|
26
|
-
const chunks = [];
|
|
27
|
-
for await (const chunk of process.stdin)
|
|
28
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
29
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
30
|
-
}
|
|
31
|
-
export async function readJsonObject(source) {
|
|
32
|
-
const text = source === "-" ? await readStdin() : await readFile(source, "utf8");
|
|
33
|
-
return parseJsonObject(text, source === "-" ? "stdin" : source);
|
|
9
|
+
export const parseJsonObject = parseBoardJsonObject;
|
|
10
|
+
export async function readJsonObject(source, maxBytes = BOARD_TRANSACTION_INPUT_MAX_BYTES) {
|
|
11
|
+
return readBoardJsonObject(source, maxBytes);
|
|
34
12
|
}
|
|
35
13
|
function parseNumber(value, name, options = {}) {
|
|
36
14
|
if (!value.trim())
|
|
@@ -116,6 +94,27 @@ function showBoard(result) {
|
|
|
116
94
|
{ key: "clips", label: "Clips" },
|
|
117
95
|
]);
|
|
118
96
|
}
|
|
97
|
+
function showSummary(result) {
|
|
98
|
+
const background = result.board.metadata.appearance;
|
|
99
|
+
table([{
|
|
100
|
+
id: result.board.id,
|
|
101
|
+
title: result.board.title,
|
|
102
|
+
version: result.board.version,
|
|
103
|
+
...result.counts,
|
|
104
|
+
background: background?.background?.kind ?? "default",
|
|
105
|
+
updatedAt: result.board.updatedAt,
|
|
106
|
+
}], [
|
|
107
|
+
{ key: "id", label: "ID" },
|
|
108
|
+
{ key: "title", label: "TITLE" },
|
|
109
|
+
{ key: "version", label: "VERSION" },
|
|
110
|
+
{ key: "nodes", label: "NODES" },
|
|
111
|
+
{ key: "connections", label: "CONNECTIONS" },
|
|
112
|
+
{ key: "effects", label: "EFFECTS" },
|
|
113
|
+
{ key: "sequences", label: "SEQUENCES" },
|
|
114
|
+
{ key: "background", label: "BACKGROUND" },
|
|
115
|
+
{ key: "updatedAt", label: "UPDATED" },
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
119
118
|
function showValidation(result) {
|
|
120
119
|
table([{ valid: result.valid, diagnostics: result.diagnostics.length }], [
|
|
121
120
|
{ key: "valid", label: "Valid" },
|
|
@@ -145,16 +144,37 @@ function showPlayback(result) {
|
|
|
145
144
|
function withJson(command) {
|
|
146
145
|
return command.option("--json", "Output as JSON");
|
|
147
146
|
}
|
|
147
|
+
function capabilityUnits(schema) {
|
|
148
|
+
const params = schema?.params;
|
|
149
|
+
if (!params || typeof params !== "object" || Array.isArray(params))
|
|
150
|
+
return "";
|
|
151
|
+
return Object.entries(params).flatMap(([field, value]) => {
|
|
152
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
153
|
+
return [];
|
|
154
|
+
const meta = value;
|
|
155
|
+
const detail = [meta.coordinateSpace, meta.unit]
|
|
156
|
+
.filter((item) => typeof item === "string")
|
|
157
|
+
.join("/");
|
|
158
|
+
return detail ? [`${field}:${detail}`] : [];
|
|
159
|
+
}).join(", ");
|
|
160
|
+
}
|
|
148
161
|
function registerTransactionCommand(boards, name) {
|
|
149
|
-
withJson(boards.command(`${name} <board
|
|
162
|
+
withJson(boards.command(`${name} <board>`)
|
|
150
163
|
.description(name === "validate" ? "Validate a transaction" : "Apply a transaction")
|
|
151
164
|
.requiredOption("-i, --input <file>", "Transaction JSON file; use - for stdin")
|
|
152
165
|
.option("--tx-id <id>", "Override txId; generated when omitted")
|
|
153
|
-
.option("--base-version <version>", "Override baseVersion")
|
|
154
|
-
.
|
|
166
|
+
.option("--base-version <version>", "Override baseVersion")
|
|
167
|
+
.addHelpText("after", `
|
|
168
|
+
Input example:
|
|
169
|
+
{"baseVersion":12,"operations":[{"type":"board.patch","payload":{"patch":{"title":"Launch plan"}}}]}
|
|
170
|
+
|
|
171
|
+
Prefer semantic commands such as boards background, nodes, effects, or sequences for common edits.`))
|
|
172
|
+
.action(async (target, options) => {
|
|
155
173
|
try {
|
|
156
|
-
const transaction = createTransactionInput(await readJsonObject(options.input), options);
|
|
157
|
-
const
|
|
174
|
+
const transaction = createTransactionInput(await readJsonObject(options.input, BOARD_TRANSACTION_INPUT_MAX_BYTES), options);
|
|
175
|
+
const spaceId = resolveSpace(boards);
|
|
176
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
177
|
+
const board = createClient().space(spaceId).board(boardId);
|
|
158
178
|
const result = await board[name](transaction);
|
|
159
179
|
if (jsonRequested(options))
|
|
160
180
|
return outJson(result);
|
|
@@ -240,7 +260,8 @@ function registerExportCommand(boards) {
|
|
|
240
260
|
.option("--background <mode>", "paper or transparent", "paper")
|
|
241
261
|
.option("--format <format>", `Override format (${BOARD_EXPORT_FORMATS.join(", ")})`)
|
|
242
262
|
.option("--quality <q>", "JPEG/WebP quality from 0 to 1", "0.92")
|
|
243
|
-
.option("--no-images", "Skip image downloads and draw placeholders")
|
|
263
|
+
.option("--no-images", "Skip image downloads and draw placeholders")
|
|
264
|
+
.option("--force", "Replace an existing output file"))
|
|
244
265
|
.action(async (board, options) => {
|
|
245
266
|
try {
|
|
246
267
|
const out = options.out;
|
|
@@ -263,7 +284,7 @@ function registerExportCommand(boards) {
|
|
|
263
284
|
if (!result) {
|
|
264
285
|
return error("Nothing to export", "The selected region contains no items.");
|
|
265
286
|
}
|
|
266
|
-
await
|
|
287
|
+
await writeBoardOutput(out, result.bytes, Boolean(options.force));
|
|
267
288
|
if (jsonRequested(options)) {
|
|
268
289
|
return outJson({
|
|
269
290
|
path: out,
|
|
@@ -293,14 +314,26 @@ export function registerBoards(program) {
|
|
|
293
314
|
withJson(boards.command("create <path>")
|
|
294
315
|
.description("Create a Board")
|
|
295
316
|
.option("--title <title>", "Board title")
|
|
296
|
-
.option("-
|
|
317
|
+
.option("--mutation-id <id>", "Stable id for safe retries")
|
|
318
|
+
.option("-i, --input <file>", "BoardCreateInput fields; use - for stdin")
|
|
319
|
+
.addHelpText("after", `
|
|
320
|
+
For normal use, create an empty Board and add content with boards nodes, effects, and sequences.
|
|
321
|
+
--input is intended for bulk creation and accepts BoardCreateInput fields except path and title.`))
|
|
297
322
|
.action(async (path, options) => {
|
|
298
323
|
try {
|
|
299
|
-
const content = options.input
|
|
324
|
+
const content = options.input
|
|
325
|
+
? await readJsonObject(options.input, BOARD_CREATE_INPUT_MAX_BYTES)
|
|
326
|
+
: {};
|
|
300
327
|
if ("path" in content || "title" in content) {
|
|
301
328
|
throw new Error("create input must not contain path or title; use the command argument and --title");
|
|
302
329
|
}
|
|
303
|
-
const input = {
|
|
330
|
+
const input = {
|
|
331
|
+
...content,
|
|
332
|
+
path,
|
|
333
|
+
mutationId: options.mutationId ??
|
|
334
|
+
(typeof content.mutationId === "string" ? content.mutationId : randomUUID()),
|
|
335
|
+
...(options.title ? { title: options.title } : {}),
|
|
336
|
+
};
|
|
304
337
|
const result = await createClient().space(resolveSpace(boards)).boards.create(input);
|
|
305
338
|
if (jsonRequested(options))
|
|
306
339
|
return outJson(result);
|
|
@@ -311,14 +344,20 @@ export function registerBoards(program) {
|
|
|
311
344
|
handleHttp(cause);
|
|
312
345
|
}
|
|
313
346
|
});
|
|
314
|
-
withJson(boards.command("inspect <board
|
|
347
|
+
withJson(boards.command("inspect <board>")
|
|
315
348
|
.alias("get")
|
|
316
349
|
.description("Inspect a Board")
|
|
317
350
|
.option("--include <sections>", "Comma-separated nodes,connections,effects,sequences,clips,playback")
|
|
318
351
|
.option("--viewport <rect>", "Viewport as x,y,width,height"))
|
|
319
|
-
.action(async (
|
|
352
|
+
.action(async (target, options) => {
|
|
320
353
|
try {
|
|
321
|
-
const
|
|
354
|
+
const spaceId = resolveSpace(boards);
|
|
355
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
356
|
+
const board = createClient().space(spaceId).board(boardId);
|
|
357
|
+
if (!jsonRequested(options) && !options.include && !options.viewport) {
|
|
358
|
+
return showSummary(await board.summary());
|
|
359
|
+
}
|
|
360
|
+
const result = await board.inspect({
|
|
322
361
|
include: parseInspectSections(options.include),
|
|
323
362
|
viewport: parseViewport(options.viewport),
|
|
324
363
|
});
|
|
@@ -330,21 +369,25 @@ export function registerBoards(program) {
|
|
|
330
369
|
handleHttp(cause);
|
|
331
370
|
}
|
|
332
371
|
});
|
|
333
|
-
withJson(boards.command("capabilities <board
|
|
372
|
+
withJson(boards.command("capabilities <board>")
|
|
334
373
|
.description("Show supported capabilities"))
|
|
335
|
-
.action(async (
|
|
374
|
+
.action(async (target, options) => {
|
|
336
375
|
try {
|
|
337
|
-
const
|
|
376
|
+
const spaceId = resolveSpace(boards);
|
|
377
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
378
|
+
const result = await createClient().space(spaceId).board(boardId).capabilities();
|
|
338
379
|
if (jsonRequested(options))
|
|
339
380
|
return outJson(result);
|
|
340
381
|
table(result.capabilities.map((capability) => ({
|
|
341
382
|
...capability,
|
|
342
383
|
renderers: capability.renderers?.join(", ") ?? "",
|
|
384
|
+
coordinates: capabilityUnits(capability.schema),
|
|
343
385
|
})), [
|
|
344
386
|
{ key: "kind", label: "Kind" },
|
|
345
387
|
{ key: "id", label: "ID" },
|
|
346
388
|
{ key: "version", label: "Version" },
|
|
347
389
|
{ key: "renderers", label: "Renderers" },
|
|
390
|
+
{ key: "coordinates", label: "Coordinates / units" },
|
|
348
391
|
{ key: "digest", label: "Digest" },
|
|
349
392
|
]);
|
|
350
393
|
const nodes = result.nodes;
|
|
@@ -371,16 +414,19 @@ export function registerBoards(program) {
|
|
|
371
414
|
});
|
|
372
415
|
registerTransactionCommand(boards, "validate");
|
|
373
416
|
registerTransactionCommand(boards, "apply");
|
|
417
|
+
registerBoardDomainCommands(boards);
|
|
374
418
|
registerExportCommand(boards);
|
|
375
|
-
withJson(boards.command("play <board
|
|
419
|
+
withJson(boards.command("play <board> <sequence-id>")
|
|
376
420
|
.description("Start shared playback")
|
|
377
421
|
.option("--position <time>", "Initial position in milliseconds")
|
|
378
422
|
.option("--time-scale <scale>", "Playback speed from 0 to 4")
|
|
379
423
|
.option("--seed <seed>", "Deterministic playback seed")
|
|
380
424
|
.option("--command-id <id>", "Idempotency command ID"))
|
|
381
|
-
.action(async (
|
|
425
|
+
.action(async (target, sequenceId, options) => {
|
|
382
426
|
try {
|
|
383
|
-
const
|
|
427
|
+
const spaceId = resolveSpace(boards);
|
|
428
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
429
|
+
const result = await createClient().space(spaceId).board(boardId).play({
|
|
384
430
|
commandId: commandId(options),
|
|
385
431
|
type: "play",
|
|
386
432
|
sequenceId,
|
|
@@ -397,9 +443,11 @@ export function registerBoards(program) {
|
|
|
397
443
|
handleHttp(cause);
|
|
398
444
|
}
|
|
399
445
|
});
|
|
400
|
-
const playbackAction = (type) => async (
|
|
446
|
+
const playbackAction = (type) => async (target, playbackId, options) => {
|
|
401
447
|
try {
|
|
402
|
-
const
|
|
448
|
+
const spaceId = resolveSpace(boards);
|
|
449
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
450
|
+
const board = createClient().space(spaceId).board(boardId);
|
|
403
451
|
const id = commandId(options);
|
|
404
452
|
const result = type === "pause"
|
|
405
453
|
? await board.pause({ commandId: id, type: "pause", playbackId })
|
|
@@ -412,16 +460,18 @@ export function registerBoards(program) {
|
|
|
412
460
|
handleHttp(cause);
|
|
413
461
|
}
|
|
414
462
|
};
|
|
415
|
-
withJson(boards.command("pause <board
|
|
463
|
+
withJson(boards.command("pause <board> <playback-id>")
|
|
416
464
|
.description("Pause playback")
|
|
417
465
|
.option("--command-id <id>", "Idempotency command ID"))
|
|
418
466
|
.action(playbackAction("pause"));
|
|
419
|
-
withJson(boards.command("seek <board
|
|
467
|
+
withJson(boards.command("seek <board> <playback-id> <position>")
|
|
420
468
|
.description("Seek playback")
|
|
421
469
|
.option("--command-id <id>", "Idempotency command ID"))
|
|
422
|
-
.action(async (
|
|
470
|
+
.action(async (target, playbackId, position, options) => {
|
|
423
471
|
try {
|
|
424
|
-
const
|
|
472
|
+
const spaceId = resolveSpace(boards);
|
|
473
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
474
|
+
const result = await createClient().space(spaceId).board(boardId).seek({
|
|
425
475
|
commandId: commandId(options),
|
|
426
476
|
type: "seek",
|
|
427
477
|
playbackId,
|
|
@@ -435,18 +485,32 @@ export function registerBoards(program) {
|
|
|
435
485
|
handleHttp(cause);
|
|
436
486
|
}
|
|
437
487
|
});
|
|
438
|
-
withJson(boards.command("stop <board
|
|
488
|
+
withJson(boards.command("stop <board> <playback-id>")
|
|
439
489
|
.description("Stop playback")
|
|
440
490
|
.option("--command-id <id>", "Idempotency command ID"))
|
|
441
491
|
.action(playbackAction("stop"));
|
|
442
|
-
withJson(boards.command("watch <board
|
|
492
|
+
withJson(boards.command("watch <board>")
|
|
443
493
|
.description("Stream Board events"))
|
|
444
|
-
.action((
|
|
494
|
+
.action(async (target, options) => {
|
|
445
495
|
try {
|
|
446
|
-
const
|
|
496
|
+
const spaceId = resolveSpace(boards);
|
|
497
|
+
const boardId = await resolveBoardId(spaceId, target);
|
|
498
|
+
const client = createRealtimeClient();
|
|
499
|
+
const board = client.space(spaceId).board(boardId);
|
|
447
500
|
if (!jsonRequested(options))
|
|
448
501
|
process.stderr.write(`Listening for Board ${boardId} events...\n`);
|
|
449
|
-
|
|
502
|
+
const offConnection = client.onConnection((state) => {
|
|
503
|
+
if (jsonRequested(options)) {
|
|
504
|
+
process.stdout.write(`${JSON.stringify({ type: "connection", ...state })}\n`);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
const detail = state.state === "reconnecting" && state.attempt
|
|
508
|
+
? ` (attempt ${state.attempt})`
|
|
509
|
+
: "";
|
|
510
|
+
process.stderr.write(`${state.state}${detail}\n`);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
const offBoard = board.subscribe({
|
|
450
514
|
event(event) {
|
|
451
515
|
if (jsonRequested(options)) {
|
|
452
516
|
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
@@ -463,6 +527,11 @@ export function registerBoards(program) {
|
|
|
463
527
|
}
|
|
464
528
|
},
|
|
465
529
|
});
|
|
530
|
+
process.once("SIGINT", () => {
|
|
531
|
+
offBoard();
|
|
532
|
+
offConnection();
|
|
533
|
+
process.exit(0);
|
|
534
|
+
});
|
|
466
535
|
}
|
|
467
536
|
catch (cause) {
|
|
468
537
|
handleHttp(cause);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const REMOTE_IMAGE_MAX_BYTES: number;
|
|
2
|
+
export declare const REMOTE_IMAGE_TIMEOUT_MS = 15000;
|
|
3
|
+
type ResolvedAddress = {
|
|
4
|
+
address: string;
|
|
5
|
+
family: 4 | 6;
|
|
6
|
+
};
|
|
7
|
+
type Lookup = (hostname: string) => Promise<readonly ResolvedAddress[]>;
|
|
8
|
+
type RemoteResponse = {
|
|
9
|
+
status: number;
|
|
10
|
+
headers: Headers;
|
|
11
|
+
bytes: Uint8Array;
|
|
12
|
+
};
|
|
13
|
+
type Requester = (url: URL, address: ResolvedAddress, timeoutMs: number, maxBytes: number) => Promise<RemoteResponse>;
|
|
14
|
+
export type RemoteImageDownloadOptions = {
|
|
15
|
+
lookup?: Lookup;
|
|
16
|
+
requester?: Requester;
|
|
17
|
+
maxBytes?: number;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
};
|
|
20
|
+
export declare function downloadPublicImage(input: string, options?: RemoteImageDownloadOptions): Promise<{
|
|
21
|
+
bytes: Uint8Array;
|
|
22
|
+
mimeType: string;
|
|
23
|
+
}>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { isPublicBoardRemoteAddress, normalizeBoardRemoteUrl, } from "@neta-art/cohub/board";
|
|
6
|
+
export const REMOTE_IMAGE_MAX_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
export const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
|
|
8
|
+
const MAX_REDIRECTS = 3;
|
|
9
|
+
const IMAGE_MIME_TYPES = new Set([
|
|
10
|
+
"image/avif",
|
|
11
|
+
"image/gif",
|
|
12
|
+
"image/jpeg",
|
|
13
|
+
"image/png",
|
|
14
|
+
"image/webp",
|
|
15
|
+
]);
|
|
16
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
17
|
+
async function defaultLookup(hostname) {
|
|
18
|
+
const records = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
19
|
+
return records.map((record) => ({
|
|
20
|
+
address: record.address,
|
|
21
|
+
family: record.family,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
function responseHeaders(input) {
|
|
25
|
+
const headers = new Headers();
|
|
26
|
+
for (const [key, value] of Object.entries(input)) {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
for (const item of value)
|
|
29
|
+
headers.append(key, item);
|
|
30
|
+
}
|
|
31
|
+
else if (value !== undefined) {
|
|
32
|
+
headers.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return headers;
|
|
36
|
+
}
|
|
37
|
+
function requestPinned(url, address, timeoutMs, maxBytes) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const client = url.protocol === "https:" ? https : http;
|
|
40
|
+
const request = client.request(url, {
|
|
41
|
+
agent: false,
|
|
42
|
+
headers: { Accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
|
|
43
|
+
lookup: (_hostname, _options, callback) => {
|
|
44
|
+
callback(null, address.address, address.family);
|
|
45
|
+
},
|
|
46
|
+
}, (response) => {
|
|
47
|
+
const status = response.statusCode ?? 0;
|
|
48
|
+
const headers = responseHeaders(response.headers);
|
|
49
|
+
if (REDIRECT_STATUSES.has(status)) {
|
|
50
|
+
response.resume();
|
|
51
|
+
resolve({ status, headers, bytes: new Uint8Array() });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const declaredLength = Number(headers.get("content-length") ?? 0);
|
|
55
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
56
|
+
response.destroy();
|
|
57
|
+
reject(new Error(`Image exceeds the ${maxBytes} byte download limit`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let total = 0;
|
|
62
|
+
response.on("data", (chunk) => {
|
|
63
|
+
total += chunk.byteLength;
|
|
64
|
+
if (total > maxBytes) {
|
|
65
|
+
response.destroy(new Error(`Image exceeds the ${maxBytes} byte download limit`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
response.once("error", reject);
|
|
71
|
+
response.once("end", () => {
|
|
72
|
+
const bytes = new Uint8Array(total);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
bytes.set(chunk, offset);
|
|
76
|
+
offset += chunk.byteLength;
|
|
77
|
+
}
|
|
78
|
+
resolve({ status, headers, bytes });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
request.setTimeout(timeoutMs, () => {
|
|
82
|
+
request.destroy(new Error(`Image download timed out after ${timeoutMs}ms`));
|
|
83
|
+
});
|
|
84
|
+
request.once("error", reject);
|
|
85
|
+
request.end();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function remainingMs(deadline) {
|
|
89
|
+
const remaining = deadline - Date.now();
|
|
90
|
+
if (remaining <= 0)
|
|
91
|
+
throw new Error("Image download timed out");
|
|
92
|
+
return remaining;
|
|
93
|
+
}
|
|
94
|
+
async function withDeadline(promise, deadline) {
|
|
95
|
+
const timeoutMs = remainingMs(deadline);
|
|
96
|
+
let timer;
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([
|
|
99
|
+
promise,
|
|
100
|
+
new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => reject(new Error(`Image download timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (timer)
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function resolvePublicUrl(value, lookup, deadline) {
|
|
111
|
+
const normalized = normalizeBoardRemoteUrl(value);
|
|
112
|
+
if (!normalized)
|
|
113
|
+
throw new Error("Image URL must be a public HTTP(S) URL");
|
|
114
|
+
const url = new URL(normalized);
|
|
115
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
116
|
+
const addresses = isIP(hostname)
|
|
117
|
+
? [{ address: hostname, family: isIP(hostname) }]
|
|
118
|
+
: await withDeadline(lookup(hostname), deadline);
|
|
119
|
+
if (addresses.length === 0 ||
|
|
120
|
+
addresses.some((entry) => !isPublicBoardRemoteAddress(entry.address))) {
|
|
121
|
+
throw new Error("Image URL resolves to a private address");
|
|
122
|
+
}
|
|
123
|
+
return { url, address: addresses[0] };
|
|
124
|
+
}
|
|
125
|
+
export async function downloadPublicImage(input, options = {}) {
|
|
126
|
+
const lookup = options.lookup ?? defaultLookup;
|
|
127
|
+
const requester = options.requester ?? requestPinned;
|
|
128
|
+
const maxBytes = options.maxBytes ?? REMOTE_IMAGE_MAX_BYTES;
|
|
129
|
+
const deadline = Date.now() + (options.timeoutMs ?? REMOTE_IMAGE_TIMEOUT_MS);
|
|
130
|
+
let current = input;
|
|
131
|
+
for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) {
|
|
132
|
+
const { url, address } = await resolvePublicUrl(current, lookup, deadline);
|
|
133
|
+
const response = await withDeadline(requester(url, address, remainingMs(deadline), maxBytes), deadline);
|
|
134
|
+
if (REDIRECT_STATUSES.has(response.status)) {
|
|
135
|
+
const location = response.headers.get("location");
|
|
136
|
+
if (!location)
|
|
137
|
+
throw new Error("Image redirect is missing a location");
|
|
138
|
+
if (redirect === MAX_REDIRECTS)
|
|
139
|
+
throw new Error("Too many image redirects");
|
|
140
|
+
current = new URL(location, url).toString();
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (response.status < 200 || response.status >= 300) {
|
|
144
|
+
throw new Error(`HTTP ${response.status}`);
|
|
145
|
+
}
|
|
146
|
+
if (response.bytes.byteLength > maxBytes) {
|
|
147
|
+
throw new Error(`Image exceeds the ${maxBytes} byte download limit`);
|
|
148
|
+
}
|
|
149
|
+
const mimeType = response.headers
|
|
150
|
+
.get("content-type")
|
|
151
|
+
?.split(";", 1)[0]
|
|
152
|
+
?.trim()
|
|
153
|
+
.toLowerCase();
|
|
154
|
+
if (!mimeType || !IMAGE_MIME_TYPES.has(mimeType)) {
|
|
155
|
+
throw new Error("Remote background must be a supported raster image");
|
|
156
|
+
}
|
|
157
|
+
return { bytes: response.bytes, mimeType };
|
|
158
|
+
}
|
|
159
|
+
throw new Error("Too many image redirects");
|
|
160
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
4
4
|
"description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"commander": "^15.0.0",
|
|
20
20
|
"pixi.js": "^8.19.0",
|
|
21
21
|
"sharp": "^0.35.3",
|
|
22
|
-
"@neta-art/cohub": "5.
|
|
22
|
+
"@neta-art/cohub": "5.10.0"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|