@kolbo/mcp 1.72.3 → 1.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skill/SKILL.md +13 -0
- package/skill/references/workflows/visual-dna.md +1 -0
- package/src/apps/bridge.js +42 -3
- package/src/apps/theme.js +11 -1
- package/src/apps/widgets/generation.js +114 -10
- package/src/apps/widgets/mediaGrid.js +8 -3
- package/src/tools/_shared.js +62 -4
- package/src/tools/generate.js +265 -187
- package/src/tools/media.js +49 -52
- package/src/tools/moodboards.js +21 -21
- package/src/tools/visual_dna.js +20 -20
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -139,6 +139,19 @@ Passing `visual_dna_ids` is **not enough**. For every DNA in that array you MUST
|
|
|
139
139
|
|
|
140
140
|
Resolve names with `list_visual_dnas` first. Full binding rules: `references/workflows/visual-dna.md`.
|
|
141
141
|
|
|
142
|
+
## ⚠️ `enhance_prompt` — leave it OFF (HARD RULE)
|
|
143
|
+
|
|
144
|
+
**Never pass `enhance_prompt: true` unless the user asked for it in words.** It is
|
|
145
|
+
not a quality knob you turn on to be helpful — it sends the prompt to a rewriter
|
|
146
|
+
first, so the model renders *different words than the ones the user wrote*.
|
|
147
|
+
|
|
148
|
+
- The user says "make it more cinematic" → that is a request to change **your**
|
|
149
|
+
prompt. Write the better prompt yourself. It is NOT permission to enhance.
|
|
150
|
+
- Only "enhance the prompt" / "improve my prompt" / "expand this prompt" is.
|
|
151
|
+
- Passing it silently is the worst case: the card shows an `enhanced` chip the
|
|
152
|
+
user never asked for, and their own wording never reached the model.
|
|
153
|
+
- The default is `false` in every generation tool. Leave the argument out.
|
|
154
|
+
|
|
142
155
|
## ⚠️ Seedance / Elements prompt contract (HARD RULE)
|
|
143
156
|
|
|
144
157
|
`generate_elements`, Seedance 2, and Seedance 2.5 share **one** compile shape — the Locked Intro in `references/models/seedance.md`:
|
|
@@ -112,6 +112,7 @@ The match is **literal and case-insensitive**, so:
|
|
|
112
112
|
- The `@name` must equal the stored `name` field (e.g. if `name: "esther_model"` → write `@esther_model`, not `@Esther`, not `@אסתר`, not `@the model`).
|
|
113
113
|
- Any-language characters are supported — if the DNA was created with `name: "אסתר"` you write `@אסתר`. Use the EXACT stored string.
|
|
114
114
|
- Mentions terminate at punctuation (`.,!?`), double-spaces, another `@`, or end of string. So `@maya, wearing...` matches `maya`.
|
|
115
|
+
- **Never glue a possessive onto the tag.** Write `the face of @maya stays stable`, not `@maya's face stays stable` — the tag must end at the stored name. kolbo-api does strip a trailing `'s` / `’s` before the lookup, so a possessive no longer loses the DNA, but the apostrophe is left sitting in the prompt text the model sees.
|
|
115
116
|
|
|
116
117
|
This composes with `@image1` / `@image2` positional tags for plain reference/source images — see "Reference Tagging" below.
|
|
117
118
|
|
package/src/apps/bridge.js
CHANGED
|
@@ -103,12 +103,46 @@ const BRIDGE_JS = `
|
|
|
103
103
|
var card = document.querySelector('.k-card');
|
|
104
104
|
var rect = card ? card.getBoundingClientRect() : null;
|
|
105
105
|
var height = rect ? Math.ceil(rect.bottom + 8) : document.documentElement.scrollHeight;
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
106
|
+
// Ceiling comes from the SCREEN, never window.innerHeight. Inside an iframe
|
|
107
|
+
// innerHeight IS the height the host already granted, so clamping to it made
|
|
108
|
+
// the request a feedback loop: once the host capped us, we could never ask
|
|
109
|
+
// for more than the cap, and any content past it became an inner scrollbar
|
|
110
|
+
// that no amount of growing could clear. The host clamps too, so this is
|
|
111
|
+
// only a sanity ceiling.
|
|
112
|
+
var ceiling = (window.screen && window.screen.availHeight) || 1200;
|
|
113
|
+
height = Math.min(height, Math.max(ceiling, 500));
|
|
109
114
|
notify('ui/notifications/size-changed', {
|
|
110
115
|
width: document.documentElement.scrollWidth, height: height
|
|
111
116
|
});
|
|
117
|
+
markDraggable();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Every image/video in a widget can be dragged straight into the host's
|
|
121
|
+
// composer. <img> is natively draggable but <video> is not, and neither sets
|
|
122
|
+
// a clean URL, so both get an explicit dragstart carrying text/uri-list —
|
|
123
|
+
// exactly the format the composer's drop handler already reads.
|
|
124
|
+
// Hooked off notifySize because that is the one thing every render path
|
|
125
|
+
// already calls, so new render sites are covered without touching them.
|
|
126
|
+
function markDraggable() {
|
|
127
|
+
var nodes = document.querySelectorAll('img[src], video[src]');
|
|
128
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
129
|
+
var node = nodes[i];
|
|
130
|
+
if (node.getAttribute('data-kolbo-drag')) continue;
|
|
131
|
+
node.setAttribute('data-kolbo-drag', '1');
|
|
132
|
+
node.setAttribute('draggable', 'true');
|
|
133
|
+
node.addEventListener('dragstart', onMediaDragStart);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onMediaDragStart(e) {
|
|
138
|
+
var node = e.currentTarget;
|
|
139
|
+
// Strip the #t=0.05 poster fragment videos carry, or the host would attach
|
|
140
|
+
// a URL the CDN answers differently.
|
|
141
|
+
var url = String(node.currentSrc || node.getAttribute('src') || '').split('#')[0];
|
|
142
|
+
if (!/^https?:/i.test(url) || !e.dataTransfer) return;
|
|
143
|
+
e.dataTransfer.setData('text/uri-list', url);
|
|
144
|
+
e.dataTransfer.setData('text/plain', url);
|
|
145
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
112
146
|
}
|
|
113
147
|
|
|
114
148
|
var sizeTimer = null;
|
|
@@ -149,6 +183,11 @@ const BRIDGE_JS = `
|
|
|
149
183
|
return request('ui/message', { role: 'user', content: [{ type: 'text', text: text }] });
|
|
150
184
|
},
|
|
151
185
|
openLink: function (url) { return request('ui/open-link', { url: url }); },
|
|
186
|
+
// Hand a piece of this widget's media to the host's composer. Dragging it
|
|
187
|
+
// out cannot work: a widget is a sandboxed cross-origin iframe, so a native
|
|
188
|
+
// HTML5 drag started in here never delivers its dataTransfer to the host
|
|
189
|
+
// document. Hosts that ignore this method simply do nothing.
|
|
190
|
+
attachMedia: function (url) { return request('ui/attach-media', { url: url }); },
|
|
152
191
|
updateModelContext: function (text) {
|
|
153
192
|
return request('ui/update-model-context', { content: [{ type: 'text', text: text }] });
|
|
154
193
|
},
|
package/src/apps/theme.js
CHANGED
|
@@ -122,6 +122,12 @@ body {
|
|
|
122
122
|
}
|
|
123
123
|
.k-chip img.k-voice-thumb { width: 18px; height: 18px; border-radius: 999px; margin-left: -3px; }
|
|
124
124
|
.k-ref-thumb { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-strong); }
|
|
125
|
+
/* Visual DNA chips: the character's face, so you can see WHICH DNA is locked in. */
|
|
126
|
+
.k-dna-face { width: 18px; height: 18px; border-radius: 999px; object-fit: cover; margin-left: -3px; background: var(--border-strong); }
|
|
127
|
+
.k-dna-stack .k-dna-stack-item { display: inline-flex; }
|
|
128
|
+
.k-dna-stack .k-dna-stack-item + .k-dna-stack-item .k-dna-face { margin-left: -9px; }
|
|
129
|
+
.k-dna-stack .k-dna-face { box-shadow: 0 0 0 1.5px var(--card-solid); }
|
|
130
|
+
.k-dna-stack { cursor: default; }
|
|
125
131
|
|
|
126
132
|
/* ---- Generating state ---- */
|
|
127
133
|
.k-gen-grid { display: grid; gap: 8px; }
|
|
@@ -192,6 +198,8 @@ body {
|
|
|
192
198
|
}
|
|
193
199
|
.k-media:hover .k-dl, .k-viewer:hover .k-dl, .k-skel:hover .k-dl { opacity: 1; }
|
|
194
200
|
.k-dl:hover { background: var(--brand); border-color: var(--brand); }
|
|
201
|
+
/* Attach-to-prompt sits immediately left of Download (30px button + 8px gap). */
|
|
202
|
+
.k-attach { right: 46px; }
|
|
195
203
|
.k-viewer { position: relative; }
|
|
196
204
|
|
|
197
205
|
/* Keep the whole completed card under the host's iframe height cap (~800px):
|
|
@@ -199,7 +207,9 @@ body {
|
|
|
199
207
|
or claude.ai adds an inner scrollbar. Click the image to expand in-Claude. */
|
|
200
208
|
.k-viewer { margin-bottom: 10px; }
|
|
201
209
|
.k-viewer img, .k-viewer video { display: block; width: 100%;
|
|
202
|
-
|
|
210
|
+
/* Fixed px, no vh: vh inside an iframe is the height the host granted, so a
|
|
211
|
+
vh-based cap grew as the iframe grew and the card chased its own tail. */
|
|
212
|
+
max-height: 320px; object-fit: contain;
|
|
203
213
|
border-radius: 12px; background: #000; border: 1px solid var(--border); cursor: zoom-in; }
|
|
204
214
|
.k-viewer video { cursor: default; }
|
|
205
215
|
|
|
@@ -148,7 +148,19 @@ function voiceLabel(sc) { return sc.voice_name || sc.voice || (sc.settings || {}
|
|
|
148
148
|
// real reference assets — rendering them as flat prose hid the single most
|
|
149
149
|
// consequential part of the prompt. Escape FIRST, then wrap: the pattern only
|
|
150
150
|
// matches after a boundary, so an email or a #fff hex never lights up.
|
|
151
|
-
|
|
151
|
+
// DOUBLE backslashes: this whole file is a JS template literal, so a single
|
|
152
|
+
// backslash-s / backslash-w is eaten before the browser ever sees it. This was
|
|
153
|
+
// emitting /(^|[s([{"'>])([@#][A-Za-z][w-]*)/g — character classes of the
|
|
154
|
+
// LITERAL letters s and w — so "@zohar_apocalypse" highlighted as just "@z",
|
|
155
|
+
// and a mention after a space (rather than at the very start of the prompt)
|
|
156
|
+
// did not highlight at all.
|
|
157
|
+
// The boundary is a NEGATIVE set, not a whitelist of openers. The old
|
|
158
|
+
// whitelist ([\s([{"'>]) meant any other character glued to a mention killed
|
|
159
|
+
// the chip — "×@tel_aviv_invasion" rendered as flat text. Excluding word chars
|
|
160
|
+
// keeps the thing that whitelist was really protecting: an email's "a@b.com"
|
|
161
|
+
// has a word char before the @, so it still never lights up. (. and - are in
|
|
162
|
+
// the set for the same reason: "file.name@host", "co-op@x".)
|
|
163
|
+
var MENTION_RE = /(^|[^\\w@.-])([@#][A-Za-z][\\w-]*)/g;
|
|
152
164
|
// #ff8800 / #fff are hex colors, and prompts are full of them. A moodboard tag
|
|
153
165
|
// that happens to be 3 or 6 hex letters loses this coin flip; a grading note
|
|
154
166
|
// mistaken for a moodboard is the worse read.
|
|
@@ -164,7 +176,8 @@ function renderChips(sc) {
|
|
|
164
176
|
var h = modelChipHTML(modelLabel(sc), sc.model_icon);
|
|
165
177
|
var s = sc.settings || {};
|
|
166
178
|
if (sc.kind) h += chip(iconFor(sc.kind) + ' ' + sc.kind);
|
|
167
|
-
if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration));
|
|
179
|
+
if (s.duration) h += chip(ICONS.clock + ' ' + fmtDur(s.duration) + (s.shots > 1 ? ' · ' + s.shots + ' shots' : ''));
|
|
180
|
+
else if (s.shots > 1) h += chip(s.shots + ' shots');
|
|
168
181
|
if (s.resolution) h += chip(esc(s.resolution));
|
|
169
182
|
if (s.aspect_ratio) h += chip(esc(s.aspect_ratio));
|
|
170
183
|
if (s.quality) h += chip(esc(s.quality) + ' quality');
|
|
@@ -173,9 +186,7 @@ function renderChips(sc) {
|
|
|
173
186
|
// Ids where we have them (title = the id, so it can be copied / reused),
|
|
174
187
|
// falling back to the old count/boolean shape for payloads generated before
|
|
175
188
|
// the ids were carried.
|
|
176
|
-
|
|
177
|
-
if (dnaIds.length) h += chipT(dnaIds.length + ' Visual DNA', dnaIds.join('\\n'));
|
|
178
|
-
else if (s.visual_dna) h += chip(s.visual_dna + ' Visual DNA');
|
|
189
|
+
h += dnaChipsHTML(sc, s);
|
|
179
190
|
var mbIds = s.moodboard_ids || (s.moodboard_id ? [s.moodboard_id] : []);
|
|
180
191
|
if (mbIds.length) h += chipT(mbIds.length > 1 ? mbIds.length + ' moodboards' : 'moodboard', mbIds.join('\\n'));
|
|
181
192
|
else if (s.moodboard) h += chip('moodboard');
|
|
@@ -192,12 +203,94 @@ function renderChips(sc) {
|
|
|
192
203
|
}
|
|
193
204
|
if (s.mode) h += chip(esc(s.mode));
|
|
194
205
|
if (sc.count > 1) h += chip('×' + sc.count);
|
|
195
|
-
|
|
196
|
-
|
|
206
|
+
h += referenceHTML(sc);
|
|
207
|
+
el('chips').innerHTML = h;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
var REF_VIDEO_RE = /\\.(mp4|mov|webm|mkv|avi|m4v)(\\?|#|$)/i;
|
|
211
|
+
var REF_AUDIO_RE = /\\.(mp3|wav|m4a|aac|ogg|flac)(\\?|#|$)/i;
|
|
212
|
+
|
|
213
|
+
// Every reference the generation was actually given — images, videos AND audio.
|
|
214
|
+
// Kind is taken from the URL rather than from which field it arrived in: callers
|
|
215
|
+
// legitimately pack a video into reference_images (Elements files, v2v
|
|
216
|
+
// elements), and an <img> pointed at an .mp4 renders nothing, so those
|
|
217
|
+
// references were silently invisible. Extension wins, field is the fallback.
|
|
218
|
+
function refKind(url, fallback) {
|
|
219
|
+
if (REF_VIDEO_RE.test(url)) return 'video';
|
|
220
|
+
if (REF_AUDIO_RE.test(url)) return 'audio';
|
|
221
|
+
return fallback;
|
|
222
|
+
}
|
|
223
|
+
function collectRefs(sc) {
|
|
224
|
+
var out = [];
|
|
225
|
+
var push = function (list, fallback) {
|
|
226
|
+
(Array.isArray(list) ? list : []).forEach(function (url) {
|
|
227
|
+
if (typeof url !== 'string' || !url) return;
|
|
228
|
+
if (out.some(function (r) { return r.url === url; })) return;
|
|
229
|
+
out.push({ url: url, kind: refKind(url, fallback) });
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
push(sc.reference_images && sc.reference_images.length ? sc.reference_images
|
|
233
|
+
: (sc.reference_image ? [sc.reference_image] : []), 'image');
|
|
234
|
+
push(sc.reference_videos, 'video');
|
|
235
|
+
push(sc.reference_audio, 'audio');
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
function referenceHTML(sc) {
|
|
239
|
+
var refs = collectRefs(sc);
|
|
240
|
+
var h = '';
|
|
197
241
|
for (var i = 0; i < refs.length; i++) {
|
|
198
|
-
|
|
242
|
+
var url = esc(refs[i].url);
|
|
243
|
+
var title = 'Reference ' + refs[i].kind + ' ' + (i + 1) + ' of ' + refs.length;
|
|
244
|
+
if (refs[i].kind === 'video') {
|
|
245
|
+
// #t=0.1 so the poster is a real frame, not a black canvas.
|
|
246
|
+
h += '<video class="k-ref-thumb" src="' + url + '#t=0.1" muted playsinline preload="metadata" title="'
|
|
247
|
+
+ title + '" onerror="this.style.display=\\'none\\'"></video>';
|
|
248
|
+
} else if (refs[i].kind === 'audio') {
|
|
249
|
+
h += '<span class="k-chip" title="' + title + '">' + ICONS.sound + ' audio ref</span>';
|
|
250
|
+
} else {
|
|
251
|
+
h += '<img class="k-ref-thumb" src="' + url + '" alt="" loading="lazy" title="' + title
|
|
252
|
+
+ '" onerror="this.style.display=\\'none\\'">';
|
|
253
|
+
}
|
|
199
254
|
}
|
|
200
|
-
|
|
255
|
+
return h;
|
|
256
|
+
}
|
|
257
|
+
// Which characters/looks are locked into this generation — by face and name,
|
|
258
|
+
// resolved from visual_dna_ids server-side. "1 Visual DNA" told the user nothing
|
|
259
|
+
// about WHICH DNA. Up to DNA_CHIP_MAX get their own named chip; beyond that they
|
|
260
|
+
// collapse into one stack of overlapping faces whose tooltip lists every name,
|
|
261
|
+
// so a 12-DNA scene can't push the model and aspect chips off the card.
|
|
262
|
+
var DNA_CHIP_MAX = 3;
|
|
263
|
+
function dnaFaceHTML(dna, cls) {
|
|
264
|
+
if (dna.thumbnail) {
|
|
265
|
+
return '<img class="' + cls + '" src="' + esc(dna.thumbnail) + '" alt="" loading="lazy" onerror="this.style.display=\\'none\\'">';
|
|
266
|
+
}
|
|
267
|
+
return '';
|
|
268
|
+
}
|
|
269
|
+
function dnaChipsHTML(sc, s) {
|
|
270
|
+
var dnas = Array.isArray(sc.visual_dnas) ? sc.visual_dnas : [];
|
|
271
|
+
if (!dnas.length) {
|
|
272
|
+
// Payloads from before the ids were resolved (or an offline resolve).
|
|
273
|
+
var ids = s.visual_dna_ids || [];
|
|
274
|
+
if (ids.length) return chipT(ids.length + ' Visual DNA', ids.join('\\n'));
|
|
275
|
+
if (s.visual_dna) return chip(s.visual_dna + ' Visual DNA');
|
|
276
|
+
return '';
|
|
277
|
+
}
|
|
278
|
+
var h = '';
|
|
279
|
+
if (dnas.length <= DNA_CHIP_MAX) {
|
|
280
|
+
for (var i = 0; i < dnas.length; i++) {
|
|
281
|
+
h += '<span class="k-chip" title="' + esc(dnas[i].id) + '">'
|
|
282
|
+
+ dnaFaceHTML(dnas[i], 'k-dna-face') + esc(dnas[i].name) + '</span>';
|
|
283
|
+
}
|
|
284
|
+
return h;
|
|
285
|
+
}
|
|
286
|
+
var names = [];
|
|
287
|
+
var stack = '';
|
|
288
|
+
for (var j = 0; j < dnas.length; j++) {
|
|
289
|
+
names.push(dnas[j].name);
|
|
290
|
+
if (j < 4) stack += '<span class="k-dna-stack-item">' + dnaFaceHTML(dnas[j], 'k-dna-face') + '</span>';
|
|
291
|
+
}
|
|
292
|
+
return '<span class="k-chip k-dna-stack" title="' + esc(names.join('\\n')) + '">'
|
|
293
|
+
+ stack + dnas.length + ' Visual DNA</span>';
|
|
201
294
|
}
|
|
202
295
|
function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
|
|
203
296
|
// Same chip with a hover title — used to surface the asset id behind a
|
|
@@ -617,7 +710,12 @@ function renderLinks(urls) {
|
|
|
617
710
|
// Small hover download button attached to a media cell (per-item downloads —
|
|
618
711
|
// batch grids and CD scenes have no single "current" url for the action row).
|
|
619
712
|
function dlBtnHTML(u) {
|
|
620
|
-
|
|
713
|
+
// Attach sits beside Download on the same hover overlay. It is the reliable
|
|
714
|
+
// route into the composer — see window.kolbo.attachMedia for why dragging the
|
|
715
|
+
// media out of the iframe cannot be made to work.
|
|
716
|
+
return '<button class="k-dl k-attach" data-attach="' + esc(u) + '" title="Attach to prompt" aria-label="Attach to prompt">'
|
|
717
|
+
+ ICONS.upload + '</button>'
|
|
718
|
+
+ '<button class="k-dl" data-dl="' + esc(u) + '" title="Download" aria-label="Download">' + ICONS.download + '</button>';
|
|
621
719
|
}
|
|
622
720
|
function wireDlButtons(root) {
|
|
623
721
|
Array.prototype.forEach.call((root || document).querySelectorAll('.k-dl[data-dl]'), function (b) {
|
|
@@ -626,6 +724,12 @@ function wireDlButtons(root) {
|
|
|
626
724
|
window.kolbo.openLink(downloadUrl(b.getAttribute('data-dl')));
|
|
627
725
|
};
|
|
628
726
|
});
|
|
727
|
+
Array.prototype.forEach.call((root || document).querySelectorAll('.k-attach[data-attach]'), function (b) {
|
|
728
|
+
b.onclick = function (e) {
|
|
729
|
+
e.stopPropagation();
|
|
730
|
+
window.kolbo.attachMedia(b.getAttribute('data-attach'));
|
|
731
|
+
};
|
|
732
|
+
});
|
|
629
733
|
}
|
|
630
734
|
|
|
631
735
|
// CD scenes render as the SAME viewer+thumbnail carousel as image batches —
|
|
@@ -63,10 +63,15 @@ function boot(sc) {
|
|
|
63
63
|
function cellHTML(item, i) {
|
|
64
64
|
var idx = state.items.indexOf(item);
|
|
65
65
|
var isVideo = item.media_type === 'video' && item.url;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// onerror matters: a thumbnail that 404s / expires / can't be fetched by the
|
|
67
|
+
// host webview left a BLACK tile with no glyph and no label hint — visually
|
|
68
|
+
// identical to "corrupted media". Fall back to the same kind icon a
|
|
69
|
+
// thumbnail-less item gets, so a dead URL degrades instead of looking broken.
|
|
70
|
+
var fallbackCell = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-faint);font-size:22px">' +
|
|
69
71
|
kindIcon(item.media_type) + '</div>';
|
|
72
|
+
var media = item.thumbnail
|
|
73
|
+
? '<img src="' + esc(item.thumbnail) + '" loading="lazy" alt="" onerror="this.parentNode.innerHTML=this.getAttribute(\\'data-fb\\')" data-fb="' + esc(fallbackCell) + '">'
|
|
74
|
+
: fallbackCell;
|
|
70
75
|
return '<div class="k-cell" data-i="' + idx + '">' +
|
|
71
76
|
'<div class="k-cell-media">' + media +
|
|
72
77
|
(isVideo ? '<button class="k-play k-cell-play" data-video-play="' + esc(item.url) + '">' + ICONS.play + '</button>' : '') +
|
package/src/tools/_shared.js
CHANGED
|
@@ -512,6 +512,48 @@ async function modelChipFields(client, model) {
|
|
|
512
512
|
};
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Resolve `visual_dna_ids` to {id, name, thumbnail} so the card can show WHICH
|
|
517
|
+
* characters are locked in, not just how many. An id tells the user nothing;
|
|
518
|
+
* the face does.
|
|
519
|
+
*
|
|
520
|
+
* One list fetch per process, cached — the DNA catalog barely moves within a
|
|
521
|
+
* session, and a generation card must never add a round-trip per chip. A miss
|
|
522
|
+
* (id not in the caller's own DNAs) degrades to the bare id, which is exactly
|
|
523
|
+
* what the card showed before.
|
|
524
|
+
*/
|
|
525
|
+
const _dnaChipCache = new Map();
|
|
526
|
+
let _dnaChipLoaded = 0;
|
|
527
|
+
const DNA_CHIP_TTL = 5 * 60 * 1000;
|
|
528
|
+
|
|
529
|
+
async function resolveVisualDnas(client, ids) {
|
|
530
|
+
const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : [];
|
|
531
|
+
if (!list.length) return [];
|
|
532
|
+
|
|
533
|
+
const stale = Date.now() - _dnaChipLoaded > DNA_CHIP_TTL;
|
|
534
|
+
if (stale || list.some((id) => !_dnaChipCache.has(id))) {
|
|
535
|
+
try {
|
|
536
|
+
const res = await client.get('/v1/visual-dna?scope=mine');
|
|
537
|
+
const rows = res?.visual_dnas || res?.data || [];
|
|
538
|
+
for (const row of rows) {
|
|
539
|
+
const id = row?.id || row?._id;
|
|
540
|
+
if (!id) continue;
|
|
541
|
+
_dnaChipCache.set(String(id), {
|
|
542
|
+
id: String(id),
|
|
543
|
+
name: row.name || String(id),
|
|
544
|
+
// Same hero-image rule as the app: reference sheet, then thumbnail.
|
|
545
|
+
thumbnail: row.sheet_url || row.thumbnail_url || (Array.isArray(row.images) ? row.images[0] : null) || null,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
_dnaChipLoaded = Date.now();
|
|
549
|
+
} catch {
|
|
550
|
+
// Offline / rate-limited — fall through to bare ids.
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return list.map((id) => _dnaChipCache.get(id) || { id, name: id, thumbnail: null });
|
|
555
|
+
}
|
|
556
|
+
|
|
515
557
|
/**
|
|
516
558
|
* Build the "submitted — widget is live" tool result for a UI host.
|
|
517
559
|
* @param {object} p
|
|
@@ -543,6 +585,7 @@ async function uiGenerating(p) {
|
|
|
543
585
|
prompt: p.prompt,
|
|
544
586
|
count: p.count || 1,
|
|
545
587
|
settings: p.settings || {},
|
|
588
|
+
visual_dnas: await resolveVisualDnas(p.client, (p.settings || {}).visual_dna_ids),
|
|
546
589
|
// `reference_image` is retained for older widget builds. New widgets render
|
|
547
590
|
// every browser-loadable image supplied to the generation.
|
|
548
591
|
reference_images: Array.isArray(p.reference_images)
|
|
@@ -573,10 +616,20 @@ async function uiGenerating(p) {
|
|
|
573
616
|
}
|
|
574
617
|
|
|
575
618
|
/**
|
|
576
|
-
* Wrap an already-completed generation result with the widget
|
|
577
|
-
*
|
|
619
|
+
* Wrap an already-completed generation result with the widget.
|
|
620
|
+
*
|
|
621
|
+
* Used by tools that stay blocking even on UI hosts (creative director), AND —
|
|
622
|
+
* since structuredContent costs a text host nothing — by every generation tool
|
|
623
|
+
* on its normal blocking return. That second case is why model names, model
|
|
624
|
+
* avatars and Visual DNA chips were missing in Kolbo Code: it does not advertise
|
|
625
|
+
* MCP Apps, so `ui()` is false, `uiGenerating` never runs, and the host had to
|
|
626
|
+
* rebuild the card from raw text that carries only a model IDENTIFIER. Shipping
|
|
627
|
+
* the resolved payload here fixes every non-Apps host at once, exactly the way
|
|
628
|
+
* the list tools already do it (see listResult).
|
|
629
|
+
*
|
|
630
|
+
* The TEXT is unchanged, so text-only hosts see precisely what they saw before.
|
|
578
631
|
*/
|
|
579
|
-
async function uiCompleted(p, textPayload) {
|
|
632
|
+
async function uiCompleted(p, textPayload, extraContent) {
|
|
580
633
|
const chip = await modelChipFields(p.client, p.model);
|
|
581
634
|
const structured = {
|
|
582
635
|
phase: 'completed',
|
|
@@ -587,6 +640,7 @@ async function uiCompleted(p, textPayload) {
|
|
|
587
640
|
prompt: p.prompt,
|
|
588
641
|
count: p.count || 1,
|
|
589
642
|
settings: p.settings || {},
|
|
643
|
+
visual_dnas: await resolveVisualDnas(p.client, (p.settings || {}).visual_dna_ids),
|
|
590
644
|
reference_images: Array.isArray(p.reference_images)
|
|
591
645
|
? p.reference_images.filter(Boolean)
|
|
592
646
|
: (p.reference_image ? [p.reference_image] : []),
|
|
@@ -599,7 +653,11 @@ async function uiCompleted(p, textPayload) {
|
|
|
599
653
|
credits_used: p.credits_used,
|
|
600
654
|
open_url: buildOpenUrl(p.tool, p.gen),
|
|
601
655
|
};
|
|
602
|
-
|
|
656
|
+
const out = uiResult(UI.generation, textPayload, structured);
|
|
657
|
+
if (Array.isArray(extraContent) && extraContent.length) {
|
|
658
|
+
out.content = [...out.content, ...extraContent];
|
|
659
|
+
}
|
|
660
|
+
return out;
|
|
603
661
|
}
|
|
604
662
|
|
|
605
663
|
// ─── Text-payload budget ─────────────────────────────────────────────────────
|
package/src/tools/generate.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const FormData = require('form-data');
|
|
8
8
|
const { pollUntilDone, waitWindowMs } = require('../polling');
|
|
9
|
-
const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
|
|
9
|
+
const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, uiCompleted, appsEnabled } = require('./_shared');
|
|
10
10
|
const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
|
|
11
11
|
|
|
12
12
|
// ─── Cinematic Dimensions schema (shared by generate_image + generate_image_edit) ───
|
|
@@ -125,7 +125,13 @@ const imageSettings = (a = {}) => ({
|
|
|
125
125
|
// Same block for the video tools, which carried only { duration, resolution,
|
|
126
126
|
// aspect_ratio } — a DNA-anchored video card showed no sign a DNA was in play.
|
|
127
127
|
const videoSettings = (a = {}) => ({
|
|
128
|
-
|
|
128
|
+
// Multi-shot elements calls usually set shot COUNT and let the model take the
|
|
129
|
+
// default per-shot length, so `duration` came through undefined and the card
|
|
130
|
+
// showed model/resolution/ratio but never how long the video actually is —
|
|
131
|
+
// the one number the user picked and is paying for. Fall back to what the API
|
|
132
|
+
// echoed back on start, then to per-shot × shots.
|
|
133
|
+
duration: a.duration ?? a.reported_duration ?? (a.shot_duration && a.shots ? a.shot_duration * a.shots : undefined),
|
|
134
|
+
...(a.shots > 1 ? { shots: a.shots } : {}),
|
|
129
135
|
resolution: a.resolution,
|
|
130
136
|
aspect_ratio: a.aspect_ratio,
|
|
131
137
|
...(a.mode ? { mode: a.mode } : {}),
|
|
@@ -218,19 +224,20 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
218
224
|
const result = poll.result;
|
|
219
225
|
|
|
220
226
|
const images = await inlineImageBlocks(result.result.urls, { enabled: inlineImages });
|
|
221
|
-
return {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
227
|
+
return uiCompleted({
|
|
228
|
+
tool: 'generate_image', kind: 'image', gen, client, model, prompt,
|
|
229
|
+
count: num_images, settings: imageSettings(shared),
|
|
230
|
+
reference_images,
|
|
231
|
+
urls: result.result.urls,
|
|
232
|
+
credits_used: creditFields(result).credits_used,
|
|
233
|
+
}, JSON.stringify({
|
|
234
|
+
...creditFields(result),
|
|
235
|
+
session_id: gen.session_id,
|
|
236
|
+
urls: result.result.urls,
|
|
237
|
+
model: result.result.model,
|
|
238
|
+
prompt_used: result.result.prompt_used,
|
|
239
|
+
_followup_hint: 'If the user asks to edit/change/modify this image next (scene, lighting, objects, style, color — any content edit), pass urls[0] to generate_image_edit. Use edit_image ONLY for mechanical ops (upscale/reframe/removebg/enhance_skin). Do NOT call generate_image again.'
|
|
240
|
+
}, null, 2), images);
|
|
234
241
|
}
|
|
235
242
|
);
|
|
236
243
|
|
|
@@ -305,19 +312,20 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
305
312
|
const result = poll.result;
|
|
306
313
|
|
|
307
314
|
const images = await inlineImageBlocks(result.result.urls, { enabled: inlineImages });
|
|
308
|
-
return {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
315
|
+
return uiCompleted({
|
|
316
|
+
tool: 'generate_image_edit', kind: 'image', gen, client, model, prompt,
|
|
317
|
+
count: num_images, settings: imageSettings(shared),
|
|
318
|
+
reference_images: source_images,
|
|
319
|
+
urls: result.result.urls,
|
|
320
|
+
credits_used: creditFields(result).credits_used,
|
|
321
|
+
}, JSON.stringify({
|
|
322
|
+
...creditFields(result),
|
|
323
|
+
session_id: gen.session_id,
|
|
324
|
+
urls: result.result.urls,
|
|
325
|
+
model: result.result.model,
|
|
326
|
+
prompt_used: result.result.prompt_used,
|
|
327
|
+
_followup_hint: 'If the user asks for another edit on this output, pass urls[0] back into generate_image_edit as source_images. For targeted ops (upscale/reframe/removebg/enhance_skin) use edit_image instead. Do NOT call generate_image from scratch.'
|
|
328
|
+
}, null, 2), images);
|
|
321
329
|
}
|
|
322
330
|
);
|
|
323
331
|
|
|
@@ -532,21 +540,24 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
532
540
|
if (poll.timedOut) return poll.timedOut;
|
|
533
541
|
const result = poll.result;
|
|
534
542
|
|
|
535
|
-
return {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
543
|
+
return uiCompleted({
|
|
544
|
+
tool: 'generate_video', kind: 'video', gen, client, model, prompt,
|
|
545
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, preset_id }),
|
|
546
|
+
reference_images,
|
|
547
|
+
urls: result.result.urls,
|
|
548
|
+
thumbnail_url: result.result.thumbnail_url,
|
|
549
|
+
duration: result.result.duration,
|
|
550
|
+
credits_used: creditFields(result).credits_used,
|
|
551
|
+
}, JSON.stringify({
|
|
552
|
+
...creditFields(result),
|
|
553
|
+
session_id: gen.session_id,
|
|
554
|
+
urls: result.result.urls,
|
|
555
|
+
model: result.result.model,
|
|
556
|
+
duration: result.result.duration,
|
|
557
|
+
thumbnail_url: result.result.thumbnail_url,
|
|
558
|
+
prompt_used: result.result.prompt_used,
|
|
559
|
+
_followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video (upscale/reframe/face_swap/extend/generate_audio/lipsync/magic_edit) or generate_video_from_video (restyle). Do NOT call generate_video from scratch.'
|
|
560
|
+
}, null, 2));
|
|
550
561
|
}
|
|
551
562
|
);
|
|
552
563
|
|
|
@@ -615,20 +626,23 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
615
626
|
if (poll.timedOut) return poll.timedOut;
|
|
616
627
|
const result = poll.result;
|
|
617
628
|
|
|
618
|
-
return {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
629
|
+
return uiCompleted({
|
|
630
|
+
tool: 'generate_video_from_image', kind: 'video', gen, client, model, prompt,
|
|
631
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
|
|
632
|
+
reference_images: [image_url],
|
|
633
|
+
urls: result.result.urls,
|
|
634
|
+
thumbnail_url: result.result.thumbnail_url,
|
|
635
|
+
duration: result.result.duration,
|
|
636
|
+
credits_used: creditFields(result).credits_used,
|
|
637
|
+
}, JSON.stringify({
|
|
638
|
+
...creditFields(result),
|
|
639
|
+
session_id: gen.session_id,
|
|
640
|
+
urls: result.result.urls,
|
|
641
|
+
model: result.result.model,
|
|
642
|
+
duration: result.result.duration,
|
|
643
|
+
thumbnail_url: result.result.thumbnail_url,
|
|
644
|
+
_followup_hint: 'If the user asks to edit/restyle/extend this video next, pass urls[0] to edit_video or generate_video_from_video. Do NOT re-run generate_video_from_image unless they want a fresh animation from a different source image. Animating more shots of THIS same sequence? Pass the session_id above back on each of those calls so they all land in one session.'
|
|
645
|
+
}, null, 2));
|
|
632
646
|
}
|
|
633
647
|
);
|
|
634
648
|
|
|
@@ -680,19 +694,21 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
680
694
|
if (poll.timedOut) return poll.timedOut;
|
|
681
695
|
const result = poll.result;
|
|
682
696
|
|
|
683
|
-
return {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
697
|
+
return uiCompleted({
|
|
698
|
+
tool: 'generate_music', kind: 'audio', gen, client, model: model || 'Suno', prompt,
|
|
699
|
+
settings: { mode: instrumental ? 'instrumental' : (style || undefined) },
|
|
700
|
+
urls: result.result.urls,
|
|
701
|
+
title: result.result.title,
|
|
702
|
+
duration: result.result.duration,
|
|
703
|
+
credits_used: creditFields(result).credits_used,
|
|
704
|
+
}, JSON.stringify({
|
|
705
|
+
...creditFields(result),
|
|
706
|
+
session_id: gen.session_id,
|
|
707
|
+
urls: result.result.urls,
|
|
708
|
+
title: result.result.title,
|
|
709
|
+
duration: result.result.duration,
|
|
710
|
+
lyrics: result.result.lyrics
|
|
711
|
+
}, null, 2));
|
|
696
712
|
}
|
|
697
713
|
);
|
|
698
714
|
|
|
@@ -782,19 +798,26 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
782
798
|
if (poll.timedOut) return poll.timedOut;
|
|
783
799
|
const result = poll.result;
|
|
784
800
|
|
|
785
|
-
return {
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
}
|
|
801
|
+
return uiCompleted({
|
|
802
|
+
tool: 'generate_speech', kind: 'audio', gen, client, model, prompt: text,
|
|
803
|
+
voice: voiceRecord,
|
|
804
|
+
settings: {
|
|
805
|
+
voice: voice || 'Rachel',
|
|
806
|
+
style: selected_style || emotion || style_instructions_preset_id || style_instructions,
|
|
807
|
+
speaking_speed,
|
|
808
|
+
language,
|
|
809
|
+
},
|
|
810
|
+
urls: result.result.urls,
|
|
811
|
+
duration: result.result.duration,
|
|
812
|
+
credits_used: creditFields(result).credits_used,
|
|
813
|
+
}, JSON.stringify({
|
|
814
|
+
...creditFields(result),
|
|
815
|
+
session_id: gen.session_id,
|
|
816
|
+
urls: result.result.urls,
|
|
817
|
+
voice: result.result.voice,
|
|
818
|
+
duration: result.result.duration,
|
|
819
|
+
...(unknownVoice ? { _warning: unknownVoice } : {})
|
|
820
|
+
}, null, 2));
|
|
798
821
|
}
|
|
799
822
|
);
|
|
800
823
|
|
|
@@ -845,17 +868,19 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
845
868
|
if (poll.timedOut) return poll.timedOut;
|
|
846
869
|
const result = poll.result;
|
|
847
870
|
|
|
848
|
-
return {
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
871
|
+
return uiCompleted({
|
|
872
|
+
tool: 'generate_sound', kind: 'audio', gen, client, model, prompt,
|
|
873
|
+
settings: { duration },
|
|
874
|
+
reference_images: seed_reference_image_url ? [seed_reference_image_url] : [],
|
|
875
|
+
urls: result.result.urls,
|
|
876
|
+
duration: result.result.duration,
|
|
877
|
+
credits_used: creditFields(result).credits_used,
|
|
878
|
+
}, JSON.stringify({
|
|
879
|
+
...creditFields(result),
|
|
880
|
+
session_id: gen.session_id,
|
|
881
|
+
urls: result.result.urls,
|
|
882
|
+
duration: result.result.duration
|
|
883
|
+
}, null, 2));
|
|
859
884
|
}
|
|
860
885
|
);
|
|
861
886
|
|
|
@@ -1093,12 +1118,22 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1093
1118
|
|
|
1094
1119
|
if (ui()) return uiGenerating({
|
|
1095
1120
|
tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
|
|
1096
|
-
settings: videoSettings({
|
|
1121
|
+
settings: videoSettings({
|
|
1122
|
+
duration,
|
|
1123
|
+
reported_duration: startResponse?.duration ?? startResponse?.result?.duration,
|
|
1124
|
+
shots: multi_shot_count ?? (Array.isArray(multi_shots) ? multi_shots.length : undefined),
|
|
1125
|
+
resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id,
|
|
1126
|
+
}),
|
|
1097
1127
|
reference_images: [
|
|
1098
1128
|
...(reference_images || []),
|
|
1099
1129
|
...(keyframes || []).map((keyframe) => keyframe.image_url),
|
|
1100
1130
|
...(files || []).filter((source) => /^https?:\/\//i.test(source))
|
|
1101
|
-
]
|
|
1131
|
+
],
|
|
1132
|
+
// Elements is the one tool that takes all three modalities. The widget
|
|
1133
|
+
// sorts kind by extension, so a video that arrived via `files` is still
|
|
1134
|
+
// rendered as a video — these two just make sure nothing is dropped.
|
|
1135
|
+
reference_videos: reference_videos || [],
|
|
1136
|
+
reference_audio: [...(reference_audio_urls || []), ...(audio_url ? [audio_url] : [])]
|
|
1102
1137
|
});
|
|
1103
1138
|
|
|
1104
1139
|
const poll = await pollOrTimedOut(client, startResponse.generation_id, {
|
|
@@ -1108,19 +1143,33 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1108
1143
|
if (poll.timedOut) return poll.timedOut;
|
|
1109
1144
|
const result = poll.result;
|
|
1110
1145
|
|
|
1111
|
-
return {
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1146
|
+
return uiCompleted({
|
|
1147
|
+
tool: 'generate_elements', kind: 'video', gen: startResponse, client, model, prompt,
|
|
1148
|
+
settings: videoSettings({
|
|
1149
|
+
duration,
|
|
1150
|
+
reported_duration: result.result?.duration,
|
|
1151
|
+
shots: multi_shot_count ?? (Array.isArray(multi_shots) ? multi_shots.length : undefined),
|
|
1152
|
+
resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id,
|
|
1153
|
+
}),
|
|
1154
|
+
reference_images: [
|
|
1155
|
+
...(reference_images || []),
|
|
1156
|
+
...(keyframes || []).map((keyframe) => keyframe.image_url),
|
|
1157
|
+
...(files || []).filter((source) => /^https?:\/\//i.test(source))
|
|
1158
|
+
],
|
|
1159
|
+
reference_videos: reference_videos || [],
|
|
1160
|
+
reference_audio: [...(reference_audio_urls || []), ...(audio_url ? [audio_url] : [])],
|
|
1161
|
+
urls: result.result?.urls || [],
|
|
1162
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1163
|
+
duration: result.result?.duration || null,
|
|
1164
|
+
credits_used: creditFields(result).credits_used,
|
|
1165
|
+
}, JSON.stringify({
|
|
1166
|
+
...creditFields(result),
|
|
1167
|
+
session_id: startResponse.session_id,
|
|
1168
|
+
urls: result.result?.urls || [],
|
|
1169
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1170
|
+
duration: result.result?.duration || null,
|
|
1171
|
+
model: result.result?.model || null
|
|
1172
|
+
}, null, 2));
|
|
1124
1173
|
}
|
|
1125
1174
|
);
|
|
1126
1175
|
|
|
@@ -1193,19 +1242,23 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1193
1242
|
if (poll.timedOut) return poll.timedOut;
|
|
1194
1243
|
const result = poll.result;
|
|
1195
1244
|
|
|
1196
|
-
return {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1245
|
+
return uiCompleted({
|
|
1246
|
+
tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
|
|
1247
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
|
|
1248
|
+
reference_images: [first_frame_url || first_frame, last_frame_url || last_frame]
|
|
1249
|
+
.filter((source) => /^https?:\/\//i.test(source || '')),
|
|
1250
|
+
urls: result.result?.urls || [],
|
|
1251
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1252
|
+
duration: result.result?.duration || null,
|
|
1253
|
+
credits_used: creditFields(result).credits_used,
|
|
1254
|
+
}, JSON.stringify({
|
|
1255
|
+
...creditFields(result),
|
|
1256
|
+
session_id: startResponse.session_id,
|
|
1257
|
+
urls: result.result?.urls || [],
|
|
1258
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1259
|
+
duration: result.result?.duration || null,
|
|
1260
|
+
model: result.result?.model || null
|
|
1261
|
+
}, null, 2));
|
|
1209
1262
|
}
|
|
1210
1263
|
);
|
|
1211
1264
|
|
|
@@ -1308,19 +1361,23 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1308
1361
|
if (poll.timedOut) return poll.timedOut;
|
|
1309
1362
|
const result = poll.result;
|
|
1310
1363
|
|
|
1311
|
-
return {
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1364
|
+
return uiCompleted({
|
|
1365
|
+
tool: 'generate_lipsync', kind: 'video', gen: startResponse, client, model,
|
|
1366
|
+
prompt: text_prompt, settings: { mode: 'lipsync' },
|
|
1367
|
+
reference_images: sourceIsUrl && !/\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? [source] : [],
|
|
1368
|
+
reference_videos: sourceIsUrl && /\.(mp4|mov|webm|mkv|avi|m4v)(\?|$)/i.test(source) ? [source] : [],
|
|
1369
|
+
urls: result.result?.urls || [],
|
|
1370
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1371
|
+
duration: result.result?.duration || null,
|
|
1372
|
+
credits_used: creditFields(result).credits_used,
|
|
1373
|
+
}, JSON.stringify({
|
|
1374
|
+
...creditFields(result),
|
|
1375
|
+
session_id: startResponse.session_id,
|
|
1376
|
+
urls: result.result?.urls || [],
|
|
1377
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1378
|
+
duration: result.result?.duration || null,
|
|
1379
|
+
model: result.result?.model || null
|
|
1380
|
+
}, null, 2));
|
|
1324
1381
|
}
|
|
1325
1382
|
);
|
|
1326
1383
|
|
|
@@ -1403,7 +1460,13 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1403
1460
|
tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
|
|
1404
1461
|
prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
|
|
1405
1462
|
settings: videoSettings({ duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle', enhance_prompt, visual_dna_ids }),
|
|
1406
|
-
reference_images: [...(reference_images || []), ...(elements || [])]
|
|
1463
|
+
reference_images: [...(reference_images || []), ...(elements || [])],
|
|
1464
|
+
// The source clip IS the primary reference for a restyle — showing the
|
|
1465
|
+
// extra references while hiding the video being transformed was backwards.
|
|
1466
|
+
reference_videos: [
|
|
1467
|
+
...(isUrl ? [source_video] : []),
|
|
1468
|
+
...(reference_videos || [])
|
|
1469
|
+
]
|
|
1407
1470
|
});
|
|
1408
1471
|
|
|
1409
1472
|
const poll = await pollOrTimedOut(client, startResponse.generation_id, {
|
|
@@ -1413,19 +1476,27 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1413
1476
|
if (poll.timedOut) return poll.timedOut;
|
|
1414
1477
|
const result = poll.result;
|
|
1415
1478
|
|
|
1416
|
-
return {
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1479
|
+
return uiCompleted({
|
|
1480
|
+
tool: 'generate_video_from_video', kind: 'video', gen: startResponse, client, model,
|
|
1481
|
+
prompt: prompt || (preset ? `Subtitles preset: ${preset}` : undefined),
|
|
1482
|
+
settings: videoSettings({ duration, resolution, aspect_ratio, mode: preset ? 'subtitles' : 'restyle', enhance_prompt, visual_dna_ids }),
|
|
1483
|
+
reference_images: [...(reference_images || []), ...(elements || [])],
|
|
1484
|
+
reference_videos: [
|
|
1485
|
+
...(isUrl ? [source_video] : []),
|
|
1486
|
+
...(reference_videos || [])
|
|
1487
|
+
],
|
|
1488
|
+
urls: result.result?.urls || [],
|
|
1489
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1490
|
+
duration: result.result?.duration || null,
|
|
1491
|
+
credits_used: creditFields(result).credits_used,
|
|
1492
|
+
}, JSON.stringify({
|
|
1493
|
+
...creditFields(result),
|
|
1494
|
+
session_id: startResponse.session_id,
|
|
1495
|
+
urls: result.result?.urls || [],
|
|
1496
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1497
|
+
duration: result.result?.duration || null,
|
|
1498
|
+
model: result.result?.model || null
|
|
1499
|
+
}, null, 2));
|
|
1429
1500
|
}
|
|
1430
1501
|
);
|
|
1431
1502
|
|
|
@@ -1556,18 +1627,20 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1556
1627
|
if (poll.timedOut) return poll.timedOut;
|
|
1557
1628
|
const result = poll.result;
|
|
1558
1629
|
|
|
1559
|
-
return {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1630
|
+
return uiCompleted({
|
|
1631
|
+
tool: 'generate_3d', kind: '3d', gen: startResponse, client, model, prompt,
|
|
1632
|
+
settings: { mode: mode || (reference_images?.length > 1 ? 'multi' : reference_images?.length === 1 ? 'single' : 'text') },
|
|
1633
|
+
reference_images,
|
|
1634
|
+
urls: result.result?.urls || [],
|
|
1635
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1636
|
+
credits_used: creditFields(result).credits_used,
|
|
1637
|
+
}, JSON.stringify({
|
|
1638
|
+
...creditFields(result),
|
|
1639
|
+
urls: result.result?.urls || [],
|
|
1640
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
1641
|
+
mode: result.result?.mode || null,
|
|
1642
|
+
prompt_used: result.result?.prompt_used || null
|
|
1643
|
+
}, null, 2));
|
|
1571
1644
|
}
|
|
1572
1645
|
);
|
|
1573
1646
|
// ─── edit_image ────────────────────────────────────────────
|
|
@@ -1702,18 +1775,20 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1702
1775
|
if (poll.timedOut) return poll.timedOut;
|
|
1703
1776
|
const result = poll.result;
|
|
1704
1777
|
|
|
1705
|
-
return {
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1778
|
+
return uiCompleted({
|
|
1779
|
+
tool: 'edit_image', kind: 'image', gen, client, model,
|
|
1780
|
+
prompt: prompt || operation,
|
|
1781
|
+
settings: { mode: operation, aspect_ratio, scale, resolution },
|
|
1782
|
+
reference_images: [image_url, mask_image_url, ...(additional_images || [])].filter(Boolean),
|
|
1783
|
+
urls: result.result?.urls || [],
|
|
1784
|
+
credits_used: creditFields(result).credits_used,
|
|
1785
|
+
}, JSON.stringify({
|
|
1786
|
+
...creditFields(result),
|
|
1787
|
+
session_id: gen.session_id,
|
|
1788
|
+
urls: result.result?.urls || [],
|
|
1789
|
+
edit_type: result.result?.edit_type || null,
|
|
1790
|
+
model: result.result?.model || null
|
|
1791
|
+
}, null, 2));
|
|
1717
1792
|
}
|
|
1718
1793
|
);
|
|
1719
1794
|
|
|
@@ -1867,20 +1942,23 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1867
1942
|
if (poll.timedOut) return poll.timedOut;
|
|
1868
1943
|
const result = poll.result;
|
|
1869
1944
|
|
|
1870
|
-
return {
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1945
|
+
return uiCompleted({
|
|
1946
|
+
tool: 'edit_video', kind: 'video', gen, client, model,
|
|
1947
|
+
prompt: prompt || operation,
|
|
1948
|
+
settings: { mode: operation, duration, aspect_ratio, resolution },
|
|
1949
|
+
reference_images: image_url ? [image_url] : [],
|
|
1950
|
+
urls: result.result?.urls || [],
|
|
1951
|
+
duration: result.result?.duration || null,
|
|
1952
|
+
credits_used: creditFields(result).credits_used,
|
|
1953
|
+
}, JSON.stringify({
|
|
1954
|
+
...creditFields(result),
|
|
1955
|
+
session_id: gen.session_id,
|
|
1956
|
+
urls: result.result?.urls || [],
|
|
1957
|
+
download_url: result.result?.download_url || null,
|
|
1958
|
+
edit_type: result.result?.edit_type || null,
|
|
1959
|
+
duration: result.result?.duration || null,
|
|
1960
|
+
model: result.result?.model || null
|
|
1961
|
+
}, null, 2));
|
|
1884
1962
|
}
|
|
1885
1963
|
);
|
|
1886
1964
|
|
package/src/tools/media.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const FormData = require('form-data');
|
|
8
8
|
const { resolveToBuffer, DEFAULT_MAX_FILE_MB, compactList } = require('./_shared');
|
|
9
|
-
const { UI, uiResult, listResult
|
|
9
|
+
const { UI, uiResult, listResult } = require('../apps');
|
|
10
10
|
|
|
11
11
|
// How many tiles the media grid renders. A rendering limit only — the text
|
|
12
12
|
// payload always carries the full page, and `total` reports the real library
|
|
@@ -65,8 +65,6 @@ function uploadTicketPayload(ticket) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function registerMediaTools(server, client, options = {}) {
|
|
68
|
-
const ui = () => appsEnabled(server, options);
|
|
69
|
-
|
|
70
68
|
// `opts.apps` is set only by kolbo-api's per-request server (see createServer
|
|
71
69
|
// in ../index.js), which makes it a TRANSPORT signal — deliberately not
|
|
72
70
|
// `appsEnabled()`, which also returns true for stdio hosts that advertise UI.
|
|
@@ -101,24 +99,27 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
101
99
|
expires_in_seconds: ticket.expires_in,
|
|
102
100
|
};
|
|
103
101
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
102
|
+
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
103
|
+
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
104
|
+
// rebuilt items from the compactList text, whose field names are
|
|
105
|
+
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
106
|
+
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
107
|
+
// upload_ui_url: top-level page for Claude iOS/Android — in-iframe
|
|
108
|
+
// <input type=file> selections are dropped by WebKit (see upload widget).
|
|
109
|
+
const uploadUiUrl = ticket.upload_ui_url
|
|
110
|
+
|| String(ticket.upload_url || '').replace(/\/upload\/?$/, '/upload-ui');
|
|
111
|
+
return uiResult(UI.upload, JSON.stringify(info, null, 2), {
|
|
112
|
+
widget: 'upload',
|
|
113
|
+
title: purpose || 'Upload media',
|
|
114
|
+
upload_url: ticket.upload_url,
|
|
115
|
+
upload_ui_url: uploadUiUrl,
|
|
116
|
+
token: ticket.token,
|
|
117
|
+
expires_at: Date.now() + (ticket.expires_in || 900) * 1000,
|
|
118
|
+
kinds: media_types && media_types.length ? media_types : undefined,
|
|
119
|
+
max_files: Math.min(Math.max(Number(max_files) || 10, 1), 20),
|
|
120
|
+
max_mb: ticket.max_file_mb || DEFAULT_MAX_FILE_MB,
|
|
121
|
+
...(project_id ? { project_id } : {}),
|
|
122
|
+
});
|
|
122
123
|
|
|
123
124
|
// Text-only host (Claude Code, Codex CLI, Cursor): no iframe to render —
|
|
124
125
|
// but these are exactly the hosts that CAN reach a filesystem, so hand
|
|
@@ -263,37 +264,33 @@ function registerMediaTools(server, client, options = {}) {
|
|
|
263
264
|
note: 'Narrow with `type`, `category`, `project_id`, `folder_id`, or `search`; get_media returns one item in full.',
|
|
264
265
|
});
|
|
265
266
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
type: 'text',
|
|
294
|
-
text
|
|
295
|
-
}]
|
|
296
|
-
};
|
|
267
|
+
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
268
|
+
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
269
|
+
// rebuilt items from the compactList text, whose field names are
|
|
270
|
+
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
271
|
+
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
272
|
+
// The SDK envelope reports `total_items` (see sdk/controller.js listMedia);
|
|
273
|
+
// reading `total` always came back undefined, so the grid claimed the page
|
|
274
|
+
// size was the whole library. Accept either, then fall back.
|
|
275
|
+
const totalItems = pagination
|
|
276
|
+
? (pagination.total_items != null ? pagination.total_items : pagination.total)
|
|
277
|
+
: null;
|
|
278
|
+
const items = media.slice(0, GRID_CAP).map((m) => ({
|
|
279
|
+
id: m.id,
|
|
280
|
+
title: m.filename,
|
|
281
|
+
subtitle: m.media_type + (m.size ? ' · ' + Math.round(m.size / 1024) + 'KB' : ''),
|
|
282
|
+
thumbnail: m.media_type === 'image' ? m.url : (m.thumbnail_url || null),
|
|
283
|
+
media_type: m.media_type,
|
|
284
|
+
url: m.url,
|
|
285
|
+
use_hint: 'Use this media library asset in my next step:\nURL: {URL}\n(id: {ID})'
|
|
286
|
+
}));
|
|
287
|
+
return uiResult(UI.mediaGrid, text, {
|
|
288
|
+
widget: 'media-grid',
|
|
289
|
+
title: 'Media Library',
|
|
290
|
+
items,
|
|
291
|
+
total: totalItems != null ? totalItems : media.length,
|
|
292
|
+
shown: Math.min(media.length, GRID_CAP)
|
|
293
|
+
});
|
|
297
294
|
}
|
|
298
295
|
);
|
|
299
296
|
|
package/src/tools/moodboards.js
CHANGED
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
5
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
|
-
const { UI, uiResult
|
|
7
|
+
const { UI, uiResult } = require('../apps');
|
|
8
8
|
const { projectScopeReadField } = require('./_shared');
|
|
9
9
|
|
|
10
10
|
function registerMoodboardTools(server, client, options = {}) {
|
|
11
|
-
const ui = () => appsEnabled(server, options);
|
|
12
11
|
// ─── list_moodboards ───────────────────────────────────────
|
|
13
12
|
server.tool(
|
|
14
13
|
'list_moodboards',
|
|
@@ -29,25 +28,26 @@ function registerMoodboardTools(server, client, options = {}) {
|
|
|
29
28
|
count: result.count || 0
|
|
30
29
|
}, null, 2);
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
31
|
+
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
32
|
+
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
33
|
+
// rebuilt items from the compactList text, whose field names are
|
|
34
|
+
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
35
|
+
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
36
|
+
return uiResult(UI.mediaGrid, text, {
|
|
37
|
+
widget: 'media-grid',
|
|
38
|
+
title: 'Moodboards',
|
|
39
|
+
items: moodboards.slice(0, 24).map(mb => ({
|
|
40
|
+
id: mb.id,
|
|
41
|
+
title: mb.name,
|
|
42
|
+
// API returns thumbnail_url + images[] (sdk listMoodboards) — both
|
|
43
|
+
// previous keys were wrong, so the fallback never fired either.
|
|
44
|
+
thumbnail: mb.thumbnail_url || mb.thumbnail || (Array.isArray(mb.images) ? mb.images[0] : undefined),
|
|
45
|
+
media_type: 'image',
|
|
46
|
+
use_hint: 'Apply moodboard "{TITLE}" (moodboard_id: {ID}) to my next generation.'
|
|
47
|
+
})),
|
|
48
|
+
total: result.count || moodboards.length,
|
|
49
|
+
has_more: moodboards.length > 24
|
|
50
|
+
});
|
|
51
51
|
}
|
|
52
52
|
);
|
|
53
53
|
|
package/src/tools/visual_dna.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const FormData = require('form-data');
|
|
8
8
|
const { resolveToBuffer: sharedResolveToBuffer, VISUAL_DNA_MAX_BYTES, projectScopeReadField, compactList } = require('./_shared');
|
|
9
|
-
const { UI, uiResult, listResult
|
|
9
|
+
const { UI, uiResult, listResult } = require('../apps');
|
|
10
10
|
|
|
11
11
|
// Reference sheets are a blocking multi-panel render; the 120s client default
|
|
12
12
|
// aborted them mid-flight while the server finished and charged anyway.
|
|
@@ -20,7 +20,6 @@ function resolveToBuffer(source, kind) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function registerVisualDnaTools(server, client, options = {}) {
|
|
23
|
-
const ui = () => appsEnabled(server, options);
|
|
24
23
|
// ─── create_visual_dna ─────────────────────────────────────
|
|
25
24
|
server.tool(
|
|
26
25
|
'create_visual_dna',
|
|
@@ -133,24 +132,25 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
133
132
|
note: 'Narrow with `search`, `tags`, or `collection`, or pass `page`/`limit` for the rest; get_visual_dna returns one in full.',
|
|
134
133
|
});
|
|
135
134
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
135
|
+
// Always ship structuredContent. Kolbo Code does NOT advertise MCP Apps, so
|
|
136
|
+
// gating the grid payload on appsEnabled() sent it text only; the host then
|
|
137
|
+
// rebuilt items from the compactList text, whose field names are
|
|
138
|
+
// `filename`/`url` — not the `title`/`thumbnail` the grid renders — so every
|
|
139
|
+
// tile came out black and unlabelled. Same reasoning as listResult().
|
|
140
|
+
return uiResult(UI.mediaGrid, text, {
|
|
141
|
+
widget: 'media-grid',
|
|
142
|
+
title: 'Visual DNA Profiles',
|
|
143
|
+
items: dnas.slice(0, 24).map(d => ({
|
|
144
|
+
id: d.id,
|
|
145
|
+
title: d.name,
|
|
146
|
+
subtitle: (d.dna_type || '') + (Array.isArray(d.tags) && d.tags.length ? ' · ' + d.tags.slice(0, 3).join(', ') : ''),
|
|
147
|
+
thumbnail: d.thumbnail_url || d.thumbnail,
|
|
148
|
+
media_type: 'image',
|
|
149
|
+
use_hint: 'Use Visual DNA "{TITLE}" (id: {ID}) in my next generation for character/style consistency.'
|
|
150
|
+
})),
|
|
151
|
+
total,
|
|
152
|
+
has_more: result.has_more || dnas.length > 24
|
|
153
|
+
});
|
|
154
154
|
}
|
|
155
155
|
);
|
|
156
156
|
|