@neta-art/cohub 5.7.0 → 5.8.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 (54) hide show
  1. package/README.md +18 -5
  2. package/dist/board/core/palette.d.ts +3 -2
  3. package/dist/board/core/shape-types.d.ts +3 -1
  4. package/dist/board/core/shape-types.js +3 -7
  5. package/dist/board/core/tool-styles.d.ts +2 -1
  6. package/dist/board/image-key.js +3 -5
  7. package/dist/board/index.d.ts +9 -4
  8. package/dist/board/index.js +7 -3
  9. package/dist/board/media-playback.d.ts +22 -0
  10. package/dist/board/media-playback.js +67 -0
  11. package/dist/board/media.d.ts +7 -0
  12. package/dist/board/media.js +70 -0
  13. package/dist/board/nodes.d.ts +113 -0
  14. package/dist/board/nodes.js +154 -0
  15. package/dist/board/render/index.d.ts +3 -1
  16. package/dist/board/render/index.js +4 -2
  17. package/dist/board/render/media-interaction.d.ts +19 -0
  18. package/dist/board/render/media-interaction.js +26 -0
  19. package/dist/board/render/renderers/audio-card-renderer.js +1 -1
  20. package/dist/board/render/renderers/board-renderer-registry.js +2 -2
  21. package/dist/board/render/renderers/draw-card-renderer.js +1 -1
  22. package/dist/board/render/renderers/file-card-renderer.js +1 -1
  23. package/dist/board/render/renderers/frame-card-renderer.js +1 -1
  24. package/dist/board/render/renderers/geo-card-renderer.js +1 -1
  25. package/dist/board/render/renderers/image-card-renderer.js +1 -1
  26. package/dist/board/render/renderers/task-card-renderer.js +16 -13
  27. package/dist/board/render/renderers/text-card-renderer.js +1 -1
  28. package/dist/board/render/renderers/unknown-card-renderer.js +1 -1
  29. package/dist/board/render/renderers/video-card-renderer.js +1 -1
  30. package/dist/board/render/video-thumbnail.d.ts +16 -0
  31. package/dist/board/render/video-thumbnail.js +86 -0
  32. package/dist/board/task.d.ts +10 -5
  33. package/dist/board/task.js +159 -103
  34. package/dist/chunks/environment.d.ts +6 -6
  35. package/dist/chunks/environment.js +6 -6
  36. package/dist/chunks/http.d.ts +71 -5
  37. package/dist/chunks/http.js +1535 -167
  38. package/dist/chunks/transport.js +8 -1
  39. package/dist/chunks/websocket.d.ts +138 -2
  40. package/dist/http.d.ts +3 -3
  41. package/dist/index.d.ts +245 -4
  42. package/dist/index.js +438 -788
  43. package/dist/protocol/dist/board-document.d.ts +155 -48
  44. package/dist/protocol/dist/board-document.js +40 -19
  45. package/dist/protocol/dist/board-node.d.ts +18 -0
  46. package/dist/protocol/dist/board-node.js +239 -0
  47. package/dist/protocol/dist/board-url.d.ts +12 -0
  48. package/dist/protocol/dist/board-url.js +83 -0
  49. package/dist/protocol/dist/board.d.ts +5 -0
  50. package/dist/protocol/dist/index.d.ts +2 -1
  51. package/dist/protocol/dist/index.js +2 -1
  52. package/dist/protocol/dist/provenance.js +1 -0
  53. package/docs/work-runtime-guide.md +7 -7
  54. package/package.json +1 -1
