@kolbo/mcp 1.76.1 → 1.76.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolbo/mcp",
3
- "version": "1.76.1",
3
+ "version": "1.76.3",
4
4
  "description": "Kolbo AI MCP Server - Generate images, videos, music, speech, and sound effects from Claude Code",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/apps/theme.js CHANGED
@@ -144,20 +144,31 @@ body {
144
144
  .k-skel.portrait { aspect-ratio: 3 / 4; }
145
145
  .k-skel::after {
146
146
  content: ''; position: absolute; inset: 0;
147
+ /* The shimmer covers the WHOLE cell and outlives the skeleton — .k-skel.done
148
+ only clears its paint, not its box. A pseudo-element hit-tests as its
149
+ originating element, so every click on a finished cell landed on the cell
150
+ instead of the media inside it: image cells still worked (their handler is
151
+ ON the cell), but a <video>'s native controls never saw a single event —
152
+ play, scrub, volume and fullscreen were all dead. It is decoration; it must
153
+ never take a click. */
154
+ pointer-events: none;
147
155
  background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 40%, rgba(255,255,255,0.10) 50%, rgba(255,255,255,0.06) 60%, transparent 100%);
148
156
  background-size: 200% 100%;
149
157
  animation: k-sweep 1.6s ease-in-out infinite;
150
158
  }
