@neta-art/cohub-cli 10.12.1 → 10.12.2

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
@@ -323,6 +323,7 @@ Use the filters so a large Board is not read whole:
323
323
  cohub -s <spaceId> boards get <boardId> --items title,note
324
324
  cohub -s <spaceId> boards get <boardId> --within s1
325
325
  cohub -s <spaceId> boards get <boardId> --rect 0,0,960,540
326
+ cohub -s <spaceId> boards get <boardId> --since 12 # only the changes after version 12
326
327
  ```
327
328
 
328
329
  Start from an example and write it with `apply`, a JSON Merge Patch where fields
package/dist/avatar.d.ts CHANGED
@@ -18,7 +18,11 @@ export declare function uploadAvatarAsset(input: {
18
18
  uploadMethod: "PUT";
19
19
  uploadHeaders?: Record<string, string>;
20
20
  }>;
21
- export declare function normalizeChatImageFile(path: string): Promise<Buffer>;
21
+ export declare function normalizeChatImageFile(path: string): Promise<{
22
+ body: Buffer<ArrayBuffer>;
23
+ width: number;
24
+ height: number;
25
+ }>;
22
26
  export declare function uploadChatImageAsset(input: {
23
27
  client: CohubHttpClient;
24
28
  spaceId?: string;
@@ -32,5 +36,7 @@ export declare function uploadChatImageAsset(input: {
32
36
  uploadMethod: "PUT";
33
37
  uploadHeaders?: Record<string, string>;
34
38
  size: number;
39
+ width: number;
40
+ height: number;
35
41
  }>;
36
42
  export {};
package/dist/avatar.js CHANGED
@@ -51,14 +51,15 @@ const CHAT_IMAGE_MAX_EDGE = 1984;
51
51
  const CHAT_IMAGE_QUALITY = 86;
52
52
  export async function normalizeChatImageFile(path) {
53
53
  const sharp = await loadSharp();
54
- return sharp(path)
54
+ const { data, info } = await sharp(path)
55
55
  .rotate()
56
56
  .resize(CHAT_IMAGE_MAX_EDGE, CHAT_IMAGE_MAX_EDGE, { fit: "inside", withoutEnlargement: true })
57
57
  .webp({ quality: CHAT_IMAGE_QUALITY })
58
- .toBuffer();
58
+ .toBuffer({ resolveWithObject: true });
59
+ return { body: data, width: info.width, height: info.height };
59
60
  }
60
61
  export async function uploadChatImageAsset(input) {
61
- const body = await normalizeChatImageFile(input.path);
62
+ const { body, width, height } = await normalizeChatImageFile(input.path);
62
63
  const asset = await input.client.publicAssets.uploadChatImageAttachment({
63
64
  spaceId: input.spaceId,
64
65
  sessionId: input.sessionId,
@@ -66,5 +67,5 @@ export async function uploadChatImageAsset(input) {
66
67
  mimeType: "image/webp",
67
68
  filename: "image.webp",
68
69
  });
69
- return { ...asset, size: body.byteLength };
70
+ return { ...asset, size: body.byteLength, width, height };
70
71
  }
@@ -9,7 +9,7 @@ import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { tmpdir } from "node:os";
11
11
  const execFile = promisify(execFileCallback);
12
- import { parseBoardMediaSource } from "@neta-art/cohub/board";
12
+ import { boardMediaNaturalSize, parseBoardMediaSource } from "@neta-art/cohub/board";
13
13
  import { imageVariantUrl, MEDIA_VARIANT_MAX, mediaVariantSize, videoFrameUrl } from "@neta-art/cohub/media";
14
14
  import { hasBoardScripts, boardAssetKey, boardAssetKeySource, boardDocumentAt, boardRenderTheme, boardTextureEdge, boardTexturePixels, createBoardRenderSampler, buildBoardScene, createNodeBoardRenderer, createNodeBoardScriptHost, exportBoardImageBytes, parseBoardDocument, planBoardExport, prepareBoardExport, selectBoardExportAssets, } from "./board-kernel.js";
15
15
  import { settleBoardDocument } from "./board-media.js";
@@ -135,12 +135,10 @@ async function fetchBoardPicture(client, spaceId, source, item, edge) {
135
135
  const url = source.kind === "url"
136
136
  ? source.value
137
137
  : video || variantEdge
138
- ? await client.space(spaceId).files.resolveUrl(source.value, { purpose: "playback" }).catch(() => null)
138
+ ? (await client.space(spaceId).files.resolve(source.value, { purpose: "playback" }).catch(() => null))?.url
139
139
  : null;
140
140
  if (video) {
141
- const { naturalWidth: w, naturalHeight: h } = (item.type === "video" && item.props.snapshot) || {};
142
- const width = w && h ? Math.min(w, (edge * w) / Math.max(w, h)) : edge;
143
- const frame = url && videoFrameUrl(url, mediaVariantSize(width));
141
+ const frame = url && videoFrameUrl(url, mediaVariantSize(edge), { natural: boardMediaNaturalSize(item) });
144
142
  return frame ? downloadPublicImage(frame) : null;
145
143
  }
146
144
  const variant = url && variantEdge && imageVariantUrl(url, variantEdge);
@@ -309,13 +307,12 @@ export async function runBoardExport(options) {
309
307
  const warnings = [];
310
308
  const textures = textureCache.textures;
311
309
  let omittedKeys = new Set();
312
- const declaredBackground = document.board.background;
313
- if (options.withImages &&
314
- options.background === "paper" &&
315
- declaredBackground.kind === "image" &&
316
- declaredBackground.imageUrl) {
310
+ const backgroundSource = document.board.background.image && parseBoardMediaSource(document.board.background.image.src);
311
+ if (options.withImages && options.background === "paper" && backgroundSource) {
317
312
  try {
318
- const { bytes, mimeType } = await downloadPublicImage(declaredBackground.imageUrl);
313
+ const { bytes, mimeType } = backgroundSource.kind === "url"
314
+ ? await downloadPublicImage(backgroundSource.value)
315
+ : await readSpaceFileBytes(createClient(), options.spaceId, backgroundSource.value);
319
316
  backgroundTexture = await renderer.decodeImage(bytes, mimeType);
320
317
  }
321
318
  catch (error) {
@@ -333,9 +330,6 @@ export async function runBoardExport(options) {
333
330
  },
334
331
  });
335
332
  const host = scriptHost;
336
- const backgroundImage = backgroundTexture
337
- ? { texture: backgroundTexture, fit: declaredBackground.fit ?? "cover", position: "center", opacity: declaredBackground.opacity ?? 1 }
338
- : undefined;
339
333
  const assetKey = (item) => {
340
334
  const key = boardExportAssetKey(item);
341
335
  return key && omittedKeys.has(key) ? null : key;
@@ -389,7 +383,7 @@ export async function runBoardExport(options) {
389
383
  background: options.background,
390
384
  textures,
391
385
  scripts: scriptHost ?? undefined,
392
- backgroundImage,
386
+ backgroundTexture,
393
387
  assetKey,
394
388
  format: options.format,
395
389
  quality: options.quality,
@@ -6281,27 +6281,28 @@ const BOARD_ENTER_PRESETS$1 = [
6281
6281
  "deal"
6282
6282
  ];
6283
6283
  const BoardBackgroundSchema$1 = object({
6284
- kind: _enum([
6285
- "solid",
6286
- "dots",
6287
- "grid",
6288
- "image"
6289
- ]).default("dots"),
6290
6284
  color: BoardColorSchema$1.optional(),
6291
- imageUrl: string().url().max(4096).optional().describe("Background image for kind image."),
6292
- fit: _enum([
6293
- "cover",
6294
- "contain",
6295
- "repeat"
6296
- ]).optional(),
6297
- opacity: ratio$1.optional()
6298
- }).strict();
6285
+ pattern: _enum([
6286
+ "none",
6287
+ "dots",
6288
+ "grid"
6289
+ ]).optional().describe("Spaced by grid.size; omit to follow the theme."),
6290
+ image: object({
6291
+ src: BoardMediaSourceSchema$1,
6292
+ fit: _enum([
6293
+ "cover",
6294
+ "contain",
6295
+ "repeat"
6296
+ ]).default("cover").describe("cover and contain fill the view; repeat tiles the Board at one board unit per image pixel."),
6297
+ opacity: ratio$1.optional()
6298
+ }).strict().optional()
6299
+ }).strict().describe("Layers drawn under every item: color, then image, then pattern. Without a color or image the theme's backdrop shows.");
6299
6300
  const BoardSettingsSchema$1 = object({
6300
6301
  title: string().min(1).max(255).optional(),
6301
- background: BoardBackgroundSchema$1.default({ kind: "dots" }),
6302
+ background: BoardBackgroundSchema$1.default({}),
6302
6303
  grid: object({
6303
- visible: boolean().default(false).describe("Show the grid overlay."),
6304
- size: finite$1.min(4).max(512).default(24)
6304
+ size: finite$1.min(4).max(512).default(24).describe("Pattern spacing and snap step, in board units."),
6305
+ snap: boolean().default(false).describe("Snap moved and resized items to the grid.")
6305
6306
  }).strict().optional(),
6306
6307
  enter: object({
6307
6308
  preset: _enum(BOARD_ENTER_PRESETS$1),
@@ -7455,10 +7456,14 @@ object({
7455
7456
  items: array(string().min(1)).max(5e4).optional(),
7456
7457
  animations: array(string().min(1)).max(1e4).optional(),
7457
7458
  limit: number$1().int().min(1).max(5e4).optional(),
7458
- cursor: string().min(1).optional()
7459
+ cursor: string().min(1).optional(),
7460
+ since: number$1().int().nonnegative().optional()
7459
7461
  }).strict().refine((read) => !(read.cursor && (read.rect || read.within)), {
7460
7462
  path: ["cursor"],
7461
7463
  message: "cursor pages by id, so it cannot be combined with rect or within"
7464
+ }).refine((read) => read.since === void 0 || Object.entries(read).every(([key, value]) => key === "since" || value === void 0), {
7465
+ path: ["since"],
7466
+ message: "since syncs the whole Board, so it cannot be combined with other read options"
7462
7467
  });
7463
7468
  object({
7464
7469
  patch: BoardPatchSchema$1,
@@ -9305,57 +9310,7 @@ function createStrokeRibbonBuilder(size) {
9305
9310
  }
9306
9311
  //#endregion
9307
9312
  //#region ../protocol/src/core/media.ts
9308
- const MEDIA_HOSTS = /* @__PURE__ */ new Map([
9309
- ["router-files.neta.art", {
9310
- transform: "oss",
9311
- metaHeaders: true
9312
- }],
9313
- ["oss.talesofai.com", { transform: "oss" }],
9314
- ["public.cohub.live", { transform: "oss" }],
9315
- ["public.cohub.run", { transform: "oss" }],
9316
- ["sessions.cohub.live", { transform: "oss" }],
9317
- ["works.cohub.live", { transform: "oss" }],
9318
- ["works.cohub.run", { transform: "oss" }],
9319
- ["cca.neta.art", { transform: "cloudflare" }]
9320
- ]);
9321
- const parseHttpsUrl = (url) => {
9322
- if (!/^https:\/\//i.test(url)) return null;
9323
- try {
9324
- return new URL(url);
9325
- } catch {
9326
- return null;
9327
- }
9328
- };
9329
- function mediaHost(url) {
9330
- const parsed = parseHttpsUrl(url);
9331
- return parsed ? MEDIA_HOSTS.get(parsed.hostname) : void 0;
9332
- }
9333
- const OSS_PROCESS_PATTERN = /[?&]x-oss-process=/;
9334
- /** Append an OSS process to `url`, keeping its query and hash. */
9335
- function ossProcessUrl(url, process) {
9336
- if (/^(data|blob):/i.test(url) || OSS_PROCESS_PATTERN.test(url)) return url;
9337
- const hashIndex = url.indexOf("#");
9338
- const base = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
9339
- const hash = hashIndex >= 0 ? url.slice(hashIndex) : "";
9340
- return `${base}${base.includes("?") ? "&" : "?"}x-oss-process=${process}${hash}`;
9341
- }
9342
- const { maxEdge, quality } = {
9343
- maxEdge: 1280,
9344
- quality: 75
9345
- };
9346
- `${maxEdge}${maxEdge}${quality}`;
9347
- `${maxEdge}${maxEdge}${quality}`;
9348
- //#endregion
9349
- //#region ../sdk/src/media.ts
9350
- /**
9351
- * Remote media delivery. Previews and metadata resolve through ordered
9352
- * strategies — the cheapest trustworthy source first, each later one only
9353
- * filling what the earlier ones left — so every consumer degrades the same way.
9354
- *
9355
- * URL rewrites happen locally without a request, so they apply only to hosts
9356
- * known to serve Aliyun OSS processing; any other URL is used as is.
9357
- */
9358
- /** Fixed variant widths keep CDN cache hits high; pick with `mediaVariantSize`. */
9313
+ /** Fixed variant edges keep CDN cache hits high; pick one with `mediaVariantSize`. */
9359
9314
  const MEDIA_VARIANT_SIZES = [
9360
9315
  192,
9361
9316
  384,
@@ -9366,15 +9321,7 @@ const MEDIA_VARIANT_SIZES = [
9366
9321
  function mediaVariantSize(pixels) {
9367
9322
  return MEDIA_VARIANT_SIZES.find((size) => size >= pixels) ?? 1536;
9368
9323
  }
9369
- function ossHost(url) {
9370
- const host = mediaHost(url);
9371
- return host?.transform === "oss" ? host : void 0;
9372
- }
9373
- /** A still JPEG of a video's first frame, `width` wide. */
9374
- function videoFrameUrl(url, width) {
9375
- if (!ossHost(url)) return null;
9376
- return ossProcessUrl(url, `video/snapshot,t_0,f_jpg,w_${width},m_fast`);
9377
- }
9324
+ Number.POSITIVE_INFINITY;
9378
9325
  //#endregion
9379
9326
  //#region ../sdk/src/board/model/media.ts
9380
9327
  function getMediaResourceTitle(value) {
@@ -9430,9 +9377,10 @@ function taskPreviewKey(item) {
9430
9377
  const artifact = featuredTaskArtifact(item.props.snapshot.artifacts);
9431
9378
  const cover = taskArtifactPreviewUrl(artifact);
9432
9379
  if (cover) return `url:${cover}`;
9433
- if (artifact?.type !== "video") return null;
9434
- const frame = videoFrameUrl(artifact.url, 768);
9435
- return frame ? `url:${frame}` : `video-url:${encodeURIComponent(artifact.url)}`;
9380
+ return artifact?.type === "video" ? stillKey("video", {
9381
+ kind: "url",
9382
+ value: artifact.url
9383
+ }, void 0) : null;
9436
9384
  }
9437
9385
  function boardAssetKey(item) {
9438
9386
  if (item.type === "task") return taskPreviewKey(item);
@@ -9913,7 +9861,13 @@ const BOARD_THEME_DATA = {
9913
9861
  label: 15330286
9914
9862
  }
9915
9863
  },
9916
- style: {}
9864
+ style: {},
9865
+ backdrop: { pattern: {
9866
+ kind: "dots",
9867
+ pitch: 24,
9868
+ color: 2566444,
9869
+ size: 2
9870
+ } }
9917
9871
  },
9918
9872
  light: {
9919
9873
  colorScheme: "light",
@@ -9976,7 +9930,13 @@ const BOARD_THEME_DATA = {
9976
9930
  label: 66050
9977
9931
  }
9978
9932
  },
9979
- style: {}
9933
+ style: {},
9934
+ backdrop: { pattern: {
9935
+ kind: "dots",
9936
+ pitch: 24,
9937
+ color: 15001321,
9938
+ size: 2
9939
+ } }
9980
9940
  },
9981
9941
  "solarized-dark": {
9982
9942
  colorScheme: "dark",
@@ -10039,7 +9999,13 @@ const BOARD_THEME_DATA = {
10039
9999
  label: 12572374
10040
10000
  }
10041
10001
  },
10042
- style: {}
10002
+ style: {},
10003
+ backdrop: { pattern: {
10004
+ kind: "dots",
10005
+ pitch: 24,
10006
+ color: 1122594,
10007
+ size: 2
10008
+ } }
10043
10009
  },
10044
10010
  "solarized-light": {
10045
10011
  colorScheme: "light",
@@ -10102,7 +10068,13 @@ const BOARD_THEME_DATA = {
10102
10068
  label: 2110772
10103
10069
  }
10104
10070
  },
10105
- style: {}
10071
+ style: {},
10072
+ backdrop: { pattern: {
10073
+ kind: "dots",
10074
+ pitch: 24,
10075
+ color: 15130570,
10076
+ size: 2
10077
+ } }
10106
10078
  },
10107
10079
  "neta-studio": {
10108
10080
  colorScheme: "dark",
@@ -10252,7 +10224,14 @@ const BOARD_THEME_DATA = {
10252
10224
  motion: "stepped",
10253
10225
  font: "\"Figtree Variable\", \"PingFang SC\", \"Noto Sans CJK SC\", \"Microsoft YaHei\", system-ui, sans-serif",
10254
10226
  frameLabelSize: 16
10255
- }
10227
+ },
10228
+ backdrop: { pattern: {
10229
+ kind: "dots",
10230
+ pitch: 24,
10231
+ color: 14935011,
10232
+ size: 2,
10233
+ shape: "square"
10234
+ } }
10256
10235
  },
10257
10236
  "neta-pixel-dark": {
10258
10237
  colorScheme: "dark",
@@ -10336,7 +10315,14 @@ const BOARD_THEME_DATA = {
10336
10315
  motion: "stepped",
10337
10316
  font: "\"Figtree Variable\", \"PingFang SC\", \"Noto Sans CJK SC\", \"Microsoft YaHei\", system-ui, sans-serif",
10338
10317
  frameLabelSize: 16
10339
- }
10318
+ },
10319
+ backdrop: { pattern: {
10320
+ kind: "dots",
10321
+ pitch: 24,
10322
+ color: 3816512,
10323
+ size: 2,
10324
+ shape: "square"
10325
+ } }
10340
10326
  }
10341
10327
  };
10342
10328
  //#endregion
@@ -33089,6 +33075,288 @@ function boardTextSize(item) {
33089
33075
  return clampBoardFontSize(item.props.fontSize ?? 24) * scale;
33090
33076
  }
33091
33077
  //#endregion
33078
+ //#region ../sdk/src/board/render/backdrop.ts
33079
+ const PATTERN_DEFAULTS = {
33080
+ dots: {
33081
+ alpha: .5,
33082
+ size: 2
33083
+ },
33084
+ grid: {
33085
+ alpha: 1,
33086
+ size: 1
33087
+ }
33088
+ };
33089
+ function resolveBoardBackdrop(settings, theme, purpose = "stage") {
33090
+ const { color, image, pattern } = settings.background;
33091
+ const themed = !(color !== void 0 || image !== void 0) && purpose === "stage" ? theme.backdrop : void 0;
33092
+ return {
33093
+ color: backgroundColor(settings.background.color, theme),
33094
+ image: image ? {
33095
+ src: image.src,
33096
+ fit: image.fit,
33097
+ opacity: image.opacity ?? 1
33098
+ } : themed?.image ?? null,
33099
+ pattern: purpose === "export" || pattern === "none" ? null : pattern ? patternStyle(theme, pattern, settings.grid?.size ?? 24, false) : themed?.pattern ? patternStyle(theme, themed.pattern.kind, themed.pattern.pitch, true) : null
33100
+ };
33101
+ }
33102
+ function backgroundColor(color, theme) {
33103
+ const value = typeof color === "object" ? color[theme.colorScheme] : color;
33104
+ if (!value) return theme.palette.bg;
33105
+ const token = value.trim().toLowerCase();
33106
+ if (isBoardColorId(token)) return pickBoardColor(theme.colors, token, theme.colorScheme).fill;
33107
+ return parseBoardCssColor(value) ?? theme.palette.bg;
33108
+ }
33109
+ function patternStyle(theme, kind, pitch, subdivide) {
33110
+ const own = theme.backdrop?.pattern?.kind === kind ? theme.backdrop.pattern : void 0;
33111
+ const defaults = PATTERN_DEFAULTS[kind];
33112
+ return {
33113
+ kind,
33114
+ pitch,
33115
+ color: own?.color ?? (kind === "grid" ? theme.palette.border : theme.palette.muted),
33116
+ alpha: own ? own.alpha ?? 1 : defaults.alpha,
33117
+ size: own?.size ?? defaults.size,
33118
+ shape: own?.shape ?? "round",
33119
+ subdivide
33120
+ };
33121
+ }
33122
+ const MIN_GAP = 8;
33123
+ const FADE = .25;
33124
+ function boardPatternLevel(pattern, zoom) {
33125
+ const gap = Math.max(pattern.pitch / 1.25, MIN_GAP);
33126
+ const unit = pattern.pitch * Math.max(zoom, 1e-6);
33127
+ const level = Math.ceil(Math.log2(gap / unit));
33128
+ const pitch = unit * 2 ** (pattern.subdivide ? level : Math.max(0, level));
33129
+ const t = Math.min(1, Math.max(0, (pitch / gap - 1) / FADE));
33130
+ return {
33131
+ pitch,
33132
+ fade: t * t * (3 - 2 * t)
33133
+ };
33134
+ }
33135
+ //#endregion
33136
+ //#region ../sdk/src/board/render/backdrop-layer.ts
33137
+ const VERTEX = `
33138
+ in vec2 aPosition;
33139
+ out vec2 vPosition;
33140
+
33141
+ uniform mat3 uProjectionMatrix;
33142
+ uniform mat3 uWorldTransformMatrix;
33143
+ uniform mat3 uTransformMatrix;
33144
+
33145
+ void main() {
33146
+ mat3 mvp = uProjectionMatrix * uWorldTransformMatrix * uTransformMatrix;
33147
+ gl_Position = vec4((mvp * vec3(aPosition, 1.0)).xy, 0.0, 1.0);
33148
+ vPosition = aPosition;
33149
+ }
33150
+ `;
33151
+ const FRAGMENT = `
33152
+ in vec2 vPosition;
33153
+ out vec4 finalColor;
33154
+
33155
+ uniform float uDensity;
33156
+ uniform vec2 uOrigin;
33157
+ uniform float uPitch;
33158
+ uniform float uFade;
33159
+ uniform float uSize;
33160
+ uniform float uSquare;
33161
+ uniform float uLines;
33162
+ uniform vec4 uInk;
33163
+
33164
+ vec2 snap(vec2 center) {
33165
+ return mod(uSize, 2.0) == 1.0 ? floor(center) + 0.5 : floor(center + 0.5);
33166
+ }
33167
+
33168
+ float coverage(vec2 p, float pitch) {
33169
+ vec2 d = abs(p - snap(uOrigin + floor((p - uOrigin) / pitch + 0.5) * pitch));
33170
+ float radius = uSize * 0.5;
33171
+ if (uLines > 0.5) return min(d.x, d.y) < radius ? 1.0 : 0.0;
33172
+ if (uSquare > 0.5) return max(d.x, d.y) < radius ? 1.0 : 0.0;
33173
+ float edge = 0.5 * uDensity;
33174
+ return clamp((radius + edge - length(d)) / edge, 0.0, 1.0);
33175
+ }
33176
+
33177
+ void main() {
33178
+ vec2 p = vPosition * uDensity;
33179
+ float alpha = uInk.a * max(coverage(p, uPitch * 2.0), coverage(p, uPitch) * uFade);
33180
+ finalColor = vec4(uInk.rgb * alpha, alpha);
33181
+ }
33182
+ `;
33183
+ function wrap(value, period) {
33184
+ return (value % period + period) % period;
33185
+ }
33186
+ function createPatternMesh() {
33187
+ const positions = /* @__PURE__ */ new Float32Array(8);
33188
+ const geometry = new Geometry({
33189
+ attributes: { aPosition: {
33190
+ buffer: positions,
33191
+ format: "float32x2"
33192
+ } },
33193
+ indexBuffer: new Uint32Array([
33194
+ 0,
33195
+ 1,
33196
+ 2,
33197
+ 0,
33198
+ 2,
33199
+ 3
33200
+ ])
33201
+ });
33202
+ const uniforms = new UniformGroup({
33203
+ uDensity: {
33204
+ value: 1,
33205
+ type: "f32"
33206
+ },
33207
+ uOrigin: {
33208
+ value: /* @__PURE__ */ new Float32Array(2),
33209
+ type: "vec2<f32>"
33210
+ },
33211
+ uPitch: {
33212
+ value: 1,
33213
+ type: "f32"
33214
+ },
33215
+ uFade: {
33216
+ value: 0,
33217
+ type: "f32"
33218
+ },
33219
+ uSize: {
33220
+ value: 1,
33221
+ type: "f32"
33222
+ },
33223
+ uSquare: {
33224
+ value: 0,
33225
+ type: "f32"
33226
+ },
33227
+ uLines: {
33228
+ value: 0,
33229
+ type: "f32"
33230
+ },
33231
+ uInk: {
33232
+ value: /* @__PURE__ */ new Float32Array(4),
33233
+ type: "vec4<f32>"
33234
+ }
33235
+ });
33236
+ const shader = Shader.from({
33237
+ gl: {
33238
+ name: "board-backdrop-pattern",
33239
+ vertex: VERTEX,
33240
+ fragment: FRAGMENT,
33241
+ preferredFragmentPrecision: "highp"
33242
+ },
33243
+ resources: { patternUniforms: uniforms }
33244
+ });
33245
+ const mesh = new Mesh({
33246
+ geometry,
33247
+ shader,
33248
+ label: "board-backdrop-pattern"
33249
+ });
33250
+ let width = 0;
33251
+ let height = 0;
33252
+ return {
33253
+ mesh,
33254
+ sync(pattern, view) {
33255
+ if (view.width !== width || view.height !== height) {
33256
+ width = view.width;
33257
+ height = view.height;
33258
+ positions.set([
33259
+ 0,
33260
+ 0,
33261
+ width,
33262
+ 0,
33263
+ width,
33264
+ height,
33265
+ 0,
33266
+ height
33267
+ ]);
33268
+ geometry.getBuffer("aPosition").update();
33269
+ }
33270
+ const level = boardPatternLevel(pattern, view.zoom);
33271
+ const pitch = level.pitch * view.density;
33272
+ const values = uniforms.uniforms;
33273
+ values.uDensity = view.density;
33274
+ values.uOrigin[0] = wrap(view.x * view.density, pitch * 2);
33275
+ values.uOrigin[1] = wrap(view.y * view.density, pitch * 2);
33276
+ values.uPitch = pitch;
33277
+ values.uFade = level.fade;
33278
+ values.uSize = Math.max(1, Math.round(pattern.size * view.density));
33279
+ values.uSquare = pattern.shape === "square" ? 1 : 0;
33280
+ values.uLines = pattern.kind === "grid" ? 1 : 0;
33281
+ values.uInk[0] = (pattern.color >> 16 & 255) / 255;
33282
+ values.uInk[1] = (pattern.color >> 8 & 255) / 255;
33283
+ values.uInk[2] = (pattern.color & 255) / 255;
33284
+ values.uInk[3] = pattern.alpha;
33285
+ },
33286
+ destroy() {
33287
+ mesh.destroy();
33288
+ geometry.destroy();
33289
+ shader.destroy();
33290
+ }
33291
+ };
33292
+ }
33293
+ function placeImage(parts, image, texture, view, alpha) {
33294
+ if (image.fit === "repeat") {
33295
+ if (parts.picture) parts.picture.visible = false;
33296
+ parts.tiles ??= parts.container.addChildAt(new TilingSprite({
33297
+ texture,
33298
+ label: "board-backdrop-tiles"
33299
+ }), 0);
33300
+ const tiles = parts.tiles;
33301
+ const tileWidth = (image.tile?.width ?? texture.width) * view.zoom;
33302
+ const tileHeight = (image.tile?.height ?? texture.height) * view.zoom;
33303
+ tiles.texture = texture;
33304
+ tiles.visible = true;
33305
+ tiles.alpha = alpha;
33306
+ tiles.width = view.width;
33307
+ tiles.height = view.height;
33308
+ tiles.tileScale.set(tileWidth / texture.width, tileHeight / texture.height);
33309
+ tiles.tilePosition.set(wrap(view.x, tileWidth), wrap(view.y, tileHeight));
33310
+ return;
33311
+ }
33312
+ if (parts.tiles) parts.tiles.visible = false;
33313
+ parts.picture ??= parts.container.addChildAt(new Sprite({
33314
+ texture,
33315
+ label: "board-backdrop-picture"
33316
+ }), 0);
33317
+ const picture = parts.picture;
33318
+ const scale = (image.fit === "cover" ? Math.max : Math.min)(view.width / texture.width, view.height / texture.height);
33319
+ picture.texture = texture;
33320
+ picture.visible = true;
33321
+ picture.alpha = alpha;
33322
+ picture.width = texture.width * scale;
33323
+ picture.height = texture.height * scale;
33324
+ picture.position.set((view.width - picture.width) / 2, (view.height - picture.height) / 2);
33325
+ }
33326
+ function createBoardBackdropLayer(options) {
33327
+ const container = new Container({ label: "board-backdrop" });
33328
+ const parts = {
33329
+ tiles: null,
33330
+ picture: null,
33331
+ container
33332
+ };
33333
+ let pattern = null;
33334
+ return {
33335
+ container,
33336
+ sync(backdrop, view, texture, imageAlpha = 1) {
33337
+ if (backdrop.image && texture) placeImage(parts, backdrop.image, texture, view, backdrop.image.opacity * imageAlpha);
33338
+ else {
33339
+ if (parts.tiles) parts.tiles.visible = false;
33340
+ if (parts.picture) parts.picture.visible = false;
33341
+ }
33342
+ const shown = options.patterns && backdrop.pattern !== null;
33343
+ if (shown && backdrop.pattern) {
33344
+ if (!pattern) {
33345
+ pattern = createPatternMesh();
33346
+ container.addChild(pattern.mesh);
33347
+ }
33348
+ pattern.sync(backdrop.pattern, view);
33349
+ }
33350
+ if (pattern) pattern.mesh.visible = shown;
33351
+ },
33352
+ destroy() {
33353
+ pattern?.destroy();
33354
+ pattern = null;
33355
+ container.destroy({ children: true });
33356
+ }
33357
+ };
33358
+ }
33359
+ //#endregion
33092
33360
  //#region ../sdk/src/board/render/text-fit.ts
33093
33361
  function fitTextToLines(text, value, maxLines, wrapWidth) {
33094
33362
  text.style.wordWrap = false;
@@ -36929,27 +37197,28 @@ const BOARD_ENTER_PRESETS = [
36929
37197
  "deal"
36930
37198
  ];
36931
37199
  const BoardBackgroundSchema = object({
36932
- kind: _enum([
36933
- "solid",
36934
- "dots",
36935
- "grid",
36936
- "image"
36937
- ]).default("dots"),
36938
37200
  color: BoardColorSchema.optional(),
36939
- imageUrl: string().url().max(4096).optional().describe("Background image for kind image."),
36940
- fit: _enum([
36941
- "cover",
36942
- "contain",
36943
- "repeat"
36944
- ]).optional(),
36945
- opacity: ratio.optional()
36946
- }).strict();
37201
+ pattern: _enum([
37202
+ "none",
37203
+ "dots",
37204
+ "grid"
37205
+ ]).optional().describe("Spaced by grid.size; omit to follow the theme."),
37206
+ image: object({
37207
+ src: BoardMediaSourceSchema,
37208
+ fit: _enum([
37209
+ "cover",
37210
+ "contain",
37211
+ "repeat"
37212
+ ]).default("cover").describe("cover and contain fill the view; repeat tiles the Board at one board unit per image pixel."),
37213
+ opacity: ratio.optional()
37214
+ }).strict().optional()
37215
+ }).strict().describe("Layers drawn under every item: color, then image, then pattern. Without a color or image the theme's backdrop shows.");
36947
37216
  object({
36948
37217
  title: string().min(1).max(255).optional(),
36949
- background: BoardBackgroundSchema.default({ kind: "dots" }),
37218
+ background: BoardBackgroundSchema.default({}),
36950
37219
  grid: object({
36951
- visible: boolean().default(false).describe("Show the grid overlay."),
36952
- size: finite.min(4).max(512).default(24)
37220
+ size: finite.min(4).max(512).default(24).describe("Pattern spacing and snap step, in board units."),
37221
+ snap: boolean().default(false).describe("Snap moved and resized items to the grid.")
36953
37222
  }).strict().optional(),
36954
37223
  enter: object({
36955
37224
  preset: _enum(BOARD_ENTER_PRESETS),
@@ -37349,12 +37618,14 @@ function resolveBoardRenderStyle(mode, style) {
37349
37618
  //#region ../sdk/src/board/render/theme.ts
37350
37619
  /** A built-in Cohub theme as the app draws it; spread it into export or sampler options. */
37351
37620
  function boardRenderTheme(id) {
37352
- const { colorScheme, palette, colors, style } = BOARD_THEME_DATA[id];
37621
+ const data = BOARD_THEME_DATA[id];
37622
+ const { colorScheme, palette, colors, style } = data;
37353
37623
  return {
37354
37624
  colorScheme,
37355
37625
  palette: { ...palette },
37356
37626
  colors: Object.fromEntries(Object.entries(colors).map(([key, value]) => [key, { ...value }])),
37357
- style: resolveBoardRenderStyle(colorScheme, style)
37627
+ style: resolveBoardRenderStyle(colorScheme, style),
37628
+ ..."backdrop" in data ? { backdrop: structuredClone(data.backdrop) } : {}
37358
37629
  };
37359
37630
  }
37360
37631
  //#endregion
@@ -37405,31 +37676,23 @@ function createBoardExportScene(input) {
37405
37676
  const root = new Container({ label: "board-export-root" });
37406
37677
  const outputWidth = input.world.width * input.scale;
37407
37678
  const outputHeight = input.world.height * input.scale;
37408
- if (input.background != null) root.addChild(new Graphics().rect(0, 0, outputWidth, outputHeight).fill({
37409
- color: input.background,
37410
- alpha: 1
37411
- }));
37412
- if (input.backgroundImage) {
37413
- const { texture, fit, position, opacity } = input.backgroundImage;
37414
- if (fit === "repeat") root.addChild(new TilingSprite({
37415
- texture,
37679
+ let backdrop = null;
37680
+ if (input.backdrop) {
37681
+ root.addChild(new Graphics().rect(0, 0, outputWidth, outputHeight).fill({
37682
+ color: input.backdrop.color,
37683
+ alpha: 1
37684
+ }));
37685
+ backdrop = createBoardBackdropLayer({ patterns: false });
37686
+ const view = {
37687
+ x: -input.world.x * input.scale,
37688
+ y: -input.world.y * input.scale,
37689
+ zoom: input.scale,
37416
37690
  width: outputWidth,
37417
37691
  height: outputHeight,
37418
- alpha: opacity
37419
- }));
37420
- else {
37421
- const sprite = new Sprite({
37422
- texture,
37423
- alpha: opacity
37424
- });
37425
- const scale = fit === "cover" ? Math.max(outputWidth / texture.width, outputHeight / texture.height) : Math.min(outputWidth / texture.width, outputHeight / texture.height);
37426
- sprite.width = texture.width * scale;
37427
- sprite.height = texture.height * scale;
37428
- const x = position === "left" ? 0 : position === "right" ? outputWidth - sprite.width : (outputWidth - sprite.width) / 2;
37429
- const y = position === "top" ? 0 : position === "bottom" ? outputHeight - sprite.height : (outputHeight - sprite.height) / 2;
37430
- sprite.position.set(x, y);
37431
- root.addChild(sprite);
37432
- }
37692
+ density: 1
37693
+ };
37694
+ backdrop.sync(input.backdrop, view, input.backdropTexture ?? null);
37695
+ root.addChild(backdrop.container);
37433
37696
  }
37434
37697
  const world = new Container({
37435
37698
  isRenderGroup: true,
@@ -37468,17 +37731,21 @@ function createBoardExportScene(input) {
37468
37731
  return {
37469
37732
  root,
37470
37733
  missingImageKeys: [...missing],
37471
- destroy: () => root.destroy({ children: true })
37734
+ destroy: () => {
37735
+ backdrop?.destroy();
37736
+ root.destroy({ children: true });
37737
+ }
37472
37738
  };
37473
37739
  }
37474
37740
  //#endregion
37475
37741
  //#region ../sdk/src/board/export/index.ts
37476
- function resolveBackground(background, palette, document, colorScheme) {
37477
- if (background === "transparent") return null;
37478
- if (typeof background === "number") return background;
37479
- const declared = document.board.background.color;
37480
- const css = typeof declared === "object" ? declared[colorScheme] : declared;
37481
- return css ? parseBoardCssColor(css) ?? palette.bg : palette.bg;
37742
+ function resolveBackdrop(options, document, theme) {
37743
+ if (options.background === "transparent") return null;
37744
+ const backdrop = resolveBoardBackdrop(document.board, theme, "export");
37745
+ return typeof options.background === "number" ? {
37746
+ ...backdrop,
37747
+ color: options.background
37748
+ } : backdrop;
37482
37749
  }
37483
37750
  function prepareBoardExport(input, options = {}) {
37484
37751
  ensureBoardTextMeasurement();
@@ -37545,8 +37812,12 @@ function renderBoardExport(renderer, input, options = {}) {
37545
37812
  scripts: options.scripts,
37546
37813
  assetKey: options.assetKey,
37547
37814
  renderers: options.renderers,
37548
- background: resolveBackground(options.background, palette, document, colorScheme),
37549
- backgroundImage: options.backgroundImage
37815
+ backdrop: resolveBackdrop(options, document, {
37816
+ palette,
37817
+ colors: options.colors ?? buildFallbackShapeColors(colorScheme),
37818
+ colorScheme
37819
+ }),
37820
+ backdropTexture: options.backgroundTexture
37550
37821
  });
37551
37822
  try {
37552
37823
  const texture = renderer.textureGenerator.generateTexture({
@@ -38029,15 +38300,12 @@ const MIME = {
38029
38300
  webp: "image/webp"
38030
38301
  };
38031
38302
  function exportBoardImageBytes(renderer, document, options = {}) {
38032
- const { format = "png", quality = .92, textures, scripts, backgroundImage, ...rest } = options;
38303
+ const { format = "png", quality = .92, textures, scripts, backgroundTexture, ...rest } = options;
38033
38304
  const result = renderBoardExport(renderer.renderer, document, {
38034
38305
  ...rest,
38035
38306
  ...scripts ? { scripts } : {},
38036
38307
  ...textures ? { textures } : {},
38037
- ...backgroundImage ? { backgroundImage: {
38038
- ...backgroundImage,
38039
- texture: backgroundImage.texture
38040
- } } : {}
38308
+ ...backgroundTexture ? { backgroundTexture } : {}
38041
38309
  });
38042
38310
  if (!result) return null;
38043
38311
  const canvas = result.canvas;
@@ -1,37 +1,17 @@
1
- import { execFile } from "node:child_process";
2
1
  import { resolve } from "node:path";
3
- import { promisify } from "node:util";
4
2
  import { diffMergePatch, mergePatch, parseBoardItem, settleBoardMediaSizes, } from "@neta-art/cohub/board";
5
3
  import { createClient } from "./client.js";
4
+ import { localMediaFacts } from "./local-media.js";
6
5
  import { WORKSPACE_ROOT } from "./space-files/operands.js";
7
- const run = promisify(execFile);
8
6
  const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
9
- const oriented = (width, height, turned) => width > 0 && height > 0 ? (turned ? { width: height, height: width } : { width, height }) : null;
10
- async function localMediaSize(file, type, signal) {
11
- try {
12
- if (type === "image") {
13
- const { default: sharp } = await import("sharp");
14
- const { width = 0, height = 0, orientation = 1 } = await sharp(file).metadata();
15
- return oriented(width, height, orientation >= 5);
16
- }
17
- const { stdout } = await run("ffprobe", [
18
- "-v", "error", "-select_streams", "v:0",
19
- "-show_entries", "stream=width,height:stream_side_data=rotation", "-of", "json", file,
20
- ], { signal, timeout: 5_000 });
21
- const stream = JSON.parse(stdout).streams?.[0];
22
- const rotation = stream?.side_data_list?.find((data) => data.rotation !== undefined)?.rotation ?? 0;
23
- return oriented(stream?.width ?? 0, stream?.height ?? 0, Math.abs(rotation) % 180 === 90);
24
- }
25
- catch {
26
- return null;
27
- }
28
- }
29
7
  export function boardMediaFileResolver(spaceId) {
30
8
  const local = process.env.COHUB_SPACE_ID === spaceId;
31
9
  return async (path, type, signal) => {
32
10
  const file = resolve(WORKSPACE_ROOT, path);
33
- const size = local && file.startsWith(`${WORKSPACE_ROOT}/`) ? await localMediaSize(file, type, signal) : null;
34
- return size ?? createClient().space(spaceId).files.resolveUrl(path, { purpose: "playback", signal }).catch(() => null);
11
+ const facts = local && file.startsWith(`${WORKSPACE_ROOT}/`) ? await localMediaFacts(file, type, signal) : null;
12
+ const resolved = facts ? null : await createClient().space(spaceId).files.resolve(path, { purpose: "playback", signal }).catch(() => null);
13
+ const size = facts ?? resolved?.media;
14
+ return size ? { width: size.width, height: size.height } : (resolved?.url ?? null);
35
15
  };
36
16
  }
37
17
  export async function settleBoardDocument(spaceId, document) {
@@ -361,10 +361,12 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
361
361
  .option("--animations <ids>", "Only these animations")
362
362
  .option("--limit <n>", "Page size for items")
363
363
  .option("--cursor <cursor>", "Continue from a previous page's next")
364
+ .option("--since <version>", "Only the changes after this version; the whole Board when they are unavailable")
364
365
  .addHelpText("after", `
