@kolbo/mcp 1.6.5 → 1.6.8

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.6.5",
3
+ "version": "1.6.8",
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/client.js CHANGED
@@ -248,6 +248,10 @@ class KolboClient {
248
248
  return this.request('GET', reqPath);
249
249
  }
250
250
 
251
+ async put(reqPath, body = null) {
252
+ return this.request('PUT', reqPath, body);
253
+ }
254
+
251
255
  async delete(reqPath) {
252
256
  return this.request('DELETE', reqPath);
253
257
  }
@@ -119,8 +119,7 @@ function registerAppBuilderTools(server, client) {
119
119
  edit_prompt: z.string().describe('Natural language instruction describing the change to make.')
120
120
  },
121
121
  async ({ session_id, generation_id, edit_prompt }) => {
122
- await client.request(
123
- 'PUT',
122
+ await client.put(
124
123
  `/app-builder/generation/${encodeURIComponent(session_id)}/${encodeURIComponent(generation_id)}`,
125
124
  { editPrompt: edit_prompt }
126
125
  );
@@ -19,11 +19,11 @@ function registerGenerateTools(server, client) {
19
19
  aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "1:1", "16:9", "9:16"). Default: "1:1"'),
20
20
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
21
21
  num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1'),
22
- reference_images: z.array(z.string()).optional().describe('Array of image URLs used as composition/style references (NOT as source images for editing use generate_image_edit for that).'),
23
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs (from create_visual_dna / list_visual_dnas) to apply for character / style / product / scene consistency. Pass the `id` field of each profile. Use this when the user wants to keep the same character or style across multiple images.'),
22
+ reference_images: z.array(z.string()).optional().describe('STYLE/COMPOSITION inspiration only — does NOT embed reference pixels. Array of image URLs used to guide the look-and-feel of a brand-new generation. The model interprets the references and regenerates approximations conditioned on them. It will NOT copy pixels from these images into the output. To embed a specific logo, icon, watermark, or asset pixel-accurately, use generate_image_edit with the asset in source_images. To EDIT an existing image, also use generate_image_edit.'),
23
+ visual_dna_ids: z.array(z.string()).optional().describe('Visual DNA profile IDs (from create_visual_dna / list_visual_dnas) for character / style / product / scene consistency. How DNA works: the server fetches the DNA\'s reference images AND always injects its `description` field into the prompt as plaintext (this is by design — the description carries the identity signal, independent of enhance_prompt). Practical implication: do NOT also write physical descriptors of the same subject in your own prompt — they will compete with the DNA description text. For pixel-accurate face anchoring of a specific person, prefer passing the DNA\'s reference image directly via source_images on generate_image_edit and OMIT visual_dna_ids. visual_dna_ids is best for style / scene / product DNAs and for soft consistency across a set.'),
24
24
  moodboard_id: z.string().optional().describe('Moodboard ID (from list_moodboards / get_moodboard) whose master_prompt and style_guide should be applied to this generation.'),
25
25
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding for the prompt (useful for current events, brand references, real-world accuracy). Default: false'),
26
- resolution: z.string().optional().describe('Image resolution tier: "1K" (~1024px), "2K" (Full HD), "3K" (QHD), or "4K" (UHD). Model-dependent — call list_models and read supported_resolutions on the chosen model. Read resolutionMultipliers on the same model to predict credit cost. Omit to use the model default.'),
26
+ resolution: z.string().optional().describe('Image resolution tier: "1K" (~1024px), "2K" (Full HD), "3K" (QHD), or "4K" (UHD). Model-dependent — call list_models and read supported_resolutions on the chosen model. Read resolution_multipliers on the same model to predict credit cost. Omit to use the model default.'),
27
27
  preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.')
28
28
  },
29
29
  async ({ prompt, model, aspect_ratio, enhance_prompt, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id }) => {
@@ -43,7 +43,8 @@ function registerGenerateTools(server, client) {
43
43
  text: JSON.stringify({
44
44
  urls: result.result.urls,
45
45
  model: result.result.model,
46
- prompt_used: result.result.prompt_used
46
+ prompt_used: result.result.prompt_used,
47
+ _followup_hint: 'If the user asks to edit/change/modify this image next, pass urls[0] to generate_image_edit (free-form edits) or edit_image (upscale/reframe/removebg/enhance_skin/magic_edit). Do NOT call generate_image again.'
47
48
  }, null, 2)
48
49
  }]
49
50
  };