@@ -0,0 +1,154 @@
1
+ import "../protocol/dist/board-constants.js";
2
+ import { validateBoardNodeInput } from "../protocol/dist/board-node.js";
3
+ import "../protocol/dist/index.js";
4
+ import { computeDrawBounds } from "./core/draw-geometry.js";
5
+ //#region src/board/nodes.ts
6
+ var BoardInputError = class extends Error {
7
+ code = "INVALID_BOARD_NODE";
8
+ diagnostics;
9
+ body;
10
+ constructor(diagnostics) {
11
+ const message = diagnostics[0]?.message ?? "Invalid Board node";
12
+ super(message);
13
+ this.name = "BoardInputError";
14
+ this.diagnostics = diagnostics;
15
+ this.body = {
16
+ code: this.code,
17
+ message,
18
+ diagnostics
19
+ };
20
+ }
21
+ };
22
+ function baseNode(spec, frame) {
23
+ return {
24
+ nodeId: spec.id,
25
+ type: spec.type,
26
+ parentId: spec.parentId ?? null,
27
+ orderKey: spec.orderKey ?? null,
28
+ x: frame.x,
29
+ y: frame.y,
30
+ width: frame.width,
31
+ height: frame.height,
32
+ rotation: frame.rotation ?? 0,
33
+ refKind: null,
34
+ refPath: null,
35
+ refUrl: null,
36
+ view: {},
37
+ style: spec.style ?? {},
38
+ data: {}
39
+ };
40
+ }
41
+ function arrowFrame(start, end) {
42
+ const padding = 16;
43
+ return {
44
+ x: Math.min(start.x, end.x) - padding,
45
+ y: Math.min(start.y, end.y) - padding,
46
+ width: Math.max(1, Math.abs(end.x - start.x) + padding * 2),
47
+ height: Math.max(1, Math.abs(end.y - start.y) + padding * 2)
48
+ };
49
+ }
50
+ /**
51
+ * Create one validated wire node from semantic Board input.
52
+ *
53
+ * Box nodes take an explicit world-space frame. Draw samples and arrow endpoints
54
+ * take world coordinates; the builder derives their frame and local storage form.
55
+ */
56
+ function createBoardNode(spec) {
57
+ let node;
58
+ if (spec.type === "draw") {
59
+ const size = spec.size ?? 4;
60
+ const worldPoints = spec.points.map((point) => ({
61
+ x: point.x,
62
+ y: point.y,
63
+ p: point.p ?? .5
64
+ }));
65
+ const bounds = computeDrawBounds(worldPoints, size);
66
+ node = baseNode(spec, bounds);
67
+ node.data = {
68
+ points: worldPoints.map((point) => ({
69
+ x: point.x - bounds.x,
70
+ y: point.y - bounds.y,
71
+ p: point.p
72
+ })),
73
+ color: spec.color ?? "brand",
74
+ size
75
+ };
76
+ } else if (spec.type === "arrow") {
77
+ node = baseNode(spec, arrowFrame(spec.start, spec.end));
78
+ node.data = {
79
+ start: spec.start,
80
+ end: spec.end,
81
+ bend: spec.bend ?? 0,
82
+ color: spec.color ?? "brand",
83
+ size: spec.size ?? 2.5,
84
+ arrowStart: spec.arrowStart ?? false,
85
+ arrowEnd: spec.arrowEnd ?? true,
86
+ label: spec.label ?? ""
87
+ };
88
+ } else {
89
+ node = baseNode(spec, spec.frame);
90
+ switch (spec.type) {
91
+ case "text":
92
+ node.data = {
93
+ text: spec.text ?? "",
94
+ color: spec.color ?? "neutral",
95
+ fontSize: spec.fontSize ?? 24
96
+ };
97
+ break;
98
+ case "geo":
99
+ node.data = {
100
+ geo: spec.geo ?? "rectangle",
101
+ text: spec.text ?? "",
102
+ color: spec.color ?? "brand",
103
+ fillOpacity: spec.fillOpacity ?? 0
104
+ };
105
+ break;
106
+ case "frame":
107
+ node.data = {
108
+ label: spec.label ?? "Frame",
109
+ color: spec.color ?? "neutral"
110
+ };
111
+ break;
112
+ case "image":
113
+ node.refKind = "space_file";
114
+ node.refPath = spec.path;
115
+ node.view = spec.snapshot ?? {};
116
+ node.data = spec.crop ? { crop: spec.crop } : {};
117
+ break;
118
+ case "video":
119
+ case "audio":
120
+ node.refKind = "space_file";
121
+ node.refPath = spec.path;
122
+ node.view = spec.snapshot ?? {};
123
+ break;
124
+ case "file":
125
+ node.refKind = "space_file";
126
+ node.refPath = spec.path;
127
+ node.view = spec.snapshot ?? {};
128
+ break;
129
+ case "task":
130
+ node.view = spec.snapshot;
131
+ node.data = { taskRunId: spec.taskRunId };
132
+ break;
133
+ }
134
+ }
135
+ assertBoardNodes([node]);
136
+ return node;
137
+ }
138
+ function validateBoardNodes(nodes, path = "nodes") {
139
+ return nodes.flatMap((node, index) => validateBoardNodeInput(node, `${path}.${index}`));
140
+ }
141
+ function assertBoardNodes(nodes, path = "nodes") {
142
+ const diagnostics = validateBoardNodes(nodes, path);
143
+ if (diagnostics.length > 0) throw new BoardInputError(diagnostics);
144
+ }
145
+ function assertBoardTransactionNodeCreates(operations) {
146
+ const diagnostics = operations.flatMap((operation, index) => {
147
+ if (operation.type !== "node.create") return [];
148
+ const payload = operation.payload;
149
+ return payload.node ? validateBoardNodeInput(payload.node, `operations.${index}.payload.node`) : [];
150
+ });
151
+ if (diagnostics.length > 0) throw new BoardInputError(diagnostics);
152
+ }
153
+ //#endregion
154
+ export { BoardInputError, assertBoardNodes, assertBoardTransactionNodeCreates, createBoardNode, validateBoardNodes };
@@ -1,8 +1,10 @@
1
1
  import { ConnectionLayer, ConnectionRenderInput, createConnectionLayer, framesFromItems } from "./connection-layer.js";
