@neta-art/cohub-cli 8.4.0 → 9.0.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.
Files changed (84) hide show
  1. package/README.md +25 -2
  2. package/dist/avatar.js +3 -1
  3. package/dist/board-export.d.ts +22 -41
  4. package/dist/board-export.js +178 -115
  5. package/dist/board-fonts.d.ts +7 -0
  6. package/dist/board-fonts.js +10 -0
  7. package/dist/board-kernel/browserAll.js +2760 -0
  8. package/dist/board-kernel/getPo2TextureFromSource.js +13138 -0
  9. package/dist/board-kernel/init.js +867 -0
  10. package/dist/board-kernel/webworkerAll.js +3 -0
  11. package/dist/board-kernel.d.ts +2 -0
  12. package/dist/board-kernel.js +28812 -0
  13. package/dist/board-time.d.ts +3 -0
  14. package/dist/board-time.js +28 -0
  15. package/dist/commands/apps.js +2 -2
  16. package/dist/commands/auth.js +2 -2
  17. package/dist/commands/boards/examples.d.ts +8 -2
  18. package/dist/commands/boards/examples.js +79 -411
  19. package/dist/commands/boards.d.ts +4 -9
  20. package/dist/commands/boards.js +356 -286
  21. package/dist/commands/desktop.d.ts +1 -5
  22. package/dist/commands/desktop.js +10 -19
  23. package/dist/commands/generations.js +1 -1
  24. package/dist/commands/me.js +1 -1
  25. package/dist/commands/public.js +3 -3
  26. package/dist/commands/references.js +3 -0
  27. package/dist/commands/referrals.js +2 -2
  28. package/dist/commands/run.js +3 -3
  29. package/dist/commands/runtime.d.ts +1 -1
  30. package/dist/commands/runtime.js +12 -8
  31. package/dist/commands/search.js +15 -7
  32. package/dist/commands/space-activity.js +3 -5
  33. package/dist/commands/space-displays.d.ts +2 -0
  34. package/dist/commands/space-displays.js +347 -0
  35. package/dist/commands/space-files.d.ts +5 -0
  36. package/dist/commands/space-files.js +215 -0
  37. package/dist/commands/space-invitations.js +3 -3
  38. package/dist/commands/spaces.d.ts +3 -11
  39. package/dist/commands/spaces.js +146 -75
  40. package/dist/fonts/geist-latin-400-normal.woff2 +0 -0
  41. package/dist/fonts/geist-latin-500-normal.woff2 +0 -0
  42. package/dist/fonts/geist-latin-600-normal.woff2 +0 -0
  43. package/dist/fonts/geist-latin-700-normal.woff2 +0 -0
  44. package/dist/fonts/geist-mono-latin-500-normal.woff2 +0 -0
  45. package/dist/fonts/geist-mono-latin-600-normal.woff2 +0 -0
  46. package/dist/fonts/geist-mono-latin-700-normal.woff2 +0 -0
  47. package/dist/index.js +7 -4
  48. package/dist/runtime/launch.d.ts +2 -0
  49. package/dist/runtime/launch.js +35 -8
  50. package/dist/runtime/presentation.d.ts +1 -0
  51. package/dist/runtime/presentation.js +3 -2
  52. package/dist/runtime/supervisor.d.ts +1 -0
  53. package/dist/runtime/supervisor.js +2 -1
  54. package/dist/self-update.d.ts +1 -0
  55. package/dist/self-update.js +52 -16
  56. package/dist/space-files/limit.d.ts +2 -0
  57. package/dist/space-files/limit.js +16 -0
  58. package/dist/space-files/operands.d.ts +16 -0
  59. package/dist/space-files/operands.js +33 -0
  60. package/dist/space-files/transfer.d.ts +53 -0
  61. package/dist/space-files/transfer.js +296 -0
  62. package/dist/space-files/upload.d.ts +14 -0
  63. package/dist/space-files/upload.js +53 -0
  64. package/dist/space.d.ts +18 -16
  65. package/dist/space.js +86 -39
  66. package/dist/web.d.ts +1 -0
  67. package/dist/web.js +6 -0
  68. package/package.json +8 -4
  69. package/dist/commands/board-domain.d.ts +0 -2
  70. package/dist/commands/board-domain.js +0 -16
  71. package/dist/commands/boards/animation.d.ts +0 -2
  72. package/dist/commands/boards/animation.js +0 -171
  73. package/dist/commands/boards/appearance.d.ts +0 -2
  74. package/dist/commands/boards/appearance.js +0 -127
  75. package/dist/commands/boards/batch.d.ts +0 -4
  76. package/dist/commands/boards/batch.js +0 -62
  77. package/dist/commands/boards/connections.d.ts +0 -2
  78. package/dist/commands/boards/connections.js +0 -95
  79. package/dist/commands/boards/context.d.ts +0 -31
  80. package/dist/commands/boards/context.js +0 -71
  81. package/dist/commands/boards/items.d.ts +0 -2
  82. package/dist/commands/boards/items.js +0 -184
  83. package/dist/commands/boards/transactions.d.ts +0 -12
  84. package/dist/commands/boards/transactions.js +0 -69