@@ -57,11 +58,11 @@ function registerGenerateTools(server, client) {
57
58
  {
58
59
  prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
59
60
  model: z.string().optional().describe('Model identifier. Use list_models type="image_editing" to see options. Omit for Smart Select.'),
60
- source_images: z.array(z.string()).describe('Array of source image URLs to edit. Typically one, but some models accept multiple for compositing.'),
61
+ source_images: z.array(z.string()).describe('PIXEL-ACCURATE compositing. Array of source image URLs whose pixel content is composited into the output. Three modes the model auto-detects from input shape: (1) Single image → edit/transform that image. (2) Multiple images, one base + others composite the others into the base. (3) Multiple images with no clear base → generate a new scene that pixel-accurately embeds the supplied images at positions described in the prompt. Mode 3 is the canonical pattern for thumbnails / branded compositions where exact-pixel logo + face fidelity matter. Refer to source images in the prompt by ordinal position ("FIRST source image", "SECOND source image"). Add "composite AS-IS, do not redraw or restyle" to lock pixels.'),
61
62
  aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Default: "1:1"'),
62
63
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt for better results. Default: true'),
63
64
  num_images: z.number().optional().describe('Number of output images. Default: 1'),
64
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for consistency with an existing character / style / product.'),
65
+ visual_dna_ids: z.array(z.string()).optional().describe('Visual DNA profile IDs for character / style / product consistency. How DNA works: the server fetches the DNA\'s reference images AND always injects its `description` field into the prompt as plaintext (by design — independent of enhance_prompt). For pixel-accurate face anchoring of a specific person on this tool, the PREFERRED pattern is to pass the face photo directly via source_images and OMIT visual_dna_ids — that way the face pixels anchor the output and no description text competes. Do NOT pass visual_dna_ids if source_images already contains the same person\'s face (face averaging). visual_dna_ids is best here for style / product DNAs.'),
65
66
  moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
66
67
  enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
67
68
  resolution: z.string().optional().describe('Image resolution tier: "1K" / "2K" / "3K" / "4K". Model-dependent — call list_models and read supported_resolutions. Default: "1K" for most edit models.')
@@ -72,9 +73,13 @@ function registerGenerateTools(server, client) {
72
73
  visual_dna_ids, moodboard_id, enable_web_search, resolution
73
74
  });
74
75
 
76
+ // Multi-source compositing or DNA-anchored edits routinely exceed 120s
77
+ // server-side. Extend the polling window in those cases to avoid forcing
78
+ // every call into the timeout-and-recover path via get_generation_status.
79
+ const heavy = (source_images && source_images.length > 1) || (visual_dna_ids && visual_dna_ids.length > 0);
75
80
  const result = await pollUntilDone(client, gen.generation_id, {
76
81
  interval: (gen.poll_interval_hint || 3) * 1000,
77
- timeout: 120000
82
+ timeout: heavy ? 240000 : 120000
78
83
  });
79
84
 
80
85
  return {
@@ -83,7 +88,8 @@ function registerGenerateTools(server, client) {
83
88
  text: JSON.stringify({
84
89
  urls: result.result.urls,
85
90
  model: result.result.model,
86
- prompt_used: result.result.prompt_used
91
+ prompt_used: result.result.prompt_used,
92
+ _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.'
87
93
  }, null, 2)
88
94
  }]
89
95
  };
