@kolbo/mcp 1.70.0 → 1.70.2
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/widgets/generation.js +7 -2
- package/src/install.js +62 -3
- package/src/tools/_shared.js +11 -3
- package/src/tools/generate.js +22 -16
|
@@ -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())
|
|
@@ -16,7 +16,8 @@ const { widgetPage } = require('../html');
|
|
|
16
16
|
* model, model_icon, prompt, count,
|
|
17
17
|
* settings: { duration, resolution, aspect_ratio, quality, audio, voice, mode,
|
|
18
18
|
* enhance_prompt, web_search, visual_dna, moodboard, preset, cinematic },
|
|
19
|
-
*
|
|
19
|
+
* reference_images, // all reference thumbnail URLs (optional)
|
|
20
|
+
* reference_image, // legacy single-thumbnail fallback
|
|
20
21
|
* urls, thumbnail_url, title, duration, credits_used,
|
|
21
22
|
* tracks: [{ title, duration, thumbnail_url, model }], // optional audio metadata by URL index
|
|
22
23
|
* scenes: [{ scene_number, title, image_urls, video_urls }],
|
|
@@ -135,7 +136,11 @@ function renderChips(sc) {
|
|
|
135
136
|
}
|
|
136
137
|
if (s.mode) h += chip(esc(s.mode));
|
|
137
138
|
if (sc.count > 1) h += chip('×' + sc.count);
|
|
138
|
-
|
|
139
|
+
var refs = Array.isArray(sc.reference_images) && sc.reference_images.length
|
|
140
|
+
? sc.reference_images : (sc.reference_image ? [sc.reference_image] : []);
|
|
141
|
+
for (var i = 0; i < refs.length; i++) {
|
|
142
|
+
h += '<img class="k-ref-thumb" src="' + esc(refs[i]) + '" alt="" loading="lazy" title="Reference image ' + (i + 1) + ' of ' + refs.length + '" onerror="this.style.display=\\'none\\'">';
|
|
143
|
+
}
|
|
139
144
|
el('chips').innerHTML = h;
|
|
140
145
|
}
|
|
141
146
|
function chip(inner) { return '<span class="k-chip">' + inner + '</span>'; }
|
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/_shared.js
CHANGED
|
@@ -518,7 +518,7 @@ async function modelChipFields(client, model) {
|
|
|
518
518
|
* kind 'image' | 'video' | 'audio' | '3d' | 'scenes'
|
|
519
519
|
* gen the submit response ({ generation_id, poll_interval_hint })
|
|
520
520
|
* client KolboClient (for model icon lookup)
|
|
521
|
-
* model, prompt, count, settings,
|
|
521
|
+
* model, prompt, count, settings, reference_images, estimated_seconds
|
|
522
522
|
* voice resolved voice record { name, thumbnail } (speech only)
|
|
523
523
|
* poll_tool widget-side status tool (default 'get_generation_status')
|
|
524
524
|
* status_args args for poll_tool (default { generation_id, wait: true })
|
|
@@ -542,7 +542,12 @@ async function uiGenerating(p) {
|
|
|
542
542
|
prompt: p.prompt,
|
|
543
543
|
count: p.count || 1,
|
|
544
544
|
settings: p.settings || {},
|
|
545
|
-
reference_image
|
|
545
|
+
// `reference_image` is retained for older widget builds. New widgets render
|
|
546
|
+
// every browser-loadable image supplied to the generation.
|
|
547
|
+
reference_images: Array.isArray(p.reference_images)
|
|
548
|
+
? p.reference_images.filter(Boolean)
|
|
549
|
+
: (p.reference_image ? [p.reference_image] : []),
|
|
550
|
+
reference_image: p.reference_image || p.reference_images?.find(Boolean),
|
|
546
551
|
open_url: buildOpenUrl(p.tool, p.gen),
|
|
547
552
|
};
|
|
548
553
|
// Batch mode (prompts[] fan-out): ONE widget tracks every id in the set.
|
|
@@ -581,7 +586,10 @@ async function uiCompleted(p, textPayload) {
|
|
|
581
586
|
prompt: p.prompt,
|
|
582
587
|
count: p.count || 1,
|
|
583
588
|
settings: p.settings || {},
|
|
584
|
-
|
|
589
|
+
reference_images: Array.isArray(p.reference_images)
|
|
590
|
+
? p.reference_images.filter(Boolean)
|
|
591
|
+
: (p.reference_image ? [p.reference_image] : []),
|
|
592
|
+
reference_image: p.reference_image || p.reference_images?.find(Boolean),
|
|
585
593
|
urls: p.urls,
|
|
586
594
|
thumbnail_url: p.thumbnail_url,
|
|
587
595
|
title: p.title,
|