2
+ import { BoardMediaAction, boardMediaActionAt, mediaPlayBadgeHit, mediaPlayBadgeVisible } from "./media-interaction.js";
2
3
  import { BoardCardRenderer, BoardRenderContext, BoardRenderPalette, boardCardRenderersForTest, getBoardCardRenderer, registerBoardCardRenderer } from "./renderers/board-renderer-registry.js";
3
4
  import { defaultBoardPalette } from "./palette.js";
4
5
  import { TASK_CARD_FULL_DETAIL_ZOOM } from "./renderers/task-card-renderer.js";
5
6
  import { ensureBoardTextMeasurement, installBoardTextMeasurement } from "./text-measurement.js";
6
7
  import { getBoardResolution, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket } from "./text-resolution.js";
7
8
  import { BoardThemeContext, BoardThemeRenderer, getBoardThemeRenderer, registerBoardThemeRenderer } from "./themes/board-theme-registry.js";
8
- export { BoardCardRenderer, BoardRenderContext, BoardRenderPalette, BoardThemeContext, BoardThemeRenderer, ConnectionLayer, ConnectionRenderInput, TASK_CARD_FULL_DETAIL_ZOOM, boardCardRenderersForTest, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, getBoardThemeRenderer, installBoardTextMeasurement, registerBoardCardRenderer, registerBoardThemeRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket };
9
+ import { VIDEO_THUMBNAIL_MAX_EDGE, VideoNaturalSize, loadVideoThumbnailTexture, videoTextureNaturalSize, videoThumbnailSize } from "./video-thumbnail.js";
10
+ export { BoardCardRenderer, BoardMediaAction, BoardRenderContext, BoardRenderPalette, BoardThemeContext, BoardThemeRenderer, ConnectionLayer, ConnectionRenderInput, TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, VideoNaturalSize, boardCardRenderersForTest, boardMediaActionAt, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, getBoardThemeRenderer, installBoardTextMeasurement, loadVideoThumbnailTexture, mediaPlayBadgeHit, mediaPlayBadgeVisible, registerBoardCardRenderer, registerBoardThemeRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket, videoTextureNaturalSize, videoThumbnailSize };
@@ -1,8 +1,10 @@
1
1
  import { getBoardResolution, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket } from "./text-resolution.js";
2
2
  import { createConnectionLayer, framesFromItems } from "./connection-layer.js";
3
+ import { TASK_CARD_FULL_DETAIL_ZOOM } from "./renderers/task-card-renderer.js";
4
+ import { boardMediaActionAt, mediaPlayBadgeHit, mediaPlayBadgeVisible } from "./media-interaction.js";
3
5
  import { defaultBoardPalette } from "./palette.js";
4
6
  import { ensureBoardTextMeasurement, installBoardTextMeasurement } from "./text-measurement.js";
5
- import { TASK_CARD_FULL_DETAIL_ZOOM } from "./renderers/task-card-renderer.js";
6
7
  import { boardCardRenderersForTest, getBoardCardRenderer, registerBoardCardRenderer } from "./renderers/board-renderer-registry.js";
7
8
  import { getBoardThemeRenderer, registerBoardThemeRenderer } from "./themes/board-theme-registry.js";