@@ -135,7 +141,8 @@ function registerGenerateTools(server, client) {
135
141
  text: JSON.stringify({
136
142
  scenes,
137
143
  total_scenes: result.scenes?.length || 0,
138
- completed_scenes: scenes.length
144
+ completed_scenes: scenes.length,
145
+ _followup_hint: 'Each scene is a separate asset. If the user asks to edit one scene, find that scene by scene_number/title and pass its image_urls[0] (or video_urls[0]) to generate_image_edit / edit_image / edit_video / generate_video_from_video. Do NOT re-run generate_creative_director unless the user explicitly wants a brand-new set.'
139
146
  }, null, 2)
140
147
  }]
141
148
  };
@@ -143,9 +150,13 @@ function registerGenerateTools(server, client) {
143
150
  );
144
151
 
145
152
  // ─── generate_video ────────────────────────────────────────
153
+ // NOTE: text-to-video does NOT support Visual DNA — the textToVideoGeneration
154
+ // controller in kolbo-api never reads visualDnaIds. For character-consistent
155
+ // video, use generate_elements (which DOES honor visual_dna_ids) or animate a
156
+ // DNA-locked still via generate_video_from_image.
146
157
  server.tool(
147
158
  'generate_video',
148
- 'Generate a video from a text prompt using Kolbo AI. For animating an existing still image into motion, use generate_video_from_image instead. For a coordinated multi-scene video campaign, use generate_creative_director with workflow_type="video". Supports Visual DNA profiles (for character consistency) and reference images (for style guidance). Returns the final video URL when complete.',
159
+ 'Generate a video from a text prompt using Kolbo AI. For animating an existing still image into motion, use generate_video_from_image instead. For a coordinated multi-scene video campaign, use generate_creative_director with workflow_type="video". Supports reference images (for style/composition guidance). Does NOT support Visual DNA for character-consistent video use generate_elements or animate a DNA-locked still via generate_video_from_image. Returns the final video URL when complete.',
149
160
  {
150
161
  prompt: z.string().describe('Text description of the video to generate'),
151
162
  model: z.string().optional().describe('Model identifier. Use list_models type="text_to_video" to see options. Check supported_durations and supported_aspect_ratios.'),
@@ -153,13 +164,12 @@ function registerGenerateTools(server, client) {
153
164
  duration: z.number().optional().describe('Duration in seconds. Must be a value the chosen model supports — check supported_durations from list_models. Default: 5'),
154
165
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
155
166
  reference_images: z.array(z.string()).optional().describe('Array of image URLs used as visual references (style / composition / subject).'),
156
- visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to keep a character / style consistent with prior generations.'),
157
- resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions. Read resolutionMultipliers to predict cost.'),
167
+ resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions. Read resolution_multipliers to predict cost.'),
158
168
  preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.')
159
169
  },
160
- async ({ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, visual_dna_ids, resolution, preset_id }) => {
170
+ async ({ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id }) => {
161
171
  const gen = await client.post('/v1/generate/video', {
162
- prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, visual_dna_ids, resolution, preset_id
172
+ prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, preset_id
163
173
  });
164
174
 
165
175
  const result = await pollUntilDone(client, gen.generation_id, {
@@ -175,7 +185,8 @@ function registerGenerateTools(server, client) {
175
185
  model: result.result.model,
176
186
  duration: result.result.duration,
177
187
  thumbnail_url: result.result.thumbnail_url,
178
- prompt_used: result.result.prompt_used
188
+ prompt_used: result.result.prompt_used,
189
+ _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.'
179
190
  }, null, 2)
180
191
  }]
181
192
  };
@@ -194,7 +205,7 @@ function registerGenerateTools(server, client) {
194
205
  duration: z.number().optional().describe('Duration in seconds. Must be a value the chosen model supports. Default: 5'),
195
206
  enhance_prompt: z.boolean().optional().describe('Enhance the motion prompt. Default: true'),
196
207
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles.'),
197
- resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions. Read resolutionMultipliers to predict cost.')
208
+ resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Some models use labels like "512P"/"1024P"/"768P"/"1080P". Model-dependent — call list_models and read supported_resolutions. Read resolution_multipliers to predict cost.')
198
209
  },
199
210
  async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution }) => {
200
211
  const gen = await client.post('/v1/generate/video/from-image', {
@@ -213,7 +224,8 @@ function registerGenerateTools(server, client) {
213
224
  urls: result.result.urls,
214
225
  model: result.result.model,
215
226
  duration: result.result.duration,
216
- thumbnail_url: result.result.thumbnail_url
227
+ thumbnail_url: result.result.thumbnail_url,
228
+ _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.'
217
229
  }, null, 2)
218
230
  }]
