@kolbo/mcp 1.15.0 → 1.16.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 +56 -56
- package/src/polling.js +4 -3
- package/src/tools/chat.js +140 -140
- package/src/tools/generate.js +979 -910
package/src/tools/generate.js
CHANGED
|
@@ -1,910 +1,979 @@
|
|
|
1
|
-
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
-
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
-
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
-
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
-
|
|
6
|
-
const { z } = require('zod');
|
|
7
|
-
const FormData = require('form-data');
|
|
8
|
-
const { pollUntilDone, PollingTimeoutError } = require('../polling');
|
|
9
|
-
const { resolveToBuffer, creditFields } = require('./_shared');
|
|
10
|
-
|
|
11
|
-
function registerGenerateTools(server, client) {
|
|
12
|
-
// ─── generate_image ────────────────────────────────────────
|
|
13
|
-
server.tool(
|
|
14
|
-
'generate_image',
|
|
15
|
-
'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), reference images (for composition guidance), batch generation (num_images), and web-search grounding. For EDITING an existing image, use generate_image_edit instead. For a coordinated multi-scene set (storyboard, ad campaign), use generate_creative_director. Returns the final image URL(s) when complete.',
|
|
16
|
-
{
|
|
17
|
-
prompt: z.string().describe('Text description of the image to generate'),
|
|
18
|
-
model: z.string().optional().describe('Model identifier. Use list_models type="text_to_img" to see options. Omit for Smart Select.'),
|
|
19
|
-
aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be a value present in the model\'s `supported_aspect_ratios` from list_models — pass an unsupported value and the API rejects. Default: "1:1"'),
|
|
20
|
-
enhance_prompt: z.boolean().optional().describe('
|
|
21
|
-
num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1. Note: some models (Midjourney etc.) have a fixed `images_per_request` and ignore this — check list_models.'),
|
|
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. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** 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. **Cap: pass at most `max_visual_dna` IDs from list_models — if the field is null/0 or `supports_visual_dna: false`, the model rejects DNA entirely (silently ignored in some paths).** 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). 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
|
-
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
|
-
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 resolution_multipliers on the same model to predict credit cost. Omit to use the model default.'),
|
|
27
|
-
preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.')
|
|
28
|
-
},
|
|
29
|
-
async ({ prompt, model, aspect_ratio, enhance_prompt, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id }) => {
|
|
30
|
-
const gen = await client.post('/v1/generate/image', {
|
|
31
|
-
prompt, model, aspect_ratio, enhance_prompt, num_images,
|
|
32
|
-
reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
form.append('
|
|
549
|
-
form.append('
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
if (
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1
|
+
/* ⛔ BACKWARD COMPATIBILITY: Tool names and arg names below are a PUBLIC
|
|
2
|
+
* CONTRACT. Never rename, remove, or break an existing tool/arg — old cached
|
|
3
|
+
* `npx @kolbo/mcp` installs in the wild will break silently. Add new tools or
|
|
4
|
+
* new OPTIONAL args only. Full rules: ../index.js top-of-file and CLAUDE.md. */
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
const FormData = require('form-data');
|
|
8
|
+
const { pollUntilDone, PollingTimeoutError } = require('../polling');
|
|
9
|
+
const { resolveToBuffer, creditFields } = require('./_shared');
|
|
10
|
+
|
|
11
|
+
function registerGenerateTools(server, client) {
|
|
12
|
+
// ─── generate_image ────────────────────────────────────────
|
|
13
|
+
server.tool(
|
|
14
|
+
'generate_image',
|
|
15
|
+
'Generate image(s) from a text prompt using Kolbo AI. Supports Visual DNA profiles (for character/style/product consistency), moodboards (for style direction), reference images (for composition guidance), batch generation (num_images), and web-search grounding. For EDITING an existing image, use generate_image_edit instead. For a coordinated multi-scene set (storyboard, ad campaign), use generate_creative_director. Returns the final image URL(s) when complete.',
|
|
16
|
+
{
|
|
17
|
+
prompt: z.string().describe('Text description of the image to generate'),
|
|
18
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="text_to_img" to see options. Omit for Smart Select.'),
|
|
19
|
+
aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be a value present in the model\'s `supported_aspect_ratios` from list_models — pass an unsupported value and the API rejects. Default: "1:1"'),
|
|
20
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
21
|
+
num_images: z.number().optional().describe('Number of images to generate in one call. Default: 1. Note: some models (Midjourney etc.) have a fixed `images_per_request` and ignore this — check list_models.'),
|
|
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. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** 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. **Cap: pass at most `max_visual_dna` IDs from list_models — if the field is null/0 or `supports_visual_dna: false`, the model rejects DNA entirely (silently ignored in some paths).** 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). 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
|
+
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
|
+
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 resolution_multipliers on the same model to predict credit cost. Omit to use the model default.'),
|
|
27
|
+
preset_id: z.string().optional().describe('Preset ID from list_presets type="image" to apply a saved style preset to this generation.')
|
|
28
|
+
},
|
|
29
|
+
async ({ prompt, model, aspect_ratio, enhance_prompt = false, num_images, reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id }) => {
|
|
30
|
+
const gen = await client.post('/v1/generate/image', {
|
|
31
|
+
prompt, model, aspect_ratio, enhance_prompt, num_images,
|
|
32
|
+
reference_images, visual_dna_ids, moodboard_id, enable_web_search, resolution, preset_id
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// 60s here (not 120s) so a stuck generation surfaces faster — the
|
|
36
|
+
// PollingTimeoutError tells the model to switch to get_generation_status,
|
|
37
|
+
// which block-polls for 10min by default. Same total wait, but the
|
|
38
|
+
// first hand-off happens at 60s instead of 120s so the user isn't
|
|
39
|
+
// staring at a frozen spinner.
|
|
40
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
41
|
+
interval: (gen.poll_interval_hint || 3) * 1000,
|
|
42
|
+
timeout: 60000
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
content: [{
|
|
47
|
+
type: 'text',
|
|
48
|
+
text: JSON.stringify({
|
|
49
|
+
...creditFields(result),
|
|
50
|
+
urls: result.result.urls,
|
|
51
|
+
model: result.result.model,
|
|
52
|
+
prompt_used: result.result.prompt_used,
|
|
53
|
+
_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.'
|
|
54
|
+
}, null, 2)
|
|
55
|
+
}]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// ─── generate_image_edit ──────────────────────────────────
|
|
61
|
+
server.tool(
|
|
62
|
+
'generate_image_edit',
|
|
63
|
+
'Edit or transform an existing image using AI. Provide the source image URL(s) in `source_images` and describe the edit in `prompt` (e.g., "remove the background", "change the car color to red", "add sunglasses to the person"). Supports Visual DNA profiles and moodboards for style-consistent edits. For creating a brand new image from scratch, use generate_image. Returns the edited image URL(s) when complete.',
|
|
64
|
+
{
|
|
65
|
+
prompt: z.string().describe('Description of the edit to apply (e.g., "remove the background", "change the sky to sunset")'),
|
|
66
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="image_editing" to see options. Omit for Smart Select.'),
|
|
67
|
+
source_images: z.array(z.string()).describe('PIXEL-ACCURATE compositing. Array of source image URLs whose pixel content is composited into the output. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.** 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") or use @image1/@image2 tags. Add "composite AS-IS, do not redraw or restyle" to lock pixels.'),
|
|
68
|
+
aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
|
|
69
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
70
|
+
num_images: z.number().optional().describe('Number of output images. Default: 1'),
|
|
71
|
+
visual_dna_ids: z.array(z.string()).optional().describe('Visual DNA profile IDs for character / style / product consistency. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.** 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.'),
|
|
72
|
+
moodboard_id: z.string().optional().describe('Moodboard ID whose master_prompt and style_guide should be applied.'),
|
|
73
|
+
enable_web_search: z.boolean().optional().describe('Enable web-search grounding. Default: false'),
|
|
74
|
+
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.')
|
|
75
|
+
},
|
|
76
|
+
async ({ prompt, model, source_images, aspect_ratio, enhance_prompt = false, num_images, visual_dna_ids, moodboard_id, enable_web_search, resolution }) => {
|
|
77
|
+
const gen = await client.post('/v1/generate/image-edit', {
|
|
78
|
+
prompt, model, source_images, aspect_ratio, enhance_prompt, num_images,
|
|
79
|
+
visual_dna_ids, moodboard_id, enable_web_search, resolution
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Flux/Seedance/DNA-anchored edits routinely run 3-5 min. Old 120s
|
|
83
|
+
// timeout forced every call into the timeout-and-recover path via
|
|
84
|
+
// get_generation_status — and when the model fired multiple parallel
|
|
85
|
+
// edits, some recovered, some got abandoned with their URLs lost
|
|
86
|
+
// (the user's "lost generations" bug). 360s/480s covers the realistic
|
|
87
|
+
// p99 single-call duration without making the model poll forever.
|
|
88
|
+
const heavy = (source_images && source_images.length > 1) || (visual_dna_ids && visual_dna_ids.length > 0);
|
|
89
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
90
|
+
interval: (gen.poll_interval_hint || 3) * 1000,
|
|
91
|
+
timeout: heavy ? 480000 : 360000
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
content: [{
|
|
96
|
+
type: 'text',
|
|
97
|
+
text: JSON.stringify({
|
|
98
|
+
...creditFields(result),
|
|
99
|
+
urls: result.result.urls,
|
|
100
|
+
model: result.result.model,
|
|
101
|
+
prompt_used: result.result.prompt_used,
|
|
102
|
+
_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.'
|
|
103
|
+
}, null, 2)
|
|
104
|
+
}]
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// ─── generate_creative_director ─────────────────────────────
|
|
110
|
+
server.tool(
|
|
111
|
+
'generate_creative_director',
|
|
112
|
+
'Generate 2–8 related images or videos as one coherent set from a single creative brief. Use scene_count (NOT num_images) to set the number of scenes (1–8, default 4). Use this when the user gives a general brief ("make 4 product shots", "create a storyboard") and you are planning the scenes — it handles style consistency and runs scenes in parallel. If the user explicitly provides separate prompts for each image, use parallel generate_image calls instead. Supports image and video modes (workflow_type). Visual DNA and moodboard references keep character/style consistent across every scene.',
|
|
113
|
+
{
|
|
114
|
+
prompt: z.string().describe('Creative brief or concept describing the full set of scenes to generate'),
|
|
115
|
+
scene_count: z.number().optional().describe('Number of scenes/images to generate, 1–8. Default: 4. Use this — NOT num_images — to control how many outputs are created.'),
|
|
116
|
+
model: z.string().optional().describe('Model identifier applied to every scene. Omit for Smart Select.'),
|
|
117
|
+
aspect_ratio: z.string().optional().describe('Aspect ratio applied to every scene (e.g., "1:1", "16:9", "9:16"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "1:1"'),
|
|
118
|
+
workflow_type: z.string().optional().describe('"image" (default) or "video"'),
|
|
119
|
+
duration: z.number().optional().describe('Duration in seconds per scene (video mode only). Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration`. E.g., 5 or 10.'),
|
|
120
|
+
sound_enabled: z.boolean().optional().describe('Video mode only. Enable (`true`) or disable (`false`) AI-generated synced audio on every scene. Only honored by models with `sound_generation_type: "native"` from list_models (Veo 3.1, Kling V3/2.6/O3, PixVerse V6). Omit to use each model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio.'),
|
|
121
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
122
|
+
reference_images: z.array(z.string()).optional().describe('Array of reference image URLs to guide style/composition of every scene. **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model.**'),
|
|
123
|
+
visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply consistently across every scene. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.** This is the ideal way to keep a character or product looking the same in all scenes of a campaign.'),
|
|
124
|
+
moodboard_id: z.string().optional().describe('A single moodboard ID whose master_prompt and style_guide should shape every scene.'),
|
|
125
|
+
moodboard_ids: z.array(z.string()).optional().describe('Multiple moodboard IDs when blending styles. Prefer `moodboard_id` for single moodboards.'),
|
|
126
|
+
resolution: z.string().optional().describe('Resolution tier applied to every scene. Images: "1K" / "2K" / "3K" / "4K". Videos: "720p" / "1080p" / "1440p" / "2160p". Values are model-dependent — call list_models and read supported_resolutions on the target model. Multiplied across every scene.')
|
|
127
|
+
},
|
|
128
|
+
async ({ prompt, scene_count, model, aspect_ratio, workflow_type, duration, sound_enabled, enhance_prompt = false, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution }) => {
|
|
129
|
+
const gen = await client.post('/v1/generate/creative-director', {
|
|
130
|
+
prompt, scene_count, model, aspect_ratio, workflow_type, duration, sound_enabled,
|
|
131
|
+
enhance_prompt, reference_images, visual_dna_ids, moodboard_id, moodboard_ids, resolution
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
135
|
+
interval: (gen.poll_interval_hint || 5) * 1000,
|
|
136
|
+
timeout: 600000,
|
|
137
|
+
statusUrl: `/v1/generate/creative-director/${gen.generation_id}/status`
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const scenes = (result.scenes || [])
|
|
141
|
+
.filter(s => s.status === 'completed')
|
|
142
|
+
.map(s => ({
|
|
143
|
+
scene_number: s.scene_number,
|
|
144
|
+
title: s.title,
|
|
145
|
+
image_urls: s.image_urls,
|
|
146
|
+
video_urls: s.video_urls
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
content: [{
|
|
151
|
+
type: 'text',
|
|
152
|
+
text: JSON.stringify({
|
|
153
|
+
...creditFields(result),
|
|
154
|
+
scenes,
|
|
155
|
+
total_scenes: result.scenes?.length || 0,
|
|
156
|
+
completed_scenes: scenes.length,
|
|
157
|
+
_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.'
|
|
158
|
+
}, null, 2)
|
|
159
|
+
}]
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ─── generate_video ────────────────────────────────────────
|
|
165
|
+
// NOTE: text-to-video does NOT support Visual DNA — the textToVideoGeneration
|
|
166
|
+
// controller in kolbo-api never reads visualDnaIds. For character-consistent
|
|
167
|
+
// video, use generate_elements (which DOES honor visual_dna_ids) or animate a
|
|
168
|
+
// DNA-locked still via generate_video_from_image.
|
|
169
|
+
server.tool(
|
|
170
|
+
'generate_video',
|
|
171
|
+
'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.',
|
|
172
|
+
{
|
|
173
|
+
prompt: z.string().describe('Text description of the video to generate'),
|
|
174
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="text_to_video" to see options. Check supported_durations and supported_aspect_ratios.'),
|
|
175
|
+
aspect_ratio: z.string().optional().describe('Aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "16:9"'),
|
|
176
|
+
duration: z.number().optional().describe('Duration in seconds. Must be a value in `supported_durations` from list_models, OR within `min_output_duration`-`max_output_duration` (whichever the model exposes). Default: 5'),
|
|
177
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
178
|
+
reference_images: z.array(z.string()).optional().describe('Array of image URLs used as visual references (style / composition / subject). **Cap: pass at most `max_reference_images` URLs from list_models for the chosen model — exceeding it is a deterministic 400.**'),
|
|
179
|
+
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.'),
|
|
180
|
+
sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1, Kling V3/2.6, PixVerse V6). On `sound_generation_type: "none"` models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.'),
|
|
181
|
+
preset_id: z.string().optional().describe('Preset ID from list_presets type="video" to apply a saved motion/style preset to this generation.')
|
|
182
|
+
},
|
|
183
|
+
async ({ prompt, model, aspect_ratio, duration, enhance_prompt = false, reference_images, resolution, sound_enabled, preset_id }) => {
|
|
184
|
+
const gen = await client.post('/v1/generate/video', {
|
|
185
|
+
prompt, model, aspect_ratio, duration, enhance_prompt, reference_images, resolution, sound_enabled, preset_id
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
189
|
+
interval: (gen.poll_interval_hint || 8) * 1000,
|
|
190
|
+
timeout: 300000
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
content: [{
|
|
195
|
+
type: 'text',
|
|
196
|
+
text: JSON.stringify({
|
|
197
|
+
...creditFields(result),
|
|
198
|
+
urls: result.result.urls,
|
|
199
|
+
model: result.result.model,
|
|
200
|
+
duration: result.result.duration,
|
|
201
|
+
thumbnail_url: result.result.thumbnail_url,
|
|
202
|
+
prompt_used: result.result.prompt_used,
|
|
203
|
+
_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.'
|
|
204
|
+
}, null, 2)
|
|
205
|
+
}]
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// ─── generate_video_from_image ─────────────────────────────
|
|
211
|
+
server.tool(
|
|
212
|
+
'generate_video_from_image',
|
|
213
|
+
'Animate an existing still image into a video using Kolbo AI. The image comes from `image_url`; `prompt` describes the motion (not the subject — the subject is already in the image). For generating a video from scratch, use generate_video. Returns the final video URL when complete.',
|
|
214
|
+
{
|
|
215
|
+
image_url: z.string().describe('URL of the source image to animate'),
|
|
216
|
+
prompt: z.string().describe('Text description of the desired MOTION (e.g., "camera slowly pans right while the character walks forward")'),
|
|
217
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="img_to_video" to see options.'),
|
|
218
|
+
aspect_ratio: z.string().optional().describe('Output aspect ratio (e.g., "16:9", "9:16", "1:1"). Must be in the chosen model\'s `supported_aspect_ratios` from list_models. Default: "16:9"'),
|
|
219
|
+
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'),
|
|
220
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
221
|
+
visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to maintain consistency with prior characters / styles. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false` the model ignores DNA entirely.**'),
|
|
222
|
+
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.'),
|
|
223
|
+
sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1 Lite, Kling V3 4K, PixVerse V6, Kling 2.6/v3). On `sound_generation_type: "none"` models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.')
|
|
224
|
+
},
|
|
225
|
+
async ({ image_url, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled }) => {
|
|
226
|
+
const gen = await client.post('/v1/generate/video/from-image', {
|
|
227
|
+
image_url, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution, sound_enabled
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
231
|
+
interval: (gen.poll_interval_hint || 8) * 1000,
|
|
232
|
+
timeout: 300000
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
content: [{
|
|
237
|
+
type: 'text',
|
|
238
|
+
text: JSON.stringify({
|
|
239
|
+
...creditFields(result),
|
|
240
|
+
urls: result.result.urls,
|
|
241
|
+
model: result.result.model,
|
|
242
|
+
duration: result.result.duration,
|
|
243
|
+
thumbnail_url: result.result.thumbnail_url,
|
|
244
|
+
_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.'
|
|
245
|
+
}, null, 2)
|
|
246
|
+
}]
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// ─── generate_music ────────────────────────────────────────
|
|
252
|
+
server.tool(
|
|
253
|
+
'generate_music',
|
|
254
|
+
'Generate music from a text description using Kolbo AI. Supports instrumental mode, custom lyrics, style direction, and vocal gender. Default model is Suno. Returns the final audio URL when complete.',
|
|
255
|
+
{
|
|
256
|
+
prompt: z.string().describe('Text description of the music to generate (e.g., "upbeat electronic dance track with synthesizers")'),
|
|
257
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="music_gen" to see options. Omit for Suno (default).'),
|
|
258
|
+
style: z.string().optional().describe('Music style / genre (e.g., "pop", "rock", "lo-fi", "electronic", "jazz")'),
|
|
259
|
+
instrumental: z.boolean().optional().describe('Generate instrumental only, no vocals. Default: false'),
|
|
260
|
+
lyrics: z.string().optional().describe('Custom lyrics for the song. If omitted, lyrics are generated automatically from the prompt unless instrumental is true.'),
|
|
261
|
+
vocal_gender: z.string().optional().describe('Preferred vocal gender: "male" or "female". Only applies when instrumental is false.'),
|
|
262
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
263
|
+
preset_id: z.string().optional().describe('Preset ID from list_presets type="music" to apply a saved music style preset.')
|
|
264
|
+
},
|
|
265
|
+
async ({ prompt, model, style, instrumental, lyrics, vocal_gender, enhance_prompt = false, preset_id }) => {
|
|
266
|
+
const gen = await client.post('/v1/generate/music', {
|
|
267
|
+
prompt, model, style, instrumental, lyrics, vocal_gender, enhance_prompt, preset_id
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
271
|
+
interval: (gen.poll_interval_hint || 8) * 1000,
|
|
272
|
+
timeout: 300000
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
content: [{
|
|
277
|
+
type: 'text',
|
|
278
|
+
text: JSON.stringify({
|
|
279
|
+
...creditFields(result),
|
|
280
|
+
urls: result.result.urls,
|
|
281
|
+
title: result.result.title,
|
|
282
|
+
duration: result.result.duration,
|
|
283
|
+
lyrics: result.result.lyrics
|
|
284
|
+
}, null, 2)
|
|
285
|
+
}]
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// ─── generate_speech ───────────────────────────────────────
|
|
291
|
+
server.tool(
|
|
292
|
+
'generate_speech',
|
|
293
|
+
'Convert text to speech using Kolbo AI. Default provider is ElevenLabs. To pick a specific voice by language/gender, call list_voices first and pass the returned voice_id (or a voice display name — both work). Returns the final audio URL when complete.',
|
|
294
|
+
{
|
|
295
|
+
text: z.string().describe('The text to convert to speech'),
|
|
296
|
+
voice: z.string().optional().describe('Voice ID (from list_voices) or voice display name (e.g., "Rachel", "Adam"). Default: "Rachel"'),
|
|
297
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="text_to_speech" to see options. Default: eleven_v3'),
|
|
298
|
+
language: z.string().optional().describe('Language code (e.g., "en-US", "he-IL", "es-ES"). Default: "en-US"')
|
|
299
|
+
},
|
|
300
|
+
async ({ text, voice, model, language }) => {
|
|
301
|
+
const gen = await client.post('/v1/generate/speech', {
|
|
302
|
+
text, voice, model, language
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
306
|
+
interval: (gen.poll_interval_hint || 5) * 1000,
|
|
307
|
+
timeout: 120000
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
content: [{
|
|
312
|
+
type: 'text',
|
|
313
|
+
text: JSON.stringify({
|
|
314
|
+
...creditFields(result),
|
|
315
|
+
urls: result.result.urls,
|
|
316
|
+
voice: result.result.voice,
|
|
317
|
+
duration: result.result.duration
|
|
318
|
+
}, null, 2)
|
|
319
|
+
}]
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// ─── generate_sound ────────────────────────────────────────
|
|
325
|
+
server.tool(
|
|
326
|
+
'generate_sound',
|
|
327
|
+
'Generate sound effects (not music, not speech) from a text description using Kolbo AI. Use this for ambient sounds, foley, impacts, atmospheres, UI sounds, etc. For music use generate_music; for voice use generate_speech. Returns the final audio URL when complete.',
|
|
328
|
+
{
|
|
329
|
+
prompt: z.string().describe('Text description of the sound effect (e.g., "thunder clap with rain", "door creaking open", "futuristic UI beep")'),
|
|
330
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="text_to_sound" to see options. Default: elevenlabs-sound-effects-v1'),
|
|
331
|
+
duration: z.number().optional().describe('Duration in seconds. Omit for automatic duration.'),
|
|
332
|
+
prompt_influence: z.number().optional().describe('How strongly the prompt guides the generation (0–1). Default: 0.5. Lower values give the model more creative freedom; higher values follow the prompt more literally.')
|
|
333
|
+
},
|
|
334
|
+
async ({ prompt, model, duration, prompt_influence }) => {
|
|
335
|
+
const gen = await client.post('/v1/generate/sound', {
|
|
336
|
+
prompt, model, duration, prompt_influence
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
340
|
+
interval: (gen.poll_interval_hint || 5) * 1000,
|
|
341
|
+
timeout: 120000
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
content: [{
|
|
346
|
+
type: 'text',
|
|
347
|
+
text: JSON.stringify({
|
|
348
|
+
...creditFields(result),
|
|
349
|
+
urls: result.result.urls,
|
|
350
|
+
duration: result.result.duration
|
|
351
|
+
}, null, 2)
|
|
352
|
+
}]
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// ─── list_voices ─────────────────────────────────────────────
|
|
358
|
+
server.tool(
|
|
359
|
+
'list_voices',
|
|
360
|
+
'List available TTS voices for generate_speech. Returns preset voices and the user\'s own cloned/designed voices. Filter by provider, language, or gender to find the right voice. Use the returned `voice_id` as the `voice` parameter in generate_speech.',
|
|
361
|
+
{
|
|
362
|
+
provider: z.string().optional().describe('Filter by provider (e.g., "elevenLabs", "google")'),
|
|
363
|
+
language: z.string().optional().describe('Filter by language name or code (e.g., "English", "en-US")'),
|
|
364
|
+
gender: z.string().optional().describe('Filter by gender (e.g., "Female", "Male")')
|
|
365
|
+
},
|
|
366
|
+
async ({ provider, language, gender }) => {
|
|
367
|
+
const params = new URLSearchParams();
|
|
368
|
+
if (provider) params.set('provider', provider);
|
|
369
|
+
if (language) params.set('language', language);
|
|
370
|
+
if (gender) params.set('gender', gender);
|
|
371
|
+
|
|
372
|
+
const qs = params.toString();
|
|
373
|
+
const result = await client.get(`/v1/voices${qs ? '?' + qs : ''}`);
|
|
374
|
+
|
|
375
|
+
// Summarize for context window efficiency
|
|
376
|
+
const voices = (result.voices || []).map(v => ({
|
|
377
|
+
voice_id: v.voice_id,
|
|
378
|
+
name: v.name,
|
|
379
|
+
provider: v.provider,
|
|
380
|
+
language: v.language,
|
|
381
|
+
gender: v.gender,
|
|
382
|
+
custom: v.custom
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
content: [{
|
|
387
|
+
type: 'text',
|
|
388
|
+
text: JSON.stringify({ voices, count: result.count }, null, 2)
|
|
389
|
+
}]
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Track how many consecutive get_generation_status polls land on the same
|
|
395
|
+
// id within a short window. Models routinely ignore the "stop polling"
|
|
396
|
+
// hint and re-call, burning minutes of wall-clock and confusing the user.
|
|
397
|
+
// After STATUS_POLL_CAP attempts in a row, we hard-abandon: return a
|
|
398
|
+
// distinct shape (no `still_pending`) so the model can't pattern-match
|
|
399
|
+
// its way back into the loop.
|
|
400
|
+
const STATUS_POLL_CAP = 2;
|
|
401
|
+
const STATUS_POLL_RESET_MS = 10 * 60 * 1000; // a quiet 10min resets the counter
|
|
402
|
+
const statusPollHistory = new Map(); // id → { count, lastAt }
|
|
403
|
+
|
|
404
|
+
// ─── get_generation_status ─────────────────────────────────
|
|
405
|
+
server.tool(
|
|
406
|
+
'get_generation_status',
|
|
407
|
+
'Resume polling a generation after a timeout. Pass the generation_id from a prior generation tool that timed out. This call BLOCKS server-side, polling internally — you do NOT need to call it again in a loop. Defaults to a 10-minute internal poll which covers most image edits and short videos; pass `wait_seconds` up to 1700 (~28 min) for long video / 3D / batch generations. **If you fired multiple generations in parallel and they all timed out, you MUST call get_generation_status for EACH generation_id in this same turn — do not give up on any of them, or their URLs will be lost forever.** A response with `abandoned: true` is TERMINAL — the generation will not be polled again in this turn; report it to the user and move on. A response with `still_pending: true` means the job is genuinely slow — STOP polling THAT id immediately, tell the user, and wait for them to ask again. Never call this tool more than ONCE per generation_id consecutively in the same turn.',
|
|
408
|
+
{
|
|
409
|
+
generation_id: z.string().describe('The generation ID to check'),
|
|
410
|
+
wait_seconds: z.number().int().min(60).max(1700).optional().describe('How long to block-poll internally before giving up (60–1700 seconds, default 600). Values below 300 are clamped up to 300 server-side because shorter waits cause the model to abandon still-running generations. Use higher values for video / 3D / large batches that can legitimately take 15+ minutes.'),
|
|
411
|
+
},
|
|
412
|
+
async ({ generation_id, wait_seconds }) => {
|
|
413
|
+
// Clamp the floor: shorter waits caused models to abandon generations
|
|
414
|
+
// that completed seconds later (URLs lost forever, paid-for compute
|
|
415
|
+
// wasted). 300s is the floor that survived real-world cases.
|
|
416
|
+
const requested = wait_seconds ?? 600;
|
|
417
|
+
const timeoutMs = Math.max(requested, 300) * 1000;
|
|
418
|
+
|
|
419
|
+
// Update the consecutive-call counter for this id. We reset if the
|
|
420
|
+
// last attempt was a long time ago (new user turn), so this only
|
|
421
|
+
// catches within-turn looping. Opportunistic sweep keeps the map
|
|
422
|
+
// bounded on long-lived MCP servers (no setInterval — that would
|
|
423
|
+
// keep the Node event loop alive forever).
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
if (statusPollHistory.size > 64) {
|
|
426
|
+
const cutoff = now - STATUS_POLL_RESET_MS;
|
|
427
|
+
for (const [k, v] of statusPollHistory) {
|
|
428
|
+
if (v.lastAt < cutoff) statusPollHistory.delete(k);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const prior = statusPollHistory.get(generation_id);
|
|
432
|
+
const count = prior && now - prior.lastAt < STATUS_POLL_RESET_MS ? prior.count + 1 : 1;
|
|
433
|
+
statusPollHistory.set(generation_id, { count, lastAt: now });
|
|
434
|
+
|
|
435
|
+
// Hard abandon — the model has hit the cap. Return a non-pending
|
|
436
|
+
// shape so it has nothing left to keep polling against.
|
|
437
|
+
if (count > STATUS_POLL_CAP) {
|
|
438
|
+
const current = await client
|
|
439
|
+
.get(`/v1/generate/${encodeURIComponent(generation_id)}/status`)
|
|
440
|
+
.catch(() => null);
|
|
441
|
+
// If the generation actually finished in the meantime, surface it.
|
|
442
|
+
if (current?.state === 'completed') {
|
|
443
|
+
statusPollHistory.delete(generation_id);
|
|
444
|
+
return {
|
|
445
|
+
content: [{ type: 'text', text: JSON.stringify(current, null, 2) }],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
content: [
|
|
450
|
+
{
|
|
451
|
+
type: 'text',
|
|
452
|
+
text: JSON.stringify(
|
|
453
|
+
{
|
|
454
|
+
generation_id,
|
|
455
|
+
state: current?.state ?? 'unknown',
|
|
456
|
+
abandoned: true,
|
|
457
|
+
poll_attempts: count,
|
|
458
|
+
_note: `This generation has been polled ${count} consecutive times in this turn and is still not done. ABANDONED — do NOT call get_generation_status for this id again. Tell the user the generation is taking unusually long and ask them to check back later.`,
|
|
459
|
+
},
|
|
460
|
+
null,
|
|
461
|
+
2,
|
|
462
|
+
),
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let result;
|
|
469
|
+
try {
|
|
470
|
+
result = await pollUntilDone(client, generation_id, {
|
|
471
|
+
interval: 5000,
|
|
472
|
+
timeout: timeoutMs,
|
|
473
|
+
});
|
|
474
|
+
} catch (err) {
|
|
475
|
+
if (err instanceof PollingTimeoutError) {
|
|
476
|
+
const current = await client.get(`/v1/generate/${encodeURIComponent(generation_id)}/status`).catch(() => null);
|
|
477
|
+
const minutes = Math.round(timeoutMs / 60000);
|
|
478
|
+
return {
|
|
479
|
+
content: [{
|
|
480
|
+
type: 'text',
|
|
481
|
+
text: JSON.stringify({
|
|
482
|
+
generation_id,
|
|
483
|
+
state: current?.state ?? 'unknown',
|
|
484
|
+
still_pending: true,
|
|
485
|
+
waited_seconds: Math.round(timeoutMs / 1000),
|
|
486
|
+
poll_attempts: count,
|
|
487
|
+
_note: `Polled for ${minutes} minute(s) — generation is taking longer than usual. STOP calling get_generation_status now. Tell the user the generation is still running and ask them to prompt you to check again later. Do NOT loop — one more consecutive call for this id will be hard-abandoned.`,
|
|
488
|
+
}, null, 2),
|
|
489
|
+
}],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
throw err;
|
|
493
|
+
}
|
|
494
|
+
// Completed cleanly — clear the counter so a future generation reusing
|
|
495
|
+
// the id (theoretically possible) starts fresh.
|
|
496
|
+
statusPollHistory.delete(generation_id);
|
|
497
|
+
return {
|
|
498
|
+
content: [{
|
|
499
|
+
type: 'text',
|
|
500
|
+
text: JSON.stringify(result, null, 2)
|
|
501
|
+
}]
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
// ═════════════════════════════════════════════════════════════
|
|
507
|
+
// ─── 2026-04 SDK Expansion Batch ─────────────────────────────
|
|
508
|
+
// ═════════════════════════════════════════════════════════════
|
|
509
|
+
|
|
510
|
+
// ─── generate_elements ─────────────────────────────────────
|
|
511
|
+
server.tool(
|
|
512
|
+
'generate_elements',
|
|
513
|
+
'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.',
|
|
514
|
+
{
|
|
515
|
+
prompt: z.string().describe('Text description of the desired video / animation'),
|
|
516
|
+
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.'),
|
|
517
|
+
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.**'),
|
|
518
|
+
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.**'),
|
|
519
|
+
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).**'),
|
|
520
|
+
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`.**'),
|
|
521
|
+
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'),
|
|
522
|
+
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"'),
|
|
523
|
+
motion: z.string().optional().describe('Motion style / intensity hint (optional)'),
|
|
524
|
+
preset_id: z.string().optional().describe('Preset ID from list_presets type="video" (optional)'),
|
|
525
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
526
|
+
visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency across outputs. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model.**'),
|
|
527
|
+
resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
|
|
528
|
+
sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Kling O3 4K, Kling O3 via KIE). On other models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio. Enabling sound may apply `sound_credit_multiplier` to cost.')
|
|
529
|
+
},
|
|
530
|
+
async ({ prompt, model, reference_images, reference_videos, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, sound_enabled }) => {
|
|
531
|
+
if (!prompt) throw new Error('prompt is required');
|
|
532
|
+
|
|
533
|
+
let startResponse;
|
|
534
|
+
if (files && files.length > 0) {
|
|
535
|
+
// Multipart mode: resolve each file source to a buffer and upload.
|
|
536
|
+
const resolved = await Promise.all(files.map(src => resolveToBuffer(src, 'image')));
|
|
537
|
+
const form = new FormData();
|
|
538
|
+
form.append('prompt', prompt);
|
|
539
|
+
if (model) form.append('model', model);
|
|
540
|
+
if (duration !== undefined) form.append('duration', String(duration));
|
|
541
|
+
if (aspect_ratio) form.append('aspect_ratio', aspect_ratio);
|
|
542
|
+
if (motion) form.append('motion', motion);
|
|
543
|
+
if (preset_id) form.append('preset_id', preset_id);
|
|
544
|
+
form.append('enhance_prompt', String(enhance_prompt));
|
|
545
|
+
if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
|
|
546
|
+
if (reference_images) form.append('reference_images', JSON.stringify(reference_images));
|
|
547
|
+
if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
|
|
548
|
+
if (audio_url) form.append('audio_url', audio_url);
|
|
549
|
+
if (resolution) form.append('resolution', resolution);
|
|
550
|
+
if (sound_enabled !== undefined) form.append('sound_enabled', String(sound_enabled));
|
|
551
|
+
for (const f of resolved) {
|
|
552
|
+
form.append('files', f.buffer, { filename: f.filename, contentType: f.contentType });
|
|
553
|
+
}
|
|
554
|
+
startResponse = await client.postMultipart('/v1/generate/elements', form);
|
|
555
|
+
} else {
|
|
556
|
+
// URL-only mode: plain JSON.
|
|
557
|
+
startResponse = await client.post('/v1/generate/elements', {
|
|
558
|
+
prompt, model, reference_images, reference_videos, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, sound_enabled
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
563
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
564
|
+
timeout: 600000
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
content: [{
|
|
569
|
+
type: 'text',
|
|
570
|
+
text: JSON.stringify({
|
|
571
|
+
...creditFields(result),
|
|
572
|
+
urls: result.result?.urls || [],
|
|
573
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
574
|
+
duration: result.result?.duration || null,
|
|
575
|
+
model: result.result?.model || null
|
|
576
|
+
}, null, 2)
|
|
577
|
+
}]
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
// ─── generate_first_last_frame ─────────────────────────────
|
|
583
|
+
server.tool(
|
|
584
|
+
'generate_first_last_frame',
|
|
585
|
+
'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.',
|
|
586
|
+
{
|
|
587
|
+
first_frame_url: z.string().optional().describe('Public URL of the first frame image (URL mode)'),
|
|
588
|
+
last_frame_url: z.string().optional().describe('Public URL of the last frame image (URL mode)'),
|
|
589
|
+
first_frame: z.string().optional().describe('URL or absolute local path to the first frame (file mode — alternative to first_frame_url)'),
|
|
590
|
+
last_frame: z.string().optional().describe('URL or absolute local path to the last frame (file mode — alternative to last_frame_url)'),
|
|
591
|
+
prompt: z.string().optional().describe('Optional description of the desired motion between the two frames (e.g. "smooth camera dolly in")'),
|
|
592
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="firstlastgenerations" to see options. Omit for Smart Select.'),
|
|
593
|
+
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'),
|
|
594
|
+
aspect_ratio: z.string().optional().describe('Aspect ratio (auto-detected from first frame if not provided). Must be in `supported_aspect_ratios` from list_models when set. Default: "16:9"'),
|
|
595
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
596
|
+
visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
|
|
597
|
+
resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
|
|
598
|
+
sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Veo 3.1 Lite, Kling V3 4K, PixVerse V6). On other models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio.')
|
|
599
|
+
},
|
|
600
|
+
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 }) => {
|
|
601
|
+
const urlMode = first_frame_url && last_frame_url;
|
|
602
|
+
const fileMode = first_frame && last_frame;
|
|
603
|
+
if (!urlMode && !fileMode) {
|
|
604
|
+
throw new Error('Provide either both first_frame_url + last_frame_url OR both first_frame + last_frame (URL/local path).');
|
|
605
|
+
}
|
|
606
|
+
if (urlMode && fileMode) {
|
|
607
|
+
throw new Error('Do not mix URL and file inputs. Provide either URLs OR file sources, not both.');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
let startResponse;
|
|
611
|
+
if (fileMode) {
|
|
612
|
+
const [firstResolved, lastResolved] = await Promise.all([
|
|
613
|
+
resolveToBuffer(first_frame, 'image'),
|
|
614
|
+
resolveToBuffer(last_frame, 'image')
|
|
615
|
+
]);
|
|
616
|
+
const form = new FormData();
|
|
617
|
+
form.append('files', firstResolved.buffer, { filename: firstResolved.filename, contentType: firstResolved.contentType });
|
|
618
|
+
form.append('files', lastResolved.buffer, { filename: lastResolved.filename, contentType: lastResolved.contentType });
|
|
619
|
+
if (prompt) form.append('prompt', prompt);
|
|
620
|
+
if (model) form.append('model', model);
|
|
621
|
+
if (duration !== undefined) form.append('duration', String(duration));
|
|
622
|
+
if (aspect_ratio) form.append('aspect_ratio', aspect_ratio);
|
|
623
|
+
form.append('enhance_prompt', String(enhance_prompt));
|
|
624
|
+
if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
|
|
625
|
+
if (resolution) form.append('resolution', resolution);
|
|
626
|
+
if (sound_enabled !== undefined) form.append('sound_enabled', String(sound_enabled));
|
|
627
|
+
startResponse = await client.postMultipart('/v1/generate/first-last-frame', form);
|
|
628
|
+
} else {
|
|
629
|
+
startResponse = await client.post('/v1/generate/first-last-frame', {
|
|
630
|
+
first_frame_url, last_frame_url, prompt, model, duration, aspect_ratio, enhance_prompt, visual_dna_ids, resolution, sound_enabled
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
635
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
636
|
+
timeout: 300000
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
content: [{
|
|
641
|
+
type: 'text',
|
|
642
|
+
text: JSON.stringify({
|
|
643
|
+
...creditFields(result),
|
|
644
|
+
urls: result.result?.urls || [],
|
|
645
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
646
|
+
duration: result.result?.duration || null,
|
|
647
|
+
model: result.result?.model || null
|
|
648
|
+
}, null, 2)
|
|
649
|
+
}]
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
// ─── generate_lipsync ──────────────────────────────────────
|
|
655
|
+
server.tool(
|
|
656
|
+
'generate_lipsync',
|
|
657
|
+
'Lipsync an audio track to a source image or video. Both `source` (image or video) and `audio` can be provided as URLs or as absolute local file paths. Pass a text_prompt only if the model supports it (some lipsync models do character performance from a prompt). **Validate before submitting: for `lipsync-video` sources, the input video duration must fall within `min_video_duration`-`max_video_duration` from list_models; audio duration must fall within `min_audio_duration`-`max_audio_duration` (and if `audio_max_follows_video_duration: true`, audio is also capped at the video duration); audio format must be in `supported_audio_formats` when specified.** Returns a lipsynced video URL.',
|
|
658
|
+
{
|
|
659
|
+
source: z.string().describe('URL or absolute local path to the source image or video (the face to animate). For lipsync-video: duration must fall within `min_video_duration`-`max_video_duration` from list_models.'),
|
|
660
|
+
audio: z.string().describe('URL or absolute local path to the audio track (the voice to sync to). Duration must fall within `min_audio_duration`-`max_audio_duration` from list_models; format must be in `supported_audio_formats` (when set).'),
|
|
661
|
+
text_prompt: z.string().optional().describe('Optional text prompt (for performance-capable models)'),
|
|
662
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="lipsync-image" or type="lipsync-video" to see options. Omit for Smart Select.'),
|
|
663
|
+
bounding_box_target: z.array(z.number()).optional().describe('Optional bounding box [x, y, w, h] for multi-face inputs (Hedra Character3 style). Leave empty for single-face.')
|
|
664
|
+
},
|
|
665
|
+
async ({ source, audio, text_prompt, model, bounding_box_target }) => {
|
|
666
|
+
if (!source) throw new Error('source is required (URL or absolute local path to image/video)');
|
|
667
|
+
if (!audio) throw new Error('audio is required (URL or absolute local path to audio file)');
|
|
668
|
+
|
|
669
|
+
const sourceIsUrl = typeof source === 'string' && /^https?:\/\//i.test(source);
|
|
670
|
+
const audioIsUrl = typeof audio === 'string' && /^https?:\/\//i.test(audio);
|
|
671
|
+
|
|
672
|
+
let startResponse;
|
|
673
|
+
if (sourceIsUrl && audioIsUrl) {
|
|
674
|
+
// URL mode
|
|
675
|
+
startResponse = await client.post('/v1/generate/lipsync', {
|
|
676
|
+
source_url: source,
|
|
677
|
+
audio_url: audio,
|
|
678
|
+
prompt: text_prompt,
|
|
679
|
+
model,
|
|
680
|
+
bounding_box_target
|
|
681
|
+
});
|
|
682
|
+
} else {
|
|
683
|
+
// File mode (or mixed — resolve any local paths, pass URLs through as body fields)
|
|
684
|
+
const form = new FormData();
|
|
685
|
+
if (!sourceIsUrl) {
|
|
686
|
+
const resolved = await resolveToBuffer(source, /\.(mp4|mov|webm|mkv)$/i.test(source) ? 'video' : 'image');
|
|
687
|
+
// Decide field name by kind — lipsync controller uses .fields() with image/video/audio.
|
|
688
|
+
const isVideo = /\.(mp4|mov|webm|mkv|avi|m4v)$/i.test(resolved.filename);
|
|
689
|
+
form.append(isVideo ? 'video' : 'image', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
690
|
+
} else {
|
|
691
|
+
form.append('source_url', source);
|
|
692
|
+
}
|
|
693
|
+
if (!audioIsUrl) {
|
|
694
|
+
const resolved = await resolveToBuffer(audio, 'audio');
|
|
695
|
+
form.append('audio', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
696
|
+
} else {
|
|
697
|
+
form.append('audio_url', audio);
|
|
698
|
+
}
|
|
699
|
+
if (text_prompt) form.append('prompt', text_prompt);
|
|
700
|
+
if (model) form.append('model', model);
|
|
701
|
+
if (bounding_box_target) form.append('bounding_box_target', JSON.stringify(bounding_box_target));
|
|
702
|
+
startResponse = await client.postMultipart('/v1/generate/lipsync', form);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
706
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
707
|
+
timeout: 600000
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
content: [{
|
|
712
|
+
type: 'text',
|
|
713
|
+
text: JSON.stringify({
|
|
714
|
+
...creditFields(result),
|
|
715
|
+
urls: result.result?.urls || [],
|
|
716
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
717
|
+
duration: result.result?.duration || null,
|
|
718
|
+
model: result.result?.model || null
|
|
719
|
+
}, null, 2)
|
|
720
|
+
}]
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
// ─── generate_video_from_video ─────────────────────────────
|
|
726
|
+
server.tool(
|
|
727
|
+
'generate_video_from_video',
|
|
728
|
+
'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.',
|
|
729
|
+
{
|
|
730
|
+
source_video: z.string().describe('URL or absolute local path to the primary source video to restyle. **Source duration must fall within `min_video_duration`-`max_video_duration` from list_models for the chosen model** — videos outside that range are rejected (or silently truncated by some upstream providers). 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.'),
|
|
731
|
+
prompt: z.string().describe('Text description of the desired restyle / transformation'),
|
|
732
|
+
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 / max_video_duration per model. Omit for Smart Select.'),
|
|
733
|
+
aspect_ratio: z.string().optional().describe('Output aspect ratio. Must be in `supported_aspect_ratios` from list_models when set. Default: matches source'),
|
|
734
|
+
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: matches source'),
|
|
735
|
+
enhance_prompt: z.boolean().optional().describe('Set true to ask the API to rewrite your prompt for richer detail. Default: false — by default we send your prompt as-is.'),
|
|
736
|
+
visual_dna_ids: z.array(z.string()).optional().describe('Array of Visual DNA profile IDs to apply for character/style consistency. **Cap: pass at most `max_visual_dna` IDs from list_models for the chosen model; if `supports_visual_dna: false`, DNA is silently ignored.**'),
|
|
737
|
+
resolution: z.string().optional().describe('Video resolution tier (vertical pixels): "720p" / "1080p" / "1440p" / "2160p". Model-dependent — call list_models and read supported_resolutions.'),
|
|
738
|
+
reference_images: z.array(z.string()).optional().describe('Array of reference image URLs for models that support additional image inputs. **Cap: pass at most `max_images` URLs from list_models — if `max_images === 0` the model does not accept image refs.** Examples: character reference images for Kling O1/O3, style reference for Aleph/gen4_aleph, character image for WAN VACE video-edit.'),
|
|
739
|
+
reference_videos: z.array(z.string()).optional().describe('Array of additional reference video URLs for models that support multiple video inputs. **Cap: pass at most `max_videos` URLs from list_models — if `max_videos <= 1` only the source_video is accepted.** Example: WAN 2.6 reference-to-video accepts 1–3 reference videos.'),
|
|
740
|
+
elements: z.array(z.string()).optional().describe('Array of element image URLs. **Cap: pass at most `max_elements` URLs from list_models — if `max_elements === 0` the model does not accept elements.** Elements are style or character reference assets alongside the main video.'),
|
|
741
|
+
sound_enabled: z.boolean().optional().describe('Enable (`true`) or disable (`false`) AI-generated synced audio on the output video. Only honored by models with `sound_generation_type: "native"` from list_models (e.g. Kling v3 via KIE). On other models the flag has no effect. Omit to use the model\'s `sound_enabled_by_default`. Pass `false` when the user says no sound / silent / mute / without audio.')
|
|
742
|
+
},
|
|
743
|
+
async ({ source_video, prompt, model, aspect_ratio, duration, enhance_prompt = false, visual_dna_ids, resolution, reference_images, reference_videos, elements, sound_enabled }) => {
|
|
744
|
+
if (!source_video) throw new Error('source_video is required');
|
|
745
|
+
if (!prompt) throw new Error('prompt is required');
|
|
746
|
+
|
|
747
|
+
const isUrl = /^https?:\/\//i.test(source_video);
|
|
748
|
+
let startResponse;
|
|
749
|
+
if (isUrl) {
|
|
750
|
+
startResponse = await client.post('/v1/generate/video-from-video', {
|
|
751
|
+
video_url: source_video, prompt, model, aspect_ratio, duration, enhance_prompt, visual_dna_ids, resolution,
|
|
752
|
+
reference_images, reference_videos, elements, sound_enabled
|
|
753
|
+
});
|
|
754
|
+
} else {
|
|
755
|
+
const resolved = await resolveToBuffer(source_video, 'video');
|
|
756
|
+
const form = new FormData();
|
|
757
|
+
form.append('files', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
758
|
+
form.append('prompt', prompt);
|
|
759
|
+
if (model) form.append('model', model);
|
|
760
|
+
if (aspect_ratio) form.append('aspect_ratio', aspect_ratio);
|
|
761
|
+
if (duration !== undefined) form.append('duration', String(duration));
|
|
762
|
+
form.append('enhance_prompt', String(enhance_prompt));
|
|
763
|
+
if (visual_dna_ids) form.append('visual_dna_ids', JSON.stringify(visual_dna_ids));
|
|
764
|
+
if (resolution) form.append('resolution', resolution);
|
|
765
|
+
if (reference_images) form.append('reference_images', JSON.stringify(reference_images));
|
|
766
|
+
if (reference_videos) form.append('reference_videos', JSON.stringify(reference_videos));
|
|
767
|
+
if (elements) form.append('elements', JSON.stringify(elements));
|
|
768
|
+
if (sound_enabled !== undefined) form.append('sound_enabled', String(sound_enabled));
|
|
769
|
+
startResponse = await client.postMultipart('/v1/generate/video-from-video', form);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
773
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
774
|
+
timeout: 600000
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
return {
|
|
778
|
+
content: [{
|
|
779
|
+
type: 'text',
|
|
780
|
+
text: JSON.stringify({
|
|
781
|
+
...creditFields(result),
|
|
782
|
+
urls: result.result?.urls || [],
|
|
783
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
784
|
+
duration: result.result?.duration || null,
|
|
785
|
+
model: result.result?.model || null
|
|
786
|
+
}, null, 2)
|
|
787
|
+
}]
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
// ─── transcribe_audio ──────────────────────────────────────
|
|
793
|
+
server.tool(
|
|
794
|
+
'transcribe_audio',
|
|
795
|
+
'Transcribe audio or video into text + SRT subtitles. Source can be a URL or an absolute local file path. Returns the full text, SRT content, duration, and download URLs for .srt/.txt files. Works on both audio-only files (mp3, wav, m4a) and videos with audio tracks (mp4, mov, webm).',
|
|
796
|
+
{
|
|
797
|
+
source: z.string().describe('URL or absolute local path to the audio / video file to transcribe')
|
|
798
|
+
},
|
|
799
|
+
async ({ source }) => {
|
|
800
|
+
if (!source) throw new Error('source is required (URL or absolute local path)');
|
|
801
|
+
|
|
802
|
+
const isUrl = /^https?:\/\//i.test(source);
|
|
803
|
+
let startResponse;
|
|
804
|
+
if (isUrl) {
|
|
805
|
+
startResponse = await client.post('/v1/transcribe', { audio_url: source });
|
|
806
|
+
} else {
|
|
807
|
+
const resolved = await resolveToBuffer(source, 'audio');
|
|
808
|
+
const form = new FormData();
|
|
809
|
+
form.append('file', resolved.buffer, { filename: resolved.filename, contentType: resolved.contentType });
|
|
810
|
+
startResponse = await client.postMultipart('/v1/transcribe', form);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
814
|
+
interval: (startResponse.poll_interval_hint || 5) * 1000,
|
|
815
|
+
timeout: 1800000 // 30 minutes — long podcasts are a thing
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
content: [{
|
|
820
|
+
type: 'text',
|
|
821
|
+
text: JSON.stringify({
|
|
822
|
+
...creditFields(result),
|
|
823
|
+
text: result.result?.text || '',
|
|
824
|
+
srt_url: result.result?.srt_url || null,
|
|
825
|
+
word_by_word_srt_url: result.result?.word_by_word_srt_url || null,
|
|
826
|
+
txt_url: result.result?.txt_url || null,
|
|
827
|
+
duration: result.result?.duration || null
|
|
828
|
+
}, null, 2)
|
|
829
|
+
}]
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
// ─── generate_3d ───────────────────────────────────────────
|
|
835
|
+
server.tool(
|
|
836
|
+
'generate_3d',
|
|
837
|
+
'Generate a 3D model from a text prompt, a single reference image, or multiple reference images (for multi-view reconstruction). Returns model URLs in multiple formats (GLB, FBX, OBJ, USDZ). Modes: "text" (prompt-only), "single" (one image), "multi" (multiple images for better quality). The mode is auto-detected from the inputs if not specified.',
|
|
838
|
+
{
|
|
839
|
+
prompt: z.string().optional().describe('Text description of the 3D object to generate (used in text mode and also as a hint in image modes)'),
|
|
840
|
+
reference_images: z.array(z.string()).optional().describe('Array of public image URLs. 1 image → single mode, 2+ → multi mode.'),
|
|
841
|
+
mode: z.string().optional().describe('Explicitly set mode: "text" | "single" | "multi". Auto-detected from reference_images if omitted.'),
|
|
842
|
+
texture_prompt: z.string().optional().describe('Optional prompt to guide texture generation'),
|
|
843
|
+
model: z.string().optional().describe('Model identifier. Use list_models type="three_d" to see all 3D options, or filter by sub-type: "3d_text_to_model", "3d_image_to_model", "3d_multi_image_to_model", "3d_world".'),
|
|
844
|
+
topology: z.string().optional().describe('Topology preset (optional, model-specific)'),
|
|
845
|
+
target_polycount: z.number().optional().describe('Target polygon count (optional, model-specific)'),
|
|
846
|
+
enable_tpose: z.boolean().optional().describe('Force T-pose for character models (optional)'),
|
|
847
|
+
enable_pbr: z.boolean().optional().describe('Enable PBR textures (optional)')
|
|
848
|
+
},
|
|
849
|
+
async ({ prompt, reference_images, mode, texture_prompt, model, topology, target_polycount, enable_tpose, enable_pbr }) => {
|
|
850
|
+
if (!prompt && !(reference_images && reference_images.length > 0)) {
|
|
851
|
+
throw new Error('Provide prompt (text mode) or reference_images (single/multi mode)');
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const startResponse = await client.post('/v1/generate/3d', {
|
|
855
|
+
mode,
|
|
856
|
+
prompt,
|
|
857
|
+
reference_images,
|
|
858
|
+
texture_prompt,
|
|
859
|
+
model,
|
|
860
|
+
topology,
|
|
861
|
+
target_polycount,
|
|
862
|
+
enable_tpose,
|
|
863
|
+
enable_pbr
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const result = await pollUntilDone(client, startResponse.generation_id, {
|
|
867
|
+
interval: (startResponse.poll_interval_hint || 8) * 1000,
|
|
868
|
+
timeout: 900000 // 15 minutes — 3D generation is slow
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
return {
|
|
872
|
+
content: [{
|
|
873
|
+
type: 'text',
|
|
874
|
+
text: JSON.stringify({
|
|
875
|
+
...creditFields(result),
|
|
876
|
+
urls: result.result?.urls || [],
|
|
877
|
+
thumbnail_url: result.result?.thumbnail_url || null,
|
|
878
|
+
mode: result.result?.mode || null,
|
|
879
|
+
prompt_used: result.result?.prompt_used || null
|
|
880
|
+
}, null, 2)
|
|
881
|
+
}]
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
);
|
|
885
|
+
// ─── edit_image ────────────────────────────────────────────
|
|
886
|
+
server.tool(
|
|
887
|
+
'edit_image',
|
|
888
|
+
'Apply a targeted AI edit to an existing image. Use for upscaling resolution, changing aspect ratio (reframe), removing the background, portrait skin enhancement, or a text-guided edit (magic_edit). Faster and cheaper than generate_image_edit for these specific operations because it routes to specialized models. Returns the edited image URL when complete.',
|
|
889
|
+
{
|
|
890
|
+
image_url: z.string().describe('URL of the source image to edit'),
|
|
891
|
+
operation: z.enum(['upscale', 'reframe', 'removebg', 'enhance_skin', 'magic_edit'])
|
|
892
|
+
.describe('Edit operation to apply: "upscale" (increase resolution 2×–4×), "reframe" (change aspect ratio), "removebg" (remove background), "enhance_skin" (portrait retouching), "magic_edit" (text-guided edit — requires prompt)'),
|
|
893
|
+
model: z.string().optional().describe('Model identifier override. Omit to use the default model for the operation.'),
|
|
894
|
+
scale: z.number().optional().describe('Upscale factor: 2, 3, or 4. Only used when operation="upscale". Default: 2.'),
|
|
895
|
+
aspect_ratio: z.string().optional().describe('Target aspect ratio (e.g., "16:9", "9:16", "1:1"). Required for operation="reframe".'),
|
|
896
|
+
skin_strength: z.enum(['subtle', 'realistic', 'pimple', 'freckle']).optional()
|
|
897
|
+
.describe('Skin enhancement style. Only used when operation="enhance_skin". Default: "realistic".'),
|
|
898
|
+
prompt: z.string().optional().describe('Text instruction for the edit. Required for operation="magic_edit" (e.g., "add sunglasses", "change the sky to sunset").')
|
|
899
|
+
},
|
|
900
|
+
async ({ image_url, operation, model, scale, aspect_ratio, skin_strength, prompt }) => {
|
|
901
|
+
if (operation === 'magic_edit' && !prompt) throw new Error('prompt is required for magic_edit operation');
|
|
902
|
+
if (operation === 'reframe' && !aspect_ratio) throw new Error('aspect_ratio is required for reframe operation');
|
|
903
|
+
|
|
904
|
+
const gen = await client.post('/v1/edit/image', {
|
|
905
|
+
image_url, operation, model, scale, aspect_ratio, skin_strength, prompt
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
909
|
+
interval: (gen.poll_interval_hint || 5) * 1000,
|
|
910
|
+
timeout: 180000
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
return {
|
|
914
|
+
content: [{
|
|
915
|
+
type: 'text',
|
|
916
|
+
text: JSON.stringify({
|
|
917
|
+
...creditFields(result),
|
|
918
|
+
urls: result.result?.urls || [],
|
|
919
|
+
edit_type: result.result?.edit_type || null,
|
|
920
|
+
model: result.result?.model || null
|
|
921
|
+
}, null, 2)
|
|
922
|
+
}]
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
// ─── edit_video ────────────────────────────────────────────
|
|
928
|
+
server.tool(
|
|
929
|
+
'edit_video',
|
|
930
|
+
'Apply a targeted AI edit to an existing video. Operations: upscale (4K resolution boost), reframe (change aspect ratio), generate_audio (add AI-generated sound/music from a prompt), remove_watermark, face_swap (replace faces using a reference image URL), extend (lengthen at start or end), magic_edit (restyle/transform with a prompt), lipsync (sync an audio track to a face in the video). Returns the edited video URL when complete.',
|
|
931
|
+
{
|
|
932
|
+
video_url: z.string().describe('URL of the source video to edit'),
|
|
933
|
+
operation: z.enum(['upscale', 'reframe', 'generate_audio', 'remove_watermark', 'face_swap', 'extend', 'magic_edit', 'lipsync'])
|
|
934
|
+
.describe('Edit operation: "upscale", "reframe" (requires aspect_ratio), "generate_audio" (requires prompt), "remove_watermark", "face_swap" (requires image_url), "extend" (requires duration), "magic_edit" (requires prompt), "lipsync" (requires audio_url)'),
|
|
935
|
+
model: z.string().optional().describe('Model identifier override. Omit to use the default model for the operation.'),
|
|
936
|
+
aspect_ratio: z.string().optional().describe('Target aspect ratio (e.g., "16:9", "9:16"). Required for operation="reframe".'),
|
|
937
|
+
scale: z.number().optional().describe('Upscale factor. Only used when operation="upscale".'),
|
|
938
|
+
prompt: z.string().optional().describe('Text prompt. Required for operation="magic_edit" and "generate_audio". Optional hint for "extend".'),
|
|
939
|
+
image_url: z.string().optional().describe('URL of the reference face image. Required for operation="face_swap".'),
|
|
940
|
+
audio_url: z.string().optional().describe('URL of the audio track to sync. Required for operation="lipsync".'),
|
|
941
|
+
duration: z.number().optional().describe('Seconds of video to generate. Required for operation="extend". Typical range: 1–20.'),
|
|
942
|
+
mode: z.string().optional().describe('Where to extend: "start" or "end". Only used when operation="extend". Default: "end".')
|
|
943
|
+
},
|
|
944
|
+
async ({ video_url, operation, model, aspect_ratio, scale, prompt, image_url, audio_url, duration, mode }) => {
|
|
945
|
+
if (operation === 'magic_edit' && !prompt) throw new Error('prompt is required for magic_edit');
|
|
946
|
+
if (operation === 'generate_audio' && !prompt) throw new Error('prompt is required for generate_audio');
|
|
947
|
+
if (operation === 'reframe' && !aspect_ratio) throw new Error('aspect_ratio is required for reframe');
|
|
948
|
+
if (operation === 'face_swap' && !image_url) throw new Error('image_url (reference face) is required for face_swap');
|
|
949
|
+
if (operation === 'lipsync' && !audio_url) throw new Error('audio_url is required for lipsync');
|
|
950
|
+
if (operation === 'extend' && !duration) throw new Error('duration is required for extend');
|
|
951
|
+
|
|
952
|
+
const gen = await client.post('/v1/edit/video', {
|
|
953
|
+
video_url, operation, model, aspect_ratio, scale, prompt,
|
|
954
|
+
image_url, audio_url, duration, mode
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
const result = await pollUntilDone(client, gen.generation_id, {
|
|
958
|
+
interval: (gen.poll_interval_hint || 8) * 1000,
|
|
959
|
+
timeout: 600000
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
content: [{
|
|
964
|
+
type: 'text',
|
|
965
|
+
text: JSON.stringify({
|
|
966
|
+
...creditFields(result),
|
|
967
|
+
urls: result.result?.urls || [],
|
|
968
|
+
download_url: result.result?.download_url || null,
|
|
969
|
+
edit_type: result.result?.edit_type || null,
|
|
970
|
+
duration: result.result?.duration || null,
|
|
971
|
+
model: result.result?.model || null
|
|
972
|
+
}, null, 2)
|
|
973
|
+
}]
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
module.exports = { registerGenerateTools };
|