8
- export { TASK_CARD_FULL_DETAIL_ZOOM, boardCardRenderersForTest, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, getBoardThemeRenderer, installBoardTextMeasurement, registerBoardCardRenderer, registerBoardThemeRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket };
9
+ import { VIDEO_THUMBNAIL_MAX_EDGE, loadVideoThumbnailTexture, videoTextureNaturalSize, videoThumbnailSize } from "./video-thumbnail.js";
10
+ export { TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, boardCardRenderersForTest, boardMediaActionAt, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, getBoardThemeRenderer, installBoardTextMeasurement, loadVideoThumbnailTexture, mediaPlayBadgeHit, mediaPlayBadgeVisible, registerBoardCardRenderer, registerBoardThemeRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket, videoTextureNaturalSize, videoThumbnailSize };
@@ -0,0 +1,19 @@
1
+ import { BoardItem } from "../../protocol/dist/board-document.js";
2
+ import { WorldPoint } from "../geometry.js";
3
+ //#region src/board/render/media-interaction.d.ts
4
+ type BoardMediaAction = {
5
+ action: "play-media";
6
+ itemId: string;
7
+ };
8
+ declare function mediaPlayBadgeVisible(item: BoardItem, zoom: number, options: {
9
+ materialized: boolean;
10
+ hasVideoPreview: boolean;
11
+ }): boolean;
12
+ /** The central badge is a fixed screen-space target, independent of Board zoom. */
13
+ declare function mediaPlayBadgeHit(item: BoardItem, point: WorldPoint, zoom: number): boolean;
14
+ declare function boardMediaActionAt(item: BoardItem, point: WorldPoint, zoom: number, options: {
15
+ materialized: boolean;
16
+ hasVideoPreview: boolean;
17
+ }): BoardMediaAction | null;
18
+ //#endregion
19
+ export { BoardMediaAction, boardMediaActionAt, mediaPlayBadgeHit, mediaPlayBadgeVisible };
@@ -0,0 +1,26 @@
1
+ import { featuredTaskArtifact } from "../task.js";
2
+ import "./renderers/task-card-renderer.js";
3
+ //#region src/board/render/media-interaction.ts
4
+ function mediaPlayBadgeVisible(item, zoom, options) {
5
+ if (!options.materialized) return false;
6
+ if (item.type === "video" || item.type === "audio") return true;
7
+ if (item.type !== "task" || zoom < .55) return false;
8
+ const artifact = featuredTaskArtifact(item.snapshot.artifacts);
9
+ if (artifact?.type === "audio") return true;
10
+ return artifact?.type === "video" && (Boolean(artifact.previewUrl) || options.hasVideoPreview);
11
+ }
12
+ /** The central badge is a fixed screen-space target, independent of Board zoom. */
13
+ function mediaPlayBadgeHit(item, point, zoom) {
14
+ const radius = 28 / Math.max(zoom, .05);
15
+ const centerX = item.frame.x + item.frame.width / 2;
16
+ const centerY = item.frame.y + item.frame.height / 2;
17
+ return Math.hypot(point.x - centerX, point.y - centerY) <= radius;
18
+ }
19
+ function boardMediaActionAt(item, point, zoom, options) {
20
+ return mediaPlayBadgeVisible(item, zoom, options) && mediaPlayBadgeHit(item, point, zoom) ? {
21
+ action: "play-media",
22
+ itemId: item.id
23
+ } : null;
24
+ }
25
+ //#endregion
26
+ export { boardMediaActionAt, mediaPlayBadgeHit, mediaPlayBadgeVisible };
@@ -1,8 +1,8 @@
1
1
  import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
2
  import { syncTextResolution, syncTextWrapWidth, textResolutionForZoom } from "../text-resolution.js";
3
- import { drawFarPlate } from "./far-plate.js";
4
3
  import { drawAudioWaveform } from "../audio-waveform.js";
5
4
  import { positionShell } from "./base-card-renderer.js";
5
+ import { drawFarPlate } from "./far-plate.js";
6
6
  import { Container, Graphics, Text } from "pixi.js";
7
7
  //#region src/board/render/renderers/audio-card-renderer.ts
8
8
  const RADIUS = 4;
@@ -1,12 +1,12 @@
1
+ import { fileCardRenderer } from "./file-card-renderer.js";
2
+ import { taskCardRenderer } from "./task-card-renderer.js";
1
3
  import { ensureBoardTextMeasurement } from "../text-measurement.js";
2
4
  import { arrowCardRenderer } from "./arrow-card-renderer.js";
3
5
  import { audioCardRenderer } from "./audio-card-renderer.js";
4
6
  import { drawCardRenderer } from "./draw-card-renderer.js";
5
- import { fileCardRenderer } from "./file-card-renderer.js";
6
7
  import { frameCardRenderer } from "./frame-card-renderer.js";
7
8
  import { geoCardRenderer } from "./geo-card-renderer.js";