219
231
  };
@@ -386,13 +398,13 @@ function registerGenerateTools(server, client) {
386
398
  // ─── generate_elements ─────────────────────────────────────
387
399
  server.tool(
388
400
  'generate_elements',
389
- 'Generate a video from reference elements (images, videos, and/or audio) + a text prompt. Use when the user wants to animate specific uploaded/referenced assets — e.g. "animate this product", "put these 3 characters into a scene". IMPORTANT: different models accept different numbers of inputs — call list_models type="elements" and read elementsMaxImages / elementsMaxVideos / elementsMaxAudio on the chosen model before generating. For text-only → video use generate_video instead. For animating a single still image use generate_video_from_image. Returns the final video URL when complete.',
401
+ 'Generate a video from reference elements (images, videos, and/or audio) + a text prompt. Use when the user wants to animate specific uploaded/referenced assets — e.g. "animate this product", "put these 3 characters into a scene". IMPORTANT: different models accept different numbers of inputs — call list_models type="elements" and read elements_max_images / elements_max_videos / elements_max_audio on the chosen model before generating. For text-only → video use generate_video instead. For animating a single still image use generate_video_from_image. Returns the final video URL when complete.',
390
402
  {
391
403
  prompt: z.string().describe('Text description of the desired video / animation'),
392
- model: z.string().optional().describe('Model identifier. Use list_models type="elements" to see options (Seedance 2, Kling O3 Reference, Grok Imagine, Veo 3.1, etc.). Check elementsMaxImages / elementsMaxVideos / elementsMaxAudio on the model. Omit for Smart Select.'),
393
- reference_images: z.array(z.string()).optional().describe('Array of public image URLs used as reference elements (product shots, character references, etc.). Check elementsMaxImages on the chosen model — pass at most that many URLs.'),
394
- reference_videos: z.array(z.string()).optional().describe('Array of reference video URLs for models that accept video inputs (elementsMaxVideos > 0). Check elementsMaxVideos on the chosen model from list_models before passing.'),
395
- audio_url: z.string().optional().describe('URL of a reference audio track for models that accept audio inputs (elementsMaxAudio > 0). Check elementsMaxAudio on the chosen model from list_models before passing.'),
404
+ model: z.string().optional().describe('Model identifier. Use list_models type="elements" to see options (Seedance 2, Kling O3 Reference, Grok Imagine, Veo 3.1, etc.). Check elements_max_images / elements_max_videos / elements_max_audio on the model. Omit for Smart Select.'),
405
+ reference_images: z.array(z.string()).optional().describe('Array of public image URLs used as reference elements (product shots, character references, etc.). Check elements_max_images on the chosen model — pass at most that many URLs.'),
406
+ reference_videos: z.array(z.string()).optional().describe('Array of reference video URLs for models that accept video inputs (elements_max_videos > 0). Check elements_max_videos on the chosen model from list_models before passing.'),
407
+ audio_url: z.string().optional().describe('URL of a reference audio track for models that accept audio inputs (elements_max_audio > 0). Check elements_max_audio on the chosen model from list_models before passing.'),
396
408
  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.'),
397
409
  duration: z.number().optional().describe('Duration in seconds. Default: 5'),
398
410
  aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Default: "16:9"'),
@@ -594,19 +606,19 @@ function registerGenerateTools(server, client) {
594
606
  // ─── generate_video_from_video ─────────────────────────────
595
607
  server.tool(
596
608
  'generate_video_from_video',
597
- 'Restyle / transform an existing video using a text prompt (video-to-video). Use for style transfer, scene restyling, subject swap, motion transfer, or character replacement. Source video can be a URL or absolute local path. IMPORTANT: different models support different extra inputs — call list_models type="video_to_video" and read maxImages / maxVideos / maxElements on the chosen model before generating. Pass reference_images for models with maxImages > 0 (e.g. Kling O1/O3, Aleph, WAN VACE), reference_videos for models with maxVideos > 1 (e.g. WAN 2.6 reference-to-video accepts up to 3), and elements for models with maxElements > 0. For animating a still image use generate_video_from_image instead. For text-only → video use generate_video.',
609
+ 'Restyle / transform an existing video using a text prompt (video-to-video). Use for style transfer, scene restyling, subject swap, motion transfer, or character replacement. Source video can be a URL or absolute local path. IMPORTANT: different models support different extra inputs — call list_models type="video_to_video" and read max_images / max_videos / max_elements on the chosen model before generating. Pass reference_images for models with max_images > 0 (e.g. Kling O1/O3, Aleph, WAN VACE), reference_videos for models with max_videos > 1 (e.g. WAN 2.6 reference-to-video accepts up to 3), and elements for models with max_elements > 0. For animating a still image use generate_video_from_image instead. For text-only → video use generate_video.',
598
610
  {
599
611
  source_video: z.string().describe('URL or absolute local path to the primary source video to restyle. For models that use reference_videos as their primary input (e.g. WAN 2.6 reference-to-video), pass the first reference video here and also include it in reference_videos.'),
600
612
  prompt: z.string().describe('Text description of the desired restyle / transformation'),
601
- model: z.string().optional().describe('Model identifier. Use list_models type="video_to_video" to see options and check maxImages / maxVideos / maxElements per model. Omit for Smart Select.'),
613
+ model: z.string().optional().describe('Model identifier. Use list_models type="video_to_video" to see options and check max_images / max_videos / max_elements per model. Omit for Smart Select.'),
602
614
  aspect_ratio: z.string().optional().describe('Output aspect ratio. Default: matches source'),
603
615
  duration: z.number().optional().describe('Duration in seconds (default: matches source)'),
604
616
  enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: true'),
605
617
  visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency.'),
606
618
  resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
607
- reference_images: z.array(z.string()).optional().describe('Array of reference image URLs for models that support additional image inputs (maxImages > 0). Examples: character reference images for Kling O1/O3, style reference for Aleph/gen4_aleph, character image for WAN VACE video-edit. Check maxImages on the model from list_models before passing.'),
608
- reference_videos: z.array(z.string()).optional().describe('Array of additional reference video URLs for models that support multiple video inputs (maxVideos > 1). Example: WAN 2.6 reference-to-video accepts 1–3 reference videos. Check maxVideos on the model from list_models before passing.'),
609
- elements: z.array(z.string()).optional().describe('Array of element image URLs for models with maxElements > 0. Elements are used as style or character reference assets alongside the main video. Check maxElements on the model from list_models before passing.')
619
+ reference_images: z.array(z.string()).optional().describe('Array of reference image URLs for models that support additional image inputs (max_images > 0). Examples: character reference images for Kling O1/O3, style reference for Aleph/gen4_aleph, character image for WAN VACE video-edit. Check max_images on the model from list_models before passing.'),
620
+ reference_videos: z.array(z.string()).optional().describe('Array of additional reference video URLs for models that support multiple video inputs (max_videos > 1). Example: WAN 2.6 reference-to-video accepts 1–3 reference videos. Check max_videos on the model from list_models before passing.'),
621
+ elements: z.array(z.string()).optional().describe('Array of element image URLs for models with max_elements > 0. Elements are used as style or character reference assets alongside the main video. Check max_elements on the model from list_models before passing.')
610
622
  },
611
623
  async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, reference_images, reference_videos, elements }) => {
612
624
  if (!source_video) throw new Error('source_video is required');
@@ -21,8 +21,64 @@ function registerModelTools(server, client) {
21
21
  const withSummary = result.models.filter(m => m.summary && m.summary.trim() !== '');
22
22
  const withoutSummary = result.models.filter(m => !m.summary || m.summary.trim() === '');
23
23
 
24
+ // Format the per-model spec line. The agent NEEDS this — without it,
25
+ // it has to guess `supported_resolutions`/`supported_durations` and
26
+ // either invents values (then the API silently substitutes) or asks
27
+ // the user to clarify what's only knowable from this list.
28
+ const formatSpecs = m => {
29
+ const parts = [];
30
+
31
+ if (Array.isArray(m.supported_resolutions) && m.supported_resolutions.length) {
32
+ const mult = m.resolution_multipliers || {};
33
+ parts.push(
34
+ 'resolutions: ' +
35
+ m.supported_resolutions
36
+ .map(r => (mult[r] != null && mult[r] !== 1 ? `${r} (${mult[r]}×)` : r))
37
+ .join(' · ')
38
+ );
39
+ }
40
+
41
+ if (Array.isArray(m.supported_durations) && m.supported_durations.length) {
42
+ const ds = m.supported_durations;
43
+ // Compact ranges like 4-15 if it's a contiguous run.
44
+ const sorted = [...ds].sort((a, b) => a - b);
45
+ const isRange = sorted.length > 2 && sorted.every((v, i) => i === 0 || v - sorted[i - 1] === 1);
46
+ parts.push(`durations: ${isRange ? `${sorted[0]}-${sorted[sorted.length - 1]}s` : sorted.join('/') + 's'}`);
47
+ }
48
+
49
+ if (Array.isArray(m.supported_aspect_ratios) && m.supported_aspect_ratios.length) {
50
+ parts.push(`aspect: ${m.supported_aspect_ratios.join(', ')}`);
51
+ }
52
+
53
+ // Elements-type caps (only show when at least one is non-zero)
54
+ const eImg = m.elements_max_images, eVid = m.elements_max_videos, eAud = m.elements_max_audio;
55
+ if ((eImg ?? 0) > 0 || (eVid ?? 0) > 0 || (eAud ?? 0) > 0) {
56
+ parts.push(`elements: ${eImg ?? 0} imgs / ${eVid ?? 0} vids / ${eAud ?? 0} audio`);
57
+ }
58
+
59
+ // Video-to-video / multi-input caps
60
+ const mImg = m.max_images, mVid = m.max_videos, mElm = m.max_elements;
61
+ if ((mImg ?? 0) > 0 || (mVid ?? 0) > 0 || (mElm ?? 0) > 0) {
62
+ parts.push(`refs: ${mImg ?? 0} imgs / ${mVid ?? 0} vids / ${mElm ?? 0} elms`);
63
+ }
64
+
65
+ if ((m.max_visual_dna ?? 0) > 0) parts.push(`max_dna: ${m.max_visual_dna}`);
66
+
67
+ // Sound (only show when sound costs more or is generated natively)
68
+ if (m.sound_generation_type === 'native') {
69
+ const mult = m.sound_credit_multiplier && m.sound_credit_multiplier !== 1
70
+ ? ` (${m.sound_credit_multiplier}×)`
71
+ : '';
72
+ parts.push(`sound: native${mult}${m.sound_enabled_by_default ? ' on-by-default' : ''}`);
73
+ }
74
+
75
+ if (m.max_audio_duration != null) parts.push(`audio_max: ${m.max_audio_duration}s`);
76
+
77
+ return parts.length ? `\n ${parts.join(' | ')}` : '';
78
+ };
79
+
24
80
  const formatModel = m =>
25
- `${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${m.summary}` : ''}`;
81
+ `${m.identifier} (${m.name}) - ${m.credit} credits${m.recommended ? ' [RECOMMENDED]' : ''}${m.new_model ? ' [NEW]' : ''}${m.summary ? ` — ${m.summary}` : ''}${formatSpecs(m)}`;
26
82
 
27
83
  const sections = [];
28
84
  if (withSummary.length > 0) {