@neta-art/cohub-cli 9.1.2 → 10.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/README.md +22 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +39 -28
- package/dist/board-export.d.ts +14 -0
- package/dist/board-export.js +136 -80
- package/dist/board-kernel/lexer.js +84 -0
- package/dist/board-kernel.d.ts +4 -2
- package/dist/board-kernel.js +3318 -990
- package/dist/board-time.js +6 -0
- package/dist/commands/boards/authoring.d.ts +2 -0
- package/dist/commands/boards/authoring.js +90 -0
- package/dist/commands/boards/examples.js +12 -7
- package/dist/commands/boards.js +46 -11
- package/dist/commands/rooms.d.ts +3 -0
- package/dist/commands/rooms.js +232 -0
- package/dist/commands/run.js +14 -2
- package/dist/endpoint-options.d.ts +4 -0
- package/dist/endpoint-options.js +26 -0
- package/dist/help-path.js +2 -2
- package/dist/index.js +8 -1
- package/dist/runtime/model-catalog.d.ts +1 -1
- package/dist/runtime/model-catalog.js +2 -4
- package/dist/runtime/supervisor.js +3 -3
- package/dist/safe-remote-image.d.ts +4 -0
- package/dist/safe-remote-image.js +30 -15
- package/dist/space.d.ts +1 -0
- package/dist/space.js +26 -18
- package/dist/web.js +2 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -33,6 +33,28 @@ ENV=dev cohub auth login
|
|
|
33
33
|
ENV=dev cohub spaces ls
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
Use a custom API endpoint for self-hosted deployments or proxies:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cohub --base-url https://api.example.com spaces ls
|
|
40
|
+
COHUB_BASE_URL=https://api.example.com cohub spaces ls
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`--base-url` takes precedence over `COHUB_BASE_URL`. Without either, the CLI uses
|
|
44
|
+
the selected environment's default API address. Configure other endpoints separately:
|
|
45
|
+
|
|
46
|
+
| Variable | Endpoint |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `COHUB_BASE_URL` | API base URL |
|
|
49
|
+
| `COHUB_WS_URL` | Realtime gateway URL (`http(s)` or `ws(s)`) |
|
|
50
|
+
| `COHUB_WEB_URL` | Web base URL used in links |
|
|
51
|
+
|
|
52
|
+
Changing the API base URL does not change the gateway or authentication provider.
|
|
53
|
+
For self-hosted authentication, set `COHUB_AUTH_ISSUER`, `COHUB_AUTH_CLIENT_ID`,
|
|
54
|
+
and, if needed, `COHUB_AUTH_RESOURCE` and `COHUB_AUTH_SCOPE`. Stored login sessions,
|
|
55
|
+
Home Space caches, and Runtime directory bindings are scoped to the API base URL.
|
|
56
|
+
Sign in once for each custom server, or supply `COHUB_EXECUTION_TOKEN`.
|
|
57
|
+
|
|
36
58
|
## Usage
|
|
37
59
|
|
|
38
60
|
Use `--json` when reading output for decisions, extracting IDs, or chaining commands.
|
package/dist/auth.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ type DeviceCode = {
|
|
|
12
12
|
type AuthSession = {
|
|
13
13
|
schemaVersion: 1;
|
|
14
14
|
env: CohubEnvironment;
|
|
15
|
+
baseUrl?: string;
|
|
15
16
|
issuer: string;
|
|
16
17
|
clientId: string;
|
|
17
18
|
resource: string;
|
|
@@ -28,7 +29,7 @@ export declare class AuthRequiredError extends Error {
|
|
|
28
29
|
constructor(message?: string);
|
|
29
30
|
}
|
|
30
31
|
export declare const readAuthSession: () => AuthSession | null;
|
|
31
|
-
export declare const clearAuthSession: () => void;
|
|
32
|
+
export declare const clearAuthSession: (env?: CohubEnvironment, baseUrl?: string) => void;
|
|
32
33
|
export declare const authSource: () => AuthSource;
|
|
33
34
|
export declare function resolveAccessToken(options?: {
|
|
34
35
|
forceRefresh?: boolean;
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { resolveCohubEnvironment } from "@neta-art/cohub";
|
|
1
|
+
import { COHUB_ENVIRONMENTS, normalizeBaseUrl, resolveApiBaseUrl, resolveCohubEnvironment } from "@neta-art/cohub";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { withRuntimeSpaceBindingsLock } from "./runtime/space-binding.js";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
@@ -28,9 +28,17 @@ const authConfig = (env = currentEnv()) => {
|
|
|
28
28
|
scope: process.env[`${prefix}_AUTH_SCOPE`] ?? process.env.COHUB_AUTH_SCOPE ?? DEFAULT_SCOPE,
|
|
29
29
|
};
|
|
30
30
|
};
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
31
|
+
const endpointSuffix = (env, baseUrl = resolveApiBaseUrl({ env })) => {
|
|
32
|
+
const url = normalizeBaseUrl(baseUrl);
|
|
33
|
+
const endpoint = url === COHUB_ENVIRONMENTS[env].apiBaseUrl
|
|
34
|
+
? ""
|
|
35
|
+
: `.${createHash("sha256").update(url).digest("hex")}`;
|
|
36
|
+
return `${env === "dev" ? ".dev" : ""}${endpoint}`;
|
|
37
|
+
};
|
|
38
|
+
const sessionPath = (env = currentEnv(), baseUrl) => join(CONFIG_DIR, `auth${endpointSuffix(env, baseUrl)}.json`);
|
|
39
|
+
const deviceCodePath = (env = currentEnv(), baseUrl) => join(CONFIG_DIR, `device-code${endpointSuffix(env, baseUrl)}.json`);
|
|
40
|
+
const legacyTokenPath = (env = currentEnv(), baseUrl) => join(CONFIG_DIR, `token${endpointSuffix(env, baseUrl)}`);
|
|
41
|
+
const sessionBaseUrl = (session) => session.baseUrl ?? COHUB_ENVIRONMENTS[session.env].apiBaseUrl;
|
|
34
42
|
const writePrivateJson = (path, value) => {
|
|
35
43
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
36
44
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
@@ -106,6 +114,7 @@ const toSession = (token, config, env, previous) => {
|
|
|
106
114
|
return {
|
|
107
115
|
schemaVersion: 1,
|
|
108
116
|
env,
|
|
117
|
+
baseUrl: previous ? sessionBaseUrl(previous) : resolveApiBaseUrl({ env }),
|
|
109
118
|
issuer: config.issuer,
|
|
110
119
|
clientId: config.clientId,
|
|
111
120
|
resource: config.resource,
|
|
@@ -120,13 +129,10 @@ const toSession = (token, config, env, previous) => {
|
|
|
120
129
|
};
|
|
121
130
|
};
|
|
122
131
|
export const readAuthSession = () => readJson(sessionPath());
|
|
123
|
-
const
|
|
124
|
-
removeIfExists(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
removeIfExists(sessionPath());
|
|
128
|
-
clearDeviceCode();
|
|
129
|
-
removeIfExists(legacyTokenPath());
|
|
132
|
+
export const clearAuthSession = (env = currentEnv(), baseUrl = resolveApiBaseUrl({ env })) => {
|
|
133
|
+
removeIfExists(sessionPath(env, baseUrl));
|
|
134
|
+
removeIfExists(deviceCodePath(env, baseUrl));
|
|
135
|
+
removeIfExists(legacyTokenPath(env, baseUrl));
|
|
130
136
|
};
|
|
131
137
|
export const authSource = () => {
|
|
132
138
|
if (process.env.COHUB_EXECUTION_TOKEN?.trim())
|
|
@@ -152,14 +158,15 @@ export async function requireAccessToken() {
|
|
|
152
158
|
throw new AuthRequiredError();
|
|
153
159
|
return token;
|
|
154
160
|
}
|
|
155
|
-
|
|
161
|
+
const refreshesInFlight = new Map();
|
|
156
162
|
export function refreshAccessToken(session = readAuthSession()) {
|
|
157
163
|
if (!session?.refreshToken)
|
|
158
164
|
return Promise.reject(new AuthRequiredError());
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
const path = sessionPath(session.env, sessionBaseUrl(session));
|
|
166
|
+
const inFlight = refreshesInFlight.get(path);
|
|
167
|
+
if (inFlight)
|
|
168
|
+
return inFlight;
|
|
169
|
+
const refresh = withRuntimeSpaceBindingsLock(async () => {
|
|
163
170
|
const current = readJson(path);
|
|
164
171
|
if (!current)
|
|
165
172
|
throw new AuthRequiredError();
|
|
@@ -167,11 +174,13 @@ export function refreshAccessToken(session = readAuthSession()) {
|
|
|
167
174
|
if (current.refreshToken !== session.refreshToken || current.updatedAt !== session.updatedAt)
|
|
168
175
|
return current.accessToken;
|
|
169
176
|
return performRefresh(current);
|
|
170
|
-
}, { path, lockPath: `${path}.refresh.lock` }).finally(() => {
|
|
171
|
-
|
|
177
|
+
}, { path, lockPath: `${path}.refresh.lock` }).finally(() => { refreshesInFlight.delete(path); });
|
|
178
|
+
refreshesInFlight.set(path, refresh);
|
|
179
|
+
return refresh;
|
|
172
180
|
}
|
|
173
181
|
async function performRefresh(session) {
|
|
174
182
|
const config = authConfig(session.env);
|
|
183
|
+
const path = sessionPath(session.env, sessionBaseUrl(session));
|
|
175
184
|
const { response, data } = await formPost(tokenEndpoint(session.issuer), new URLSearchParams({
|
|
176
185
|
client_id: session.clientId,
|
|
177
186
|
grant_type: "refresh_token",
|
|
@@ -181,24 +190,25 @@ async function performRefresh(session) {
|
|
|
181
190
|
}));
|
|
182
191
|
if (!response.ok) {
|
|
183
192
|
if (isUnrecoverableRefreshError(data)) {
|
|
184
|
-
if (readJson(
|
|
185
|
-
clearAuthSession();
|
|
193
|
+
if (readJson(path)?.refreshToken === session.refreshToken)
|
|
194
|
+
clearAuthSession(session.env, sessionBaseUrl(session));
|
|
186
195
|
return null;
|
|
187
196
|
}
|
|
188
197
|
throw new Error(formatAuthError(data, response.status));
|
|
189
198
|
}
|
|
190
199
|
const next = toSession(data, config, session.env, session);
|
|
191
200
|
// Never overwrite a login/logout that completed while the request was in flight.
|
|
192
|
-
const current = readJson(
|
|
201
|
+
const current = readJson(path);
|
|
193
202
|
if (!current)
|
|
194
203
|
throw new AuthRequiredError();
|
|
195
204
|
if (current.refreshToken !== session.refreshToken)
|
|
196
205
|
return current.accessToken;
|
|
197
|
-
writePrivateJson(
|
|
206
|
+
writePrivateJson(path, next);
|
|
198
207
|
return next.accessToken;
|
|
199
208
|
}
|
|
200
209
|
export async function requestDeviceCode() {
|
|
201
210
|
const env = currentEnv();
|
|
211
|
+
const baseUrl = resolveApiBaseUrl({ env });
|
|
202
212
|
const config = authConfig(env);
|
|
203
213
|
const { response, data } = await formPost(deviceEndpoint(config.issuer), new URLSearchParams({
|
|
204
214
|
client_id: config.clientId,
|
|
@@ -224,13 +234,14 @@ export async function requestDeviceCode() {
|
|
|
224
234
|
interval,
|
|
225
235
|
createdAt: now,
|
|
226
236
|
};
|
|
227
|
-
writePrivateJson(deviceCodePath(env), deviceCode);
|
|
237
|
+
writePrivateJson(deviceCodePath(env, baseUrl), deviceCode);
|
|
228
238
|
return deviceCode;
|
|
229
239
|
}
|
|
230
240
|
export async function verifyDeviceCode() {
|
|
231
241
|
const env = currentEnv();
|
|
242
|
+
const baseUrl = resolveApiBaseUrl({ env });
|
|
232
243
|
const config = authConfig(env);
|
|
233
|
-
const deviceCode = readJson(deviceCodePath(env));
|
|
244
|
+
const deviceCode = readJson(deviceCodePath(env, baseUrl));
|
|
234
245
|
if (!deviceCode)
|
|
235
246
|
throw new Error("Device code not found. Run `cohub auth login --request-code` first.");
|
|
236
247
|
if (deviceCode.expiresAt <= Date.now())
|
|
@@ -243,9 +254,9 @@ export async function verifyDeviceCode() {
|
|
|
243
254
|
}));
|
|
244
255
|
if (!response.ok)
|
|
245
256
|
throw new Error(formatAuthError(data, response.status));
|
|
246
|
-
const session = toSession(data, config, env);
|
|
247
|
-
writePrivateJson(sessionPath(env), session);
|
|
248
|
-
removeIfExists(deviceCodePath(env));
|
|
257
|
+
const session = { ...toSession(data, config, env), baseUrl };
|
|
258
|
+
writePrivateJson(sessionPath(env, sessionBaseUrl(session)), session);
|
|
259
|
+
removeIfExists(deviceCodePath(env, baseUrl));
|
|
249
260
|
return session;
|
|
250
261
|
}
|
|
251
262
|
export async function loginWithDeviceFlow(onCode, onPoll) {
|
package/dist/board-export.d.ts
CHANGED
|
@@ -7,8 +7,13 @@ export type BoardExportSource = {
|
|
|
7
7
|
title: string | null;
|
|
8
8
|
};
|
|
9
9
|
export declare function loadBoardDocument(spaceId: string, target: string): Promise<BoardExportSource>;
|
|
10
|
+
export type BoardExportTextureCache = {
|
|
11
|
+
textures: Map<string, BoardNodeTexture>;
|
|
12
|
+
failed: Set<string>;
|
|
13
|
+
};
|
|
10
14
|
export declare function loadBoardTextures(renderer: BoardNodeRenderer, spaceId: string, items: readonly BoardSceneItem[], options?: {
|
|
11
15
|
concurrency?: number;
|
|
16
|
+
cache?: BoardExportTextureCache;
|
|
12
17
|
}): Promise<{
|
|
13
18
|
textures: Map<string, BoardNodeTexture>;
|
|
14
19
|
failed: string[];
|
|
@@ -27,6 +32,14 @@ export type BoardExportRunOptions = {
|
|
|
27
32
|
format: BoardNodeExportFormat;
|
|
28
33
|
quality?: number;
|
|
29
34
|
withImages: boolean;
|
|
35
|
+
camera?: {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
};
|
|
39
|
+
document?: BoardDocument;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
onFrame?: (frame: BoardExportFrame, index: number) => Promise<void>;
|
|
42
|
+
onProgress?: (completed: number, total: number) => void;
|
|
30
43
|
};
|
|
31
44
|
export type BoardExportFrame = {
|
|
32
45
|
time: number | null;
|
|
@@ -46,6 +59,7 @@ export type BoardVideoExportOptions = Omit<BoardExportRunOptions, "format"> & {
|
|
|
46
59
|
format: BoardVideoExportFormat;
|
|
47
60
|
fps: number;
|
|
48
61
|
output: string;
|
|
62
|
+
force?: boolean;
|
|
49
63
|
};
|
|
50
64
|
export declare function runBoardVideoExport(options: BoardVideoExportOptions): Promise<{
|
|
51
65
|
width: number;
|
package/dist/board-export.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, rm, writeFile, copyFile, link, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
3
5
|
import { execFile as execFileCallback } from "node:child_process";
|
|
4
6
|
import { promisify } from "node:util";
|
|
5
7
|
import { createRequire } from "node:module";
|
|
@@ -7,11 +9,12 @@ import { dirname, join } from "node:path";
|
|
|
7
9
|
import { fileURLToPath } from "node:url";
|
|
8
10
|
import { tmpdir } from "node:os";
|
|
9
11
|
const execFile = promisify(execFileCallback);
|
|
10
|
-
import {
|
|
12
|
+
import { parseBoardMediaSource } from "@neta-art/cohub/board";
|
|
13
|
+
import { hasBoardScripts, boardDocumentAt, createBoardRenderSampler, boardImageKeySource, buildBoardScene, createNodeBoardRenderer, createNodeBoardScriptHost, exportBoardImageBytes, imageAssetKey, parseBoardDocument, planBoardExport, prepareBoardExport, selectBoardExportAssets, } from "./board-kernel.js";
|
|
11
14
|
import { resolveBoardId } from "./board-command-support.js";
|
|
12
15
|
import { BOARD_EXPORT_FONTS } from "./board-fonts.js";
|
|
13
16
|
import { createClient } from "./client.js";
|
|
14
|
-
import { downloadPublicImage } from "./safe-remote-image.js";
|
|
17
|
+
import { downloadPublicAudio, downloadPublicImage } from "./safe-remote-image.js";
|
|
15
18
|
export function resolveBundledFonts() {
|
|
16
19
|
const require = createRequire(import.meta.url);
|
|
17
20
|
const bundled = fileURLToPath(new URL("./fonts/", import.meta.url));
|
|
@@ -35,21 +38,34 @@ export function resolveBundledFonts() {
|
|
|
35
38
|
export async function loadBoardDocument(spaceId, target) {
|
|
36
39
|
const board = createClient().space(spaceId).board(await resolveBoardId(spaceId, target));
|
|
37
40
|
let result = await board.get();
|
|
41
|
+
const snapshot = result;
|
|
38
42
|
const items = { ...result.items };
|
|
39
43
|
while (result.next) {
|
|
40
44
|
result = await board.get({ only: ["items"], cursor: result.next });
|
|
45
|
+
if (result.version !== snapshot.version)
|
|
46
|
+
throw new Error("Board changed while reading. Retry the export.");
|
|
41
47
|
Object.assign(items, result.items);
|
|
42
48
|
}
|
|
43
|
-
const parsed = parseBoardDocument({ board:
|
|
49
|
+
const parsed = parseBoardDocument({ board: snapshot.board, items, animations: snapshot.animations ?? {} });
|
|
44
50
|
if (!parsed.ok)
|
|
45
51
|
throw new Error(`Board ${result.id} could not be read: ${parsed.diagnostics[0]?.path}: ${parsed.diagnostics[0]?.message}`);
|
|
46
52
|
return { document: parsed.document, boardId: result.id, title: result.title };
|
|
47
53
|
}
|
|
48
54
|
export async function loadBoardTextures(renderer, spaceId, items, options = {}) {
|
|
49
55
|
const selection = selectBoardExportAssets([...items], imageAssetKey);
|
|
50
|
-
const
|
|
51
|
-
const failed =
|
|
52
|
-
const
|
|
56
|
+
const cache = options.cache ?? { textures: new Map(), failed: new Set() };
|
|
57
|
+
const { textures, failed } = cache;
|
|
58
|
+
const wanted = new Set(selection.keys);
|
|
59
|
+
for (const [key, texture] of textures) {
|
|
60
|
+
if (wanted.has(key))
|
|
61
|
+
continue;
|
|
62
|
+
texture.destroy(true);
|
|
63
|
+
textures.delete(key);
|
|
64
|
+
}
|
|
65
|
+
for (const key of failed)
|
|
66
|
+
if (!wanted.has(key))
|
|
67
|
+
failed.delete(key);
|
|
68
|
+
const pending = selection.keys.filter((key) => !textures.has(key) && !failed.has(key));
|
|
53
69
|
const concurrency = Math.max(1, Math.min(options.concurrency ?? 4, 16));
|
|
54
70
|
const client = createClient();
|
|
55
71
|
async function worker() {
|
|
@@ -59,7 +75,7 @@ export async function loadBoardTextures(renderer, spaceId, items, options = {})
|
|
|
59
75
|
return;
|
|
60
76
|
const source = boardImageKeySource(key);
|
|
61
77
|
if (!source) {
|
|
62
|
-
failed.
|
|
78
|
+
failed.add(key);
|
|
63
79
|
continue;
|
|
64
80
|
}
|
|
65
81
|
try {
|
|
@@ -70,12 +86,12 @@ export async function loadBoardTextures(renderer, spaceId, items, options = {})
|
|
|
70
86
|
textures.set(key, texture);
|
|
71
87
|
}
|
|
72
88
|
catch {
|
|
73
|
-
failed.
|
|
89
|
+
failed.add(key);
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
92
|
}
|
|
77
93
|
await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
|
|
78
|
-
return { textures, failed, omitted: selection.omittedKeys };
|
|
94
|
+
return { textures, failed: [...failed], omitted: selection.omittedKeys };
|
|
79
95
|
}
|
|
80
96
|
async function readSpaceFileBytes(client, spaceId, path) {
|
|
81
97
|
const { blob, mimeType } = await client.space(spaceId).files.download(path);
|
|
@@ -93,24 +109,36 @@ export async function runBoardVideoExport(options) {
|
|
|
93
109
|
const derivedFps = 1000 / step;
|
|
94
110
|
if (Math.abs(options.fps - derivedFps) > 0.01)
|
|
95
111
|
throw new Error(`--fps must match the --at step (${derivedFps.toFixed(3)} fps).`);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
if (times.some((time, index) => index > 0 && Math.abs(time - times[index - 1] - step) > 0.001))
|
|
113
|
+
throw new Error("Video export needs evenly spaced times.");
|
|
114
|
+
await execFile("ffmpeg", ["-version"], { signal: options.signal }).catch(() => { throw new Error("Video export needs ffmpeg. Install ffmpeg and try again."); });
|
|
115
|
+
const document = options.document ?? (await loadBoardDocument(options.spaceId, options.target)).document;
|
|
99
116
|
const tempDir = await mkdtemp(join(tmpdir(), "cohub-board-export-"));
|
|
100
117
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
118
|
+
let count = 0;
|
|
119
|
+
let dimensions = { width: 0, height: 0 };
|
|
120
|
+
const result = await runBoardExport({ ...options, document, format: "png", onFrame: async (frame, index) => {
|
|
121
|
+
await writeFile(join(tempDir, `${String(index + 1).padStart(6, "0")}.png`), frame.bytes, { signal: options.signal });
|
|
122
|
+
dimensions = { width: frame.width, height: frame.height };
|
|
123
|
+
count++;
|
|
124
|
+
} });
|
|
125
|
+
if (!result || !count)
|
|
126
|
+
throw new Error("Nothing to export: the region contains no items.");
|
|
104
127
|
const audioDocument = boardDocumentAt(document, options.animation, times[0]).document;
|
|
105
128
|
const audio = Object.values(audioDocument.items).filter((item) => item.type === "audio");
|
|
106
129
|
const audioInputs = [];
|
|
107
130
|
for (const [index, item] of audio.entries()) {
|
|
108
|
-
const
|
|
109
|
-
|
|
131
|
+
const source = parseBoardMediaSource(item.props.src);
|
|
132
|
+
if (!source)
|
|
133
|
+
throw new Error("Invalid board audio source");
|
|
134
|
+
const { bytes } = source.kind === "url"
|
|
135
|
+
? await downloadPublicAudio(source.value)
|
|
136
|
+
: await readSpaceFileBytes(createClient(), options.spaceId, source.value);
|
|
137
|
+
const path = join(tempDir, `audio-${index}`);
|
|
110
138
|
await writeFile(path, bytes);
|
|
111
139
|
audioInputs.push({ path, offset: Math.max(0, item.props.time ?? 0) / 1000 });
|
|
112
140
|
}
|
|
113
|
-
const duration = Math.max(0.001,
|
|
141
|
+
const duration = Math.max(0.001, count / options.fps);
|
|
114
142
|
const args = ["-y", "-f", "image2", "-framerate", String(options.fps), "-i", join(tempDir, "%06d.png")];
|
|
115
143
|
for (const input of audioInputs) {
|
|
116
144
|
if (input.offset > 0)
|
|
@@ -130,9 +158,10 @@ export async function runBoardVideoExport(options) {
|
|
|
130
158
|
args.push("-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", "-f", "mp4");
|
|
131
159
|
else
|
|
132
160
|
args.push("-c:v", "libvpx-vp9", "-crf", "32", "-b:v", "0", "-c:a", "libopus", "-f", "webm");
|
|
133
|
-
|
|
161
|
+
const encoded = join(tempDir, `video.${options.format}`);
|
|
162
|
+
args.push("-t", duration.toFixed(3), encoded);
|
|
134
163
|
try {
|
|
135
|
-
await execFile("ffmpeg", args, { encoding: "utf8", maxBuffer: 2 * 1024 * 1024 });
|
|
164
|
+
await execFile("ffmpeg", args, { encoding: "utf8", maxBuffer: 2 * 1024 * 1024, signal: options.signal });
|
|
136
165
|
}
|
|
137
166
|
catch (error) {
|
|
138
167
|
const cause = error;
|
|
@@ -140,45 +169,50 @@ export async function runBoardVideoExport(options) {
|
|
|
140
169
|
throw new Error("Video export needs ffmpeg. Install ffmpeg and try again.");
|
|
141
170
|
throw new Error(`ffmpeg could not encode the video: ${(cause.stderr ?? cause.message ?? "unknown error").trim().split("\n").slice(-6).join("\n")}`);
|
|
142
171
|
}
|
|
143
|
-
|
|
172
|
+
options.signal?.throwIfAborted();
|
|
173
|
+
const staged = `${options.output}.${randomUUID()}.tmp`;
|
|
174
|
+
try {
|
|
175
|
+
await copyFile(encoded, staged, constants.COPYFILE_EXCL);
|
|
176
|
+
options.signal?.throwIfAborted();
|
|
177
|
+
if (options.force)
|
|
178
|
+
await rename(staged, options.output);
|
|
179
|
+
else
|
|
180
|
+
await link(staged, options.output);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await unlink(staged).catch(() => undefined);
|
|
184
|
+
}
|
|
185
|
+
return { ...dimensions, frames: count, warnings: result.warnings };
|
|
144
186
|
}
|
|
145
187
|
finally {
|
|
146
188
|
await rm(tempDir, { recursive: true, force: true });
|
|
147
189
|
}
|
|
148
190
|
}
|
|
149
191
|
export async function runBoardExport(options) {
|
|
150
|
-
|
|
192
|
+
options.signal?.throwIfAborted();
|
|
193
|
+
const document = options.document ?? (await loadBoardDocument(options.spaceId, options.target)).document;
|
|
151
194
|
if (options.animation && !document.animations[options.animation]) {
|
|
152
195
|
throw new Error(`Animation ${options.animation} does not exist.`);
|
|
153
196
|
}
|
|
154
197
|
const times = options.times?.length ? options.times : [null];
|
|
155
|
-
const documentAt = (time) => time === null ? document : boardDocumentAt(document, options.animation ?? null, time).document;
|
|
156
|
-
const plan = planBoardExport({
|
|
157
|
-
scene: buildBoardScene(documentAt(times[0] ?? null)),
|
|
158
|
-
region: options.region,
|
|
159
|
-
scale: options.scale,
|
|
160
|
-
...(options.padding === undefined ? {} : { padding: options.padding }),
|
|
161
|
-
});
|
|
162
|
-
if (!plan)
|
|
163
|
-
return null;
|
|
164
198
|
const renderer = await createNodeBoardRenderer({ fonts: resolveBundledFonts() });
|
|
165
|
-
let
|
|
199
|
+
let scriptHost = null;
|
|
200
|
+
const textureCache = { textures: new Map(), failed: new Set() };
|
|
201
|
+
let backgroundTexture;
|
|
166
202
|
try {
|
|
203
|
+
const sampler = createBoardRenderSampler(document, options.animation ?? null, { colorScheme: options.colorScheme });
|
|
204
|
+
const documentAt = (time) => time === null ? document : sampler.at(time).document;
|
|
205
|
+
const plan = planBoardExport({
|
|
206
|
+
scene: buildBoardScene(documentAt(times[0] ?? null)),
|
|
207
|
+
region: options.region,
|
|
208
|
+
scale: options.scale,
|
|
209
|
+
...(options.padding === undefined ? {} : { padding: options.padding }),
|
|
210
|
+
});
|
|
211
|
+
if (!plan)
|
|
212
|
+
return null;
|
|
167
213
|
const warnings = [];
|
|
168
|
-
|
|
169
|
-
let backgroundTexture;
|
|
214
|
+
const textures = textureCache.textures;
|
|
170
215
|
let omittedKeys = new Set();
|
|
171
|
-
if (options.withImages) {
|
|
172
|
-
const loaded = await loadBoardTextures(renderer, options.spaceId, plan.items);
|
|
173
|
-
textures = loaded.textures;
|
|
174
|
-
omittedKeys = new Set(loaded.omitted);
|
|
175
|
-
if (loaded.failed.length > 0) {
|
|
176
|
-
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 ? ", …" : ""}`);
|
|
177
|
-
}
|
|
178
|
-
if (loaded.omitted.length > 0) {
|
|
179
|
-
warnings.push(`${loaded.omitted.length} previews were drawn as placeholders to stay within the export texture limit.`);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
216
|
const declaredBackground = document.board.background;
|
|
183
217
|
if (options.withImages &&
|
|
184
218
|
options.background === "paper" &&
|
|
@@ -193,32 +227,15 @@ export async function runBoardExport(options) {
|
|
|
193
227
|
}
|
|
194
228
|
}
|
|
195
229
|
const videoCount = plan.items.filter((item) => item.type === "video").length;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const region = times.length > 1 ? { kind: "rect", rect: plan.world } : options.region;
|
|
203
|
-
const host = sketchHost;
|
|
204
|
-
if (!host)
|
|
205
|
-
throw new Error("Node sketch host could not be created.");
|
|
206
|
-
const sketchVariants = new Map();
|
|
207
|
-
for (const time of times) {
|
|
208
|
-
const sketchPlan = planBoardExport({
|
|
209
|
-
scene: buildBoardScene(documentAt(time)),
|
|
210
|
-
region,
|
|
211
|
-
scale: options.scale,
|
|
212
|
-
...(times.length > 1 ? { padding: 0 } : options.padding === undefined ? {} : { padding: options.padding }),
|
|
230
|
+
if (hasBoardScripts(document))
|
|
231
|
+
scriptHost = createNodeBoardScriptHost(renderer, {
|
|
232
|
+
readModule: async (src) => {
|
|
233
|
+
const { bytes } = await readSpaceFileBytes(createClient(), options.spaceId, src);
|
|
234
|
+
return new TextDecoder().decode(bytes);
|
|
235
|
+
},
|
|
213
236
|
});
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const variant = sketchVariants.get(key) ?? { item, times: [] };
|
|
217
|
-
variant.times.push(time ?? 0);
|
|
218
|
-
sketchVariants.set(key, variant);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
await Promise.all([...sketchVariants.values()].map((variant) => host.prepare([variant.item], variant.times, plan.scale)));
|
|
237
|
+
const region = times.length > 1 && !options.camera ? { kind: "rect", rect: plan.world } : options.region;
|
|
238
|
+
const host = scriptHost;
|
|
222
239
|
if (videoCount > 0) {
|
|
223
240
|
warnings.push(`${videoCount} video preview${videoCount === 1 ? " was" : "s were"} drawn as placeholders; video decoding is unavailable in Node.`);
|
|
224
241
|
}
|
|
@@ -233,16 +250,46 @@ export async function runBoardExport(options) {
|
|
|
233
250
|
: undefined;
|
|
234
251
|
const frames = [];
|
|
235
252
|
const reported = new Set();
|
|
253
|
+
let completed = 0;
|
|
236
254
|
for (const time of times) {
|
|
255
|
+
options.signal?.throwIfAborted();
|
|
256
|
+
host?.clearFrames();
|
|
257
|
+
const frameOptions = { sampler, camera: options.camera, ...(time === null ? {} : { at: { animation: options.animation, time } }), region, scale: options.scale, padding: times.length > 1 ? 0 : options.padding };
|
|
258
|
+
const initial = prepareBoardExport(document, frameOptions);
|
|
259
|
+
if (!initial)
|
|
260
|
+
continue;
|
|
261
|
+
let prepared = initial;
|
|
262
|
+
if (host) {
|
|
263
|
+
await host.prepare(initial.scene.items, [time ?? 0], initial.plan.scale);
|
|
264
|
+
for (const item of initial.scene.items) {
|
|
265
|
+
const error = host.error(item.id);
|
|
266
|
+
if (error)
|
|
267
|
+
throw new Error(`${item.script?.src ?? item.id}: ${error}`);
|
|
268
|
+
}
|
|
269
|
+
const scripted = prepareBoardExport(document, { ...frameOptions, scripts: host });
|
|
270
|
+
if (!scripted)
|
|
271
|
+
continue;
|
|
272
|
+
prepared = scripted;
|
|
273
|
+
}
|
|
274
|
+
if (options.withImages) {
|
|
275
|
+
const loaded = await loadBoardTextures(renderer, options.spaceId, prepared.plan.items.filter((item) => prepared.scene.opacity(item.id) > 0), { cache: textureCache });
|
|
276
|
+
omittedKeys = new Set(loaded.omitted);
|
|
277
|
+
const assetWarnings = [
|
|
278
|
+
loaded.failed.length ? `${loaded.failed.length} image(s) could not be loaded: ${loaded.failed.slice(0, 3).join(", ")}` : null,
|
|
279
|
+
loaded.omitted.length ? `${loaded.omitted.length} previews were drawn as placeholders to stay within the export texture limit.` : null,
|
|
280
|
+
];
|
|
281
|
+
for (const warning of assetWarnings)
|
|
282
|
+
if (warning && !reported.has(warning)) {
|
|
283
|
+
warnings.push(warning);
|
|
284
|
+
reported.add(warning);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
237
287
|
const result = exportBoardImageBytes(renderer, document, {
|
|
238
|
-
...
|
|
239
|
-
region,
|
|
240
|
-
scale: options.scale,
|
|
241
|
-
padding: times.length > 1 ? 0 : options.padding,
|
|
288
|
+
...frameOptions,
|
|
242
289
|
colorScheme: options.colorScheme,
|
|
243
290
|
background: options.background,
|
|
244
291
|
textures,
|
|
245
|
-
|
|
292
|
+
scripts: scriptHost ?? undefined,
|
|
246
293
|
backgroundImage,
|
|
247
294
|
...(assetKey ? { assetKey } : {}),
|
|
248
295
|
format: options.format,
|
|
@@ -258,7 +305,7 @@ export async function runBoardExport(options) {
|
|
|
258
305
|
warnings.push(text);
|
|
259
306
|
reported.add(text);
|
|
260
307
|
}
|
|
261
|
-
|
|
308
|
+
const frame = {
|
|
262
309
|
time,
|
|
263
310
|
bytes: result.bytes,
|
|
264
311
|
width: result.plan.width,
|
|
@@ -266,12 +313,21 @@ export async function runBoardExport(options) {
|
|
|
266
313
|
scale: result.plan.scale,
|
|
267
314
|
itemCount: result.plan.items.length,
|
|
268
315
|
format: result.format,
|
|
269
|
-
}
|
|
316
|
+
};
|
|
317
|
+
if (options.onFrame)
|
|
318
|
+
await options.onFrame(frame, completed);
|
|
319
|
+
else
|
|
320
|
+
frames.push(frame);
|
|
321
|
+
completed++;
|
|
322
|
+
options.onProgress?.(completed, times.length);
|
|
270
323
|
}
|
|
271
|
-
return
|
|
324
|
+
return completed ? { frames, warnings } : null;
|
|
272
325
|
}
|
|
273
326
|
finally {
|
|
274
|
-
|
|
327
|
+
scriptHost?.destroy();
|
|
328
|
+
for (const texture of textureCache.textures.values())
|
|
329
|
+
texture.destroy(true);
|
|
330
|
+
backgroundTexture?.destroy(true);
|
|
275
331
|
renderer.destroy();
|
|
276
332
|
}
|
|
277
333
|
}
|