@neta-art/cohub 8.21.1 → 8.23.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.
@@ -1,22 +1,27 @@
1
1
  import { BOARD_FONT_STACK } from "../../protocol/dist/board-constants.js";
2
- import { connectionArrowheads, resolveConnection } from "../core/connections.js";
2
+ import { rectsIntersect } from "../geometry.js";
3
+ import { connectionArrowheads, createConnectionGeometryCache } from "../core/connections.js";
3
4
  import { pickBoardColor } from "../core/palette.js";
4
- import { syncTextResolution, textResolutionForZoom } from "./text-resolution.js";
5
+ import { syncTextResolution, textResolutionForZoom, textZoomBucket } from "./text-resolution.js";
5
6
  import { Container, Graphics, Text } from "pixi.js";
6
7
  //#region src/board/render/connection-layer.ts
7
8
  /**
8
9
  * Connection drawing.
9
10
  *
10
- * Connections render into one shared layer beneath the cards rather than as a
11
+ * Connections render into shared layers beneath the cards rather than as a
11
12
  * container per relation. A connection is a thin stroke with no texture and no
12
13
  * interactive chrome of its own, so a per-connection container would add a
13
14
  * transform, a render group and a draw call each for geometry that batches
14
15
  * perfectly — on a densely connected board that is the difference between a few
15
16
  * draw calls and a few thousand.
16
17
  *
17
- * Labels are the exception: text needs its own object to rasterise, so a `Text`
18
- * is materialised only for connections that actually carry one and is pooled by
19
- * connection id.
18
+ * Tessellation is the real cost, so both layers are cached: `base` holds idle
19
+ * relations, `live` those touching manipulated, selected or hovered nodes.
20
+ * Details too small to read degrade to plain strokes; the document never changes.
21
+ *
22
+ * Labels are the exception to batching: text needs its own object to rasterise,
23
+ * so a `Text` is materialised only for visible connections that carry one and is
24
+ * pooled by connection id.
20
25
  */
21
26
  /** Arrowhead length relative to stroke width, and its floor in world units. */
22
27
  const HEAD_SCALE = 5.5;
@@ -25,85 +30,203 @@ const HEAD_SPREAD = Math.PI / 6;
25
30
  /** Dash pattern for `dashed` connections, in world units. */
26
31
  const DASH_LENGTH = 10;
27
32
  const DASH_GAP = 7;