365
366
  cohub boards get plan.board
366
367
  cohub boards get plan.board --within s2
367
- cohub boards get plan.board --only board`)
368
+ cohub boards get plan.board --only board
369
+ cohub boards get plan.board --since 12`)
368
370
  .action(async (target, options) => {
369
371
  try {
370
372
  const only = list(options.only);
@@ -379,6 +381,7 @@ Times are milliseconds; 500ms, 12.5s and 2m also work on the command line.`);
379
381
  ...(list(options.animations) ? { animations: list(options.animations) } : {}),
380
382
  ...(options.limit ? { limit: parseNumber(options.limit, "--limit", { min: 1, integer: true }) } : {}),
381
383
  ...(options.cursor ? { cursor: options.cursor } : {}),
384
+ ...(options.since ? { since: parseNumber(options.since, "--since", { min: 0, integer: true }) } : {}),
382
385
  };
383
386
  const result = await (await boardClient(boards, target)).get(input);
384
387
  if (!sections)
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import { lstat, readdir } from "node:fs/promises";
3
3
  import { basename, relative, resolve } from "node:path";
4
4
  import { isTextMime, mimeTypeForPath, } from "@neta-art/cohub";
5
+ import { mediaFacts } from "@neta-art/cohub/media";
5
6
  import { createClient } from "../client.js";
6
7
  import { HttpPutError, putLocalFile } from "../http-put.js";
8
+ import { localMediaFacts } from "../local-media.js";
7
9
  import { error, handleHttp, json as outJson, jsonRequested } from "../output.js";
8
10
  import { resolveSpace } from "../space.js";
9
11
  const UPLOAD_CONCURRENCY = 4;
@@ -136,6 +138,20 @@ async function putPublicFile(file, plan, fetchImpl) {
136
138
  throw error;
137
139
  }
138
140
  }
141
+ async function publicMediaFacts(files) {
142
+ const facts = new Map();
143
+ const media = files.flatMap((file) => {
144
+ const type = file.mimeType.split("/")[0];
145
+ return type === "image" || type === "video" ? [{ file, type: type }] : [];
146
+ });
147
+ await mapSettledWithConcurrency(media, UPLOAD_CONCURRENCY, async ({ file, type }) => {
148
+ // Facts only help previews; out-of-range ones are dropped rather than failing the upload.
149
+ const found = mediaFacts(await localMediaFacts(file.localPath, type));
150
+ if (found)
151
+ facts.set(file.id, found);
152
+ });
153
+ return facts;
154
+ }
139
155
  function uploadFailure(errors) {
140
156
  if (errors.length === 1)
141
157
  return errors[0];
@@ -155,6 +171,7 @@ async function uploadPublic(command, source, destination, opts, deps) {
155
171
  const spaceId = await resolveSpace(command, { home: true });
156
172
  try {
157
173
  const upload = await collectPublicUpload(source, destination);
174
+ const media = await publicMediaFacts(upload.files);
158
175
  const plan = await client.space(spaceId).publicFiles.createUpload({
159
176
  overwrite: Boolean(opts.overwrite),
160
177
  entries: upload.files.map((file) => ({
@@ -162,6 +179,7 @@ async function uploadPublic(command, source, destination, opts, deps) {
162
179
  relativePath: file.publicPath,
163
180
  size: file.size,
164
181
  mimeType: file.mimeType,
182
+ ...(media.has(file.id) ? { media: media.get(file.id) } : {}),
165
183
  })),
166
184
  });
167
185
  if (opts.overwrite && !jsonRequested(opts)) {
@@ -222,6 +222,8 @@ async function sendPrompt(command, words, opts) {
222
222
  filename: basename(path),
223
223
  mediaType: "image/webp",
224
224
  size: asset.size,
225
+ width: asset.width,
226
+ height: asset.height,
225
227
  objectKey: asset.objectKey,
226
228
  },
227
229
  };
@@ -0,0 +1,2 @@
1
+ import type { MediaFacts } from "@neta-art/cohub/media";
2
+ export declare function localMediaFacts(file: string, type: "image" | "video", signal?: AbortSignal): Promise<MediaFacts | null>;
@@ -0,0 +1,30 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const run = promisify(execFile);
4
+ const oriented = (width, height, turned) => (turned ? { width: height, height: width } : { width, height });
5
+ export async function localMediaFacts(file, type, signal) {
6
+ try {
7
+ if (type === "image") {
8
+ const { default: sharp } = await import("sharp");
9
+ const { width = 0, height = 0, orientation = 1 } = await sharp(file).metadata();
10
+ return width > 0 && height > 0 ? oriented(width, height, orientation >= 5) : null;
11
+ }
12
+ const { stdout } = await run("ffprobe", [
13
+ "-v", "error", "-select_streams", "v:0",
14
+ "-show_entries", "stream=width,height:stream_side_data=rotation:format=duration", "-of", "json", file,
15
+ ], { signal, timeout: 5_000 });
16
+ const { streams, format } = JSON.parse(stdout);
17
+ const stream = streams?.[0];
18
+ if (!stream?.width || !stream.height)
19
+ return null;
20
+ const rotation = stream.side_data_list?.find((data) => data.rotation !== undefined)?.rotation ?? 0;
21
+ const durationMs = Math.round(Number(format?.duration) * 1000);
22
+ return {
23
+ ...oriented(stream.width, stream.height, Math.abs(rotation) % 180 === 90),
24
+ ...(durationMs > 0 ? { durationMs } : {}),
25
+ };
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neta-art/cohub-cli",
3
- "version": "10.12.1",
3
+ "version": "10.12.2",
4
4
  "description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -18,7 +18,7 @@
18
18
  "NOTICE"
19
19
  ],
20
20
  "dependencies": {
21
- "@neta-art/cohub": "12.6.0",
21
+ "@neta-art/cohub": "13.0.0",
22
22
  "@neta-art/generation": "^0.1.40",
23
23
  "commander": "^15.0.0",
24
24
  "sharp": "^0.35.4",