8
9
  import { imageCardRenderer } from "./image-card-renderer.js";
9
- import { taskCardRenderer } from "./task-card-renderer.js";
10
10
  import { textCardRenderer } from "./text-card-renderer.js";
11
11
  import { unknownCardRenderer } from "./unknown-card-renderer.js";
12
12
  import { videoCardRenderer } from "./video-card-renderer.js";
@@ -1,7 +1,7 @@
1
1
  import { buildStrokeOutline, computeDrawBounds } from "../../core/draw-geometry.js";
2
2
  import { pickBoardColor } from "../../core/palette.js";
3
- import { drawFarStroke } from "./far-plate.js";
4
3
  import { positionShell } from "./base-card-renderer.js";
4
+ import { drawFarStroke } from "./far-plate.js";
5
5
  import { Container, Graphics } from "pixi.js";
6
6
  //#region src/board/render/renderers/draw-card-renderer.ts
7
7
  const partsByContainer = /* @__PURE__ */ new WeakMap();
@@ -1,8 +1,8 @@
1
1
  import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
2
  import { fileBaseName, filePreviewKind } from "../../core/file-preview.js";
3
3
  import { syncTextResolution } from "../text-resolution.js";
4
- import { drawFarPlate } from "./far-plate.js";
5
4
  import { positionShell } from "./base-card-renderer.js";
5
+ import { drawFarPlate } from "./far-plate.js";
6
6
  import { CanvasTextMetrics, Container, Graphics, Sprite, Text, Texture } from "pixi.js";
7
7
  //#region src/board/render/renderers/file-card-renderer.ts
8
8
  /**
@@ -1,8 +1,8 @@
1
1
  import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
2
  import { pickBoardColor } from "../../core/palette.js";
3
3
  import { syncTextResolution } from "../text-resolution.js";
4
- import { drawFarPlate } from "./far-plate.js";
5
4
  import { createLabel, positionShell } from "./base-card-renderer.js";
5
+ import { drawFarPlate } from "./far-plate.js";
6
6
  import { Container, Graphics } from "pixi.js";
7
7
  //#region src/board/render/renderers/frame-card-renderer.ts
8
8
  const partsByContainer = /* @__PURE__ */ new WeakMap();
@@ -1,8 +1,8 @@
1
1
  import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
2
  import { pickBoardColor } from "../../core/palette.js";
3
3
  import { syncTextResolution, syncTextWrapWidth, textResolutionForZoom } from "../text-resolution.js";
4
- import { drawFarPlate } from "./far-plate.js";
5
4
  import { positionShell } from "./base-card-renderer.js";
5
+ import { drawFarPlate } from "./far-plate.js";
6
6
  import { Container, Graphics, Text } from "pixi.js";
7
7
  //#region src/board/render/renderers/geo-card-renderer.ts
8
8
  const RADIUS = 8;
@@ -1,5 +1,5 @@
1
- import { drawFarPlate } from "./far-plate.js";
2
1
  import { positionShell } from "./base-card-renderer.js";
2
+ import { drawFarPlate } from "./far-plate.js";
3
3
  import { Container, Graphics, Sprite, Texture } from "pixi.js";
4
4
  //#region src/board/render/renderers/image-card-renderer.ts
5
5
  const partsByContainer = /* @__PURE__ */ new WeakMap();