package/README.md CHANGED
@@ -72,12 +72,15 @@ cohub -s <spaceId> spaces invites revoke <code> --yes
72
72
  cohub -s <spaceId> run -- git status
73
73
  ```
74
74
 
75
- Many space-scoped commands need a target Space. Without an explicit target, commands
76
- also use the Space remembered for the current directory before falling back to Home:
75
+ Space-scoped commands use `-s`, then `COHUB_SPACE_ID`, then the Space remembered for
76
+ the current directory. `<space>` is a Space ID, a slug you own such as `home`, or
77
+ `username/slug`. Only `prompt`, `completion`, `generate`, `apps`, and `public` fall
78
+ back to your Home Space; other commands exit with an error when no target is given:
77
79
 
78
80
  ```bash
79
81
  cohub -s <spaceId> spaces prompt "message" --json
80
82
  COHUB_SPACE_ID=<spaceId> cohub spaces prompt "message" --json
83
+ cohub -s home spaces files ls
81
84
  ```
82
85
 
83
86
  ## Local Runtime
@@ -349,12 +352,32 @@ Pass generation parameters with `--param key=value` or `--parameters '<json>'`.
349
352
  ```bash
350
353
  cohub -s <spaceId> spaces files ls [path] --json
351
354
  cohub -s <spaceId> spaces files cat <path>
355
+ cohub -s <spaceId> spaces files cat <path> > <local-file>
352
356
  cohub -s <spaceId> spaces files write <path> -c "<content>"
353
357
  cohub -s <spaceId> spaces files upload <files...> --dir <dir>
358
+ cohub spaces files cp [-r] [-n] [-p] <source>... <destination>
354
359
  cohub -s <spaceId> spaces files mv <from> <to>
355
360
  cohub -s <spaceId> spaces files rm <path>
