@kolbo/mcp 1.70.1 → 1.70.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/bin/kolbo-mcp.js +14 -5
- package/package.json +3 -2
- package/skill/GENERATED.md +4 -5
- package/skill/SKILL.md +11 -18
- package/skill/VERSION +1 -1
- package/skill/assets/filmmaking/continuity-ledger.template.json +27 -0
- package/skill/assets/filmmaking/generation-log.template.csv +2 -0
- package/skill/assets/filmmaking/production-bible.template.json +97 -0
- package/skill/assets/filmmaking/scene-card.template.json +40 -0
- package/skill/assets/filmmaking/shot-card.template.json +72 -0
- package/skill/references/filmmaking/acting-direction.md +131 -0
- package/skill/references/filmmaking/asset-preproduction.md +97 -0
- package/skill/references/filmmaking/audio-dialogue-music.md +111 -0
- package/skill/references/filmmaking/blocking-continuity.md +125 -0
- package/skill/references/filmmaking/cinematography.md +101 -0
- package/skill/references/filmmaking/physics-action.md +93 -0
- package/skill/references/filmmaking/production-bible.md +108 -0
- package/skill/references/filmmaking/prompt-contracts.md +140 -0
- package/skill/references/filmmaking/routing.md +105 -0
- package/skill/references/filmmaking/scene-engine.md +95 -0
- package/skill/references/filmmaking/validation.md +109 -0
- package/skill/references/filmmaking/workflows.md +80 -0
- package/skill/references/models/gpt-image.md +1 -1
- package/skill/references/models/nano-banana.md +1 -1
- package/skill/references/models/prompt-copilot.md +0 -1
- package/skill/references/models/seedance.md +24 -44
- package/skill/references/models/seedance25.md +3 -3
- package/skill/references/workflows/filmmaking.md +168 -0
- package/skill/scripts/filmmaking/lint_prompt.py +249 -0
- package/skill/scripts/filmmaking/validate_film_package.py +435 -0
- package/src/apps/index.js +26 -3
- package/src/index.js +11 -11
- package/src/install.js +62 -3
- package/src/tools/generate.js +2 -2
- package/src/tools/models.js +1 -1
- package/src/tools/visual_dna.js +1 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate a Kolbo Filmmaker production package using only the Python stdlib."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
VALID_MODES = {
|
|
14
|
+
"text-to-video",
|
|
15
|
+
"image-to-video",
|
|
16
|
+
"reference-to-video",
|
|
17
|
+
"first-last-frame",
|
|
18
|
+
"video-to-video",
|
|
19
|
+
}
|
|
20
|
+
VALID_DENSITIES = {"strict", "anchored", "exploratory"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Report:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.items: list[dict[str, str]] = []
|
|
26
|
+
|
|
27
|
+
def add(self, level: str, code: str, path: str, message: str) -> None:
|
|
28
|
+
self.items.append(
|
|
29
|
+
{"level": level, "code": code, "path": path, "message": message}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def error(self, code: str, path: str, message: str) -> None:
|
|
33
|
+
self.add("error", code, path, message)
|
|
34
|
+
|
|
35
|
+
def warn(self, code: str, path: str, message: str) -> None:
|
|
36
|
+
self.add("warning", code, path, message)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def error_count(self) -> int:
|
|
40
|
+
return sum(item["level"] == "error" for item in self.items)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def warning_count(self) -> int:
|
|
44
|
+
return sum(item["level"] == "warning" for item in self.items)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_json(path: Path, report: Report) -> Any | None:
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
report.error("missing_file", str(path), "Required file does not exist.")
|
|
52
|
+
except json.JSONDecodeError as exc:
|
|
53
|
+
report.error(
|
|
54
|
+
"invalid_json",
|
|
55
|
+
str(path),
|
|
56
|
+
f"JSON parse error at line {exc.lineno}, column {exc.colno}: {exc.msg}",
|
|
57
|
+
)
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
report.error("read_failed", str(path), f"Could not read file: {exc}")
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def require_fields(
|
|
64
|
+
obj: Any, fields: Iterable[str], path: str, report: Report
|
|
65
|
+
) -> None:
|
|
66
|
+
if not isinstance(obj, dict):
|
|
67
|
+
report.error("wrong_type", path, "Expected a JSON object.")
|
|
68
|
+
return
|
|
69
|
+
for field in fields:
|
|
70
|
+
if field not in obj or obj[field] in (None, "", []):
|
|
71
|
+
report.error("missing_field", f"{path}.{field}", "Required field is empty.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def collect_registry_tags(bible: dict[str, Any]) -> set[str]:
|
|
75
|
+
tags: set[str] = set()
|
|
76
|
+
|
|
77
|
+
def walk(value: Any) -> None:
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
for key, child in value.items():
|
|
80
|
+
if key in {"tag", "canonical_tag"} and isinstance(child, str):
|
|
81
|
+
if child.startswith("@"):
|
|
82
|
+
tags.add(child)
|
|
83
|
+
walk(child)
|
|
84
|
+
elif isinstance(value, list):
|
|
85
|
+
for child in value:
|
|
86
|
+
walk(child)
|
|
87
|
+
|
|
88
|
+
for section in ("characters", "locations", "assets"):
|
|
89
|
+
walk(bible.get(section, []))
|
|
90
|
+
return tags
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def json_files(folder: Path) -> list[Path]:
|
|
94
|
+
return sorted(path for path in folder.glob("*.json") if path.is_file())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_bible(bible: Any, path: Path, report: Report) -> tuple[str, set[str]]:
|
|
98
|
+
require_fields(
|
|
99
|
+
bible,
|
|
100
|
+
[
|
|
101
|
+
"schema_version",
|
|
102
|
+
"project_id",
|
|
103
|
+
"title",
|
|
104
|
+
"story",
|
|
105
|
+
"characters",
|
|
106
|
+
"locations",
|
|
107
|
+
"assets",
|
|
108
|
+
"audio_policy",
|
|
109
|
+
],
|
|
110
|
+
str(path),
|
|
111
|
+
report,
|
|
112
|
+
)
|
|
113
|
+
if not isinstance(bible, dict):
|
|
114
|
+
return "", set()
|
|
115
|
+
project_id = str(bible.get("project_id", ""))
|
|
116
|
+
tags = collect_registry_tags(bible)
|
|
117
|
+
if not tags:
|
|
118
|
+
report.warn(
|
|
119
|
+
"empty_asset_registry",
|
|
120
|
+
str(path),
|
|
121
|
+
"No @ asset tags were found in characters, locations, or assets.",
|
|
122
|
+
)
|
|
123
|
+
for section in ("characters", "locations", "assets"):
|
|
124
|
+
if not isinstance(bible.get(section), list):
|
|
125
|
+
report.error(
|
|
126
|
+
"wrong_type", f"{path}.{section}", "Expected this registry to be a list."
|
|
127
|
+
)
|
|
128
|
+
return project_id, tags
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_scene_cards(
|
|
132
|
+
paths: list[Path], project_id: str, registry_tags: set[str], report: Report
|
|
133
|
+
) -> set[str]:
|
|
134
|
+
scene_ids: set[str] = set()
|
|
135
|
+
for path in paths:
|
|
136
|
+
card = load_json(path, report)
|
|
137
|
+
if card is None:
|
|
138
|
+
continue
|
|
139
|
+
require_fields(
|
|
140
|
+
card,
|
|
141
|
+
["project_id", "scene_id", "slugline", "story_job", "structure"],
|
|
142
|
+
str(path),
|
|
143
|
+
report,
|
|
144
|
+
)
|
|
145
|
+
if not isinstance(card, dict):
|
|
146
|
+
continue
|
|
147
|
+
scene_id = str(card.get("scene_id", ""))
|
|
148
|
+
if scene_id in scene_ids:
|
|
149
|
+
report.error("duplicate_scene_id", str(path), f"Duplicate scene_id: {scene_id}")
|
|
150
|
+
scene_ids.add(scene_id)
|
|
151
|
+
if project_id and card.get("project_id") != project_id:
|
|
152
|
+
report.error(
|
|
153
|
+
"project_mismatch",
|
|
154
|
+
str(path),
|
|
155
|
+
f"Scene project_id must be {project_id!r}.",
|
|
156
|
+
)
|
|
157
|
+
location_tag = card.get("location_tag")
|
|
158
|
+
if location_tag and location_tag not in registry_tags:
|
|
159
|
+
report.error(
|
|
160
|
+
"unknown_asset_tag",
|
|
161
|
+
f"{path}.location_tag",
|
|
162
|
+
f"Tag is absent from production-bible.json: {location_tag}",
|
|
163
|
+
)
|
|
164
|
+
structure = card.get("structure")
|
|
165
|
+
if isinstance(structure, dict):
|
|
166
|
+
for key in ("goal", "obstacle", "tactic", "reversal", "audience_value_shift"):
|
|
167
|
+
if not structure.get(key):
|
|
168
|
+
report.warn(
|
|
169
|
+
"incomplete_scene_engine",
|
|
170
|
+
f"{path}.structure.{key}",
|
|
171
|
+
"Dramatic structure field is empty.",
|
|
172
|
+
)
|
|
173
|
+
return scene_ids
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_timed_blocks(
|
|
177
|
+
blocks: Any, duration: float, path: str, report: Report, label: str
|
|
178
|
+
) -> None:
|
|
179
|
+
if not isinstance(blocks, list):
|
|
180
|
+
report.error("wrong_type", path, f"{label} must be a list.")
|
|
181
|
+
return
|
|
182
|
+
intervals: list[tuple[float, float, int, dict[str, Any]]] = []
|
|
183
|
+
for index, block in enumerate(blocks):
|
|
184
|
+
item_path = f"{path}[{index}]"
|
|
185
|
+
if not isinstance(block, dict):
|
|
186
|
+
report.error("wrong_type", item_path, "Timed block must be an object.")
|
|
187
|
+
continue
|
|
188
|
+
start = block.get("start_seconds")
|
|
189
|
+
end = block.get("end_seconds")
|
|
190
|
+
if not isinstance(start, (int, float)) or not isinstance(end, (int, float)):
|
|
191
|
+
report.error("invalid_timecode", item_path, "Start and end must be numbers.")
|
|
192
|
+
continue
|
|
193
|
+
if start < 0 or end <= start:
|
|
194
|
+
report.error("invalid_timecode", item_path, "Require 0 <= start < end.")
|
|
195
|
+
if end > duration:
|
|
196
|
+
report.error(
|
|
197
|
+
"timecode_overflow",
|
|
198
|
+
item_path,
|
|
199
|
+
f"End {end}s exceeds shot duration {duration}s.",
|
|
200
|
+
)
|
|
201
|
+
intervals.append((float(start), float(end), index, block))
|
|
202
|
+
if label == "Dialogue":
|
|
203
|
+
ordered = sorted(intervals)
|
|
204
|
+
for previous, current in zip(ordered, ordered[1:]):
|
|
205
|
+
if current[0] < previous[1] and not (
|
|
206
|
+
previous[3].get("allow_overlap") or current[3].get("allow_overlap")
|
|
207
|
+
):
|
|
208
|
+
report.error(
|
|
209
|
+
"dialogue_overlap",
|
|
210
|
+
path,
|
|
211
|
+
f"Dialogue items {previous[2]} and {current[2]} overlap without allow_overlap.",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate_shot_cards(
|
|
216
|
+
paths: list[Path],
|
|
217
|
+
project_id: str,
|
|
218
|
+
scene_ids: set[str],
|
|
219
|
+
registry_tags: set[str],
|
|
220
|
+
report: Report,
|
|
221
|
+
) -> set[str]:
|
|
222
|
+
shot_ids: set[str] = set()
|
|
223
|
+
for path in paths:
|
|
224
|
+
card = load_json(path, report)
|
|
225
|
+
if card is None:
|
|
226
|
+
continue
|
|
227
|
+
require_fields(
|
|
228
|
+
card,
|
|
229
|
+
[
|
|
230
|
+
"project_id",
|
|
231
|
+
"scene_id",
|
|
232
|
+
"shot_id",
|
|
233
|
+
"editorial_job",
|
|
234
|
+
"generation_mode",
|
|
235
|
+
"control_density",
|
|
236
|
+
"duration_seconds",
|
|
237
|
+
"active_assets",
|
|
238
|
+
"first_frame",
|
|
239
|
+
"final_state",
|
|
240
|
+
],
|
|
241
|
+
str(path),
|
|
242
|
+
report,
|
|
243
|
+
)
|
|
244
|
+
if not isinstance(card, dict):
|
|
245
|
+
continue
|
|
246
|
+
shot_id = str(card.get("shot_id", ""))
|
|
247
|
+
if shot_id in shot_ids:
|
|
248
|
+
report.error("duplicate_shot_id", str(path), f"Duplicate shot_id: {shot_id}")
|
|
249
|
+
shot_ids.add(shot_id)
|
|
250
|
+
if project_id and card.get("project_id") != project_id:
|
|
251
|
+
report.error(
|
|
252
|
+
"project_mismatch", str(path), f"Shot project_id must be {project_id!r}."
|
|
253
|
+
)
|
|
254
|
+
scene_id = str(card.get("scene_id", ""))
|
|
255
|
+
if scene_ids and scene_id not in scene_ids:
|
|
256
|
+
report.error(
|
|
257
|
+
"unknown_scene_id", str(path), f"No scene card exists for {scene_id!r}."
|
|
258
|
+
)
|
|
259
|
+
mode = card.get("generation_mode")
|
|
260
|
+
if mode not in VALID_MODES:
|
|
261
|
+
report.error(
|
|
262
|
+
"invalid_generation_mode",
|
|
263
|
+
f"{path}.generation_mode",
|
|
264
|
+
f"Use one of: {', '.join(sorted(VALID_MODES))}.",
|
|
265
|
+
)
|
|
266
|
+
density = card.get("control_density")
|
|
267
|
+
if density not in VALID_DENSITIES:
|
|
268
|
+
report.error(
|
|
269
|
+
"invalid_control_density",
|
|
270
|
+
f"{path}.control_density",
|
|
271
|
+
f"Use one of: {', '.join(sorted(VALID_DENSITIES))}.",
|
|
272
|
+
)
|
|
273
|
+
duration = card.get("duration_seconds")
|
|
274
|
+
if not isinstance(duration, (int, float)) or duration <= 0:
|
|
275
|
+
report.error("invalid_duration", f"{path}.duration_seconds", "Use a positive number.")
|
|
276
|
+
duration_value = 0.0
|
|
277
|
+
else:
|
|
278
|
+
duration_value = float(duration)
|
|
279
|
+
if card.get("model") == "seedance-2.5" and duration_value and not 4 <= duration_value <= 30:
|
|
280
|
+
report.error(
|
|
281
|
+
"seedance_duration",
|
|
282
|
+
f"{path}.duration_seconds",
|
|
283
|
+
"Seedance 2.5 adapter expects 4-30 seconds per generation.",
|
|
284
|
+
)
|
|
285
|
+
assets = card.get("active_assets")
|
|
286
|
+
if isinstance(assets, list):
|
|
287
|
+
seen: set[str] = set()
|
|
288
|
+
for index, asset in enumerate(assets):
|
|
289
|
+
tag = asset.get("tag") if isinstance(asset, dict) else None
|
|
290
|
+
if not isinstance(tag, str) or not tag.startswith("@"):
|
|
291
|
+
report.error(
|
|
292
|
+
"invalid_asset_reference",
|
|
293
|
+
f"{path}.active_assets[{index}]",
|
|
294
|
+
"Every active asset needs a @tag.",
|
|
295
|
+
)
|
|
296
|
+
continue
|
|
297
|
+
if tag in seen:
|
|
298
|
+
report.warn(
|
|
299
|
+
"duplicate_asset_reference",
|
|
300
|
+
f"{path}.active_assets[{index}]",
|
|
301
|
+
f"Asset is listed more than once: {tag}",
|
|
302
|
+
)
|
|
303
|
+
seen.add(tag)
|
|
304
|
+
if tag not in registry_tags:
|
|
305
|
+
report.error(
|
|
306
|
+
"unknown_asset_tag",
|
|
307
|
+
f"{path}.active_assets[{index}]",
|
|
308
|
+
f"Tag is absent from production-bible.json: {tag}",
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
report.error("wrong_type", f"{path}.active_assets", "Expected a list.")
|
|
312
|
+
if "action_timing" in card and duration_value:
|
|
313
|
+
validate_timed_blocks(
|
|
314
|
+
card["action_timing"], duration_value, f"{path}.action_timing", report, "Action"
|
|
315
|
+
)
|
|
316
|
+
dialogue = card.get("dialogue", [])
|
|
317
|
+
if dialogue and duration_value:
|
|
318
|
+
validate_timed_blocks(
|
|
319
|
+
dialogue, duration_value, f"{path}.dialogue", report, "Dialogue"
|
|
320
|
+
)
|
|
321
|
+
return shot_ids
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def validate_continuity(
|
|
325
|
+
path: Path, project_id: str, shot_ids: set[str], report: Report
|
|
326
|
+
) -> None:
|
|
327
|
+
if not path.exists():
|
|
328
|
+
report.warn(
|
|
329
|
+
"missing_continuity_ledger",
|
|
330
|
+
str(path),
|
|
331
|
+
"Add continuity-ledger.json before generating a multi-shot sequence.",
|
|
332
|
+
)
|
|
333
|
+
return
|
|
334
|
+
ledger = load_json(path, report)
|
|
335
|
+
if ledger is None:
|
|
336
|
+
return
|
|
337
|
+
require_fields(ledger, ["project_id", "entries"], str(path), report)
|
|
338
|
+
if not isinstance(ledger, dict):
|
|
339
|
+
return
|
|
340
|
+
if project_id and ledger.get("project_id") != project_id:
|
|
341
|
+
report.error(
|
|
342
|
+
"project_mismatch", str(path), f"Ledger project_id must be {project_id!r}."
|
|
343
|
+
)
|
|
344
|
+
entries = ledger.get("entries")
|
|
345
|
+
if not isinstance(entries, list):
|
|
346
|
+
report.error("wrong_type", f"{path}.entries", "Expected a list.")
|
|
347
|
+
return
|
|
348
|
+
ledger_ids: set[str] = set()
|
|
349
|
+
for index, entry in enumerate(entries):
|
|
350
|
+
item_path = f"{path}.entries[{index}]"
|
|
351
|
+
require_fields(entry, ["shot_id", "entering", "leaving", "handoff_proof"], item_path, report)
|
|
352
|
+
if not isinstance(entry, dict):
|
|
353
|
+
continue
|
|
354
|
+
shot_id = str(entry.get("shot_id", ""))
|
|
355
|
+
if shot_id in ledger_ids:
|
|
356
|
+
report.error("duplicate_ledger_entry", item_path, f"Duplicate shot_id: {shot_id}")
|
|
357
|
+
ledger_ids.add(shot_id)
|
|
358
|
+
if shot_ids and shot_id not in shot_ids:
|
|
359
|
+
report.error("unknown_shot_id", item_path, f"No shot card exists for {shot_id!r}.")
|
|
360
|
+
missing = shot_ids - ledger_ids
|
|
361
|
+
for shot_id in sorted(missing):
|
|
362
|
+
report.warn(
|
|
363
|
+
"missing_ledger_entry",
|
|
364
|
+
str(path),
|
|
365
|
+
f"Shot has no explicit entering/leaving handoff: {shot_id}",
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def render_text(report: Report, package: Path, counts: dict[str, int]) -> str:
|
|
370
|
+
lines = [f"Kolbo Filmmaker package: {package}"]
|
|
371
|
+
lines.append(
|
|
372
|
+
"Validated "
|
|
373
|
+
+ ", ".join(f"{value} {key}" for key, value in counts.items())
|
|
374
|
+
+ "."
|
|
375
|
+
)
|
|
376
|
+
for item in report.items:
|
|
377
|
+
lines.append(
|
|
378
|
+
f"[{item['level'].upper()}] {item['code']} - {item['path']}: {item['message']}"
|
|
379
|
+
)
|
|
380
|
+
lines.append(f"Result: {report.error_count} error(s), {report.warning_count} warning(s).")
|
|
381
|
+
return "\n".join(lines)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def main() -> int:
|
|
385
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
386
|
+
parser.add_argument("package", type=Path, help="Production package folder")
|
|
387
|
+
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
|
|
388
|
+
args = parser.parse_args()
|
|
389
|
+
|
|
390
|
+
package = args.package.resolve()
|
|
391
|
+
report = Report()
|
|
392
|
+
if not package.is_dir():
|
|
393
|
+
report.error("missing_package", str(package), "Package folder does not exist.")
|
|
394
|
+
counts = {"scene card(s)": 0, "shot card(s)": 0, "registered tag(s)": 0}
|
|
395
|
+
else:
|
|
396
|
+
bible_path = package / "production-bible.json"
|
|
397
|
+
bible = load_json(bible_path, report)
|
|
398
|
+
project_id, tags = validate_bible(bible, bible_path, report) if bible is not None else ("", set())
|
|
399
|
+
scene_paths = json_files(package / "scene-cards")
|
|
400
|
+
shot_paths = json_files(package / "shot-cards")
|
|
401
|
+
if not scene_paths:
|
|
402
|
+
report.warn("no_scene_cards", str(package / "scene-cards"), "No scene cards found.")
|
|
403
|
+
if not shot_paths:
|
|
404
|
+
report.warn("no_shot_cards", str(package / "shot-cards"), "No shot cards found.")
|
|
405
|
+
scene_ids = validate_scene_cards(scene_paths, project_id, tags, report)
|
|
406
|
+
shot_ids = validate_shot_cards(shot_paths, project_id, scene_ids, tags, report)
|
|
407
|
+
validate_continuity(package / "continuity-ledger.json", project_id, shot_ids, report)
|
|
408
|
+
counts = {
|
|
409
|
+
"scene card(s)": len(scene_paths),
|
|
410
|
+
"shot card(s)": len(shot_paths),
|
|
411
|
+
"registered tag(s)": len(tags),
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if args.json:
|
|
415
|
+
print(
|
|
416
|
+
json.dumps(
|
|
417
|
+
{
|
|
418
|
+
"package": str(package),
|
|
419
|
+
"valid": report.error_count == 0,
|
|
420
|
+
"counts": counts,
|
|
421
|
+
"errors": report.error_count,
|
|
422
|
+
"warnings": report.warning_count,
|
|
423
|
+
"items": report.items,
|
|
424
|
+
},
|
|
425
|
+
indent=2,
|
|
426
|
+
ensure_ascii=False,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
print(render_text(report, package, counts))
|
|
431
|
+
return 1 if report.error_count else 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
if __name__ == "__main__":
|
|
435
|
+
raise SystemExit(main())
|
package/src/apps/index.js
CHANGED
|
@@ -321,6 +321,26 @@ function pickForType(candidates, types) {
|
|
|
321
321
|
return pool.reduce((a, b) => (b.id.length < a.id.length ? b : a)).id;
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
// Strip modality tokens so a t2v id and its i2v sibling share one family key
|
|
325
|
+
// (grok-imagine-text-to-video ↔ grok-imagine-image-to-video; kling …/text-to-video
|
|
326
|
+
// ↔ …/image-to-video). Version tokens stay (1.5 ≠ 1.0).
|
|
327
|
+
function fam(s) {
|
|
328
|
+
return normId(s).replace(
|
|
329
|
+
/texttovideo|imagetovideo|imgtovideo|texttoimage|imagetoimage|imageediting|imageedit|referencetovideo|videotovideo|videoedit|editvideo|firstlastframe|firstlast/g,
|
|
330
|
+
'',
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function sibling(models, hit, types) {
|
|
335
|
+
if (!hit || !types.length) return hit;
|
|
336
|
+
const row = models.find((i) => i.id === hit);
|
|
337
|
+
if (row && row.types.some((t) => types.includes(t))) return hit;
|
|
338
|
+
const key = fam(hit);
|
|
339
|
+
const sibs = models.filter((i) => fam(i.id) === key && i.types.some((t) => types.includes(t)));
|
|
340
|
+
if (!sibs.length) return hit;
|
|
341
|
+
return sibs.reduce((a, b) => (b.id.length < a.id.length ? b : a)).id;
|
|
342
|
+
}
|
|
343
|
+
|
|
324
344
|
/**
|
|
325
345
|
* Lenient model-identifier resolution for LLM-supplied model args.
|
|
326
346
|
* Users say "z-image"; the real identifier is "z-image/turbo" — the backend
|
|
@@ -334,6 +354,9 @@ function pickForType(candidates, types) {
|
|
|
334
354
|
* without it, "Kling 2.6 Pro" from generate_video_from_image resolved to
|
|
335
355
|
* kling-video/v2.6/pro/text-to-video (2026-08-10), so the image-to-video
|
|
336
356
|
* pipeline submitted the TEXT-to-video endpoint and billed against it.
|
|
357
|
+
* An explicit t2v identifier on an i2v tool remaps to the unique same-family
|
|
358
|
+
* sibling (grok-imagine-text-to-video → grok-imagine-image-to-video). No
|
|
359
|
+
* sibling → the id is passed through unchanged (MiniMax H3).
|
|
337
360
|
*
|
|
338
361
|
* Still unresolved: throw with the near misses named. The API answers a bad
|
|
339
362
|
* identifier with a bare INVALID_*_MODEL and no hint, which on 2026-08-09 sent
|
|
@@ -362,13 +385,13 @@ async function canonicalModelId(client, input, type) {
|
|
|
362
385
|
const dashed = key.replace(/\s+/g, '-');
|
|
363
386
|
|
|
364
387
|
// 1. exact name / identifier hit
|
|
365
|
-
const exact = pickForType(models.filter((i) => [i.id, i.name].some(
|
|
388
|
+
const exact = sibling(models, pickForType(models.filter((i) => [i.id, i.name].some(
|
|
366
389
|
(k) => k && (k.toLowerCase() === key || k.toLowerCase() === dashed)
|
|
367
|
-
)), types);
|
|
390
|
+
)), types), types);
|
|
368
391
|
if (exact) return exact;
|
|
369
392
|
|
|
370
393
|
// 2. separator-insensitive exact ("flux-2-flash" → "flux-2/flash")
|
|
371
|
-
const loose = pickForType(models.filter((i) => normId(i.id) === want || normId(i.name) === want), types);
|
|
394
|
+
const loose = sibling(models, pickForType(models.filter((i) => normId(i.id) === want || normId(i.name) === want), types), types);
|
|
372
395
|
if (loose) return loose;
|
|
373
396
|
|
|
374
397
|
// 3. unique prefix ("z-image" → "z-image/turbo") — the modality filter runs
|
package/src/index.js
CHANGED
|
@@ -76,8 +76,8 @@ const { registerReviewTools } = require('./tools/review');
|
|
|
76
76
|
const { registerVoiceTools } = require('./tools/voices');
|
|
77
77
|
const { registerMusicLibraryTools } = require('./tools/music_library');
|
|
78
78
|
const { registerStockLibraryTools } = require('./tools/stock_library');
|
|
79
|
-
const { registerApps, attachToolWidgetMeta } = require('./apps');
|
|
80
|
-
const { attachToolAnnotations } = require('./toolAnnotations');
|
|
79
|
+
const { registerApps, attachToolWidgetMeta } = require('./apps');
|
|
80
|
+
const { attachToolAnnotations } = require('./toolAnnotations');
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Build a fully-configured Kolbo MCP server (all tool groups registered)
|
|
@@ -126,9 +126,9 @@ function createServer(opts = {}) {
|
|
|
126
126
|
'6. TIMEOUT HANDLING (applies to EVERY generate_* / edit_* / chat_send_message / transcribe_audio tool): each tool blocks and polls internally, then gives up after its own window if the job is not yet terminal. A timeout is NOT a failure — it returns `_timed_out:true` with the `generation_id` (not an error), because the job is almost always STILL RUNNING on the server (or already finished). Call `get_generation_status` with that `generation_id` and `wait=true` to keep checking until state="completed". NEVER conclude the generation failed and re-run the same tool from scratch after a `_timed_out:true` result — that wastes the user\'s credits by paying twice. DIRECTOR / BATCH JOBS follow the same convention through a dedicated tool: generate_creative_director runs its scenes (image OR video) in parallel and only reports state="completed" once EVERY scene is terminal; on `_timed_out:true` call `get_creative_director_status` (not get_generation_status) with the returned generation_id and keep checking. If scenes already carry image_urls/video_urls, they are done; do not regenerate.',
|
|
127
127
|
'7. SESSION CONTINUITY — one task, one session, always: every generation tool returns a `session_id`. For ANY follow-up, refinement, retry, or next step on the SAME task, pass that session_id back — never start fresh. BATCH RULE (critical): when a single user request produces multiple parallel generations (e.g. "animate these 5 images", "generate 3 variants"), do NOT launch them all at once without a session_id. Instead: (1) run the FIRST generation without session_id to create the session, (2) capture the session_id from its response, (3) pass that session_id to ALL remaining generations in the batch. This keeps the entire batch in one session. Exception: only omit session_id and start fresh when the user explicitly starts an unrelated new task.',
|
|
128
128
|
'8. LOCAL FILES / REFERENCE MEDIA — HOW TO HANDLE EVERY CASE. (A) User has a LOCAL file (audio, video, image, document) on their machine. What matters is WHERE THIS SERVER RUNS, not what your client can do — your own filesystem access is irrelevant if the server is somewhere else. On a LOCAL stdio install (server and client share a machine) → call `upload_media` with the absolute path, or pass the path straight to tools like `transcribe_audio` that accept local paths. Over a REMOTE connector the server cannot see that path no matter how capable you are, so a local path will always fail: if you can run shell commands or issue HTTP requests → call `create_upload_ticket` and POST the file to the returned upload_url yourself (fastest, no user interaction); if you cannot → call `media_upload_widget` IMMEDIATELY, the user uploads, and a `media.kolbo.ai` CDN URL comes back for any follow-up call. (B) You already have a public URL (media.kolbo.ai, any CDN, any direct link) → pass it directly; all Kolbo tools accept public URLs. NEVER search for DO Spaces keys, DigitalOcean credentials, or server-side upload credentials. NEVER ask the user to put the file on Google Drive, Dropbox, or Loom. NEVER invent or guess a URL. NEVER base64 anything but a tiny file — it costs context in proportion to file size; use the ticket or the widget instead.',
|
|
129
|
-
'9. MODEL SELECTION —
|
|
130
|
-
'10. IMAGE EDITING: for ANY prompt-driven / content edit of an existing image — "make it night", changing scene/lighting/colors, adding/removing/replacing objects, restyling — use `generate_image_edit`. Auto-pick only Nano Banana 2 (`nano-banana-2` / `nano-banana-2-image-editing`) or GPT Image 2 (`gpt-image-2` / `gpt-image-2/edit`) for photoreal photo edits, object removal, keep-subject/remove-others, or crowd cleanup. Do NOT auto-pick Flux 2 / flux-2/edit / Flux Klein — those are generate-from-scratch / style, named-only for editing. Do NOT use `edit_image` for content edits — `edit_image` is ONLY for mechanical enhancements (upscale, expand/outpaint, remove-background, skin retouch). Its `magic_edit` operation is deprecated in favor of `generate_image_edit`. EXPANDING AN IMAGE: to widen/extend/uncrop an image or fit it into a wider frame while KEEPING the existing artwork, use `edit_image` with operation="zoom_out" (outpainting — original pixels preserved; size it with `zoom_out_percentage` or the `expand_left/right/top/bottom` pixel args). The "reframe" operation is NOT this: it re-generates the whole picture at a new aspect ratio and the subject comes back re-imagined. Only pick "reframe" when the user wants the shot re-taken, never when they want their image extended.',
|
|
131
|
-
'11. PRESET CONTRACT: if the user asks for a preset, names a preset, or says to use one of their/Kolbo presets, you MUST call `list_presets` with the matching type before generation, resolve the named or closest matching preset, and pass its exact returned `id` as `preset_id`. Use type="image" for generate_image and type="image_edit" for generate_image_edit. Never silently ignore a preset request, never invent an id, and never claim a preset was applied unless `preset_id` was present in the generation call.'
|
|
129
|
+
'9. MODEL SELECTION — NAMED MODEL WINS, THEN STRENGTHS SUMMARY: ALWAYS pass a specific `model` on every generation tool — do NOT omit it (omitting falls back to "Smart Select" auto-routing, which hides the choice from the user; use it ONLY if the user explicitly asks you to auto-pick). If the user named a model this turn OR earlier in the conversation (including a compaction "Locked choices" / summary), that name is a FAMILY LOCK: pass it (or its display name) on every follow-up, including when the tool changes (text-to-video → image-to-video). Identifier resolution remaps a t2v id to the family\'s i2v sibling automatically. NEVER substitute a different brand because it is cheaper, faster, or "best balance" (Grok Imagine named → do not fire Seedance). If the named family has no variant for this modality, ASK — do not silently switch. Cheapest-summary routing applies ONLY when no model was named on this task: call `list_models` with the matching `type` and read each model\'s STRENGTHS SUMMARY — the "— …" clause printed after the credit cost — then pick the CHEAPEST model whose summary covers the task. `[NEW]` and `[RECOMMENDED]` badges, a high credit number, and "flagship"/"most intelligent" wording are NOT selection signals — never pick a model because it is newest, biggest or most expensive. Escalate to a premium/frontier model only when the user explicitly asks for maximum quality, or when no cheaper summary covers the requirement. Models printed under "Named-only" (no summary) are opt-in: use them only when the user names them. TEXT/CHAT: `chat_send_message` bills PER TOKEN, so the listed credit number is not the cost — a frontier text model (Claude Fable 5, GPT-5.6 Sol, Pro-class) costs 5-30x a mid-tier one per reply. Default ordinary chat (writing, brainstorming, Q&A, summarising) to a balanced mid-tier model and reserve the frontier tier for hard reasoning or long-form code the user asked for.',
|
|
130
|
+
'10. IMAGE EDITING: for ANY prompt-driven / content edit of an existing image — "make it night", changing scene/lighting/colors, adding/removing/replacing objects, restyling — use `generate_image_edit`. Auto-pick only Nano Banana 2 (`nano-banana-2` / `nano-banana-2-image-editing`) or GPT Image 2 (`gpt-image-2` / `gpt-image-2/edit`) for photoreal photo edits, object removal, keep-subject/remove-others, or crowd cleanup. Do NOT auto-pick Flux 2 / flux-2/edit / Flux Klein — those are generate-from-scratch / style, named-only for editing. Do NOT use `edit_image` for content edits — `edit_image` is ONLY for mechanical enhancements (upscale, expand/outpaint, remove-background, skin retouch). Its `magic_edit` operation is deprecated in favor of `generate_image_edit`. EXPANDING AN IMAGE: to widen/extend/uncrop an image or fit it into a wider frame while KEEPING the existing artwork, use `edit_image` with operation="zoom_out" (outpainting — original pixels preserved; size it with `zoom_out_percentage` or the `expand_left/right/top/bottom` pixel args). The "reframe" operation is NOT this: it re-generates the whole picture at a new aspect ratio and the subject comes back re-imagined. Only pick "reframe" when the user wants the shot re-taken, never when they want their image extended.',
|
|
131
|
+
'11. PRESET CONTRACT: if the user asks for a preset, names a preset, or says to use one of their/Kolbo presets, you MUST call `list_presets` with the matching type before generation, resolve the named or closest matching preset, and pass its exact returned `id` as `preset_id`. Use type="image" for generate_image and type="image_edit" for generate_image_edit. Never silently ignore a preset request, never invent an id, and never claim a preset was applied unless `preset_id` was present in the generation call.'
|
|
132
132
|
].join('\n')
|
|
133
133
|
});
|
|
134
134
|
const progress = require('./progress');
|
|
@@ -173,12 +173,12 @@ function createServer(opts = {}) {
|
|
|
173
173
|
// transport. Without it, a remote-connector model reads "absolute local path",
|
|
174
174
|
// sees no filesystem, and tells the user Kolbo cannot accept their file —
|
|
175
175
|
// the single most-reported failure, despite the upload tools existing.
|
|
176
|
-
attachFileInputHints(server, toolOptions);
|
|
177
|
-
// OpenAI public-app review requires every exposed tool to declare the three
|
|
178
|
-
// safety hints explicitly. The exact contract also fails closed when a tool
|
|
179
|
-
// is added or removed without a classification.
|
|
180
|
-
attachToolAnnotations(server);
|
|
181
|
-
// Declaration-level `_meta['ui/resourceUri']` on every widget-carrying tool —
|
|
176
|
+
attachFileInputHints(server, toolOptions);
|
|
177
|
+
// OpenAI public-app review requires every exposed tool to declare the three
|
|
178
|
+
// safety hints explicitly. The exact contract also fails closed when a tool
|
|
179
|
+
// is added or removed without a classification.
|
|
180
|
+
attachToolAnnotations(server);
|
|
181
|
+
// Declaration-level `_meta['ui/resourceUri']` on every widget-carrying tool —
|
|
182
182
|
// claude.ai prepares the widget iframe from tools/list, not from the result.
|
|
183
183
|
attachToolWidgetMeta(server);
|
|
184
184
|
|
package/src/install.js
CHANGED
|
@@ -16,6 +16,8 @@ const path = require('path');
|
|
|
16
16
|
const os = require('os');
|
|
17
17
|
|
|
18
18
|
const KOLBO_ENTRY = { command: 'npx', args: ['-y', '@kolbo/mcp@latest'] };
|
|
19
|
+
const MANAGED_FILE = '.kolbo-managed.json';
|
|
20
|
+
const PACKAGE_VERSION = require('../package.json').version;
|
|
19
21
|
|
|
20
22
|
function targets() {
|
|
21
23
|
const home = os.homedir();
|
|
@@ -78,6 +80,7 @@ function skillTargets() {
|
|
|
78
80
|
return [
|
|
79
81
|
{ name: 'Claude Code skill', root: path.join(home, '.claude'), dir: path.join(home, '.claude', 'skills', 'kolbo') },
|
|
80
82
|
{ name: 'Agents skill (Cursor/Codex)', root: path.join(home, '.agents'), dir: path.join(home, '.agents', 'skills', 'kolbo') },
|
|
83
|
+
{ name: 'Kolbo Code skill', root: path.join(home, '.kolbo'), dir: path.join(home, '.kolbo', 'skills', 'kolbo') },
|
|
81
84
|
];
|
|
82
85
|
}
|
|
83
86
|
|
|
@@ -85,15 +88,45 @@ function installSkill(t) {
|
|
|
85
88
|
const src = path.join(__dirname, '..', 'skill');
|
|
86
89
|
if (!fs.existsSync(src)) return { ...t, status: 'skill not bundled' };
|
|
87
90
|
if (!fs.existsSync(t.root)) return { ...t, status: 'not found' };
|
|
91
|
+
const tmp = `${t.dir}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
88
92
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
// Replace the generated tree so source deletions propagate too. A simple
|
|
94
|
+
// recursive copy leaves obsolete references behind indefinitely.
|
|
95
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
96
|
+
fs.mkdirSync(path.dirname(t.dir), { recursive: true });
|
|
97
|
+
fs.cpSync(src, tmp, { recursive: true });
|
|
98
|
+
fs.writeFileSync(path.join(tmp, MANAGED_FILE), JSON.stringify({
|
|
99
|
+
source: '@kolbo/mcp',
|
|
100
|
+
skill: 'kolbo',
|
|
101
|
+
version: fs.readFileSync(path.join(src, 'VERSION'), 'utf8').trim(),
|
|
102
|
+
packageVersion: PACKAGE_VERSION,
|
|
103
|
+
}, null, 2) + '\n');
|
|
104
|
+
fs.rmSync(t.dir, { recursive: true, force: true });
|
|
105
|
+
fs.renameSync(tmp, t.dir);
|
|
91
106
|
return { ...t, status: 'installed' };
|
|
92
107
|
} catch (e) {
|
|
108
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
93
109
|
return { ...t, status: `failed — ${e.message}` };
|
|
94
110
|
}
|
|
95
111
|
}
|
|
96
112
|
|
|
113
|
+
// Local MCP launches refresh only installs created by Kolbo. A manually
|
|
114
|
+
// authored `kolbo` skill without the marker is never overwritten.
|
|
115
|
+
function refreshManagedSkills() {
|
|
116
|
+
return skillTargets().map((t) => {
|
|
117
|
+
const marker = path.join(t.dir, MANAGED_FILE);
|
|
118
|
+
if (!fs.existsSync(marker)) return { ...t, status: 'unmanaged' };
|
|
119
|
+
try {
|
|
120
|
+
const installed = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
|
121
|
+
const skillVersion = fs.readFileSync(path.join(__dirname, '..', 'skill', 'VERSION'), 'utf8').trim();
|
|
122
|
+
if (installed.packageVersion === PACKAGE_VERSION && installed.version === skillVersion) {
|
|
123
|
+
return { ...t, status: 'current' };
|
|
124
|
+
}
|
|
125
|
+
} catch (_) {}
|
|
126
|
+
return installSkill(t);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
97
130
|
// Versions before this fix wrote the Claude Code entry into
|
|
98
131
|
// ~/.claude/settings.json, where Claude Code silently ignores it. Anyone who ran
|
|
99
132
|
// those is left with a dead entry that makes it look configured. Detect and say
|
|
@@ -156,4 +189,30 @@ async function run() {
|
|
|
156
189
|
return 0;
|
|
157
190
|
}
|
|
158
191
|
|
|
159
|
-
|
|
192
|
+
async function runSkillOnly() {
|
|
193
|
+
const out = (s = '') => process.stdout.write(s + '\n');
|
|
194
|
+
const skills = skillTargets().map(installSkill);
|
|
195
|
+
const ready = skills.filter((s) => s.status === 'installed');
|
|
196
|
+
|
|
197
|
+
out();
|
|
198
|
+
out(' Kolbo Skill — standalone install');
|
|
199
|
+
out(' ────────────────────────────────');
|
|
200
|
+
for (const s of skills) {
|
|
201
|
+
out(` ${s.status === 'installed' ? '✓' : '·'} ${s.name}: ${s.status}`);
|
|
202
|
+
}
|
|
203
|
+
out();
|
|
204
|
+
|
|
205
|
+
if (ready.length === 0) {
|
|
206
|
+
out(' No supported skill directory found (.claude or .agents).');
|
|
207
|
+
out(' Install or open a compatible agent, then run this command again.');
|
|
208
|
+
out();
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
out(' Done — the Kolbo Skill is installed.');
|
|
213
|
+
out(' MCP settings were not changed. Restart your agent to load the skill.');
|
|
214
|
+
out();
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { run, runSkillOnly, refreshManagedSkills, MANAGED_FILE };
|
package/src/tools/generate.js
CHANGED
|
@@ -438,7 +438,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
438
438
|
{
|
|
439
439
|
prompt: z.string().optional().describe('Text description of the video to generate. Required unless `prompts` is provided.'),
|
|
440
440
|
prompts: promptsField('videos'),
|
|
441
|
-
model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid).
|
|
441
|
+
model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). If the user already named a model/family (this turn or earlier), pass that name — do not substitute a cheaper default. Only when no model was named: "seedance-2" (versatile) or "veo3" (cinematic + native audio) are reasonable auto-picks; Kling is strongest for motion (list_models type="text_to_video" for exact ids). Call list_models for supported_durations / supported_aspect_ratios.'),
|
|
442
442
|
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"'),
|
|
443
443
|
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'),
|
|
444
444
|
enhance_prompt: z.boolean().optional().describe('Enhance the prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
|
|
@@ -520,7 +520,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
520
520
|
})).max(MAX_BATCH_PROMPTS).optional().describe(
|
|
521
521
|
`BATCH MODE — several DIFFERENT stills (2–${MAX_BATCH_PROMPTS}) animated concurrently in ONE call and rendered together in ONE combined widget. Unlike the \`prompts\` array on generate_image / generate_video, each entry pairs its OWN \`image_url\` with its OWN motion \`prompt\` — the image is what varies, and that is the point. **Hard cap: ${MAX_BATCH_PROMPTS} items per call — more than that is REJECTED with an error (never silently truncated), so split a longer sequence across several calls of at most ${MAX_BATCH_PROMPTS}.** Whenever the user wants several stills animated (a shot sequence, a storyboard, an animatic), ALWAYS pass them all here instead of making several separate calls — separate calls clutter the chat with stacked widgets. All items share the same model / duration / resolution / aspect_ratio / sound_enabled / project_id / session_id. When set, \`image_url\` and \`prompt\` are ignored.`
|
|
522
522
|
),
|
|
523
|
-
model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid).
|
|
523
|
+
model: z.string().optional().describe('Model identifier — pick a SPECIFIC model, do NOT omit (omitting = Smart Select auto-pick, which we avoid). If the user already named a model/family (this turn or earlier), pass that name — a text-to-video id remaps to the family\'s image-to-video sibling. Do not substitute a cheaper default (named Grok Imagine → not Seedance). Only when no model was named: "seedance-2" or "veo3" are reasonable auto-picks; Kling is strongest for motion (list_models type="img_to_video" for exact ids).'),
|
|
524
524
|
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"'),
|
|
525
525
|
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'),
|
|
526
526
|
enhance_prompt: z.boolean().optional().describe('Enhance the motion prompt. Default: false — only pass true if the user explicitly asks to enhance/improve the prompt.'),
|