@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 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 sessionPath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "auth.dev.json" : "auth.json");
32
- const deviceCodePath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "device-code.dev.json" : "device-code.json");
33
- const legacyTokenPath = (env = currentEnv()) => join(CONFIG_DIR, env === "dev" ? "token.dev" : "token");
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 clearDeviceCode = () => {
124
- removeIfExists(deviceCodePath());
125
- };
126
- export const clearAuthSession = () => {
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
- let refreshInFlight = null;
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
- if (refreshInFlight)
160
- return refreshInFlight;
161
- const path = sessionPath(session.env);
162
- refreshInFlight = withRuntimeSpaceBindingsLock(async () => {
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(() => { refreshInFlight = null; });
171
- return refreshInFlight;
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(sessionPath(session.env))?.refreshToken === session.refreshToken)
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(sessionPath(session.env));
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(sessionPath(session.env), next);
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) {
@@ -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;
@@ -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 { boardDocumentAt, boardImageKeySource, buildBoardScene, createNodeBoardRenderer, createNodeBoardSketchHost, exportBoardImageBytes, imageAssetKey, parseBoardDocument, planBoardExport, selectBoardExportAssets, } from "./board-kernel.js";
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: result.board, items, animations: result.animations ?? {} });
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 textures = new Map();
51
- const failed = [];
52
- const pending = [...selection.keys];
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.push(key);
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.push(key);
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
- const result = await runBoardExport({ ...options, format: "png" });
97
- if (!result)
98
- throw new Error("Nothing to export: the region contains no items.");
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
- for (const [index, frame] of result.frames.entries())
102
- await writeFile(join(tempDir, `${String(index + 1).padStart(6, "0")}.png`), frame.bytes);
103
- const document = (await loadBoardDocument(options.spaceId, options.target)).document;
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 { bytes } = await readSpaceFileBytes(createClient(), options.spaceId, item.props.src);
109
- const path = join(tempDir, `audio-${index}-${item.props.src.split("/").pop() ?? "track"}`);
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, result.frames.length / options.fps);
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
- args.push("-t", duration.toFixed(3), options.output);
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
- return { width: result.frames[0]?.width ?? 0, height: result.frames[0]?.height ?? 0, frames: result.frames.length, warnings: result.warnings };
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
- const { document } = await loadBoardDocument(options.spaceId, options.target);
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 sketchHost = null;
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
- let textures;
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
- sketchHost = createNodeBoardSketchHost(renderer, {
197
- readModule: async (src) => {
198
- const { bytes } = await readSpaceFileBytes(createClient(), options.spaceId, src);
199
- return new TextDecoder().decode(bytes);
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
- for (const item of sketchPlan?.items.filter((entry) => entry.type === "sketch") ?? []) {
215
- const key = `${item.id}:${item.frame.width}:${item.frame.height}:${item.props.src}:${item.props.seed ?? ""}:${JSON.stringify(item.props.params)}`;
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
- ...(time === null ? {} : { at: { ...(options.animation ? { animation: options.animation } : {}), time } }),
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
- sketches: sketchHost ?? undefined,
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
- frames.push({
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 frames.length ? { frames, warnings } : null;
324
+ return completed ? { frames, warnings } : null;
272
325
  }
273
326
  finally {
274
- sketchHost?.destroy();
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
  }