151
159
  @keyframes k-sweep { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
152
160
  /* Batch grid: per-cell prompt caption + a cell that already finished */
153
- .k-skel-cap { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
161
+ /* bottom: 0 puts this exactly where a <video> draws its control bar, so it has
162
+ to be click-through too — the caption is a label, not a target. The full text
163
+ stays reachable: the title tooltip moved onto the cell. */
164
+ .k-skel-cap { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; pointer-events: none;
154
165
  padding: 12px 8px 6px; font-size: 10.5px; color: #fff;
155
166
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.65));
156
167
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
157
168
  .k-skel.done::after { animation: none; background: none; }
158
169
  .k-cell-fill { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
159
170
  .k-gen-badge {
160
- position: absolute; top: 10px; left: 10px; z-index: 2;
171
+ position: absolute; top: 10px; left: 10px; z-index: 2; pointer-events: none;
161
172
  display: inline-flex; align-items: center; gap: 6px;
162
173
  padding: 4px 10px; border-radius: 999px;
163
174
  background: rgba(0, 0, 0, 0.65); /* no backdrop-filter: nested blur over the
@@ -586,8 +586,12 @@ function fillBatchCell(sc, i, g) {
586
586
  cell.classList.add('done');
587
587
  var cap = (sc.prompts && sc.prompts[i])
588
588
  ? '<span class="k-skel-cap" title="' + esc(sc.prompts[i]) + '">' + esc(sc.prompts[i]) + '</span>' : '';
589
+ if (sc.prompts && sc.prompts[i]) cell.title = sc.prompts[i];
590
+ // The controls attribute is not optional here: this cell is a FINISHED result
591
+ // the user is meant to watch, and without it the tile was a muted poster with
592
+ // no way to play, seek or unmute until the whole batch finished and repainted.
589
593
  cell.innerHTML = (sc.kind === 'video'
590
- ? '<video class="k-cell-fill" src="' + esc(u) + '"' + (r.thumbnail_url ? ' poster="' + esc(r.thumbnail_url) + '"' : '') + ' muted playsinline preload="metadata"></video>'
594
+ ? '<video class="k-cell-fill" src="' + esc(u) + '"' + (r.thumbnail_url ? ' poster="' + esc(r.thumbnail_url) + '"' : '') + ' controls playsinline preload="metadata"></video>'
591
595
  : '<img class="k-cell-fill" src="' + esc(u) + '" alt="">') + cap;
592
596
  window.kolbo.notifySize();
593
597
  }
@@ -767,7 +771,8 @@ function renderBatchGrid(sc) {
767
771
  var shape = items[0].type === 'video' ? 'video' : 'square';
768
772
  el('stage').innerHTML = '<div class="k-gen-grid n' + Math.min(items.length, 4) + '">' +
769
773
  items.map(function (it, i) {
770
- return '<div class="k-skel done ' + shape + '" data-focus="' + i + '">' +
774
+ return '<div class="k-skel done ' + shape + '" data-focus="' + i + '"' +
775
+ (it.label ? ' title="' + esc(it.label) + '"' : '') + '>' +
771
776
  (it.type === 'video'
772
777
  ? '<video class="k-cell-fill" src="' + esc(it.url) + '" controls playsinline preload="metadata"></video>'
773
778
  : '<img class="k-cell-fill" src="' + esc(it.url) + '" alt="" loading="lazy" style="cursor:zoom-in">') +
@@ -803,7 +808,8 @@ function renderStatusGrid(sc) {
803
808
  // file — the tool only knows a url came back, not what kind it is.
804
809
  it.kind = refKind(it.url, 'image');
805
810
  var shape = it.kind === 'video' ? 'video' : 'square';
806
- return '<div class="k-skel done ' + shape + '" data-focus="' + i + '">' +
811
+ return '<div class="k-skel done ' + shape + '" data-focus="' + i + '"' +
812
+ (it.title ? ' title="' + esc(it.title) + '"' : '') + '>' +
807
813
  (it.kind === 'video'
808
814
  ? '<video class="k-cell-fill" src="' + esc(it.url) + '" controls playsinline preload="metadata"></video>'
809
815
  : it.kind === 'audio'
@@ -639,7 +639,11 @@ async function uiCompleted(p, textPayload, extraContent) {
639
639
  ...chip,
640
640
  prompt: p.prompt,
641
641
  count: p.count || 1,
642
- settings: p.settings || {},
642
+ // Omitted entirely when the caller has none. A live generation card merges
643
+ // an incoming status payload over its own state, so an empty-but-present
644
+ // `settings` wiped the resolution / aspect / DNA chips off the finished
645
+ // card. Every reader already does `sc.settings || {}`.
646
+ ...(p.settings ? { settings: p.settings } : {}),
643
647
  visual_dnas: await resolveVisualDnas(p.client, (p.settings || {}).visual_dna_ids),
644
648
  reference_images: Array.isArray(p.reference_images)
645
649
  ? p.reference_images.filter(Boolean)
@@ -119,6 +119,22 @@ function mediaKind(url) {
119
119
  return 'image';
120
120
  }
121
121
 
122
+ const isUrlSource = (source) => typeof source === 'string' && /^https?:\/\//i.test(source);
123
+
124
+ // Which multipart kind a LOCAL file should upload as. mediaKind answers for the
125
+ // card; the upload routes only know these three, and the kind decides the
126
+ // content type a receiving controller dispatches on (kolbo-api's elements
127
+ // controller buckets req.files by `file.mimetype`), so guessing 'image' for
128
+ // everything filed a local .mp4 as a reference image.
129
+ const uploadKind = (source) => {
130
+ const kind = mediaKind(source);
131
+ return kind === 'video' || kind === 'audio' ? kind : 'image';
132
+ };
133
+
134
+ // The elements route's own multer ceiling (kolbo-api sdk/index.js). Cap here so
135
+ // an oversized file fails with a readable message instead of after the upload.
136
+ const ELEMENTS_MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
137
+
122
138
  // ─── Widget settings block ──────────────────────────────────────────────────
123
139
  // What the CALLER actually asked for, for the generation card AND for the model
124
140
  // reading the tool result. Undefined/false keys are dropped by JSON.stringify, so
@@ -1044,7 +1060,6 @@ function registerGenerateTools(server, client, options = {}) {
1044
1060
  // generation, so 'Generations' here overwrote the real model name on
1045
1061
  // the finished card the moment the live widget merged this payload in.
1046
1062
  model: res.model || 'Generations', gen: { generation_id: single.generation_id },
1047
- settings: {},
1048
1063
  state: single.state,
1049
1064
  urls: done ? urls : undefined,
1050
1065
  thumbnail_url: res.thumbnail_url,
@@ -1166,11 +1181,11 @@ function registerGenerateTools(server, client, options = {}) {
1166
1181
  {
1167
1182
  prompt: z.string().describe('Locked Intro prompt (Seedance/Elements): Total line, [GLOBAL LOOK], [CAST] with @ExactDNAName for every visual_dna_ids entry, [LOCATION], then SHOT N. Not SCENE CONTEXT/OPTICS/ACTION packs. Never substitute "the left man" or "Zohar\'s" for @Name.'),
1168
1183
  model: z.string().optional().describe('Model identifier. If the user already named a family (Grok / Kling / Veo / Seedance / …), pass THAT family — never default to Seedance because Elements often uses it. Use list_models type="elements" for exact ids and elements_max_* caps. Do NOT omit (omitting = Smart Select).'),
1169
- reference_images: z.array(z.string()).optional().describe('Array of public image URLs used as reference elements (product shots, character references, etc.). **Cap: pass at most `elements_max_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
1170
- reference_videos: z.array(z.string()).optional().describe('Array of reference video URLs for models that accept video inputs. **Cap: pass at most `elements_max_videos` URLs from list_models — if the cap is 0 the model rejects videos.**'),
1171
- reference_audio_urls: z.array(z.string()).optional().describe('Array of reference audio URLs for models that accept audio inputs. **Cap: pass at most `elements_max_audio` URLs from list_models.** `audio_url` remains supported as the legacy single-track form.'),
1172
- audio_url: z.string().optional().describe('URL of a reference audio track. **Audio constraints: `elements_max_audio` from list_models gates whether audio is accepted at all; audio duration must fall within `min_audio_duration`-`max_audio_duration`; format must be in `supported_audio_formats` (if specified).**'),
1173
- files: z.array(z.string()).optional().describe('Array of URLs or absolute local paths alternative to reference_images. Use this when you have local files to upload. Each item can be a URL OR a local path. **Total count across files + reference_images still capped by `elements_max_images`.**'),
1184
+ reference_images: z.array(z.string()).optional().describe('Array of image references (product shots, character references, etc.). Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Cap: pass at most `elements_max_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
1185
+ reference_videos: z.array(z.string()).optional().describe('Array of reference videos for models that accept video inputs. Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Cap: pass at most `elements_max_videos` URLs from list_models — if the cap is 0 the model rejects videos.**'),
1186
+ reference_audio_urls: z.array(z.string()).optional().describe('Array of reference audio tracks for models that accept audio inputs. Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Cap: pass at most `elements_max_audio` URLs from list_models.** `audio_url` remains supported as the legacy single-track form.'),
1187
+ audio_url: z.string().optional().describe('A single reference audio track — legacy form of reference_audio_urls. Accepts a public URL (forwarded as-is; if the API rejects an external URL as untrusted, it is auto-rehosted into the media library and retried once) OR an absolute local path, which is uploaded for you. **Audio constraints: `elements_max_audio` from list_models gates whether audio is accepted at all; audio duration must fall within `min_audio_duration`-`max_audio_duration`; format must be in `supported_audio_formats` (if specified).**'),
1188
+ files: z.array(z.string()).optional().describe('Untyped catch-all for mixed media — images, videos AND audio, each a URL or an absolute local path. The kind is detected from the file extension and the item is routed to the matching reference list, so a local .mp4 is sent as a video and a local .mp3 as audio. Prefer the typed lists (reference_images / reference_videos / reference_audio_urls) when you already know the kind; they accept local paths too. URLs given here are forwarded as URLs, never re-uploaded. **Caps still apply per kind: `elements_max_images` / `elements_max_videos` / `elements_max_audio` from list_models. Local uploads are capped at 200MB each.**'),
1174
1189
  duration: z.number().optional().describe('Output duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
1175
1190
  aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in `supported_aspect_ratios` from list_models. Default: "16:9"'),
1176
1191
  motion: z.string().optional().describe('Motion style / intensity hint (optional)'),
@@ -1193,40 +1208,105 @@ function registerGenerateTools(server, client, options = {}) {
1193
1208
  model = await canonicalModelId(client, model, 'elements'); // lenient id resolution ("z-image" → "z-image/turbo")
1194
1209
  if (!prompt) throw new Error('prompt is required');
1195
1210
 
1196
- let startResponse;
1197
- if (files && files.length > 0) {
1198
- // Multipart mode: resolve each file source to a buffer and upload.
1199
- const resolved = await Promise.all(files.map(src => resolveToBuffer(src, 'image')));
1211
+ // Elements is the one tool that takes all three modalities, and either a
1212
+ // URL or a local path for any of them. Bucket every input by the kind the
1213
+ // API expects, then split by transport:
1214
+ // URL → forwarded AS a url. Downloading one only to re-upload it
1215
+ // costs the round-trip, risks the 200MB multer ceiling, and
1216
+ // duplicates an asset that already lives on the CDN. `files`
1217
+ // used to do exactly that to every URL handed to it.
1218
+ // local → the only thing that becomes a multipart part, uploaded under
1219
+ // its real kind. `files` forced 'image' on everything, so a
1220
+ // local .mp4/.mp3 arrived with the wrong content type and the
1221
+ // elements controller — which buckets req.files by mimetype —
1222
+ // never saw it as video or audio.
1223
+ const media = { image: [], video: [], audio: [] };
1224
+ const collect = (list, forced) => {
1225
+ for (const src of Array.isArray(list) ? list : (list ? [list] : [])) {
1226
+ if (typeof src !== 'string' || !src.trim()) continue;
1227
+ // The typed lists declare their own kind; `files` is the untyped
1228
+ // catch-all, so it is classified by extension.
1229
+ const bucket = media[forced || uploadKind(src)];
1230
+ if (!bucket.includes(src)) bucket.push(src);
1231
+ }
1232
+ };
1233
+ collect(reference_images, 'image');
1234
+ collect(reference_videos, 'video');
1235
+ collect(reference_audio_urls, 'audio');
1236
+ collect(audio_url, 'audio'); // legacy single-track form; the API merges it into audioUrls anyway
1237
+ collect(files);
1238
+
1239
+ const urlsOf = (kind) => media[kind].filter(isUrlSource);
1240
+ const locals = ['image', 'video', 'audio']
1241
+ .flatMap((kind) => media[kind].filter((src) => !isUrlSource(src)).map((src) => ({ src, kind })));
1242
+ const some = (list) => (list.length ? list : undefined);
1243
+ const body = {
1244
+ prompt, model,
1245
+ reference_images: some(urlsOf('image')),
1246
+ reference_videos: some(urlsOf('video')),
1247
+ reference_audio_urls: some(urlsOf('audio')),
1248
+ duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids,
1249
+ resolution, sound_enabled, keyframes, multi_shots, multi_shot_count,
1250
+ session_name, project_id, session_id
1251
+ };
1252
+
1253
+ const startElements = async () => {
1254
+ if (!locals.length) {
1255
+ return client.post('/v1/generate/elements', body);
1256
+ }
1257
+ const resolved = await Promise.all(locals.map(({ src, kind }) =>
1258
+ resolveToBuffer(src, kind, { maxBytes: ELEMENTS_MAX_UPLOAD_BYTES })));
1200
1259
  const form = new FormData();
1201
- form.append('prompt', prompt);
1202
- if (model) form.append('model', model);
1203
- if (duration !== undefined) form.append('duration', String(duration));
1204
- if (aspect_ratio) form.append('aspect_ratio', aspect_ratio);
1205
- if (motion) form.append('motion', motion);
1206
- if (preset_id) form.append('preset_id', preset_id);
1207
- if (enhance_prompt !== undefined) form.append('enhance_prompt', String(enhance_prompt));
1208
- if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
1209
- if (reference_images) form.append('reference_images', JSON.stringify(reference_images));
1210
- if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
1211
- if (reference_audio_urls) form.append('reference_audio_urls', JSON.stringify(reference_audio_urls));
1212
- if (audio_url) form.append('audio_url', audio_url);
1213
- if (resolution) form.append('resolution', resolution);
1214
- if (sound_enabled !== undefined) form.append('sound_enabled', String(sound_enabled));
1215
- if (keyframes) form.append('keyframes', JSON.stringify(keyframes));
1216
- if (multi_shots !== undefined) form.append('multi_shots', String(multi_shots));
1217
- if (multi_shot_count !== undefined) form.append('multi_shot_count', String(multi_shot_count));
1218
- if (session_name) form.append('session_name', session_name);
1219
- if (project_id) form.append('project_id', project_id);
1220
- if (session_id) form.append('session_id', session_id);
1260
+ for (const [key, value] of Object.entries(body)) {
1261
+ if (value === undefined || value === null) continue;
1262
+ // The two URL lists are read by DIFFERENT parsers on the API side and
1263
+ // only one shape satisfies both. reference_videos/_audio_urls go
1264
+ // through parseJsonArray, which needs a JSON string (a bare url makes
1265
+ // JSON.parse throw and the list is dropped). reference_images goes
1266
+ // through collectHttpMediaUrls, which walks strings and arrays but
1267
+ // never JSON-decodes — so a stringified array fails its
1268
+ // `^https?://` test and every URL reference is silently lost. Repeated
1269
+ // form fields arrive as a string (one) or an array (several), and that
1270
+ // walker handles both.
1271
+ if (key === 'reference_images') {
1272
+ for (const url of value) form.append('reference_images', url);
1273
+ continue;
1274
+ }
1275
+ form.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
1276
+ }
1221
1277
  for (const f of resolved) {
1222
1278
  form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
1223
1279
  }
1224
- startResponse = await client.postMultipart('/v1/generate/elements', form);
1225
- } else {
1226
- // URL-only mode: plain JSON.
1227
- startResponse = await client.post('/v1/generate/elements', {
1228
- prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, sound_enabled, keyframes, multi_shots, multi_shot_count, session_name, project_id, session_id
1229
- });
1280
+ return client.postMultipart('/v1/generate/elements', form);
1281
+ };
1282
+
1283
+ let startResponse;
1284
+ try {
1285
+ startResponse = await startElements();
1286
+ } catch (err) {
1287
+ // The elements trust gate only accepts remote references that are BOTH
1288
+ // Kolbo-hosted AND registered to this caller's library/project — any
1289
+ // other public URL 400s with this code, even though every schema here
1290
+ // says "public URL, forwarded as-is". Agents used to recover by hand
1291
+ // (upload_media, then resend). Do that detour for them: rehost every
1292
+ // remote reference into the caller's library and retry ONCE. The gate
1293
+ // fires before any charge, so the failed first attempt costs nothing.
1294
+ if (err?.code !== 'UNTRUSTED_REFERENCE_MEDIA_URL') throw err;
1295
+ for (const kind of ['image', 'video', 'audio']) {
1296
+ media[kind] = await Promise.all(media[kind].map(async (src) => {
1297
+ if (!isUrlSource(src)) return src;
1298
+ const file = await resolveToBuffer(src, kind, { maxBytes: ELEMENTS_MAX_UPLOAD_BYTES });
1299
+ const form = new FormData();
1300
+ form.append('file', file.buffer, { filename: file.filename, contentType: file.contentType });
1301
+ if (project_id) form.append('project_id', project_id);
1302
+ const uploaded = await client.postMultipart('/v1/media/upload', form);
1303
+ return uploaded?.media?.url || uploaded?.url || src;
1304
+ }));
1305
+ }
1306
+ body.reference_images = some(urlsOf('image'));
1307
+ body.reference_videos = some(urlsOf('video'));
1308
+ body.reference_audio_urls = some(urlsOf('audio'));
1309
+ startResponse = await startElements();
1230
1310
  }
1231
1311
 
1232
1312
  if (ui()) return uiGenerating({
@@ -1237,16 +1317,11 @@ function registerGenerateTools(server, client, options = {}) {
1237
1317
  shots: multi_shot_count ?? (Array.isArray(multi_shots) ? multi_shots.length : undefined),
1238
1318
  resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id,
1239
1319
  }),
1240
- reference_images: [
1241
- ...(reference_images || []),
1242
- ...(keyframes || []).map((keyframe) => keyframe.image_url),
1243
- ...(files || []).filter((source) => /^https?:\/\//i.test(source))
1244
- ],
1245
- // Elements is the one tool that takes all three modalities. The widget
1246
- // sorts kind by extension, so a video that arrived via `files` is still
1247
- // rendered as a video — these two just make sure nothing is dropped.
1248
- reference_videos: reference_videos || [],
1249
- reference_audio: [...(reference_audio_urls || []), ...(audio_url ? [audio_url] : [])]
1320
+ // Already bucketed and deduped above, so a URL handed to `files`
1321
+ // renders under its real kind instead of being dropped or double-listed.
1322
+ reference_images: [...urlsOf('image'), ...(keyframes || []).map((keyframe) => keyframe.image_url)],
1323
+ reference_videos: urlsOf('video'),
1324
+ reference_audio: urlsOf('audio')
1250
1325
  });
1251
1326
 
1252
1327
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1264,13 +1339,9 @@ function registerGenerateTools(server, client, options = {}) {
1264
1339
  shots: multi_shot_count ?? (Array.isArray(multi_shots) ? multi_shots.length : undefined),
1265
1340
  resolution, aspect_ratio, enhance_prompt, visual_dna_ids, preset_id,
1266
1341
  }),
1267
- reference_images: [
1268
- ...(reference_images || []),
1269
- ...(keyframes || []).map((keyframe) => keyframe.image_url),
1270
- ...(files || []).filter((source) => /^https?:\/\//i.test(source))
1271
- ],
1272
- reference_videos: reference_videos || [],
1273
- reference_audio: [...(reference_audio_urls || []), ...(audio_url ? [audio_url] : [])],
1342
+ reference_images: [...urlsOf('image'), ...(keyframes || []).map((keyframe) => keyframe.image_url)],
1343
+ reference_videos: urlsOf('video'),
1344
+ reference_audio: urlsOf('audio'),
1274
1345
  urls: result.result?.urls || [],
1275
1346
  thumbnail_url: result.result?.thumbnail_url || null,
1276
1347
  duration: result.result?.duration || null,
@@ -1289,12 +1360,12 @@ function registerGenerateTools(server, client, options = {}) {
1289
1360
  // ─── generate_first_last_frame ─────────────────────────────
1290
1361
  server.tool(
1291
1362
  'generate_first_last_frame',
1292
- 'Generate a video that morphs / interpolates from a FIRST frame to a LAST frame. Provide the two frames as URLs (first_frame_url + last_frame_url) OR as local file paths (first_frame + last_frame). Optional prompt describes the desired motion/transition. Do NOT mix URL and file inputs. Returns the final video URL when complete.',
1363
+ 'Generate a video that morphs / interpolates from a FIRST frame to a LAST frame. Provide the two frames as URLs (first_frame_url + last_frame_url) or as URLs/absolute local paths (first_frame + last_frame) — the pairs are interchangeable and may be mixed. Optional prompt describes the desired motion/transition. Returns the final video URL when complete.',
1293
1364
  {
1294
- first_frame_url: z.string().optional().describe('Public URL of the first frame image (URL mode)'),
1295
- last_frame_url: z.string().optional().describe('Public URL of the last frame image (URL mode)'),
1296
- first_frame: z.string().optional().describe('URL or absolute local path to the first frame (file mode — alternative to first_frame_url)'),
1297
- last_frame: z.string().optional().describe('URL or absolute local path to the last frame (file mode — alternative to last_frame_url)'),
1365
+ first_frame_url: z.string().optional().describe('Public URL of the first frame image. Interchangeable with first_frame.'),
1366
+ last_frame_url: z.string().optional().describe('Public URL of the last frame image. Interchangeable with last_frame.'),
1367
+ first_frame: z.string().optional().describe('URL or absolute local path to the first frame — alternative to first_frame_url. A URL here is forwarded as a URL, not re-uploaded.'),
1368
+ last_frame: z.string().optional().describe('URL or absolute local path to the last frame — alternative to last_frame_url. A URL here is forwarded as a URL, not re-uploaded.'),
1298
1369
  prompt: z.string().optional().describe('Optional description of the desired motion between the two frames (e.g. "smooth camera dolly in")'),
1299
1370
  model: z.string().optional().describe('Model identifier. Use list_models type="firstlastgenerations" to see options. Pick a SPECIFIC model — do NOT omit (omitting = Smart Select auto-pick, which we avoid); call list_models for this type and choose the model that best fits the user\'s intent.'),
1300
1371
  duration: z.number().optional().describe('Duration in seconds. Must be in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. Default: 5'),
@@ -1308,20 +1379,24 @@ function registerGenerateTools(server, client, options = {}) {
1308
1379
  },
1309
1380
  async ({ first_frame_url, last_frame_url, first_frame, last_frame, prompt, model, duration, aspect_ratio, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, project_id, session_id }) => {
1310
1381
  model = await canonicalModelId(client, model, 'firstlastgenerations'); // lenient id resolution ("z-image" → "z-image/turbo")
1311
- const urlMode = first_frame_url && last_frame_url;
1312
- const fileMode = first_frame && last_frame;
1313
- if (!urlMode && !fileMode) {
1314
- throw new Error('Provide either both first_frame_url + last_frame_url OR both first_frame + last_frame (URL/local path).');
1315
- }
1316
- if (urlMode && fileMode) {
1317
- throw new Error('Do not mix URL and file inputs. Provide either URLs OR file sources, not both.');
1382
+ // One frame per position, whichever arg carried it. The two arg pairs were
1383
+ // treated as two exclusive MODES, so a URL handed to first_frame/last_frame
1384
+ // (which their own descriptions invite) took the multipart path and got
1385
+ // downloaded and re-uploaded a pointless round-trip that also had to fit
1386
+ // the route's 10MB upload ceiling a plain URL never touches. Transport is
1387
+ // now decided per frame by what the value IS, not which arg it arrived in,
1388
+ // which also makes one-URL-one-local work instead of erroring out.
1389
+ const firstSource = first_frame_url || first_frame;
1390
+ const lastSource = last_frame_url || last_frame;
1391
+ if (!firstSource || !lastSource) {
1392
+ throw new Error('Provide both frames: first_frame_url + last_frame_url (URLs) or first_frame + last_frame (URL or absolute local path).');
1318
1393
  }
1319
1394
 
1320
1395
  let startResponse;
1321
- if (fileMode) {
1396
+ if (!isUrlSource(firstSource) || !isUrlSource(lastSource)) {
1322
1397
  const [firstResolved, lastResolved] = await Promise.all([
1323
- resolveToBuffer(first_frame, 'image'),
1324
- resolveToBuffer(last_frame, 'image')
1398
+ resolveToBuffer(firstSource, 'image'),
1399
+ resolveToBuffer(lastSource, 'image')
1325
1400
  ]);
1326
1401
  const form = new FormData();
1327
1402
  form.append('files', firstResolved.buffer, { filename: firstResolved.filename, contentType: firstResolved.contentType });
@@ -1339,15 +1414,15 @@ function registerGenerateTools(server, client, options = {}) {
1339
1414
  startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
1340
1415
  } else {
1341
1416
  startResponse = await client.post('/v1/generate/first-last-frame', {
1342
- first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, sound_enabled, project_id, session_id
1417
+ first_frame_url: firstSource, last_frame_url: lastSource,
1418
+ prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, sound_enabled, project_id, session_id
1343
1419
  });
1344
1420
  }
1345
1421
 
1346
1422
  if (ui()) return uiGenerating({
1347
1423
  tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
1348
1424
  settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
1349
- reference_images: [first_frame_url || first_frame, last_frame_url || last_frame]
1350
- .filter((source) => /^https?:\/\//i.test(source || ''))
1425
+ reference_images: [firstSource, lastSource].filter(isUrlSource)
1351
1426
  });
1352
1427
 
1353
1428
  const poll = await pollOrTimedOut(client, startResponse.generation_id, {
@@ -1360,8 +1435,7 @@ function registerGenerateTools(server, client, options = {}) {
1360
1435
  return uiCompleted({
1361
1436
  tool: 'generate_first_last_frame', kind: 'video', gen: startResponse, client, model, prompt,
1362
1437
  settings: videoSettings({ duration, resolution, aspect_ratio, enhance_prompt, visual_dna_ids }),
1363
- reference_images: [first_frame_url || first_frame, last_frame_url || last_frame]
1364
- .filter((source) => /^https?:\/\//i.test(source || '')),
1438
+ reference_images: [firstSource, lastSource].filter(isUrlSource),
1365
1439
  urls: result.result?.urls || [],
1366
1440
  thumbnail_url: result.result?.thumbnail_url || null,
1367
1441
  duration: result.result?.duration || null,
@@ -1531,10 +1605,18 @@ function registerGenerateTools(server, client, options = {}) {
1531
1605
  highlighted: z.object({ font: z.string().optional(), weight: z.number().int().min(100).max(900).optional(), color: z.string().optional() }).optional().describe('Highlighted word tier styling.'),
1532
1606
  }).optional(),
1533
1607
  }).optional().describe('VEED Subtitles only: style overrides. Any omitted field keeps the preset default. Best supported by Basic presets.'),
1608
+ enhancement_model: z.string().optional()
1609
+ .describe('Topaz Slow Motion only (model "topaz/interpolate/video"): retiming engine — "Apollo" (smooth motion, default), "Chronos" (complex motion and occlusion), or "Aion" (highest quality for extreme slow motion, and the most expensive).'),
1610
+ target_fps: z.number().optional()
1611
+ .describe('Topaz Slow Motion only: frames per second of the OUTPUT (16-120, default 60). Higher rates generate more frames and cost proportionally more.'),
1612
+ slowdown_factor: z.number().optional()
1613
+ .describe('Topaz Slow Motion only: how many times longer the output runs (1-8, default 1). 4 turns a 5s clip into 20s of slow motion. Billing is on the OUTPUT length, so an 8x pass costs 8x a 1x pass.'),
1614
+ output_format: z.string().optional()
1615
+ .describe('Topaz HDR only (model "topaz/sdr-to-hdr/video"): "mp4" for 10-bit H.265 HDR10 (default) or "prores" for 10-bit ProRes 422 HQ. Both are HDR masters — the in-app player shows a tone-mapped SDR preview and the HDR file is the download.'),
1534
1616
  project_id: projectIdField,
1535
1617
  session_id: sessionIdField
1536
1618
  },
1537
- async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, reference_images, reference_videos, elements, preset, source_language, translation_language, srt_content, srt_file_url, vocabulary, customization, project_id, session_id }) => {
1619
+ async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled, reference_images, reference_videos, elements, preset, source_language, translation_language, srt_content, srt_file_url, vocabulary, customization, enhancement_model, target_fps, slowdown_factor, output_format, project_id, session_id }) => {
1538
1620
  model = await canonicalModelId(client, model, 'video_to_video'); // lenient id resolution ("z-image" → "z-image/turbo")
1539
1621
  if (!source_video) throw new Error('source_video is required');
1540
1622
 
@@ -1544,7 +1626,9 @@ function registerGenerateTools(server, client, options = {}) {
1544
1626
  startResponse = await client.post('/v1/generate/video-from-video', {
1545
1627
  video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled,
1546
1628
  reference_images, reference_videos, elements, preset, source_language, translation_language,
1547
- srt_content, srt_file_url, vocabulary, customization, project_id, session_id
1629
+ srt_content, srt_file_url, vocabulary, customization,
1630
+ enhancement_model, target_fps, slowdown_factor, output_format,
1631
+ project_id, session_id
1548
1632
  });
1549
1633
  } else {
1550
1634
  const resolved = await resolveToBuffer(source_video, 'video');
@@ -1568,6 +1652,10 @@ function registerGenerateTools(server, client, options = {}) {
1568
1652
  if (reference_images) form.append('reference_images', JSON.stringify(reference_images));
1569
1653
  if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
1570
1654
  if (elements) form.append('elements', JSON.stringify(elements));
1655
+ if (enhancement_model) form.append('enhancement_model', enhancement_model);
1656
+ if (target_fps !== undefined) form.append('target_fps', String(target_fps));
1657
+ if (slowdown_factor !== undefined) form.append('slowdown_factor', String(slowdown_factor));
1658
+ if (output_format) form.append('output_format', output_format);
1571
1659
  if (project_id) form.append('project_id', project_id);
1572
1660
  if (session_id) form.append('session_id', session_id);
1573
1661
  startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
@@ -1777,7 +1865,8 @@ function registerGenerateTools(server, client, options = {}) {
1777
1865
  'camera_angle',
1778
1866
  'split', 'split_upscale',
1779
1867
  'multi_shot',
1780
- 'magic_edit'
1868
+ 'magic_edit',
1869
+ 'enhance'
1781
1870
  ]).describe([
1782
1871
  'Edit operation:',
1783
1872
  '"upscale" — increase resolution by 2×, 3×, or 4× (use `scale`). "clarity_upscale" — AI-powered clarity upscale with detail enhancement (use `resolution`).',
@@ -1786,6 +1875,7 @@ function registerGenerateTools(server, client, options = {}) {
1786
1875
  '"removebg" — remove the image background, output is transparent PNG.',
1787
1876
  '"background_replace" — remove background and replace it with AI-generated content from `prompt`.',
1788
1877
  '"enhance_skin" — portrait skin retouching (use `skin_strength`: "subtle" | "realistic" | "pimple" | "freckle").',
1878
+ '"enhance" — Topaz photo correction at the SOURCE resolution (no resizing). Pick the tool with `model`: "topaz/adjust/image" (exposure, white balance, or colorizing a black-and-white photo), "topaz/sharpen/image" (lens / motion / portrait / wildlife blur, or Super Focus for severely blurred shots), "topaz/denoise/image" (high-ISO and night noise), "topaz/restore/image" (old or damaged photos, dust and scratches). Choose the specific engine with `enhancement_model` — call list_models type="graphics_enhance" to see each model\'s engines. To make an image BIGGER use "upscale" instead.',
1789
1879
  '"inpaint" — paint over a masked area using `mask_image_url` (B&W mask, white = fill area) and optional `prompt`. Add reference images via `additional_images`.',
1790
1880
  '"erase" — erase an object defined by `mask_image_url` (white = erase area).',
1791
1881
  '"face_swap" — swap the face in `image_url` with the face from `mask_image_url` (required).',
@@ -1800,7 +1890,13 @@ function registerGenerateTools(server, client, options = {}) {
1800
1890
 
1801
1891
  // ── upscale ────────────────────────────────────────────
1802
1892
  scale: z.number().optional()
1803
- .describe('Upscale factor: 2, 3, or 4. Used with operation="upscale". Default: 2.'),
1893
+ .describe('Upscale factor: 1, 2, 4 or 8. Used with operation="upscale". Default: 2.'),
1894
+
1895
+ enhancement_model: z.string().optional()
1896
+ .describe('Topaz engine. With operation="upscale" on model "topaz/upscale/image" it selects the engine family: "Standard V2" / "High Fidelity V3" / "CGI" / "Text Refine" (faithful), "Wonder 3.5" (rebuilds natural detail), "Bloom 2" (reinvents detail — most expensive), "Transparent" (keeps the alpha channel). With operation="enhance" it selects the correction engine for the chosen model. Omit for the model default. Call list_models to see the engines a model offers.'),
1897
+
1898
+ output_format: z.string().optional()
1899
+ .describe('Output image format: "jpeg" or "png". Used with Topaz "upscale" and "enhance". Defaults to jpeg (the transparent upscaler always returns png).'),
1804
1900
 
1805
1901
  resolution: z.string().optional()
1806
1902
  .describe('Target output resolution (e.g. "4k", "2k", "1080p"). Used with "clarity_upscale", "split_upscale", "multi_shot".'),
@@ -1857,6 +1953,7 @@ function registerGenerateTools(server, client, options = {}) {
1857
1953
  image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
1858
1954
  mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize = false,
1859
1955
  zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
1956
+ enhancement_model, output_format,
1860
1957
  project_id, session_id
1861
1958
  }) => {
1862
1959
  // No `type` argument: these are operation-routed tools (upscale / reframe /
@@ -1866,6 +1963,9 @@ function registerGenerateTools(server, client, options = {}) {
1866
1963
 
1867
1964
  // Basic validation
1868
1965
  if (operation === 'reframe' && !aspect_ratio) throw new Error('aspect_ratio is required for reframe');
1966
+ if (operation === 'enhance' && !model) {
1967
+ throw new Error('model is required for enhance — pick one of: topaz/adjust/image, topaz/sharpen/image, topaz/denoise/image, topaz/restore/image');
1968
+ }
1869
1969
  if (operation === 'background_replace' && !prompt) throw new Error('prompt is required for background_replace');
1870
1970
  if (operation === 'face_swap' && !mask_image_url && !(additional_images && additional_images.length > 0)) {
1871
1971
  throw new Error('mask_image_url (face reference) is required for face_swap');
@@ -1875,6 +1975,7 @@ function registerGenerateTools(server, client, options = {}) {
1875
1975
  image_url, operation, model, scale, aspect_ratio, skin_strength, prompt,
1876
1976
  mask_image_url, additional_images, generate_all_angles, resolution, quality, ai_optimize,
1877
1977
  zoom_out_percentage, expand_left, expand_right, expand_top, expand_bottom,
1978
+ enhancement_model, output_format,
1878
1979
  project_id, session_id
1879
1980
  });
1880
1981
 
@@ -1947,6 +2048,8 @@ function registerGenerateTools(server, client, options = {}) {
1947
2048
  .describe('Target resolution (e.g. "4k", "2k", "1080p"). Used with "upscale" and "reframe".'),
1948
2049
  target_fps: z.number().optional()
1949
2050
  .describe('Target frame rate (e.g. 24, 30, 60). Used with operation="upscale".'),
2051
+ enhancement_model: z.string().optional()
2052
+ .describe('Topaz enhancement engine for operation="upscale" on model "topaz/upscale/video". Families: Precision (faithful — "Proteus", "Artemis High Quality", "Iris", "Dione TV", "Gaia CG", "Gaia 2"), Denoise ("Nyx", "Nyx Fast"), Generative (rebuilds detail that is not in the source — "Starlight Fast 2", "Starlight HQ", "Starlight Precise 2.6"), Creative ("Astra 2", always renders 4K). Generative and Creative engines cost several times the Precision rate. Omit for the default.'),
1950
2053
 
1951
2054
  // ── reframe ────────────────────────────────────────────
1952
2055
  aspect_ratio: z.string().optional()
@@ -2010,7 +2113,7 @@ function registerGenerateTools(server, client, options = {}) {
2010
2113
  async ({
2011
2114
  video_url, operation, model, aspect_ratio, scale, prompt,
2012
2115
  image_url, audio_url, duration, mode,
2013
- target_fps, resolution,
2116
+ target_fps, resolution, enhancement_model,
2014
2117
  grid_position_x, grid_position_y,
2015
2118
  sound_effect_prompt, background_music_prompt, original_sound, cfg_strength,
2016
2119
  refine_edges, subject_is_person,
@@ -2033,7 +2136,7 @@ function registerGenerateTools(server, client, options = {}) {
2033
2136
  if (operation === 'extend' && !duration) throw new Error('duration is required for extend');
2034
2137
 
2035
2138
  const gen = await client.post('/v1/edit/video', {
2036
- video_url, operation, model, aspect_ratio, scale, prompt,
2139
+ video_url, operation, model, aspect_ratio, scale, prompt, enhancement_model,
2037
2140
  image_url, audio_url, duration, mode,
2038
2141
  target_fps, resolution,
2039
2142
  grid_position_x, grid_position_y,