28
- /** Draw an open chevron arrowhead aimed along `angle`. */
29
- function drawArrowhead(graphics, tip, angle, size, color, width, alpha) {
30
- graphics.moveTo(tip.x - size * Math.cos(angle - HEAD_SPREAD), tip.y - size * Math.sin(angle - HEAD_SPREAD)).lineTo(tip.x, tip.y).lineTo(tip.x - size * Math.cos(angle + HEAD_SPREAD), tip.y - size * Math.sin(angle + HEAD_SPREAD)).stroke({
31
- color,
32
- width,
33
- alpha,
34
- cap: "round",
35
- join: "round"
36
- });
33
+ const LABEL_FONT_SIZE = 12;
34
+ /** Minimum on-screen sizes before a detail is drawn. */
35
+ const MIN_DASH_PX = 4;
36
+ const MIN_HEAD_PX = 3;
37
+ const MIN_LABEL_PX = 5;
38
+ const MIN_SEGMENT_PX = 2;
39
+ const MIN_ROUND_PX = 2;
40
+ /** Dashes per frame; past it all dashed relations draw solid (with hysteresis). */
41
+ const DASH_BUDGET = 4e4;
42
+ const DASH_RESUME_RATIO = .8;
43
+ function detailForZoom(zoom) {
44
+ const bucket = 2 ** (Math.floor(Math.log2(Math.max(zoom, 1e-4)) * 2) / 2);
45
+ return {
46
+ zoom: bucket,
47
+ dashes: DASH_LENGTH * bucket >= MIN_DASH_PX,
48
+ labels: LABEL_FONT_SIZE * bucket >= MIN_LABEL_PX,
49
+ minWidth: 1 / bucket,
50
+ minStep: MIN_SEGMENT_PX / bucket
51
+ };
37
52
  }
38
- /** Trace a polyline as a dashed path, walking segment by segment. */
39
- function traceDashed(graphics, path) {
53
+ /** Visit each dash piece inside `clip`; `start` marks the first piece of a dash. */
54
+ function walkDashes(path, clip, visit) {
40
55
  let penDown = true;
41
56
  let remaining = DASH_LENGTH;
42
- let current = path[0];
43
- if (!current) return;
44
- graphics.moveTo(current.x, current.y);
57
+ let open = false;
45
58
  for (let index = 1; index < path.length; index += 1) {
46
- const next = path[index];
47
- if (!next) continue;
48
- let segmentRemaining = Math.hypot(next.x - current.x, next.y - current.y);
49
- let from = current;
50
- while (segmentRemaining > 1e-4) {
51
- const step = Math.min(segmentRemaining, remaining);
52
- const ratio = step / segmentRemaining;
53
- const to = {
54
- x: from.x + (next.x - from.x) * ratio,
55
- y: from.y + (next.y - from.y) * ratio
56
- };
57
- if (penDown) graphics.lineTo(to.x, to.y);
58
- else graphics.moveTo(to.x, to.y);
59
+ const a = path[index - 1];
60
+ const b = path[index];
61
+ if (!a || !b) continue;
62
+ const dx = b.x - a.x;
63
+ const dy = b.y - a.y;
64
+ const length = Math.hypot(dx, dy);
65
+ let travelled = 0;
66
+ while (length - travelled > 1e-4) {
67
+ const step = Math.min(length - travelled, remaining);
68
+ if (penDown) {
69
+ const t0 = travelled / length;
70
+ const t1 = (travelled + step) / length;
71
+ const x0 = a.x + dx * t0;
72
+ const y0 = a.y + dy * t0;
73
+ const x1 = a.x + dx * t1;
74
+ const y1 = a.y + dy * t1;
75
+ if (!clip || Math.max(x0, x1) >= clip.x && Math.min(x0, x1) <= clip.x + clip.width && Math.max(y0, y1) >= clip.y && Math.min(y0, y1) <= clip.y + clip.height) {
76
+ visit(x0, y0, x1, y1, !open);
77
+ open = true;
78
+ } else open = false;
79
+ }
80
+ travelled += step;
59
81
  remaining -= step;
60
- segmentRemaining -= step;
61
- from = to;
62
82
  if (remaining <= 1e-4) {
63
83
  penDown = !penDown;
64
84
  remaining = penDown ? DASH_LENGTH : DASH_GAP;
85
+ open = false;
65
86
  }
66
87
  }
67
- current = next;
68
88
  }
69
89
  }
90
+ function countDashes(entries, clip) {
91
+ let count = 0;
92
+ for (const { connection, resolved } of entries) {
93
+ if (connection.style.line !== "dashed") continue;
94
+ walkDashes(resolved.path, clip, (_x0, _y0, _x1, _y1, start) => {
95
+ if (start) count += 1;
96
+ });
97
+ if (count > DASH_BUDGET) break;
98
+ }
99
+ return count;
100
+ }
101
+ function traceSolid(graphics, path, minStep) {
102
+ const first = path[0];
103
+ const last = path[path.length - 1];
104
+ if (!first || !last) return;
105
+ graphics.moveTo(first.x, first.y);
106
+ let previous = first;
107
+ for (let index = 1; index < path.length - 1; index += 1) {
108
+ const point = path[index];
109
+ if (!point) continue;
110
+ if (Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y) < minStep) continue;
111
+ graphics.lineTo(point.x, point.y);
112
+ previous = point;
113
+ }
114
+ graphics.lineTo(last.x, last.y);
115
+ }
116
+ /** Trace an open chevron arrowhead aimed along `angle`. */
117
+ function traceArrowhead(graphics, tip, angle, size) {
118
+ graphics.moveTo(tip.x - size * Math.cos(angle - HEAD_SPREAD), tip.y - size * Math.sin(angle - HEAD_SPREAD)).lineTo(tip.x, tip.y).lineTo(tip.x - size * Math.cos(angle + HEAD_SPREAD), tip.y - size * Math.sin(angle + HEAD_SPREAD));
119
+ }
120
+ /** Stroke each style once instead of once per relation. */
121
+ function createStrokeBatch(graphics) {
122
+ const groups = /* @__PURE__ */ new Map();
123
+ return {
124
+ add(style, trace) {
125
+ const key = `${style.color}|${style.width}|${style.alpha}|${style.cap}|${style.join}`;
126
+ const group = groups.get(key);
127
+ if (group) group.traces.push(trace);
128
+ else groups.set(key, {
129
+ style,
130
+ traces: [trace]
131
+ });
132
+ },
133
+ flush() {
134
+ for (const { style, traces } of groups.values()) {
135
+ for (const trace of traces) trace();
136
+ graphics.stroke(style);
137
+ }
138
+ groups.clear();
139
+ }
140
+ };
141
+ }
142
+ function drawConnections(graphics, entries, states, input, detail, clip, dashes) {
143
+ const batch = createStrokeBatch(graphics);
144
+ entries.forEach(({ connection, resolved }, index) => {
145
+ const state = states[index] ?? "idle";
146
+ const color = pickBoardColor(input.colors, connection.style.color, input.colorScheme);
147
+ const width = Math.max(connection.style.size + (state === "selected" ? 1 : 0), detail.minWidth);
148
+ const round = width * detail.zoom >= MIN_ROUND_PX;
149
+ const alpha = state === "idle" ? .9 : 1;
150
+ const path = resolved.path;
151
+ const dashed = dashes && connection.style.line === "dashed";
152
+ batch.add({
153
+ color: color.stroke,
154
+ width,
155
+ alpha,
156
+ cap: dashed || !round ? "butt" : "round",
157
+ join: round ? "round" : "miter"
158
+ }, dashed ? () => walkDashes(path, clip, (x0, y0, x1, y1, start) => {
159
+ if (start) graphics.moveTo(x0, y0);
160
+ graphics.lineTo(x1, y1);
161
+ }) : () => traceSolid(graphics, path, detail.minStep));
162
+ const headSize = Math.max(HEAD_MIN, connection.style.size * HEAD_SCALE);
163
+ if (headSize * detail.zoom < MIN_HEAD_PX) return;
164
+ const heads = connectionArrowheads(connection);
165
+ const headStyle = {
166
+ color: color.stroke,
167
+ width,
168
+ alpha,
169
+ cap: round ? "round" : "butt",
170
+ join: round ? "round" : "miter"
171
+ };
172
+ const tip = path[path.length - 1];
173
+ const beforeTip = path[path.length - 2];
174
+ if (heads.atTarget && tip && beforeTip) {
175
+ const angle = Math.atan2(tip.y - beforeTip.y, tip.x - beforeTip.x);
176
+ batch.add(headStyle, () => traceArrowhead(graphics, tip, angle, headSize));
177
+ }
178
+ const origin = path[0];
179
+ const afterOrigin = path[1];
180
+ if (heads.atSource && origin && afterOrigin) {
181
+ const angle = Math.atan2(origin.y - afterOrigin.y, origin.x - afterOrigin.x);
182
+ batch.add(headStyle, () => traceArrowhead(graphics, origin, angle, headSize));
183
+ }
184
+ });
185
+ batch.flush();
186
+ }
187
+ function isCurrent(drawn, entries, states, view, colors) {
188
+ return drawn !== null && drawn.view === view && drawn.colors === colors && sameItems(drawn.entries, entries) && sameItems(drawn.states, states);
189
+ }
190
+ function sameItems(a, b) {
191
+ if (a.length !== b.length) return false;
192
+ for (let index = 0; index < a.length; index += 1) if (a[index] !== b[index]) return false;
193
+ return true;
194
+ }
70
195
  function createConnectionLayer(options) {
71
- let graphics = null;
72
- let labelLayer = null;
196
+ let layers = null;
197
+ const geometry = createConnectionGeometryCache();
73
198
  const labels = /* @__PURE__ */ new Map();
74
- let resolvedById = /* @__PURE__ */ new Map();
199
+ let lastConnections = null;
200
+ let drawnBase = null;
201
+ let drawnLive = null;
202
+ let withinBudget = true;
75
203
  function ensureAttached() {
76
- if (graphics && labelLayer) return {
77
- graphics,
78
- labelLayer
204
+ if (layers) return layers;
205
+ const next = {
206
+ base: new Graphics({ label: "board-connections" }),
207
+ live: new Graphics({ label: "board-connections-live" }),
208
+ labels: new Container({ label: "board-connection-labels" })
79
209
  };
80
- const nextGraphics = new Graphics({ label: "board-connections" });
81
- const nextLabels = new Container({ label: "board-connection-labels" });
82
210
  if (options.zIndex !== void 0) {
83
- nextGraphics.zIndex = options.zIndex;
84
- nextLabels.zIndex = options.zIndex;
211
+ next.base.zIndex = options.zIndex;
212
+ next.live.zIndex = options.zIndex;
213
+ next.labels.zIndex = options.zIndex;
85
214
  }
86
- options.parent.addChild(nextGraphics, nextLabels);
87
- graphics = nextGraphics;
88
- labelLayer = nextLabels;
89
- return {
90
- graphics: nextGraphics,
91
- labelLayer: nextLabels
92
- };
215
+ options.parent.addChild(next.base, next.live, next.labels);
216
+ layers = next;
217
+ return next;
93
218
  }
94
219
  function releaseLabel(connectionId) {
95
220
  const entry = labels.get(connectionId);
96
221
  if (!entry) return;
97
- labelLayer?.removeChild(entry.text);
222
+ layers?.labels.removeChild(entry.text);
98
223
  entry.text.destroy();
99
224
  labels.delete(connectionId);
100
225
  }
101
- function syncLabel(connection, resolved, input, color, host) {
226
+ function syncLabel({ connection, resolved }, input, host) {
102
227
  const value = connection.label.trim();
103
- if (!value) {
104
- releaseLabel(connection.id);
105
- return;
106
- }
228
+ if (!value) return false;
229
+ const color = pickBoardColor(input.colors, connection.style.color, input.colorScheme).label;
107
230
  let entry = labels.get(connection.id);
108
231
  if (!entry) {
109
232
  const resolution = textResolutionForZoom(input.zoom);
@@ -112,7 +235,7 @@ function createConnectionLayer(options) {
112
235
  style: {
113
236
  fill: color,
114
237
  fontFamily: BOARD_FONT_STACK,
115
- fontSize: 12,
238
+ fontSize: LABEL_FONT_SIZE,
116
239
  fontWeight: "500"
117
240
  },
118
241
  resolution,
@@ -135,82 +258,111 @@ function createConnectionLayer(options) {
135
258
  entry.text.style.fill = color;
136
259
  }
137
260
  entry.text.position.set(resolved.mid.x, resolved.mid.y);
261
+ return true;
138
262
  }
139
- function sync(input) {
140
- if (input.connections.length === 0 && !graphics) {
141
- resolvedById = /* @__PURE__ */ new Map();
142
- return;
263
+ function drawLayer(graphics, layer, input, detail, clip) {
264
+ graphics.clear();
265
+ drawConnections(graphics, layer.entries, layer.states, input, detail, clip, layer.dashes);
266
+ const labelIds = /* @__PURE__ */ new Set();
267
+ if (detail.labels && layers) {
268
+ for (const entry of layer.entries) if (syncLabel(entry, input, layers.labels)) labelIds.add(entry.connection.id);
143
269
  }
270
+ return {
271
+ ...layer,
272
+ labelIds
273
+ };
274
+ }
275
+ function sync(input) {
276
+ if (input.connections.length === 0 && !layers) return;
144
277
  const host = ensureAttached();
145
- host.graphics.clear();
146
- const next = /* @__PURE__ */ new Map();
147
- const selected = input.selectedIds ?? /* @__PURE__ */ new Set();
148
- const skip = input.skipIds;
149
- const minWidth = 1 / Math.max(input.zoom, 1e-4);
278
+ if (input.connections !== lastConnections) {
279
+ geometry.retain(input.connections);
280
+ lastConnections = input.connections;
281
+ }
282
+ const detail = detailForZoom(input.zoom);
283
+ const cull = input.cullRect ?? null;
284
+ const clip = detail.dashes ? cull : null;
285
+ const selected = input.selectedIds;
286
+ const liveNodes = input.liveNodeIds;
287
+ const baseEntries = [];
288
+ const baseStates = [];
289
+ const liveEntries = [];
290
+ const liveStates = [];
291
+ let hovered = null;
150
292
  for (const connection of input.connections) {
151
- const resolved = resolveConnection(connection, input.getFrame);
152
- if (!resolved) continue;
153
- next.set(connection.id, resolved);
154
- if (skip?.has(connection.id)) {
155
- releaseLabel(connection.id);
156
- continue;
157
- }
158
- const isSelected = selected.has(connection.id);
159
- const isHovered = input.hoveredId === connection.id;
160
- const color = pickBoardColor(input.colors, connection.style.color, input.colorScheme);
161
- const width = Math.max(connection.style.size + (isSelected ? 1 : 0), minWidth);
162
- const alpha = isSelected || isHovered ? 1 : .9;
163
- if (connection.style.line === "dashed") traceDashed(host.graphics, resolved.path);
164
- else {
165
- const first = resolved.path[0];
166
- if (!first) continue;
167
- host.graphics.moveTo(first.x, first.y);
168
- for (let index = 1; index < resolved.path.length; index += 1) {
169
- const point = resolved.path[index];
170
- if (point) host.graphics.lineTo(point.x, point.y);
171
- }
172
- }
173
- host.graphics.stroke({
174
- color: color.stroke,
175
- width,
176
- alpha,
177
- cap: "round",
178
- join: "round"
179
- });
180
- const heads = connectionArrowheads(connection);
181
- const headSize = Math.max(HEAD_MIN, connection.style.size * HEAD_SCALE);
182
- if (heads.atTarget) {
183
- const tip = resolved.path[resolved.path.length - 1];
184
- const previous = resolved.path[resolved.path.length - 2];
185
- if (tip && previous) drawArrowhead(host.graphics, tip, Math.atan2(tip.y - previous.y, tip.x - previous.x), headSize, color.stroke, width, alpha);
293
+ const entry = geometry.get(connection, input.getFrame);
294
+ if (!entry || input.skipIds?.has(connection.id)) continue;
295
+ if (cull && !rectsIntersect(entry.bounds, cull)) continue;
296
+ if (selected?.has(connection.id)) {
297
+ liveEntries.push(entry);
298
+ liveStates.push("selected");
299
+ } else if (liveNodes?.has(connection.source.itemId) || liveNodes?.has(connection.target.itemId)) {
300
+ liveEntries.push(entry);
301
+ liveStates.push(connection.id === input.hoveredId ? "hovered" : "idle");
302
+ } else {
303
+ baseEntries.push(entry);
304
+ baseStates.push("idle");
305
+ if (connection.id === input.hoveredId) hovered = entry;
186
306
  }
187
- if (heads.atSource) {
188
- const tip = resolved.path[0];
189
- const next2 = resolved.path[1];
190
- if (tip && next2) drawArrowhead(host.graphics, tip, Math.atan2(tip.y - next2.y, tip.x - next2.x), headSize, color.stroke, width, alpha);
191
- }
192
- syncLabel(connection, resolved, input, color.label, host.labelLayer);
193
307
  }
194
- for (const connectionId of [...labels.keys()]) if (!next.has(connectionId)) releaseLabel(connectionId);
195
- resolvedById = next;
308
+ if (hovered) {
309
+ liveEntries.push(hovered);
310
+ liveStates.push("hovered");
311
+ }
312
+ const view = `${detail.zoom}|${textZoomBucket(input.zoom)}|${input.colorScheme}|${clip ? `${clip.x},${clip.y},${clip.width},${clip.height}` : ""}`;
313
+ const baseCurrent = isCurrent(drawnBase, baseEntries, baseStates, view, input.colors);
314
+ const liveCurrent = isCurrent(drawnLive, liveEntries, liveStates, view, input.colors);
315
+ const countFor = (current, drawn, entries) => !detail.dashes ? 0 : current && drawn ? drawn.dashCount : countDashes(entries, clip);
316
+ const baseDashes = countFor(baseCurrent, drawnBase, baseEntries);
317
+ const liveDashes = countFor(liveCurrent, drawnLive, hovered ? liveEntries.slice(0, -1) : liveEntries);
318
+ const total = baseDashes + liveDashes;
319
+ withinBudget = withinBudget ? total <= DASH_BUDGET : total <= DASH_BUDGET * DASH_RESUME_RATIO;
320
+ const dashes = detail.dashes && withinBudget;
321
+ const redraw = (graphics, entries, states, dashCount) => drawLayer(graphics, {
322
+ entries,
323
+ states,
324
+ view,
325
+ colors: input.colors,
326
+ dashCount,
327
+ dashes
328
+ }, input, detail, clip);
329
+ let labelsChanged = false;
330
+ if (!baseCurrent || drawnBase?.dashes !== dashes) {
331
+ drawnBase = redraw(host.base, baseEntries, baseStates, baseDashes);
332
+ labelsChanged = true;
333
+ }
334
+ if (!liveCurrent || drawnLive?.dashes !== dashes) {
335
+ drawnLive = redraw(host.live, liveEntries, liveStates, liveDashes);
336
+ labelsChanged = true;
337
+ }
338
+ if (!labelsChanged) return;
339
+ const wanted = /* @__PURE__ */ new Set([...drawnBase?.labelIds ?? [], ...drawnLive?.labelIds ?? []]);
340
+ for (const connectionId of [...labels.keys()]) if (!wanted.has(connectionId)) releaseLabel(connectionId);
196
341
  }
197
342
  return {
198
343
  sync,
199
- resolved: (connectionId) => resolvedById.get(connectionId) ?? null,
344
+ resolved: (connectionId) => geometry.peek(connectionId)?.resolved ?? null,
200
345
  get children() {
201
- const list = [];
202
- if (graphics) list.push(graphics);
203
- if (labelLayer) list.push(labelLayer);
204
- return list;
346
+ return layers ? [
347
+ layers.base,
348
+ layers.live,
349
+ layers.labels
350
+ ] : [];
205
351
  },
206
352
  destroy: () => {
207
353
  for (const entry of labels.values()) entry.text.destroy();
208
354
  labels.clear();
209
- graphics?.destroy();
210
- labelLayer?.destroy({ children: true });
211
- graphics = null;
212
- labelLayer = null;
213
- resolvedById = /* @__PURE__ */ new Map();
355
+ if (layers) {
356
+ layers.base.destroy();
357
+ layers.live.destroy();
358
+ layers.labels.destroy({ children: true });
359
+ }
360
+ layers = null;
361
+ drawnBase = null;
362
+ drawnLive = null;
363
+ withinBudget = true;
364
+ lastConnections = null;
365
+ geometry.clear();
214
366
  }
215
367
  };
216
368
  }
@@ -2,9 +2,8 @@ import { BoardTaskArtifact, BoardTaskSnapshot } from "../protocol/dist/board-doc
2
2
  import { TaskRunRecord } from "../types.js";
3
3
  //#region src/board/task.d.ts
4
4
  /**
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.
5
+ * Group provider blocks into user-facing works. A cover belongs to the media
6
+ * it depicts (see `generationCovers`) instead of becoming a competing image.
8
7
  */
9
8
  declare function taskArtifacts(blocks: Record<string, unknown>[]): BoardTaskArtifact[];
10
9
  /** Highest-value artifact first, with provider order as the stable final tie. */
@@ -1,80 +1,6 @@
1
- import { normalizeBoardRemoteUrl } from "../protocol/dist/board-url.js";
2
1
  import "../protocol/dist/board-document.js";
2
+ import { blockDurationMs, blockIdentity, blockMimeType, blockNaturalSize, blockPreviewUrl, blockText, blockTitle, blockUrl, cleanExcerpt, generationCovers, generationOutput, generationPrompt, taskData } from "../generation-blocks.js";
3
3
  //#region src/board/task.ts
4
- const EXCERPT_LIMIT = 240;
5
- function record(value) {
6
- return value && typeof value === "object" && !Array.isArray(value) ? value : null;
7
- }
8
- function cleanExcerpt(value, limit = EXCERPT_LIMIT) {
9
- if (typeof value !== "string") return void 0;
10
- const clean = value.replace(/\s+/g, " ").trim();
11
- if (!clean) return void 0;
12
- return clean.length > limit ? `${clean.slice(0, limit - 3).trimEnd()}...` : clean;
13
- }
14
- function blockText(block) {
15
- return cleanExcerpt(block.text ?? block.content ?? block.value);
16
- }
17
- function blockUrl(block) {
18
- const source = record(block.source);
19
- return normalizeBoardRemoteUrl(source?.url ?? source?.src ?? block.url ?? block.src);
20
- }
21
- function blockMimeType(block) {
22
- const source = record(block.source);
23
- const value = source?.mediaType ?? source?.media_type ?? source?.mimeType ?? block.mediaType ?? block.media_type ?? block.mimeType;
24
- return typeof value === "string" ? value : void 0;
25
- }
26
- function positiveNumber(value) {
27
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
28
- }
29
- function blockNaturalSize(block) {
30
- const source = record(block.source);
31
- const naturalWidth = positiveNumber(source?.width ?? source?.naturalWidth ?? block.width ?? block.naturalWidth);
32
- const naturalHeight = positiveNumber(source?.height ?? source?.naturalHeight ?? block.height ?? block.naturalHeight);
33
- return {
34
- ...naturalWidth ? { naturalWidth } : {},
35
- ...naturalHeight ? { naturalHeight } : {}
36
- };
37
- }
38
- function contentBlocks(value) {
39
- return Array.isArray(value) ? value.filter((item) => record(item)) : [];
40
- }
41
- function taskData(run) {
42
- const payload = record(run.payload);
43
- return record(payload?.data) ?? payload;
44
- }
45
- function generationOutput(run) {
46
- return contentBlocks(record(run.result)?.output);
47
- }
48
- function generationPrompt(run) {
49
- for (const block of contentBlocks(taskData(run)?.content)) {
50
- const text = blockText(block);
51
- if (text) return text;
52
- }
53
- }
54
- function blockMeta(block) {
55
- return record(block.meta);
56
- }
57
- function blockIdentity(block) {
58
- const meta = blockMeta(block);
59
- const value = meta?.id ?? meta?.clip_id ?? meta?.clipId ?? block.id ?? block.clip_id ?? block.clipId;
60
- if (typeof value !== "string" && typeof value !== "number") return void 0;
61
- return cleanExcerpt(String(value), 220);
62
- }
63
- function blockTitle(block) {
64
- return cleanExcerpt(blockMeta(block)?.title ?? block.title ?? block.name, 240);
65
- }
66
- function blockDurationMs(block) {
67
- const source = record(block.source);
68
- const meta = blockMeta(block);
69
- const milliseconds = positiveNumber(source?.durationMs ?? meta?.durationMs ?? block.durationMs);
70
- if (milliseconds) return Math.round(milliseconds);
71
- const seconds = positiveNumber(source?.duration ?? meta?.duration ?? block.duration);
72
- return seconds ? Math.round(seconds * 1e3) : void 0;
73
- }
74
- function blockPreviewUrl(block) {
75
- const source = record(block.source);
76
- return normalizeBoardRemoteUrl(source?.poster ?? source?.thumbnail ?? source?.previewUrl ?? block.poster ?? block.thumbnail ?? block.previewUrl);
77
- }
78
4
  function artifactId(base, used, suffix) {
79
5
  const stem = cleanExcerpt(base, 220) ?? "output";
80
6
  let candidate = suffix ? `${stem}-${suffix}` : stem;
@@ -87,36 +13,41 @@ function artifactId(base, used, suffix) {
87
13
  return candidate;
88
14
  }
89
15
  /**
90
- * Group provider blocks into user-facing works. A cover/poster sharing a stable
91
- * provider id with playable media belongs to that work instead of becoming a
92
- * competing image result.
16
+ * Group provider blocks into user-facing works. A cover belongs to the media
17
+ * it depicts (see `generationCovers`) instead of becoming a competing image.
93
18
  */
94
19
  function taskArtifacts(blocks) {
20
+ const covers = new Map([...generationCovers(blocks)].filter(([media]) => Boolean(blockUrl(blocks[media] ?? {}))));
21
+ const coverIndexes = new Set(covers.values());
22
+ const coverUrl = (index) => {
23
+ const cover = covers.get(index);
24
+ return cover === void 0 ? void 0 : blockUrl(blocks[cover] ?? {});
25
+ };
95
26
  const groups = /* @__PURE__ */ new Map();
96
27
  blocks.forEach((block, index) => {
28
+ if (coverIndexes.has(index)) return;
97
29
  const key = blockIdentity(block) ?? `output-${index + 1}`;
30
+ const entry = {
31
+ block,
32
+ index,
33
+ url: blockUrl(block)
34
+ };
98
35
  const group = groups.get(key);
99
- if (group) group.push(block);
100
- else groups.set(key, [block]);
36
+ if (group) group.push(entry);
37
+ else groups.set(key, [entry]);
101
38
  });
39
+ const withUrl = (entry) => Boolean(entry.url);
102
40
  const artifacts = [];
103
41
  const usedIds = /* @__PURE__ */ new Set();
104
42
  for (const [groupId, blocksInGroup] of groups) {
105
- const images = blocksInGroup.filter((block) => block.type === "image").map((block) => ({
106
- block,
107
- url: blockUrl(block)
108
- })).filter((entry) => Boolean(entry.url));
109
- const media = blocksInGroup.filter((block) => block.type === "video" || block.type === "audio").map((block) => ({
110
- block,
111
- url: blockUrl(block)
112
- })).filter((entry) => Boolean(entry.url));
113
- const pairedPreview = images[0];
114
- media.forEach(({ block, url }, mediaIndex) => {
43
+ const images = blocksInGroup.filter(({ block }) => block.type === "image").filter(withUrl);
44
+ const media = blocksInGroup.filter(({ block }) => block.type === "video" || block.type === "audio").filter(withUrl);
45
+ media.forEach(({ block, index, url }, mediaIndex) => {
115
46
  const type = block.type;
116
47
  const mimeType = blockMimeType(block);
117
48
  const title = blockTitle(block);
118
49
  const durationMs = blockDurationMs(block);
119
- const previewUrl = blockPreviewUrl(block) ?? pairedPreview?.url;
50
+ const previewUrl = blockPreviewUrl(block) ?? coverUrl(index);
120
51
  const id = artifactId(groupId, usedIds, media.length > 1 ? `${type}-${mediaIndex + 1}` : void 0);
121
52
  if (type === "video") {
122
53
  artifacts.push({
@@ -141,12 +72,11 @@ function taskArtifacts(blocks) {
141
72
  ...durationMs ? { durationMs } : {}
142
73
  });
143
74
  });
144
- const firstUnpairedImage = media.length > 0 ? 1 : 0;
145
- images.slice(firstUnpairedImage).forEach(({ block, url }, imageIndex) => {
75
+ images.forEach(({ block, url }, imageIndex) => {
146
76
  const mimeType = blockMimeType(block);
147
77
  const title = blockTitle(block);
148
78
  artifacts.push({
149
- id: artifactId(groupId, usedIds, images.length - firstUnpairedImage > 1 ? `image-${imageIndex + 1}` : void 0),
79
+ id: artifactId(groupId, usedIds, images.length > 1 ? `image-${imageIndex + 1}` : void 0),
150
80
  type: "image",
151
81
  url,
152
82
  ...title ? { title } : {},
@@ -154,7 +84,7 @@ function taskArtifacts(blocks) {
154
84
  ...blockNaturalSize(block)
155
85
  });
156
86
  });
157
- blocksInGroup.filter((block) => block.type === "text").forEach((block, textIndex, texts) => {
87
+ blocksInGroup.filter(({ block }) => block.type === "text").forEach(({ block }, textIndex, texts) => {
158
88
  const textExcerpt = blockText(block);
159
89
  if (!textExcerpt) return;
160
90
  const title = blockTitle(block);