@@ -1,8 +1,9 @@
1
1
  import { BOARD_FONT_STACK, BOARD_MONO_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { featuredTaskArtifact } from "../../task.js";
2
3
  import { syncTextResolution, textResolutionForZoom } from "../text-resolution.js";
3
- import { drawFarPlate } from "./far-plate.js";
4
4
  import { drawAudioWaveform } from "../audio-waveform.js";
5
5
  import { positionShell } from "./base-card-renderer.js";
6
+ import { drawFarPlate } from "./far-plate.js";
6
7
  import { fitTextToLines } from "./file-card-renderer.js";
7
8
  import { Container, Graphics, Sprite, Text } from "pixi.js";
8
9
  //#region src/board/render/renderers/task-card-renderer.ts
@@ -12,12 +13,11 @@ const META_HEIGHT = 28;
12
13
  const TASK_CARD_FULL_DETAIL_ZOOM = .55;
13
14
  const PLAY_BADGE_RADIUS = 15;
14
15
  const partsByContainer = /* @__PURE__ */ new WeakMap();
15
- function previewKindFor(item) {
16
- const output = item.snapshot.primaryOutput;
17
- if (!output) return "empty";
18
- if (output.type === "image" || output.type === "video") return "texture";
19
- if (output.type === "audio") return "waveform";
20
- return output.textExcerpt ? "text" : "empty";
16
+ function previewKindFor(artifact) {
17
+ if (!artifact) return "empty";
18
+ if (artifact.type === "image" || artifact.type === "video") return "texture";
19
+ if (artifact.type === "audio") return artifact.previewUrl ? "texture" : "waveform";
20
+ return artifact.textExcerpt ? "text" : "empty";
21
21
  }
22
22
  function stateSurfaceFor(item, kind, hasTexture, previewFailed) {
23
23
  if (kind === "texture" && !hasTexture) {
@@ -46,7 +46,8 @@ function statusColor(item, context) {
46
46
  return context.colors.neutral.stroke;
47
47
  }
48
48
  function metadata(item) {
49
- const extra = item.snapshot.outputCount > 1 ? `+${item.snapshot.outputCount - 1}` : null;
49
+ const { artifactCount, artifacts } = item.snapshot;
50
+ const extra = artifactCount > artifacts.length ? `${artifacts.length}/${artifactCount}` : artifactCount > 1 ? `+${artifactCount - 1}` : null;
50
51
  return [item.snapshot.model, extra].filter(Boolean).join(" · ");
51
52
  }
52
53
  /** Scale a texture into a frame without cropping or distortion. */
@@ -169,7 +170,8 @@ function sync(container, item, context) {
169
170
  }
170
171
  const texture = key ? context.getTexture(key) : null;
171
172
  const previewFailed = Boolean(key && !texture && context.hasError(key));
172
- const kind = previewKindFor(item);
173
+ const artifact = featuredTaskArtifact(item.snapshot.artifacts);
174
+ const kind = previewKindFor(artifact);
173
175
  const surface = stateSurfaceFor(item, kind, Boolean(texture), previewFailed);
174
176
  const frame = {
175
177
  x: 1,
@@ -177,7 +179,7 @@ function sync(container, item, context) {
177
179
  width: Math.max(1, width - 2),
178
180
  height: Math.max(1, height - 2)
179
181
  };
180
- const metaText = item.snapshot.primaryOutput ? metadata(item) : "";
182
+ const metaText = artifact ? metadata(item) : "";
181
183
  const showMeta = full && Boolean(metaText);
182
184
  const failedWithOutput = item.snapshot.status === "failed" && kind !== "empty";
183
185
  syncTextResolution(parts.body, parts, context.zoom);
@@ -193,7 +195,8 @@ function sync(container, item, context) {
193
195
  surface,
194
196
  showMeta,
195
197
  failedWithOutput,
196
- item.snapshot.primaryOutput?.type ?? "none",
198
+ artifact?.type ?? "none",
199
+ artifact?.id ?? "none",
197
200
  item.taskRunId,
198
201
  texture ? `${texture.width}x${texture.height}` : "none",
199
202
  context.palette.surface,
@@ -227,7 +230,7 @@ function sync(container, item, context) {
227
230
  height: Math.max(1, frame.height - PADDING * 2 - bottomInset)
228
231
  }, context.colors.brand.stroke);
229
232
  }
230
- if (full && (item.snapshot.primaryOutput?.type === "audio" || item.snapshot.primaryOutput?.type === "video" && texture)) drawPlayBadge(parts.previewArt, frame, context);
233
+ if (full && (artifact?.type === "audio" || artifact?.type === "video" && texture)) drawPlayBadge(parts.previewArt, frame, context);
231
234
  if (surface) {
232
235
  const markY = frame.y + frame.height / 2 - (full && stateLabel(surface) ? 10 : 0);
233
236
  drawStateMark(parts.previewArt, surface, frame.x + frame.width / 2, markY, surface === "failed" ? color : context.colors.neutral.stroke);
@@ -246,7 +249,7 @@ function sync(container, item, context) {
246
249
  parts.preview.width = fitted.width;
247
250
  parts.preview.height = fitted.height;
248
251
  }
249
- const bodyText = kind === "text" ? item.snapshot.primaryOutput?.textExcerpt ?? "" : stateLabel(surface);
252
+ const bodyText = kind === "text" && artifact?.type === "text" ? artifact.textExcerpt : stateLabel(surface);
250
253
  const textSig = [
251
254
  bodyText,
252
255
  metaText,
@@ -2,8 +2,8 @@ import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
2
  import { TEXT_FONT_SIZE, boardTextLineHeight, clampBoardTextFontSize, measureBoardText } from "../../core/text-metrics.js";
3
3
  import { pickBoardColor } from "../../core/palette.js";
4
4
  import { syncTextResolution, textResolutionForZoom } from "../text-resolution.js";
5
- import { drawFarPlate } from "./far-plate.js";
6
5
  import { positionShell } from "./base-card-renderer.js";
6
+ import { drawFarPlate } from "./far-plate.js";
7
7
  import { Container, Text } from "pixi.js";
8
8
  //#region src/board/render/renderers/text-card-renderer.ts
9
9
  const partsByContainer = /* @__PURE__ */ new WeakMap();
@@ -1,8 +1,8 @@
1
1
  import { BOARD_MONO_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
2
  import { unknownRealType } from "../../../protocol/dist/board-document.js";
3
3
  import { syncTextResolution, textResolutionForZoom } from "../text-resolution.js";
4
- import { drawFarPlate } from "./far-plate.js";
5
4
  import { positionShell } from "./base-card-renderer.js";
5
+ import { drawFarPlate } from "./far-plate.js";
6
6
  import { Container, Graphics, Text } from "pixi.js";
7
7
  //#region src/board/render/renderers/unknown-card-renderer.ts
8
8
  const RADIUS = 10;
@@ -1,7 +1,7 @@
1
1
  import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
2
  import { syncTextResolution, syncTextWrapWidth, textResolutionForZoom } from "../text-resolution.js";
3
- import { drawFarPlate } from "./far-plate.js";
4
3
  import { positionShell } from "./base-card-renderer.js";
4
+ import { drawFarPlate } from "./far-plate.js";
5
5
  import { Container, Graphics, Sprite, Text, Texture } from "pixi.js";
6
6
  //#region src/board/render/renderers/video-card-renderer.ts
7
7
  const partsByContainer = /* @__PURE__ */ new WeakMap();
@@ -0,0 +1,16 @@
1
+ import { Texture } from "pixi.js";
2
+ //#region src/board/render/video-thumbnail.d.ts
3
+ declare const VIDEO_THUMBNAIL_MAX_EDGE = 960;
4
+ type VideoNaturalSize = {
5
+ width: number;
6
+ height: number;
7
+ };
8
+ declare function videoThumbnailSize(width: number, height: number, maxEdge?: number): {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ declare function videoTextureNaturalSize(texture: Texture): VideoNaturalSize | null;
13
+ /** Decode one static, bounded preview frame without starting playback. */
14
+ declare function loadVideoThumbnailTexture(url: string): Promise<Texture>;
15
+ //#endregion
16
+ export { VIDEO_THUMBNAIL_MAX_EDGE, VideoNaturalSize, loadVideoThumbnailTexture, videoTextureNaturalSize, videoThumbnailSize };
@@ -0,0 +1,86 @@
1
+ import { Texture } from "pixi.js";
2
+ //#region src/board/render/video-thumbnail.ts
3
+ const VIDEO_THUMBNAIL_MAX_EDGE = 960;
4
+ const VIDEO_LOAD_TIMEOUT_MS = 15e3;
5
+ const FRAME_PRESENT_TIMEOUT_MS = 250;
6
+ const naturalSizeByTexture = /* @__PURE__ */ new WeakMap();
7
+ function videoThumbnailSize(width, height, maxEdge = 960) {
8
+ if (width <= 0 || height <= 0 || maxEdge <= 0) throw new Error("Video has no displayable frame");
9
+ const scale = Math.min(1, maxEdge / Math.max(width, height));
10
+ return {
11
+ width: Math.max(1, Math.round(width * scale)),
12
+ height: Math.max(1, Math.round(height * scale))
13
+ };
14
+ }
15
+ function waitForFirstFrame(video) {
16
+ if (video.readyState >= 2) return Promise.resolve();
17
+ return new Promise((resolve, reject) => {
18
+ const timeout = setTimeout(() => finish(/* @__PURE__ */ new Error("Video preview timed out")), VIDEO_LOAD_TIMEOUT_MS);
19
+ const onLoaded = () => finish();
20
+ const onError = () => finish(/* @__PURE__ */ new Error("Video preview could not be decoded"));
21
+ function finish(error) {
22
+ clearTimeout(timeout);
23
+ video.removeEventListener("loadeddata", onLoaded);
24
+ video.removeEventListener("error", onError);
25
+ if (error) reject(error);
26
+ else resolve();
27
+ }
28
+ video.addEventListener("loadeddata", onLoaded, { once: true });
29
+ video.addEventListener("error", onError, { once: true });
30
+ });
31
+ }
32
+ function waitForPresentedFrame(video) {
33
+ if (!video.requestVideoFrameCallback) return new Promise((resolve) => requestAnimationFrame(() => resolve()));
34
+ return new Promise((resolve) => {
35
+ let settled = false;
36
+ let timeout;
37
+ const finish = () => {
38
+ if (settled) return;
39
+ settled = true;
40
+ clearTimeout(timeout);
41
+ resolve();
42
+ };
43
+ const handle = video.requestVideoFrameCallback(finish);
44
+ timeout = setTimeout(() => {
45
+ video.cancelVideoFrameCallback?.(handle);
46
+ finish();
47
+ }, FRAME_PRESENT_TIMEOUT_MS);
48
+ });
49
+ }
50
+ function videoTextureNaturalSize(texture) {
51
+ return naturalSizeByTexture.get(texture) ?? null;
52
+ }
53
+ /** Decode one static, bounded preview frame without starting playback. */
54
+ async function loadVideoThumbnailTexture(url) {
55
+ const video = document.createElement("video");
56
+ video.muted = true;
57
+ video.playsInline = true;
58
+ video.preload = "auto";
59
+ if (/^https?:/i.test(url)) video.crossOrigin = "anonymous";
60
+ try {
61
+ const ready = waitForFirstFrame(video);
62
+ video.src = url;
63
+ video.load();
64
+ await ready;
65
+ await waitForPresentedFrame(video);
66
+ const size = videoThumbnailSize(video.videoWidth, video.videoHeight);
67
+ const canvas = document.createElement("canvas");
68
+ canvas.width = size.width;
69
+ canvas.height = size.height;
70
+ const context = canvas.getContext("2d", { alpha: false });
71
+ if (!context) throw new Error("Canvas is not supported");
72
+ context.drawImage(video, 0, 0, size.width, size.height);
73
+ const texture = Texture.from(canvas);
74
+ naturalSizeByTexture.set(texture, {
75
+ width: video.videoWidth,
76
+ height: video.videoHeight
77
+ });
78
+ return texture;
79
+ } finally {
80
+ video.pause();
81
+ video.removeAttribute("src");
82
+ video.load();
83
+ }
84
+ }
85
+ //#endregion
86
+ export { VIDEO_THUMBNAIL_MAX_EDGE, loadVideoThumbnailTexture, videoTextureNaturalSize, videoThumbnailSize };
@@ -1,15 +1,20 @@
1
- import { BoardTaskSnapshot } from "../protocol/dist/board-document.js";
1
+ import { BoardTaskArtifact, BoardTaskSnapshot } from "../protocol/dist/board-document.js";
2
2
  import { TaskRunRecord } from "../types.js";
3
3
  //#region src/board/task.d.ts
4
4
  /**
5
- * Normalize a persistable remote media URL and reject credentialed or visibly
6
- * non-public hosts. Server-side fetchers must still validate resolved DNS addresses.
5
+ * Group provider blocks into user-facing works. A cover/poster sharing a stable
6
+ * provider id with playable media belongs to that work instead of becoming a
7
+ * competing image result.
7
8
  */
8
- declare function normalizeBoardTaskOutputUrl(value: unknown): string | undefined;
9
+ declare function taskArtifacts(blocks: Record<string, unknown>[]): BoardTaskArtifact[];
10
+ /** Highest-value artifact first, with provider order as the stable final tie. */
11
+ declare function rankedTaskArtifacts(artifacts: readonly BoardTaskArtifact[]): BoardTaskArtifact[];
12
+ declare function featuredTaskArtifact(artifacts: readonly BoardTaskArtifact[]): BoardTaskArtifact | undefined;
13
+ declare function taskArtifactPreviewUrl(artifact: BoardTaskArtifact | undefined): string | undefined;
9
14
  /**
10
15
  * Project an authoritative TaskRun into a small, replaceable Board display cache.
11
16
  * Raw payloads, complete results and inline media are never copied into the Board.
12
17
  */
13
18
  declare function taskRunToBoardTaskSnapshot(run: TaskRunRecord): BoardTaskSnapshot;
14
19
  //#endregion
15
- export { normalizeBoardTaskOutputUrl, taskRunToBoardTaskSnapshot };
20
+ export { featuredTaskArtifact, rankedTaskArtifacts, taskArtifactPreviewUrl, taskArtifacts, taskRunToBoardTaskSnapshot };