@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.
- package/README.md +25 -2
- package/dist/avatar.js +3 -1
- package/dist/board-export.d.ts +22 -41
- package/dist/board-export.js +178 -115
- package/dist/board-fonts.d.ts +7 -0
- package/dist/board-fonts.js +10 -0
- package/dist/board-kernel/browserAll.js +2760 -0
- package/dist/board-kernel/getPo2TextureFromSource.js +13138 -0
- package/dist/board-kernel/init.js +867 -0
- package/dist/board-kernel/webworkerAll.js +3 -0
- package/dist/board-kernel.d.ts +2 -0
- package/dist/board-kernel.js +28812 -0
- package/dist/board-time.d.ts +3 -0
- package/dist/board-time.js +28 -0
- package/dist/commands/apps.js +2 -2
- package/dist/commands/auth.js +2 -2
- package/dist/commands/boards/examples.d.ts +8 -2
- package/dist/commands/boards/examples.js +79 -411
- package/dist/commands/boards.d.ts +4 -9
- package/dist/commands/boards.js +356 -286
- package/dist/commands/desktop.d.ts +1 -5
- package/dist/commands/desktop.js +10 -19
- package/dist/commands/generations.js +1 -1
- package/dist/commands/me.js +1 -1
- package/dist/commands/public.js +3 -3
- package/dist/commands/references.js +3 -0
- package/dist/commands/referrals.js +2 -2
- package/dist/commands/run.js +3 -3
- package/dist/commands/runtime.d.ts +1 -1
- package/dist/commands/runtime.js +12 -8
- package/dist/commands/search.js +15 -7
- package/dist/commands/space-activity.js +3 -5
- package/dist/commands/space-displays.d.ts +2 -0
- package/dist/commands/space-displays.js +347 -0
- package/dist/commands/space-files.d.ts +5 -0
- package/dist/commands/space-files.js +215 -0
- package/dist/commands/space-invitations.js +3 -3
- package/dist/commands/spaces.d.ts +3 -11
- package/dist/commands/spaces.js +146 -75
- package/dist/fonts/geist-latin-400-normal.woff2 +0 -0
- package/dist/fonts/geist-latin-500-normal.woff2 +0 -0
- package/dist/fonts/geist-latin-600-normal.woff2 +0 -0
- package/dist/fonts/geist-latin-700-normal.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-500-normal.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-600-normal.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-700-normal.woff2 +0 -0
- package/dist/index.js +7 -4
- package/dist/runtime/launch.d.ts +2 -0
- package/dist/runtime/launch.js +35 -8
- package/dist/runtime/presentation.d.ts +1 -0
- package/dist/runtime/presentation.js +3 -2
- package/dist/runtime/supervisor.d.ts +1 -0
- package/dist/runtime/supervisor.js +2 -1
- package/dist/self-update.d.ts +1 -0
- package/dist/self-update.js +52 -16
- package/dist/space-files/limit.d.ts +2 -0
- package/dist/space-files/limit.js +16 -0
- package/dist/space-files/operands.d.ts +16 -0
- package/dist/space-files/operands.js +33 -0
- package/dist/space-files/transfer.d.ts +53 -0
- package/dist/space-files/transfer.js +296 -0
- package/dist/space-files/upload.d.ts +14 -0
- package/dist/space-files/upload.js +53 -0
- package/dist/space.d.ts +18 -16
- package/dist/space.js +86 -39
- package/dist/web.d.ts +1 -0
- package/dist/web.js +6 -0
- package/package.json +8 -4
- package/dist/commands/board-domain.d.ts +0 -2
- package/dist/commands/board-domain.js +0 -16
- package/dist/commands/boards/animation.d.ts +0 -2
- package/dist/commands/boards/animation.js +0 -171
- package/dist/commands/boards/appearance.d.ts +0 -2
- package/dist/commands/boards/appearance.js +0 -127
- package/dist/commands/boards/batch.d.ts +0 -4
- package/dist/commands/boards/batch.js +0 -62
- package/dist/commands/boards/connections.d.ts +0 -2
- package/dist/commands/boards/connections.js +0 -95
- package/dist/commands/boards/context.d.ts +0 -31
- package/dist/commands/boards/context.js +0 -71
- package/dist/commands/boards/items.d.ts +0 -2
- package/dist/commands/boards/items.js +0 -184
- package/dist/commands/boards/transactions.d.ts +0 -12
- 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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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 })
|
package/dist/board-export.d.ts
CHANGED
|
@@ -1,49 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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>;
|
package/dist/board-export.js
CHANGED
|
@@ -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 {
|
|
14
|
-
import {
|
|
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
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
27
|
+
return null;
|
|
55
28
|
}
|
|
56
|
-
}
|
|
57
|
-
return
|
|
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
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
?
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
: {}),
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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,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
|
+
];
|