356
361
  ```
357
362
 
363
+ `cat` streams raw bytes, so redirecting it saves binary files intact.
364
+
365
+ `cp` works like `scp` with `cp` semantics: the last path is the destination,
366
+ directories need `-r`, existing files are overwritten and directories merged
367
+ (`-n` keeps existing files, `-p` keeps modification times). `<space>:<path>`
368
+ addresses a Space by id, `username/slug`, or the slug of a Space you own. Bare
369
+ paths address the current Space when one is declared with `-s` or
370
+ `COHUB_SPACE_ID` (as inside a Cohub sandbox) and local files otherwise;
371
+ absolute paths outside `/workspace` are always local:
372
+
373
+ ```bash
374
+ cohub spaces files cp -r alice/templates:starter ./starter # download
375
+ cohub spaces files cp report.pdf <spaceId>:inbox/ # upload
376
+ cohub -s <spaceId> spaces files cp -r other:docs docs # Space to Space
377
+ ```
378
+
379
+ Copies between Spaces run server-side and never pass file content through the CLI.
380
+
358
381
  `upload` places each file under `--dir`; a directory argument contributes its
359
382
  contents directly (like `aws s3 cp dir remote:path`), so `upload dist --dir apps/demo`
360
383
  lands at `apps/demo/index.html`, not `apps/demo/dist/index.html`. Upload adds or
package/dist/avatar.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import sharp from "sharp";
2
+ const loadSharp = async () => (await import("sharp")).default;
3
3
  const AVATAR_SIZE = 1024;
4
4
  const AVATAR_QUALITY = 86;
5
5
  const detectAvatarFormat = (body) => {
@@ -25,6 +25,7 @@ export async function normalizeAvatarFile(path) {
25
25
  if (originalFormat.mimeType === "image/gif")
26
26
  return { body: original, ...originalFormat };
27
27
  try {
28
+ const sharp = await loadSharp();
28
29
  const body = await sharp(original)
29
30
  .rotate()
30
31
  .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "centre" })
@@ -49,6 +50,7 @@ export async function uploadAvatarAsset(input) {
49
50
  const CHAT_IMAGE_MAX_EDGE = 1984;
50
51
  const CHAT_IMAGE_QUALITY = 86;
51
52
  export async function normalizeChatImageFile(path) {
53
+ const sharp = await loadSharp();
52
54
  return sharp(path)
53
55
  .rotate()
54
56
  .resize(CHAT_IMAGE_MAX_EDGE, CHAT_IMAGE_MAX_EDGE, { fit: "inside", withoutEnlargement: true })
@@ -1,49 +1,13 @@
1
- /**
2
- * Board image export for the CLI.
3
- *
4
- * The CLI and web editor share canonical Board geometry and card semantics. Each
5
- * host selects the renderer primitive suited to its backend; this path uses
6
- * Canvas2D. Everything platform-specific
7
- * lives here: fetching the document over HTTP, pulling image bytes out of the
8
- * space, and locating fonts on disk.
9
- */
10
- import { type BoardDocument, type BoardExportRegion, type BoardItem } from "@neta-art/cohub/board";
11
- import { type BoardHeadlessExportFormat, type BoardHeadlessFont, type BoardHeadlessRenderer, type BoardHeadlessTexture } from "@neta-art/cohub/board/headless";
12
- export declare const BOARD_EXPORT_FORMATS: BoardHeadlessExportFormat[];
13
- /** Infer the output format from the file extension, defaulting to PNG. */
14
- export declare function formatFromPath(path: string): BoardHeadlessExportFormat;
15
- /**
16
- * Geist, as shipped to the browser.
17
- *
18
- * The board asks for the "Geist" family; registering the exact same font files
19
- * the web app loads is what makes CLI output match the editor rather than
20
- * substituting whatever sans-serif the host happens to have. Resolution is
21
- * best-effort: if the font package is not installed the export still succeeds,
22
- * falling back through the font stack.
23
- */
1
+ import type { BoardDocument, BoardExportRegion, BoardSceneItem } from "@neta-art/cohub/board";
2
+ import type { BoardHeadlessExportFormat, BoardHeadlessFont, BoardHeadlessRenderer, BoardHeadlessTexture } from "@neta-art/cohub/board/headless";
24
3
  export declare function resolveBundledFonts(): BoardHeadlessFont[];
25
4
  export type BoardExportSource = {
26
5
  document: BoardDocument;
27
6
  boardId: string;
28
7
  title: string | null;
29
8
  };
30
- /**
31
- * Load a board document by board id or by the path of its `.board` file.
32
- *
33
- * A `.board` file is a manifest holding a board id, so both forms converge on
34
- * the same inspect call — which is also what the web client does.
35
- */
36
9
  export declare function loadBoardDocument(spaceId: string, target: string): Promise<BoardExportSource>;
37
- /**
38
- * Fetch every image in `items`, keyed the way the renderers ask for it.
39
- *
40
- * Takes the planned items rather than the whole document so a partial export
41
- * does not download the rest of the board. Failures are collected rather than
42
- * thrown: one unreadable image should cost a placeholder and a warning, not the
43
- * whole export. Downloads run concurrently but bounded, so a board with hundreds
44
- * of images does not open hundreds of sockets.
45
- */
46
- export declare function loadBoardTextures(headless: BoardHeadlessRenderer, spaceId: string, items: BoardItem[], options?: {
10
+ export declare function loadBoardTextures(headless: BoardHeadlessRenderer, spaceId: string, items: readonly BoardSceneItem[], options?: {
47
11
  concurrency?: number;
48
12
  }): Promise<{
49
13
  textures: Map<string, BoardHeadlessTexture>;
@@ -54,6 +18,8 @@ export type BoardExportRunOptions = {
54
18
  spaceId: string;
55
19
  target: string;
56
20
  region: BoardExportRegion;
21
+ times?: number[];
22
+ animation?: string;
57
23
  scale: number;
58
24
  padding?: number;
59
25
  colorScheme: "dark" | "light";
@@ -62,14 +28,29 @@ export type BoardExportRunOptions = {
62
28
  quality?: number;
63
29
  withImages: boolean;
64
30
  };
65
- export type BoardExportRunResult = {
31
+ export type BoardExportFrame = {
32
+ time: number | null;
66
33
  bytes: Uint8Array;
67
34
  width: number;
68
35
  height: number;
69
36
  scale: number;
70
37
  itemCount: number;
71
38
  format: BoardHeadlessExportFormat;
39
+ };
40
+ export type BoardExportRunResult = {
41
+ frames: BoardExportFrame[];
72
42
  warnings: string[];
73
43
  };
74
- /** Render a board to image bytes. Returns null when the region is empty. */
44
+ export type BoardVideoExportFormat = "mp4" | "webm";
45
+ export type BoardVideoExportOptions = Omit<BoardExportRunOptions, "format"> & {
46
+ format: BoardVideoExportFormat;
47
+ fps: number;
48
+ output: string;
49
+ };
50
+ export declare function runBoardVideoExport(options: BoardVideoExportOptions): Promise<{
51
+ width: number;
52
+ height: number;
53
+ frames: number;
54
+ warnings: string[];
55
+ }>;
75
56
  export declare function runBoardExport(options: BoardExportRunOptions): Promise<BoardExportRunResult | null>;
@@ -1,88 +1,52 @@
1
- /**
2
- * Board image export for the CLI.
3
- *
4
- * The CLI and web editor share canonical Board geometry and card semantics. Each
5
- * host selects the renderer primitive suited to its backend; this path uses
6
- * Canvas2D. Everything platform-specific
7
- * lives here: fetching the document over HTTP, pulling image bytes out of the
8
- * space, and locating fonts on disk.
9
- */
10
1
  import { existsSync } from "node:fs";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { execFile as execFileCallback } from "node:child_process";
4
+ import { promisify } from "node:util";
11
5
  import { createRequire } from "node:module";
12
6
  import { dirname, join } from "node:path";
13
- import { boardAuthoringSnapshotToDocument, boardImageKeySource, imageAssetKey, planBoardExport, selectBoardExportAssets, } from "@neta-art/cohub/board";
14
- import { createBoardHeadlessRenderer, exportBoardImageBytes, } from "@neta-art/cohub/board/headless";
7
+ import { fileURLToPath } from "node:url";
8
+ import { tmpdir } from "node:os";
9
+ const execFile = promisify(execFileCallback);
10
+ import { boardDocumentAt, boardImageKeySource, buildBoardScene, createBoardHeadlessRenderer, createBoardHeadlessSketchHost, exportBoardImageBytes, imageAssetKey, parseBoardDocument, planBoardExport, selectBoardExportAssets, } from "./board-kernel.js";
15
11
  import { resolveBoardId } from "./board-command-support.js";
12
+ import { BOARD_EXPORT_FONTS } from "./board-fonts.js";
16
13
  import { createClient } from "./client.js";
17
14
  import { downloadPublicImage } from "./safe-remote-image.js";
18
- export const BOARD_EXPORT_FORMATS = ["png", "jpeg", "webp"];
19
- /** Infer the output format from the file extension, defaulting to PNG. */
20
- export function formatFromPath(path) {
21
- const lower = path.toLowerCase();
22
- if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
23
- return "jpeg";
24
- if (lower.endsWith(".webp"))
25
- return "webp";
26
- return "png";
27
- }
28
- /**
29
- * Geist, as shipped to the browser.
30
- *
31
- * The board asks for the "Geist" family; registering the exact same font files
32
- * the web app loads is what makes CLI output match the editor rather than
33
- * substituting whatever sans-serif the host happens to have. Resolution is
34
- * best-effort: if the font package is not installed the export still succeeds,
35
- * falling back through the font stack.
36
- */
37
15
  export function resolveBundledFonts() {
38
16
  const require = createRequire(import.meta.url);
39
- const fonts = [];
40
- const candidates = [
41
- { pkg: "@fontsource/geist", file: "geist-latin-500-normal.woff2", family: "Geist" },
42
- { pkg: "@fontsource/geist", file: "geist-latin-400-normal.woff2", family: "Geist" },
43
- { pkg: "@fontsource/geist", file: "geist-latin-600-normal.woff2", family: "Geist" },
44
- { pkg: "@fontsource/geist-mono", file: "geist-mono-latin-400-normal.woff2", family: "Geist Mono" },
45
- ];
46
- for (const candidate of candidates) {
17
+ const bundled = fileURLToPath(new URL("./fonts/", import.meta.url));
18
+ const locate = ({ pkg, file }) => {
19
+ const copied = join(bundled, file);
20
+ if (existsSync(copied))
21
+ return copied;
47
22
  try {
48
- const root = dirname(require.resolve(`${candidate.pkg}/package.json`));
49
- const path = join(root, "files", candidate.file);
50
- if (existsSync(path))
51
- fonts.push({ path, family: candidate.family });
23
+ const path = join(dirname(require.resolve(`${pkg}/package.json`)), "files", file);
24
+ return existsSync(path) ? path : null;
52
25
  }
53
26
  catch {
54
- // Font package absent; the stack's system fallbacks cover it.
27
+ return null;
55
28
  }
56
- }
57
- return fonts;
29
+ };
30
+ return BOARD_EXPORT_FONTS.flatMap((font) => {
31
+ const path = locate(font);
32
+ return path ? [{ path, family: font.family }] : [];
33
+ });
58
34
  }
59
- /**
60
- * Load a board document by board id or by the path of its `.board` file.
61
- *
62
- * A `.board` file is a manifest holding a board id, so both forms converge on
63
- * the same inspect call — which is also what the web client does.
64
- */
65
35
  export async function loadBoardDocument(spaceId, target) {
66
- const client = createClient();
67
- const boardId = await resolveBoardId(spaceId, target);
68
- const snapshot = await client.space(spaceId).board(boardId).authoring({ include: ["items", "connections"] });
69
- return {
70
- document: boardAuthoringSnapshotToDocument(snapshot),
71
- boardId: snapshot.board.id,
72
- title: snapshot.board.title ?? null,
73
- };
36
+ const board = createClient().space(spaceId).board(await resolveBoardId(spaceId, target));
37
+ let result = await board.get();
38
+ const items = { ...result.items };
39
+ while (result.next) {
40
+ result = await board.get({ only: ["items"], cursor: result.next });
41
+ Object.assign(items, result.items);
42
+ }
43
+ const parsed = parseBoardDocument({ board: result.board, items, animations: result.animations ?? {} });
44
+ if (!parsed.ok)
45
+ throw new Error(`Board ${result.id} could not be read: ${parsed.diagnostics[0]?.path}: ${parsed.diagnostics[0]?.message}`);
46
+ return { document: parsed.document, boardId: result.id, title: result.title };
74
47
  }
75
- /**
76
- * Fetch every image in `items`, keyed the way the renderers ask for it.
77
- *
78
- * Takes the planned items rather than the whole document so a partial export
79
- * does not download the rest of the board. Failures are collected rather than
80
- * thrown: one unreadable image should cost a placeholder and a warning, not the
81
- * whole export. Downloads run concurrently but bounded, so a board with hundreds
82
- * of images does not open hundreds of sockets.
83
- */
84
48
  export async function loadBoardTextures(headless, spaceId, items, options = {}) {
85
- const selection = selectBoardExportAssets(items, imageAssetKey);
49
+ const selection = selectBoardExportAssets([...items], imageAssetKey);
86
50
  const textures = new Map();
87
51
  const failed = [];
88
52
  const pending = [...selection.keys];
@@ -117,13 +81,80 @@ async function readSpaceFileBytes(client, spaceId, path) {
117
81
  const { blob, mimeType } = await client.space(spaceId).files.download(path);
118
82
  return { bytes: new Uint8Array(await blob.arrayBuffer()), mimeType };
119
83
  }
120
- /** Render a board to image bytes. Returns null when the region is empty. */
84
+ export async function runBoardVideoExport(options) {
85
+ if (!options.animation)
86
+ throw new Error("Video export needs --animation.");
87
+ if (!options.times || options.times.length < 2)
88
+ throw new Error("Video export needs an --at range with at least two frames.");
89
+ const times = options.times;
90
+ const step = times[1] - times[0];
91
+ if (!Number.isFinite(step) || step <= 0)
92
+ throw new Error("Video export needs an increasing --at range.");
93
+ const derivedFps = 1000 / step;
94
+ if (Math.abs(options.fps - derivedFps) > 0.01)
95
+ 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.");
99
+ const tempDir = await mkdtemp(join(tmpdir(), "cohub-board-export-"));
100
+ 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;
104
+ const audioDocument = boardDocumentAt(document, options.animation, times[0]).document;
105
+ const audio = Object.values(audioDocument.items).filter((item) => item.type === "audio");
106
+ const audioInputs = [];
107
+ 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"}`);
110
+ await writeFile(path, bytes);
111
+ audioInputs.push({ path, offset: Math.max(0, item.props.time ?? 0) / 1000 });
112
+ }
113
+ const duration = Math.max(0.001, result.frames.length / options.fps);
114
+ const args = ["-y", "-f", "image2", "-framerate", String(options.fps), "-i", join(tempDir, "%06d.png")];
115
+ for (const input of audioInputs) {
116
+ if (input.offset > 0)
117
+ args.push("-ss", input.offset.toFixed(3));
118
+ args.push("-i", input.path);
119
+ }
120
+ if (audioInputs.length > 1) {
121
+ const inputs = audioInputs.map((_, index) => `[${index + 1}:a]`).join("");
122
+ args.push("-filter_complex", `${inputs}amix=inputs=${audioInputs.length}:duration=longest:dropout_transition=0[aout]`, "-map", "0:v:0", "-map", "[aout]");
123
+ }
124
+ else {
125
+ args.push("-map", "0:v:0");
126
+ if (audioInputs.length === 1)
127
+ args.push("-map", "1:a:0");
128
+ }
129
+ if (options.format === "mp4")
130
+ args.push("-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", "-f", "mp4");
131
+ else
132
+ 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);
134
+ try {
135
+ await execFile("ffmpeg", args, { encoding: "utf8", maxBuffer: 2 * 1024 * 1024 });
136
+ }
137
+ catch (error) {
138
+ const cause = error;
139
+ if (cause.code === "ENOENT")
140
+ throw new Error("Video export needs ffmpeg. Install ffmpeg and try again.");
141
+ throw new Error(`ffmpeg could not encode the video: ${(cause.stderr ?? cause.message ?? "unknown error").trim().split("\n").slice(-6).join("\n")}`);
142
+ }
143
+ return { width: result.frames[0]?.width ?? 0, height: result.frames[0]?.height ?? 0, frames: result.frames.length, warnings: result.warnings };
144
+ }
145
+ finally {
146
+ await rm(tempDir, { recursive: true, force: true });
147
+ }
148
+ }
121
149
  export async function runBoardExport(options) {
122
150
  const { document } = await loadBoardDocument(options.spaceId, options.target);
123
- // Plan before fetching: a --frame / --items / --rect export should only pull
124
- // the images it will actually draw, and an empty region should pull none.
151
+ if (options.animation && !document.animations[options.animation]) {
152
+ throw new Error(`Animation ${options.animation} does not exist.`);
153
+ }
154
+ const times = options.times?.length ? options.times : [null];
155
+ const documentAt = (time) => time === null ? document : boardDocumentAt(document, options.animation ?? null, time).document;
125
156
  const plan = planBoardExport({
126
- document,
157
+ scene: buildBoardScene(documentAt(times[0] ?? null)),
127
158
  region: options.region,
128
159
  scale: options.scale,
129
160
  ...(options.padding === undefined ? {} : { padding: options.padding }),
@@ -131,6 +162,7 @@ export async function runBoardExport(options) {
131
162
  if (!plan)
132
163
  return null;
133
164
  const headless = await createBoardHeadlessRenderer({ fonts: resolveBundledFonts() });
165
+ let sketchHost = null;
134
166
  try {
135
167
  const warnings = [];
136
168
  let textures;
@@ -147,7 +179,7 @@ export async function runBoardExport(options) {
147
179
  warnings.push(`${loaded.omitted.length} previews were drawn as placeholders to stay within the export texture limit.`);
148
180
  }
149
181
  }
150
- const declaredBackground = document.appearance.background;
182
+ const declaredBackground = document.board.background;
151
183
  if (options.withImages &&
152
184
  options.background === "paper" &&
153
185
  declaredBackground.kind === "image" &&
@@ -161,54 +193,85 @@ export async function runBoardExport(options) {
161
193
  }
162
194
  }
163
195
  const videoCount = plan.items.filter((item) => item.type === "video").length;
196
+ sketchHost = createBoardHeadlessSketchHost(headless, {
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("Headless 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 }),
213
+ });
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)));
164
222
  if (videoCount > 0) {
165
223
  warnings.push(`${videoCount} video preview${videoCount === 1 ? " was" : "s were"} drawn as placeholders; headless video decoding is unavailable.`);
166
224
  }
167
- const result = exportBoardImageBytes(headless, document, {
168
- region: options.region,
169
- scale: options.scale,
170
- padding: options.padding,
171
- colorScheme: options.colorScheme,
172
- background: options.background,
173
- textures,
174
- backgroundImage: backgroundTexture
175
- ? {
176
- texture: backgroundTexture,
177
- fit: declaredBackground.fit ?? "cover",
178
- position: declaredBackground.position ?? "center",
179
- opacity: declaredBackground.opacity ?? 1,
180
- }
181
- : undefined,
182
- ...(options.withImages
183
- ? {
184
- assetKey: (item) => {
185
- const key = imageAssetKey(item);
186
- return key && omittedKeys.has(key) ? null : key;
187
- },
188
- }
189
- : {}),
190
- format: options.format,
191
- quality: options.quality,
192
- });
193
- if (!result)
194
- return null;
195
- for (const warning of result.warnings) {
196
- // Missing-image warnings are already reported above with their paths.
197
- if (warning.kind === "images-missing" && options.withImages)
225
+ const backgroundImage = backgroundTexture
226
+ ? { texture: backgroundTexture, fit: declaredBackground.fit ?? "cover", position: "center", opacity: declaredBackground.opacity ?? 1 }
227
+ : undefined;
228
+ const assetKey = options.withImages
229
+ ? (item) => {
230
+ const key = imageAssetKey(item);
231
+ return key && omittedKeys.has(key) ? null : key;
232
+ }
233
+ : undefined;
234
+ const frames = [];
235
+ const reported = new Set();
236
+ for (const time of times) {
237
+ const result = exportBoardImageBytes(headless, 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,
242
+ colorScheme: options.colorScheme,
243
+ background: options.background,
244
+ textures,
245
+ sketches: sketchHost ?? undefined,
246
+ backgroundImage,
247
+ ...(assetKey ? { assetKey } : {}),
248
+ format: options.format,
249
+ quality: options.quality,
250
+ });
251
+ if (!result)
198
252
  continue;
199
- warnings.push(describeWarning(warning));
253
+ for (const warning of result.warnings) {
254
+ if (warning.kind === "images-missing" && options.withImages)
255
+ continue;
256
+ const text = describeWarning(warning);
257
+ if (!reported.has(text))
258
+ warnings.push(text);
259
+ reported.add(text);
260
+ }
261
+ frames.push({
262
+ time,
263
+ bytes: result.bytes,
264
+ width: result.plan.width,
265
+ height: result.plan.height,
266
+ scale: result.plan.scale,
267
+ itemCount: result.plan.items.length,
268
+ format: result.format,
269
+ });
200
270
  }
201
- return {
202
- bytes: result.bytes,
203
- width: result.plan.width,
204
- height: result.plan.height,
205
- scale: result.plan.scale,
206
- itemCount: result.plan.items.length,
207
- format: result.format,
208
- warnings,
209
- };
271
+ return frames.length ? { frames, warnings } : null;
210
272
  }
211
273
  finally {
274
+ sketchHost?.destroy();
212
275
  headless.destroy();
213
276
  }
214
277
  }
@@ -0,0 +1,7 @@
1
+ /** Geist files `boards export` registers, copied into `dist/fonts` at build time. */
2
+ export type BoardExportFont = {
3
+ pkg: string;
4
+ file: string;
5
+ family: string;
6
+ };
7
+ export declare const BOARD_EXPORT_FONTS: readonly BoardExportFont[];
@@ -0,0 +1,10 @@
1
+ /** Geist files `boards export` registers, copied into `dist/fonts` at build time. */
2
+ const subset = (pkg, family, weights) => weights.map((weight) => ({
3
+ pkg,
4
+ family,
5
+ file: `${pkg.split("/").pop()}-latin-${weight}-normal.woff2`,
6
+ }));
7
+ export const BOARD_EXPORT_FONTS = [
8
+ ...subset("@fontsource/geist", "Geist", [400, 500, 600, 700]),
9
+ ...subset("@fontsource/geist-mono", "Geist Mono", [500, 600, 700]),
10
+ ];