@clipkit/mcp-server 1.0.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/LICENSE +201 -0
- package/README.md +67 -0
- package/dist/embedded-docs.d.ts +4 -0
- package/dist/embedded-docs.d.ts.map +1 -0
- package/dist/embedded-docs.js +3589 -0
- package/dist/embedded-docs.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +7 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +23 -0
- package/dist/lib.js.map +1 -0
- package/dist/project-store.d.ts +62 -0
- package/dist/project-store.d.ts.map +1 -0
- package/dist/project-store.js +89 -0
- package/dist/project-store.js.map +1 -0
- package/dist/resources.d.ts +3 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +76 -0
- package/dist/resources.js.map +1 -0
- package/dist/schema-json.d.ts +3 -0
- package/dist/schema-json.d.ts.map +1 -0
- package/dist/schema-json.js +29 -0
- package/dist/schema-json.js.map +1 -0
- package/dist/server-config.d.ts +10 -0
- package/dist/server-config.d.ts.map +1 -0
- package/dist/server-config.js +33 -0
- package/dist/server-config.js.map +1 -0
- package/dist/state.d.ts +20 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +49 -0
- package/dist/state.js.map +1 -0
- package/dist/tools.d.ts +15 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +1178 -0
- package/dist/tools.js.map +1 -0
- package/package.json +54 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
// Tool registrations for the Clipkit MCP server.
|
|
2
|
+
//
|
|
3
|
+
// Each tool reads/writes a project via the injected ProjectStore, addressed by
|
|
4
|
+
// an optional project_id (the store's single "current" project when omitted).
|
|
5
|
+
// Inputs are validated via Zod (raw-shape, MCP SDK convention). Outputs are MCP
|
|
6
|
+
// CallToolResult: a content array of text chunks.
|
|
7
|
+
import { OUTPUT_FORMATS, ELEMENT_TYPES, validate } from '@clipkit/protocol';
|
|
8
|
+
import { lintSource, describe, unknownKeys, unknownElementKeys, droppedKeys } from '@clipkit/lint';
|
|
9
|
+
import { AGENTS_MD, PROTOCOL_MD, BRAND_MD } from './embedded-docs.js';
|
|
10
|
+
import { SOURCE_SCHEMA_JSON, elementSchemaJson } from './schema-json.js';
|
|
11
|
+
import { toCaptionWords } from '@clipkit/speech-to-text/caption';
|
|
12
|
+
// NB: `@clipkit/speech-to-text/node` (the Whisper transcriber) is imported
|
|
13
|
+
// DYNAMICALLY inside the transcribe_to_captions handler — never at module top
|
|
14
|
+
// level. That module resolves its worker via
|
|
15
|
+
// `fileURLToPath(new URL('./worker.js', import.meta.url))`, which a bundler (the
|
|
16
|
+
// hosted Next.js /mcp route bundles this file) rewrites into a broken call that
|
|
17
|
+
// throws at module-init. Keeping it lazy keeps the heavy node-only path out of
|
|
18
|
+
// the route's module graph until a transcription is actually requested.
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { writeFile, unlink } from 'node:fs/promises';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { blankSource, cloneSource, locateElement } from './state.js';
|
|
24
|
+
import { openProject } from './project-store.js';
|
|
25
|
+
import { promo, heroReveal, kineticHeadline, ctaOutro, introCard, tiltedShowcase, statsScene, barsScene, rankingScene, pieScene, } from '@clipkit/patterns';
|
|
26
|
+
// ── promo composition helpers (used by the create_promo tool) ───────────────
|
|
27
|
+
const COLORS = ['pink', 'green', 'blue', 'lavender', 'purple', 'yellow', 'gray'];
|
|
28
|
+
const colorField = z.enum(COLORS).optional().describe('Accent color slot. Default "green".');
|
|
29
|
+
const durationField = z.number().positive().optional().describe('Scene length in seconds. Sensible default per scene type.');
|
|
30
|
+
// One scene of a promo — discriminated by `type`, each maps to a pattern that
|
|
31
|
+
// bakes in the camera / glass / lighting / motion / layout.
|
|
32
|
+
const sceneSchema = z.discriminatedUnion('type', [
|
|
33
|
+
z.object({ type: z.literal('hero'), wordmark: z.string(), tagline: z.string().optional(), color: colorField, duration: durationField }),
|
|
34
|
+
z.object({ type: z.literal('kinetic'), text: z.string(), subtitle: z.string().optional(), color: colorField, duration: durationField }),
|
|
35
|
+
z.object({ type: z.literal('showcase'), screenshot: z.string().url(), color: colorField, duration: durationField }),
|
|
36
|
+
z.object({ type: z.literal('title'), headline: z.string(), kicker: z.string().optional(), subtitle: z.string().optional(), color: colorField, duration: durationField }),
|
|
37
|
+
z.object({ type: z.literal('cta'), wordmark: z.string(), tagline: z.string().optional(), cta: z.string(), color: colorField, duration: durationField }),
|
|
38
|
+
z.object({ type: z.literal('stats'), title: z.string().optional(), dateRange: z.string().optional(), stats: z.array(z.object({ label: z.string(), current: z.number(), previous: z.number().optional() })).min(1).max(4), color: colorField, duration: durationField }),
|
|
39
|
+
z.object({ type: z.literal('bars'), title: z.string().optional(), dateRange: z.string().optional(), bars: z.array(z.object({ label: z.string(), value: z.number(), previous: z.number().optional() })).min(1).max(6), color: colorField, duration: durationField }),
|
|
40
|
+
z.object({ type: z.literal('ranking'), title: z.string().optional(), dateRange: z.string().optional(), items: z.array(z.object({ label: z.string(), value: z.number() })).min(1).max(12), color: colorField, duration: durationField }),
|
|
41
|
+
z.object({ type: z.literal('pie'), title: z.string().optional(), dateRange: z.string().optional(), cards: z.array(z.object({ label: z.string(), value: z.number(), total: z.number(), previous: z.number().optional() })).min(1).max(4), color: colorField, duration: durationField }),
|
|
42
|
+
]);
|
|
43
|
+
const DEFAULT_DURATION = {
|
|
44
|
+
hero: 2.6, kinetic: 2.2, showcase: 3.0, title: 2.4, cta: 2.0,
|
|
45
|
+
stats: 4.0, bars: 4.5, ranking: 4.5, pie: 4.0,
|
|
46
|
+
};
|
|
47
|
+
function buildSceneElement(s, ctx) {
|
|
48
|
+
const color = s.color ?? 'green';
|
|
49
|
+
const base = { id: ctx.id, theme: ctx.theme, time: ctx.time, duration: ctx.duration, layer: ctx.layer, color };
|
|
50
|
+
const W = ctx.canvasWidth, H = ctx.canvasHeight;
|
|
51
|
+
switch (s.type) {
|
|
52
|
+
case 'hero': return heroReveal({ ...base, canvasWidth: W, canvasHeight: H, wordmark: s.wordmark, tagline: s.tagline });
|
|
53
|
+
case 'kinetic': return kineticHeadline({ ...base, canvasWidth: W, canvasHeight: H, text: s.text, subtitle: s.subtitle });
|
|
54
|
+
case 'title': return introCard({ ...base, canvasWidth: W, canvasHeight: H, headline: s.headline, kicker: s.kicker, subtitle: s.subtitle });
|
|
55
|
+
case 'cta': return ctaOutro({ ...base, canvasWidth: W, canvasHeight: H, wordmark: s.wordmark, tagline: s.tagline, cta: s.cta });
|
|
56
|
+
case 'showcase': return tiltedShowcase({ ...base, x: W / 2, y: H / 2, source: s.screenshot });
|
|
57
|
+
case 'stats': return statsScene({ ...base, canvasWidth: W, canvasHeight: H, title: s.title, dateRange: s.dateRange, stats: s.stats });
|
|
58
|
+
case 'bars': return barsScene({ ...base, canvasWidth: W, canvasHeight: H, title: s.title, dateRange: s.dateRange, bars: s.bars });
|
|
59
|
+
case 'ranking': return rankingScene({ ...base, canvasWidth: W, canvasHeight: H, title: s.title, dateRange: s.dateRange, items: s.items });
|
|
60
|
+
case 'pie': return pieScene({ ...base, canvasWidth: W, canvasHeight: H, title: s.title, dateRange: s.dateRange, cards: s.cards });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// POST a Source to the Clipkit share API → an editor URL (or an error string).
|
|
64
|
+
// If CLIPKIT_API_KEY is set (a dashboard `ck_live_…` key), the share is sent
|
|
65
|
+
// as Bearer auth so it's owned by that team (permanent on paid plans) rather
|
|
66
|
+
// than anonymous + TTL'd. CLIPKIT_API_URL overrides the host (e.g. localhost).
|
|
67
|
+
async function shareSource(source) {
|
|
68
|
+
const base = process.env.CLIPKIT_API_URL ?? 'https://clipkit.dev';
|
|
69
|
+
const apiKey = process.env.CLIPKIT_API_KEY;
|
|
70
|
+
const headers = { 'content-type': 'application/json' };
|
|
71
|
+
if (apiKey)
|
|
72
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`${base}/api/projects`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers,
|
|
77
|
+
body: JSON.stringify({ source }),
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok)
|
|
80
|
+
return { error: `share API returned ${res.status}` };
|
|
81
|
+
const data = (await res.json());
|
|
82
|
+
return data.url ? { url: data.url } : { error: 'share API returned no url' };
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Fetch a previously shared Source back by share id or editor URL. GET
|
|
89
|
+
// /api/projects/:id returns { source }; it's public + read-only, so no key needed.
|
|
90
|
+
async function loadShare(idOrUrl) {
|
|
91
|
+
const base = (process.env.CLIPKIT_API_URL ?? 'https://clipkit.dev').replace(/\/$/, '');
|
|
92
|
+
let id = idOrUrl.trim();
|
|
93
|
+
const q = /[?&]id=([^&#]+)/.exec(id);
|
|
94
|
+
if (q) {
|
|
95
|
+
id = decodeURIComponent(q[1]);
|
|
96
|
+
}
|
|
97
|
+
else if (/^https?:\/\//i.test(id)) {
|
|
98
|
+
// A URL without ?id= — take the last non-empty path segment.
|
|
99
|
+
try {
|
|
100
|
+
const segs = new URL(id).pathname.split('/').filter(Boolean);
|
|
101
|
+
if (segs.length)
|
|
102
|
+
id = decodeURIComponent(segs[segs.length - 1]);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* fall through with id as-is */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`);
|
|
110
|
+
if (res.status === 404)
|
|
111
|
+
return { error: `No share found for "${id}" (it may have expired).` };
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
return { error: `share API returned ${res.status}` };
|
|
114
|
+
const data = (await res.json().catch(() => ({})));
|
|
115
|
+
return data.source !== undefined ? { source: data.source } : { error: 'share API returned no source' };
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Download a remote media file to a temp path so the local Whisper transcriber
|
|
122
|
+
// (which reads from the filesystem) can use it. The caller unlinks it when done.
|
|
123
|
+
async function fetchToTemp(url) {
|
|
124
|
+
const res = await fetch(url);
|
|
125
|
+
if (!res.ok)
|
|
126
|
+
throw new Error(`download failed: ${res.status}`);
|
|
127
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
128
|
+
let ext = '.bin';
|
|
129
|
+
try {
|
|
130
|
+
const m = /\.([a-z0-9]{1,5})$/i.exec(new URL(url).pathname);
|
|
131
|
+
if (m)
|
|
132
|
+
ext = m[0];
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* keep .bin */
|
|
136
|
+
}
|
|
137
|
+
const tmp = join(tmpdir(), `clipkit-transcribe-${Date.now()}-${Math.floor(Math.random() * 1e9)}${ext}`);
|
|
138
|
+
await writeFile(tmp, buf);
|
|
139
|
+
return tmp;
|
|
140
|
+
}
|
|
141
|
+
// Render ONE frame of a Source via the cloud /api/v1/still endpoint and return a
|
|
142
|
+
// base64 PNG (so the agent can SEE its work) — or an error string. Stills are
|
|
143
|
+
// FREE + rate-limited; an API key (if set) just grants a more generous bucket.
|
|
144
|
+
async function stillSource(source, time) {
|
|
145
|
+
const base = (process.env.CLIPKIT_API_URL ?? 'https://clipkit.dev').replace(/\/$/, '');
|
|
146
|
+
const apiKey = process.env.CLIPKIT_API_KEY;
|
|
147
|
+
const headers = { 'content-type': 'application/json' };
|
|
148
|
+
if (apiKey)
|
|
149
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch(`${base}/api/v1/still`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers,
|
|
154
|
+
body: JSON.stringify({ source, time }),
|
|
155
|
+
});
|
|
156
|
+
const json = (await res.json().catch(() => ({})));
|
|
157
|
+
if (res.status === 429) {
|
|
158
|
+
return { error: 'Rate limited — wait a moment, then preview again.' };
|
|
159
|
+
}
|
|
160
|
+
if (!res.ok || !json.data) {
|
|
161
|
+
return { error: json.message ?? json.error ?? `still API returned ${res.status}` };
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
data: json.data,
|
|
165
|
+
mimeType: json.mimeType ?? 'image/png',
|
|
166
|
+
width: json.width ?? 0,
|
|
167
|
+
height: json.height ?? 0,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Render a Source via the cloud API and return a signed MP4 URL (or an error).
|
|
175
|
+
// Cloud rendering is the paid, server-GPU path; it requires CLIPKIT_API_KEY
|
|
176
|
+
// (a ck_live_… key from the dashboard) — there are no anonymous cloud renders.
|
|
177
|
+
//
|
|
178
|
+
// The route is ASYNC (the render runs in a background Cloud Run Job), so this is
|
|
179
|
+
// enqueue → poll: POST /api/v1/renders returns a job id immediately; we then
|
|
180
|
+
// poll GET /api/v1/renders/:id until status is `done` (→ signed output_url) or
|
|
181
|
+
// `failed`. credits are reserved at enqueue; durationMs is wall-clock here.
|
|
182
|
+
async function renderSource(source, opts = {}) {
|
|
183
|
+
const base = (process.env.CLIPKIT_API_URL ?? 'https://clipkit.dev').replace(/\/$/, '');
|
|
184
|
+
const apiKey = process.env.CLIPKIT_API_KEY;
|
|
185
|
+
if (!apiKey) {
|
|
186
|
+
return {
|
|
187
|
+
error: 'Cloud render requires CLIPKIT_API_KEY (a ck_live_… key from Settings → API keys). ' +
|
|
188
|
+
'Set it in the MCP server env. To preview without rendering, use open_in_editor or preview_still.',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const auth = { authorization: `Bearer ${apiKey}` };
|
|
192
|
+
const startedAt = Date.now();
|
|
193
|
+
// ── Enqueue.
|
|
194
|
+
let id;
|
|
195
|
+
let credits = 0;
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(`${base}/api/v1/renders`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'content-type': 'application/json', ...auth },
|
|
200
|
+
body: JSON.stringify({ source, ...(opts.resolution ? { resolution: opts.resolution } : {}) }),
|
|
201
|
+
});
|
|
202
|
+
const data = (await res.json().catch(() => ({})));
|
|
203
|
+
if (res.status === 401)
|
|
204
|
+
return { error: 'Unauthorized — CLIPKIT_API_KEY was rejected.' };
|
|
205
|
+
if (res.status === 402)
|
|
206
|
+
return { error: data.message ?? 'Quota exceeded — upgrade your plan.' };
|
|
207
|
+
if (res.status === 413)
|
|
208
|
+
return { error: 'Source too large (2 MB max).' };
|
|
209
|
+
if (!res.ok || !data.id)
|
|
210
|
+
return { error: data.error ?? `render API returned ${res.status}` };
|
|
211
|
+
id = data.id;
|
|
212
|
+
credits = data.credits_reserved ?? 0;
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
216
|
+
}
|
|
217
|
+
// ── Poll until done/failed (10-minute cap).
|
|
218
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
219
|
+
for (;;) {
|
|
220
|
+
if (Date.now() > deadline)
|
|
221
|
+
return { error: 'Render timed out after 10 minutes.' };
|
|
222
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
223
|
+
try {
|
|
224
|
+
const res = await fetch(`${base}/api/v1/renders/${id}`, { headers: auth });
|
|
225
|
+
if (!res.ok)
|
|
226
|
+
continue; // transient — keep polling
|
|
227
|
+
const s = (await res.json());
|
|
228
|
+
if (s.status === 'done') {
|
|
229
|
+
return s.output_url
|
|
230
|
+
? { url: s.output_url, credits, durationMs: Date.now() - startedAt }
|
|
231
|
+
: { error: 'Render finished but returned no download URL.' };
|
|
232
|
+
}
|
|
233
|
+
if (s.status === 'failed')
|
|
234
|
+
return { error: s.error ?? 'Render failed.' };
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
continue; // transient network blip — keep polling until the deadline
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Format an "unrecognized keys" note for an authoring tool's result, so the
|
|
242
|
+
// agent learns the schema when it guesses a wrong field name. Two distinct
|
|
243
|
+
// failure modes — and they debug differently, so label them: `kept` keys
|
|
244
|
+
// (passthrough objects) survive into the saved project but the runtime ignores
|
|
245
|
+
// them; `stripped` keys (closed objects) are removed during validation and are
|
|
246
|
+
// gone from the project you get back.
|
|
247
|
+
function unknownNote(kept, stripped) {
|
|
248
|
+
const total = kept.length + stripped.length;
|
|
249
|
+
if (!total)
|
|
250
|
+
return '';
|
|
251
|
+
const cap = (xs) => xs.slice(0, 10).join(', ') + (xs.length > 10 ? `, +${xs.length - 10} more` : '');
|
|
252
|
+
const lines = [
|
|
253
|
+
`\n⚠ ${total} key${total === 1 ? '' : 's'} not in the schema (check spelling/nesting, or call ` +
|
|
254
|
+
`get_schema for the exact fields):`,
|
|
255
|
+
];
|
|
256
|
+
if (kept.length)
|
|
257
|
+
lines.push(` • kept but ignored by the runtime: ${cap(kept)}`);
|
|
258
|
+
if (stripped.length)
|
|
259
|
+
lines.push(` • stripped on save (not in the saved project): ${cap(stripped)}`);
|
|
260
|
+
return lines.join('\n');
|
|
261
|
+
}
|
|
262
|
+
// An editor link for a project. If the store is itself the editor's database (a
|
|
263
|
+
// hosted store that exposes editorUrl), link straight to the existing row — no
|
|
264
|
+
// snapshot copy. Otherwise (local stdio / in-memory) persist a share via the API.
|
|
265
|
+
async function editorLinkFor(store, projectId, source) {
|
|
266
|
+
if (store.editorUrl) {
|
|
267
|
+
const url = await store.editorUrl(projectId);
|
|
268
|
+
if (url)
|
|
269
|
+
return { url };
|
|
270
|
+
}
|
|
271
|
+
return shareSource(source);
|
|
272
|
+
}
|
|
273
|
+
// Optional project handle shared by every stateful tool. Omitted → the store's
|
|
274
|
+
// "current" project (the single local project on stdio); on a hosted,
|
|
275
|
+
// sessionless server it must be supplied so each call names the project it
|
|
276
|
+
// acts on (the returned id comes from create_project / set_project / etc.).
|
|
277
|
+
const projectIdField = z
|
|
278
|
+
.string()
|
|
279
|
+
.min(1)
|
|
280
|
+
.optional()
|
|
281
|
+
.describe('Which project to act on — the id returned by create_project / set_project / ' +
|
|
282
|
+
'create_promo / load_project. Omit when working on a single local project.');
|
|
283
|
+
/**
|
|
284
|
+
* Register the Clipkit tools on `server`.
|
|
285
|
+
*
|
|
286
|
+
* `options.localTranscription` (default true) gates `transcribe_to_captions`,
|
|
287
|
+
* which runs Whisper in a child process and needs ffmpeg + a writable FS. Hosts
|
|
288
|
+
* that can't provide those — the hosted /mcp route on serverless — pass `false`
|
|
289
|
+
* so the tool isn't advertised where it would always fail. (A hosted STT path,
|
|
290
|
+
* via render-service or a hosted endpoint, is a TODO — figure out later.)
|
|
291
|
+
*/
|
|
292
|
+
export function registerTools(server, store, options = {}) {
|
|
293
|
+
const { localTranscription = true } = options;
|
|
294
|
+
// ─── read_docs ────────────────────────────────────────────────────────────
|
|
295
|
+
server.registerTool('read_docs', {
|
|
296
|
+
title: 'Read the Clipkit authoring docs',
|
|
297
|
+
annotations: { readOnlyHint: true },
|
|
298
|
+
description: 'Return a canonical Clipkit doc as text. topic "agents" = the authoring guide (schema cheat ' +
|
|
299
|
+
'sheet, pattern catalog, recipes, guidance — read this BEFORE composing); "protocol" = the ' +
|
|
300
|
+
'formal field spec; "brand" = brand reference. (Same docs offered as MCP resources, exposed ' +
|
|
301
|
+
'as a tool so you can read them directly — resources are not always model-readable.)',
|
|
302
|
+
inputSchema: {
|
|
303
|
+
topic: z.enum(['agents', 'protocol', 'brand']).optional().describe('Which doc. Default "agents".'),
|
|
304
|
+
},
|
|
305
|
+
}, async ({ topic }) => {
|
|
306
|
+
const t = topic ?? 'agents';
|
|
307
|
+
const doc = t === 'protocol' ? PROTOCOL_MD : t === 'brand' ? BRAND_MD : AGENTS_MD;
|
|
308
|
+
return { content: [{ type: 'text', text: doc }] };
|
|
309
|
+
});
|
|
310
|
+
// ─── get_schema ───────────────────────────────────────────────────────────
|
|
311
|
+
server.registerTool('get_schema', {
|
|
312
|
+
title: 'Get the Clipkit JSON Schema (exact fields)',
|
|
313
|
+
annotations: { readOnlyHint: true },
|
|
314
|
+
description: 'Return the authoritative JSON Schema for a Clipkit Source — exact field names, types, and ' +
|
|
315
|
+
'enums, generated from the protocol. Call with no argument for the full Source schema, or ' +
|
|
316
|
+
'with element_type (e.g. "text", "shape", "particles") for just that element\'s fields (much ' +
|
|
317
|
+
'smaller). Use this when authoring with set_project / add_element so you never guess a field.',
|
|
318
|
+
inputSchema: {
|
|
319
|
+
element_type: z
|
|
320
|
+
.enum(ELEMENT_TYPES)
|
|
321
|
+
.optional()
|
|
322
|
+
.describe('Limit to one element type\'s fields (e.g. "text"). Omit for the full Source schema (large).'),
|
|
323
|
+
},
|
|
324
|
+
}, async ({ element_type }) => {
|
|
325
|
+
if (element_type) {
|
|
326
|
+
const js = elementSchemaJson(element_type);
|
|
327
|
+
return {
|
|
328
|
+
content: [{ type: 'text', text: js ?? `No schema found for element type "${element_type}".` }],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return { content: [{ type: 'text', text: SOURCE_SCHEMA_JSON }] };
|
|
332
|
+
});
|
|
333
|
+
// ─── create_project ─────────────────────────────────────────────────────
|
|
334
|
+
server.registerTool('create_project', {
|
|
335
|
+
title: 'Create a new Clipkit project',
|
|
336
|
+
outputSchema: {
|
|
337
|
+
project_id: z.string().describe('Id of the created project — pass to subsequent tools.'),
|
|
338
|
+
width: z.number().optional(),
|
|
339
|
+
height: z.number().optional(),
|
|
340
|
+
duration: z.number().optional(),
|
|
341
|
+
frame_rate: z.number().optional(),
|
|
342
|
+
output_format: z.string().optional(),
|
|
343
|
+
},
|
|
344
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
345
|
+
description: 'Create a new, blank Clipkit project with the given dimensions and duration, and return its ' +
|
|
346
|
+
'project_id. Defaults: 1920×1080, 10 seconds, 30 fps, output_format "mp4". ' +
|
|
347
|
+
'Call this first when starting a new video. Pass an existing project_id to reset that project ' +
|
|
348
|
+
'to blank; omit it to start a fresh project (note the returned id for subsequent tools).',
|
|
349
|
+
inputSchema: {
|
|
350
|
+
width: z.number().int().positive().optional().describe('Composition width in pixels. Default 1920.'),
|
|
351
|
+
height: z.number().int().positive().optional().describe('Composition height in pixels. Default 1080.'),
|
|
352
|
+
duration: z.number().positive().optional().describe('Composition duration in seconds. Default 10.'),
|
|
353
|
+
frame_rate: z.number().positive().optional().describe('Frame rate. Default 30.'),
|
|
354
|
+
output_format: z
|
|
355
|
+
.enum(OUTPUT_FORMATS)
|
|
356
|
+
.optional()
|
|
357
|
+
.describe('Output container/codec. Default "mp4".'),
|
|
358
|
+
background_color: z.string().optional().describe('Hex color, e.g. "#000000".'),
|
|
359
|
+
project_id: projectIdField,
|
|
360
|
+
},
|
|
361
|
+
}, async (args) => {
|
|
362
|
+
const next = blankSource();
|
|
363
|
+
if (args.width !== undefined)
|
|
364
|
+
next.width = args.width;
|
|
365
|
+
if (args.height !== undefined)
|
|
366
|
+
next.height = args.height;
|
|
367
|
+
if (args.duration !== undefined)
|
|
368
|
+
next.duration = args.duration;
|
|
369
|
+
if (args.frame_rate !== undefined)
|
|
370
|
+
next.frame_rate = args.frame_rate;
|
|
371
|
+
if (args.output_format !== undefined)
|
|
372
|
+
next.output_format = args.output_format;
|
|
373
|
+
if (args.background_color !== undefined)
|
|
374
|
+
next.background_color = args.background_color;
|
|
375
|
+
const id = await store.put(args.project_id, next);
|
|
376
|
+
return {
|
|
377
|
+
content: [
|
|
378
|
+
{
|
|
379
|
+
type: 'text',
|
|
380
|
+
text: `Created new project (project_id: ${id}): ${next.width}×${next.height}, ${next.duration}s, ${next.frame_rate}fps, ${next.output_format}.`,
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
structuredContent: {
|
|
384
|
+
project_id: id,
|
|
385
|
+
width: next.width,
|
|
386
|
+
height: next.height,
|
|
387
|
+
duration: next.duration,
|
|
388
|
+
frame_rate: next.frame_rate,
|
|
389
|
+
output_format: next.output_format,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
// ─── get_project ────────────────────────────────────────────────────────
|
|
394
|
+
server.registerTool('get_project', {
|
|
395
|
+
title: 'Get the current Clipkit project JSON',
|
|
396
|
+
annotations: { readOnlyHint: true },
|
|
397
|
+
description: 'Return the full current Clipkit source as JSON. Use this to inspect the project, ' +
|
|
398
|
+
'pass it to a render pipeline, or compose follow-up edits.',
|
|
399
|
+
inputSchema: {
|
|
400
|
+
project_id: projectIdField,
|
|
401
|
+
},
|
|
402
|
+
}, async (args) => {
|
|
403
|
+
const p = await openProject(store, args.project_id);
|
|
404
|
+
if (!p.ok) {
|
|
405
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
content: [{ type: 'text', text: JSON.stringify(p.project.source, null, 2) }],
|
|
409
|
+
};
|
|
410
|
+
});
|
|
411
|
+
// ─── describe_project ─────────────────────────────────────────────────────
|
|
412
|
+
server.registerTool('describe_project', {
|
|
413
|
+
title: 'Describe the current project in plain language',
|
|
414
|
+
annotations: { readOnlyHint: true },
|
|
415
|
+
description: 'Return a compact, human-readable summary of the current project — dimensions, fps, ' +
|
|
416
|
+
'duration, an element breakdown by type, a per-track timeline (paint order low→high), and ' +
|
|
417
|
+
"render-time warnings. Much cheaper to read than get_project's full JSON; use it to orient " +
|
|
418
|
+
'yourself or sanity-check structure without dumping the whole source.',
|
|
419
|
+
inputSchema: {
|
|
420
|
+
project_id: projectIdField,
|
|
421
|
+
},
|
|
422
|
+
}, async (args) => {
|
|
423
|
+
const p = await openProject(store, args.project_id);
|
|
424
|
+
if (!p.ok) {
|
|
425
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
426
|
+
}
|
|
427
|
+
const result = validate(p.project.source);
|
|
428
|
+
if (!result.valid) {
|
|
429
|
+
return {
|
|
430
|
+
isError: true,
|
|
431
|
+
content: [
|
|
432
|
+
{
|
|
433
|
+
type: 'text',
|
|
434
|
+
text: "The project doesn't validate, so it can't be summarized. Run validate_project to see the errors.",
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return { content: [{ type: 'text', text: describe(result.data) }] };
|
|
440
|
+
});
|
|
441
|
+
// ─── set_project ────────────────────────────────────────────────────────
|
|
442
|
+
server.registerTool('set_project', {
|
|
443
|
+
title: 'Replace the entire Clipkit project',
|
|
444
|
+
outputSchema: {
|
|
445
|
+
project_id: z.string(),
|
|
446
|
+
element_count: z.number(),
|
|
447
|
+
width: z.number().optional(),
|
|
448
|
+
height: z.number().optional(),
|
|
449
|
+
duration: z.number().optional(),
|
|
450
|
+
},
|
|
451
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
452
|
+
description: 'Replace the entire project with the given source JSON, returning its project_id. This is the ' +
|
|
453
|
+
'PRIMARY way to build: use it to create a composition or to add/change many elements at once. ' +
|
|
454
|
+
'(To tweak a single element in an existing project, use edit_element / add_element / ' +
|
|
455
|
+
'delete_element instead.) Pass an existing project_id to replace that project; omit it to ' +
|
|
456
|
+
'create a new one (note the returned id). ' +
|
|
457
|
+
'The input is validated against the @clipkit/protocol before being accepted; invalid inputs ' +
|
|
458
|
+
'return an error. Shape: { width, height, duration, frame_rate, output_format, ' +
|
|
459
|
+
'background_color?, fonts?, camera?, lights?, elements:[…] }; every element has a `type` plus ' +
|
|
460
|
+
'base fields (id, x, y, width, height, time, duration, track, opacity, rotation, animations, ' +
|
|
461
|
+
'keyframe_animations) and type-specific fields. For exact field names + types call get_schema ' +
|
|
462
|
+
'(optionally with an element_type) — the runtime ignores unrecognized keys, and this tool ' +
|
|
463
|
+
'flags any it does not recognize.',
|
|
464
|
+
inputSchema: {
|
|
465
|
+
source: z.unknown().describe('A full Clipkit source object (or JSON string).'),
|
|
466
|
+
project_id: projectIdField,
|
|
467
|
+
},
|
|
468
|
+
}, async (args) => {
|
|
469
|
+
const result = validate(args.source);
|
|
470
|
+
if (!result.valid) {
|
|
471
|
+
const summary = result.errors
|
|
472
|
+
.slice(0, 5)
|
|
473
|
+
.map((e) => `${e.path.join('.') || '(root)'}: ${e.message}`)
|
|
474
|
+
.join('; ');
|
|
475
|
+
return {
|
|
476
|
+
isError: true,
|
|
477
|
+
content: [
|
|
478
|
+
{
|
|
479
|
+
type: 'text',
|
|
480
|
+
text: `Validation failed: ${summary}${result.errors.length > 5 ? ` (+${result.errors.length - 5} more)` : ''}`,
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const id = await store.put(args.project_id, result.data);
|
|
486
|
+
let parsedInput = args.source;
|
|
487
|
+
if (typeof args.source === 'string') {
|
|
488
|
+
try {
|
|
489
|
+
parsedInput = JSON.parse(args.source);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
parsedInput = {};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const note = unknownNote(unknownKeys(parsedInput), droppedKeys(parsedInput, result.data));
|
|
496
|
+
return {
|
|
497
|
+
content: [
|
|
498
|
+
{
|
|
499
|
+
type: 'text',
|
|
500
|
+
text: `Project replaced (project_id: ${id}). ${result.data.elements.length} elements, ${result.data.duration ?? '?'}s, ${result.data.width ?? '?'}×${result.data.height ?? '?'}.${note}`,
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
structuredContent: {
|
|
504
|
+
project_id: id,
|
|
505
|
+
element_count: result.data.elements.length,
|
|
506
|
+
width: result.data.width,
|
|
507
|
+
height: result.data.height,
|
|
508
|
+
duration: result.data.duration,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
// ─── add_element ────────────────────────────────────────────────────────
|
|
513
|
+
server.registerTool('add_element', {
|
|
514
|
+
title: 'Add an element to the current project',
|
|
515
|
+
outputSchema: {
|
|
516
|
+
added_element_id: z.string().optional(),
|
|
517
|
+
element_type: z.string().optional(),
|
|
518
|
+
parent_id: z.string().optional(),
|
|
519
|
+
top_level_element_count: z.number(),
|
|
520
|
+
},
|
|
521
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
522
|
+
description: 'Append a single element to an existing project — a TWEAK, e.g. dropping in one more caption ' +
|
|
523
|
+
'or shape. By default it is added at the top level; pass parent_id to add it INTO a group ' +
|
|
524
|
+
'(nested). The element is any valid schema element: video, image, text, shape, audio, group, ' +
|
|
525
|
+
'caption, or particles. To create a composition or add several elements at once, build the ' +
|
|
526
|
+
'JSON and use set_project instead. The new element is validated as part of the project as a ' +
|
|
527
|
+
'whole before being added. Call get_schema(element_type) for the exact per-type fields; ' +
|
|
528
|
+
'unrecognized keys are flagged.',
|
|
529
|
+
inputSchema: {
|
|
530
|
+
element: z.unknown().describe('A Clipkit element object. Must include `type`.'),
|
|
531
|
+
parent_id: z
|
|
532
|
+
.string()
|
|
533
|
+
.min(1)
|
|
534
|
+
.optional()
|
|
535
|
+
.describe('Optional id of a group to add this element INTO (nested). Omit to add at the top level.'),
|
|
536
|
+
project_id: projectIdField,
|
|
537
|
+
},
|
|
538
|
+
}, async (args) => {
|
|
539
|
+
// Validate by adding to a copy of the source and running full validation.
|
|
540
|
+
// With parent_id, add INTO that group's children; otherwise at the top level.
|
|
541
|
+
const p = await openProject(store, args.project_id);
|
|
542
|
+
if (!p.ok) {
|
|
543
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
544
|
+
}
|
|
545
|
+
const trial = cloneSource(p.project.source);
|
|
546
|
+
let where = 'the project';
|
|
547
|
+
if (args.parent_id) {
|
|
548
|
+
const loc = locateElement(trial.elements, args.parent_id);
|
|
549
|
+
if (!loc) {
|
|
550
|
+
return {
|
|
551
|
+
isError: true,
|
|
552
|
+
content: [{ type: 'text', text: `No element with id "${args.parent_id}" to add into.` }],
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (!Array.isArray(loc.element.elements)) {
|
|
556
|
+
return {
|
|
557
|
+
isError: true,
|
|
558
|
+
content: [{ type: 'text', text: `Element "${args.parent_id}" is not a group, so it has no children to add into.` }],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
loc.element.elements.push(args.element);
|
|
562
|
+
where = `group "${args.parent_id}"`;
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
trial.elements.push(args.element);
|
|
566
|
+
}
|
|
567
|
+
const result = validate(trial);
|
|
568
|
+
if (!result.valid) {
|
|
569
|
+
const summary = result.errors
|
|
570
|
+
.slice(0, 5)
|
|
571
|
+
.map((e) => `${e.path.join('.') || '(root)'}: ${e.message}`)
|
|
572
|
+
.join('; ');
|
|
573
|
+
return {
|
|
574
|
+
isError: true,
|
|
575
|
+
content: [
|
|
576
|
+
{ type: 'text', text: `Element rejected by schema: ${summary}` },
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
await p.project.save(result.data);
|
|
581
|
+
const elObj = args.element;
|
|
582
|
+
const addedId = elObj && typeof elObj.id === 'string' ? elObj.id : undefined;
|
|
583
|
+
const after = addedId
|
|
584
|
+
? locateElement(result.data.elements, addedId)?.element
|
|
585
|
+
: args.parent_id
|
|
586
|
+
? undefined
|
|
587
|
+
: result.data.elements[result.data.elements.length - 1];
|
|
588
|
+
const note = unknownNote(unknownElementKeys(args.element), after ? droppedKeys(args.element, after) : []);
|
|
589
|
+
return {
|
|
590
|
+
content: [
|
|
591
|
+
{
|
|
592
|
+
type: 'text',
|
|
593
|
+
text: `Added ${String(elObj?.type ?? 'element')} element${addedId ? ` (id: ${addedId})` : ''} to ${where}. Project now has ${result.data.elements.length} top-level elements.${note}`,
|
|
594
|
+
},
|
|
595
|
+
],
|
|
596
|
+
structuredContent: {
|
|
597
|
+
added_element_id: addedId,
|
|
598
|
+
element_type: typeof elObj?.type === 'string' ? elObj.type : undefined,
|
|
599
|
+
parent_id: args.parent_id,
|
|
600
|
+
top_level_element_count: result.data.elements.length,
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
});
|
|
604
|
+
// ─── edit_element ─────────────────────────────────────────────────────────
|
|
605
|
+
server.registerTool('edit_element', {
|
|
606
|
+
title: 'Tweak one existing element (merge changed fields)',
|
|
607
|
+
outputSchema: {
|
|
608
|
+
element_id: z.string(),
|
|
609
|
+
changed_keys: z.array(z.string()),
|
|
610
|
+
},
|
|
611
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
612
|
+
description: 'Change fields on the element with the given id by merging in a partial element — only the ' +
|
|
613
|
+
'keys you include change. The id may be any element ANYWHERE in the tree, including one nested ' +
|
|
614
|
+
'inside a group (or its mask). Pass a whole nested value (e.g. a new `keyframe_animations` array) ' +
|
|
615
|
+
'to replace that key; set a key to null to remove it. This is for TWEAKING an existing ' +
|
|
616
|
+
'composition. To create a composition or change many elements at once, edit the JSON and ' +
|
|
617
|
+
'call set_project instead. The result is re-validated before being accepted.',
|
|
618
|
+
inputSchema: {
|
|
619
|
+
id: z.string().min(1).describe('The id of the element to edit.'),
|
|
620
|
+
patch: z
|
|
621
|
+
.record(z.unknown())
|
|
622
|
+
.describe('Partial element: the fields to change. Omitted keys are left as-is; a key set to null is removed.'),
|
|
623
|
+
project_id: projectIdField,
|
|
624
|
+
},
|
|
625
|
+
}, async (args) => {
|
|
626
|
+
// Merge the patch onto a snapshot, validate, then commit. The element may be
|
|
627
|
+
// anywhere in the tree — top-level, a group's children, or a mask's.
|
|
628
|
+
const p = await openProject(store, args.project_id);
|
|
629
|
+
if (!p.ok) {
|
|
630
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
631
|
+
}
|
|
632
|
+
const trial = cloneSource(p.project.source);
|
|
633
|
+
const loc = locateElement(trial.elements, args.id);
|
|
634
|
+
if (!loc) {
|
|
635
|
+
return {
|
|
636
|
+
isError: true,
|
|
637
|
+
content: [{ type: 'text', text: `No element with id "${args.id}".` }],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const el = loc.element;
|
|
641
|
+
for (const [k, v] of Object.entries(args.patch)) {
|
|
642
|
+
if (v === null)
|
|
643
|
+
delete el[k];
|
|
644
|
+
else
|
|
645
|
+
el[k] = v;
|
|
646
|
+
}
|
|
647
|
+
const result = validate(trial);
|
|
648
|
+
if (!result.valid) {
|
|
649
|
+
const summary = result.errors
|
|
650
|
+
.slice(0, 5)
|
|
651
|
+
.map((e) => `${e.path.join('.') || '(root)'}: ${e.message}`)
|
|
652
|
+
.join('; ');
|
|
653
|
+
return {
|
|
654
|
+
isError: true,
|
|
655
|
+
content: [{ type: 'text', text: `Edit rejected by schema: ${summary}` }],
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
await p.project.save(result.data);
|
|
659
|
+
const keys = Object.keys(args.patch);
|
|
660
|
+
const after = locateElement(result.data.elements, args.id)?.element ?? el;
|
|
661
|
+
const note = unknownNote(unknownElementKeys(el), droppedKeys(el, after));
|
|
662
|
+
return {
|
|
663
|
+
content: [
|
|
664
|
+
{ type: 'text', text: `Edited ${args.id} (${keys.join(', ') || 'no changes'}).${note}` },
|
|
665
|
+
],
|
|
666
|
+
structuredContent: { element_id: args.id, changed_keys: keys },
|
|
667
|
+
};
|
|
668
|
+
});
|
|
669
|
+
// ─── delete_element ───────────────────────────────────────────────────────
|
|
670
|
+
server.registerTool('delete_element', {
|
|
671
|
+
title: 'Delete one element by id',
|
|
672
|
+
outputSchema: {
|
|
673
|
+
deleted_element_id: z.string(),
|
|
674
|
+
top_level_element_count: z.number(),
|
|
675
|
+
},
|
|
676
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
677
|
+
description: 'Delete the element with the given id, anywhere in the tree (including one nested inside a ' +
|
|
678
|
+
'group or its mask) — a tweak to an existing composition. (The project must keep at least ' +
|
|
679
|
+
'one top-level element.)',
|
|
680
|
+
inputSchema: {
|
|
681
|
+
id: z.string().min(1).describe('The id of the element to delete.'),
|
|
682
|
+
project_id: projectIdField,
|
|
683
|
+
},
|
|
684
|
+
}, async (args) => {
|
|
685
|
+
const p = await openProject(store, args.project_id);
|
|
686
|
+
if (!p.ok) {
|
|
687
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
688
|
+
}
|
|
689
|
+
const trial = cloneSource(p.project.source);
|
|
690
|
+
const loc = locateElement(trial.elements, args.id);
|
|
691
|
+
if (!loc) {
|
|
692
|
+
return {
|
|
693
|
+
isError: true,
|
|
694
|
+
content: [{ type: 'text', text: `No element with id "${args.id}".` }],
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
loc.container.splice(loc.index, 1);
|
|
698
|
+
// Deleting an element should never break the schema (the only "minimum" is
|
|
699
|
+
// elements.length >= 1), but re-validate to catch that case.
|
|
700
|
+
const result = validate(trial);
|
|
701
|
+
if (!result.valid) {
|
|
702
|
+
return {
|
|
703
|
+
isError: true,
|
|
704
|
+
content: [
|
|
705
|
+
{
|
|
706
|
+
type: 'text',
|
|
707
|
+
text: `Deleting "${args.id}" would leave the project invalid: ${result.errors[0]?.message ?? ''}. The project must keep at least one element.`,
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
await p.project.save(result.data);
|
|
713
|
+
return {
|
|
714
|
+
content: [{ type: 'text', text: `Deleted element ${args.id}.` }],
|
|
715
|
+
structuredContent: {
|
|
716
|
+
deleted_element_id: args.id,
|
|
717
|
+
top_level_element_count: result.data.elements.length,
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
// ─── validate_project ─────────────────────────────────────────────────────
|
|
722
|
+
server.registerTool('validate_project', {
|
|
723
|
+
title: 'Validate the current project (schema + render-time warnings)',
|
|
724
|
+
outputSchema: {
|
|
725
|
+
valid: z.boolean(),
|
|
726
|
+
error_count: z.number(),
|
|
727
|
+
errors: z.array(z.string()).optional(),
|
|
728
|
+
warnings: z.array(z.string()).optional(),
|
|
729
|
+
unknown_keys: z.array(z.string()).optional(),
|
|
730
|
+
element_count: z.number().optional(),
|
|
731
|
+
duration: z.number().optional(),
|
|
732
|
+
width: z.number().optional(),
|
|
733
|
+
height: z.number().optional(),
|
|
734
|
+
},
|
|
735
|
+
annotations: { readOnlyHint: true },
|
|
736
|
+
description: 'Run the @clipkit/protocol validator against the current project AND surface render-time ' +
|
|
737
|
+
'warnings even when the JSON is valid — things that pass the schema but the runtime will ' +
|
|
738
|
+
'silently drop or clip: emoji / non-ASCII text (the runtime font atlas is ASCII-only), ' +
|
|
739
|
+
'elements that run past the composition end, a missing top-level duration. Run it before ' +
|
|
740
|
+
'you share or render the project. For a fuller timeline read-back, use describe_project.',
|
|
741
|
+
inputSchema: {
|
|
742
|
+
project_id: projectIdField,
|
|
743
|
+
},
|
|
744
|
+
}, async (args) => {
|
|
745
|
+
const p = await openProject(store, args.project_id);
|
|
746
|
+
if (!p.ok) {
|
|
747
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
748
|
+
}
|
|
749
|
+
const result = validate(p.project.source);
|
|
750
|
+
if (!result.valid) {
|
|
751
|
+
const details = result.errors
|
|
752
|
+
.map((e) => ` • ${e.path.join('.') || '(root)'}: ${e.message}`)
|
|
753
|
+
.join('\n');
|
|
754
|
+
return {
|
|
755
|
+
isError: true,
|
|
756
|
+
content: [
|
|
757
|
+
{ type: 'text', text: `Invalid. ${result.errors.length} error${result.errors.length === 1 ? '' : 's'}:\n${details}` },
|
|
758
|
+
],
|
|
759
|
+
structuredContent: {
|
|
760
|
+
valid: false,
|
|
761
|
+
error_count: result.errors.length,
|
|
762
|
+
errors: result.errors.map((e) => `${e.path.join('.') || '(root)'}: ${e.message}`),
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
const s = result.data;
|
|
767
|
+
const head = `Valid. ${s.elements.length} elements, ${s.duration ?? '?'}s, ${s.width ?? '?'}×${s.height ?? '?'}.`;
|
|
768
|
+
const warnings = lintSource(s);
|
|
769
|
+
// Unrecognized keys still sitting in the saved project — the kept/passthrough
|
|
770
|
+
// kind (stripped keys are already gone, so only these are observable here).
|
|
771
|
+
// Surfaces junk introduced earlier or via load_project that the write-time
|
|
772
|
+
// check on set_project wouldn't catch on a re-run.
|
|
773
|
+
const keyNote = unknownNote(unknownKeys(s), []);
|
|
774
|
+
if (warnings.length === 0 && !keyNote) {
|
|
775
|
+
return {
|
|
776
|
+
content: [{ type: 'text', text: `${head}\n✓ No warnings.` }],
|
|
777
|
+
structuredContent: {
|
|
778
|
+
valid: true,
|
|
779
|
+
error_count: 0,
|
|
780
|
+
warnings: [],
|
|
781
|
+
unknown_keys: [],
|
|
782
|
+
element_count: s.elements.length,
|
|
783
|
+
duration: s.duration,
|
|
784
|
+
width: s.width,
|
|
785
|
+
height: s.height,
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const warnText = warnings.length
|
|
790
|
+
? `\n\nRender-time warnings (valid, but will look wrong):\n${warnings.map((w) => ` ⚠ ${w.where}: ${w.message}`).join('\n')}`
|
|
791
|
+
: '';
|
|
792
|
+
return {
|
|
793
|
+
content: [{ type: 'text', text: `${head}${warnText}${keyNote}` }],
|
|
794
|
+
structuredContent: {
|
|
795
|
+
valid: true,
|
|
796
|
+
error_count: 0,
|
|
797
|
+
warnings: warnings.map((w) => `${w.where}: ${w.message}`),
|
|
798
|
+
unknown_keys: unknownKeys(s),
|
|
799
|
+
element_count: s.elements.length,
|
|
800
|
+
duration: s.duration,
|
|
801
|
+
width: s.width,
|
|
802
|
+
height: s.height,
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
});
|
|
806
|
+
// ─── preview_still ────────────────────────────────────────────────────────
|
|
807
|
+
server.registerTool('preview_still', {
|
|
808
|
+
title: 'Render one frame of the current project so you can SEE it',
|
|
809
|
+
outputSchema: {
|
|
810
|
+
time: z.number(),
|
|
811
|
+
width: z.number().optional(),
|
|
812
|
+
height: z.number().optional(),
|
|
813
|
+
mime_type: z.string().optional(),
|
|
814
|
+
},
|
|
815
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
816
|
+
description: 'Render a single frame of the current project to a PNG and return it as an image you can ' +
|
|
817
|
+
'look at. This is how you check your work — in chat there is no other way to see what a ' +
|
|
818
|
+
'composition actually looks like. Use it liberally: after composing, after edits, and at ' +
|
|
819
|
+
'different times to inspect motion. Stills are FREE (credits are only spent by ' +
|
|
820
|
+
'render_video). Pass `time` (seconds) to choose the frame; defaults to 0.',
|
|
821
|
+
inputSchema: {
|
|
822
|
+
time: z
|
|
823
|
+
.number()
|
|
824
|
+
.min(0)
|
|
825
|
+
.optional()
|
|
826
|
+
.describe('Composition time in seconds to capture. Default 0 (first frame).'),
|
|
827
|
+
project_id: projectIdField,
|
|
828
|
+
},
|
|
829
|
+
}, async ({ time, project_id }) => {
|
|
830
|
+
const p = await openProject(store, project_id);
|
|
831
|
+
if (!p.ok) {
|
|
832
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
833
|
+
}
|
|
834
|
+
const src = p.project.source;
|
|
835
|
+
const result = validate(src);
|
|
836
|
+
if (!result.valid) {
|
|
837
|
+
return {
|
|
838
|
+
isError: true,
|
|
839
|
+
content: [
|
|
840
|
+
{ type: 'text', text: 'Cannot preview: the project is invalid. Run validate_project to see the errors.' },
|
|
841
|
+
],
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const t = time ?? 0;
|
|
845
|
+
const still = await stillSource(src, t);
|
|
846
|
+
if ('error' in still) {
|
|
847
|
+
return {
|
|
848
|
+
isError: true,
|
|
849
|
+
content: [{ type: 'text', text: `Could not render a preview frame: ${still.error}` }],
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
content: [
|
|
854
|
+
{ type: 'text', text: `Frame at ${t}s (${still.width}×${still.height}):` },
|
|
855
|
+
{ type: 'image', data: still.data, mimeType: still.mimeType },
|
|
856
|
+
],
|
|
857
|
+
structuredContent: {
|
|
858
|
+
time: t,
|
|
859
|
+
width: still.width,
|
|
860
|
+
height: still.height,
|
|
861
|
+
mime_type: still.mimeType,
|
|
862
|
+
},
|
|
863
|
+
};
|
|
864
|
+
});
|
|
865
|
+
// ─── transcribe_to_captions ───────────────────────────────────────────────
|
|
866
|
+
// Gated: Whisper runs in a child process and needs ffmpeg + a writable FS, so
|
|
867
|
+
// hosts that can't run it (the serverless /mcp route) pass
|
|
868
|
+
// localTranscription:false and this tool isn't advertised there — see registerTools.
|
|
869
|
+
if (localTranscription) {
|
|
870
|
+
server.registerTool('transcribe_to_captions', {
|
|
871
|
+
title: 'Transcribe speech into a word-timestamped caption element',
|
|
872
|
+
outputSchema: {
|
|
873
|
+
word_count: z.number(),
|
|
874
|
+
duration_seconds: z.number(),
|
|
875
|
+
text: z.string(),
|
|
876
|
+
added: z.boolean(),
|
|
877
|
+
},
|
|
878
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
879
|
+
description: 'Transcribe an audio or video file into a word-timestamped `caption` element (the protocol ' +
|
|
880
|
+
'renders these). Captions need real per-word timings, which can only come from actual ' +
|
|
881
|
+
'speech-to-text — this runs Whisper in the server process (no API key, no third-party ' +
|
|
882
|
+
'upload). Provide a `url` (fetched server-side — use this in chat-mode, where there is no ' +
|
|
883
|
+
'local file) OR a local `path`. Requires ffmpeg on the host. By default the caption is added ' +
|
|
884
|
+
'to the current project; set add:false to only return it.',
|
|
885
|
+
inputSchema: {
|
|
886
|
+
url: z.string().url().optional().describe('Public URL of an audio/video file to fetch and transcribe. Use this OR path.'),
|
|
887
|
+
path: z.string().optional().describe('Local path to an audio/video file (Claude Desktop / local servers). Use this OR url.'),
|
|
888
|
+
model: z.string().optional().describe("Whisper model id. Default 'Xenova/whisper-base'. Use '…-tiny.en' for speed, '…-small' for accuracy."),
|
|
889
|
+
language: z.string().optional().describe('Force a language code (e.g. "en"); omit to auto-detect.'),
|
|
890
|
+
layer: z.number().int().optional().describe('Layer for the caption element (lower = nearer front, layer 1 on top). Default 3.'),
|
|
891
|
+
add: z.boolean().optional().describe('Add the caption to the current project. Default true.'),
|
|
892
|
+
project_id: projectIdField,
|
|
893
|
+
},
|
|
894
|
+
}, async (args) => {
|
|
895
|
+
// Resolve the media to a local path: download a url to a temp file, or use
|
|
896
|
+
// the given local path. (Whisper reads from the filesystem.)
|
|
897
|
+
let mediaPath = args.path;
|
|
898
|
+
let tempPath = null;
|
|
899
|
+
if (!mediaPath && args.url) {
|
|
900
|
+
try {
|
|
901
|
+
tempPath = await fetchToTemp(args.url);
|
|
902
|
+
mediaPath = tempPath;
|
|
903
|
+
}
|
|
904
|
+
catch (e) {
|
|
905
|
+
return { isError: true, content: [{ type: 'text', text: `Could not download ${args.url}: ${e instanceof Error ? e.message : String(e)}` }] };
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (!mediaPath) {
|
|
909
|
+
return { isError: true, content: [{ type: 'text', text: 'Provide either `url` (remote file) or `path` (local file) to transcribe.' }] };
|
|
910
|
+
}
|
|
911
|
+
let result;
|
|
912
|
+
try {
|
|
913
|
+
const { transcribeFile } = await import('@clipkit/speech-to-text/node');
|
|
914
|
+
result = await transcribeFile(mediaPath, { model: args.model, language: args.language });
|
|
915
|
+
}
|
|
916
|
+
catch (e) {
|
|
917
|
+
return { isError: true, content: [{ type: 'text', text: `Transcription failed: ${e instanceof Error ? e.message : String(e)}` }] };
|
|
918
|
+
}
|
|
919
|
+
finally {
|
|
920
|
+
if (tempPath)
|
|
921
|
+
await unlink(tempPath).catch(() => { });
|
|
922
|
+
}
|
|
923
|
+
const words = toCaptionWords(result);
|
|
924
|
+
if (words.length === 0) {
|
|
925
|
+
return { isError: true, content: [{ type: 'text', text: 'No speech detected in the file.' }] };
|
|
926
|
+
}
|
|
927
|
+
const element = { type: 'caption', time: 0, layer: args.layer ?? 3, words };
|
|
928
|
+
let note = '';
|
|
929
|
+
if (args.add !== false) {
|
|
930
|
+
const p = await openProject(store, args.project_id);
|
|
931
|
+
if (!p.ok) {
|
|
932
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
933
|
+
}
|
|
934
|
+
const trial = cloneSource(p.project.source);
|
|
935
|
+
trial.elements.push(element);
|
|
936
|
+
const v = validate(trial);
|
|
937
|
+
if (!v.valid) {
|
|
938
|
+
return { isError: true, content: [{ type: 'text', text: `Caption rejected by schema: ${v.errors.slice(0, 3).map((e) => `${e.path.join('.') || '(root)'}: ${e.message}`).join('; ')}` }] };
|
|
939
|
+
}
|
|
940
|
+
await p.project.save(v.data);
|
|
941
|
+
note = ' (added to the project)';
|
|
942
|
+
}
|
|
943
|
+
const preview = result.text.length > 80 ? result.text.slice(0, 80) + '…' : result.text;
|
|
944
|
+
return {
|
|
945
|
+
content: [
|
|
946
|
+
{ type: 'text', text: `Transcribed ${result.duration.toFixed(1)}s into ${words.length} words${note}: "${preview}"` },
|
|
947
|
+
{ type: 'text', text: JSON.stringify(element) },
|
|
948
|
+
],
|
|
949
|
+
structuredContent: {
|
|
950
|
+
word_count: words.length,
|
|
951
|
+
duration_seconds: result.duration,
|
|
952
|
+
text: result.text,
|
|
953
|
+
added: args.add !== false,
|
|
954
|
+
},
|
|
955
|
+
};
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
// ─── create_promo ─────────────────────────────────────────────────────────
|
|
959
|
+
server.registerTool('create_promo', {
|
|
960
|
+
title: 'Compose a designed promo from prebuilt scenes (one fast option)',
|
|
961
|
+
outputSchema: {
|
|
962
|
+
project_id: z.string(),
|
|
963
|
+
scene_count: z.number(),
|
|
964
|
+
width: z.number().optional(),
|
|
965
|
+
height: z.number().optional(),
|
|
966
|
+
duration: z.number().optional(),
|
|
967
|
+
editor_url: z.string().optional(),
|
|
968
|
+
},
|
|
969
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
970
|
+
description: 'Assemble a designed-looking promo/intro/product/data video from the Clipkit pattern ' +
|
|
971
|
+
'library: give an ordered list of SCENES and the words, and it bakes in the camera, glass, ' +
|
|
972
|
+
'lighting, motion blur, timing, and layout, then returns an editor link. This is a FAST ' +
|
|
973
|
+
'option when a conventional promo structure fits — it is NOT the only way to make a video ' +
|
|
974
|
+
'and NOT a default; for anything specific or original, author the JSON yourself and call ' +
|
|
975
|
+
'set_project (the full creative range). When you do use this, MIX scene types to fit the ' +
|
|
976
|
+
'brief and vary the structure — a video can be a single kinetic headline, three title ' +
|
|
977
|
+
'cards, a showcase montage, or a data explainer; you do NOT need a hero or a cta. ' +
|
|
978
|
+
'Scene types: ' +
|
|
979
|
+
'hero (glass-orb logo reveal: wordmark, tagline?), ' +
|
|
980
|
+
'kinetic (letter-fly headline: text, subtitle?), ' +
|
|
981
|
+
'showcase (a screenshot tilted in 3D: screenshot URL), ' +
|
|
982
|
+
'title (full-frame title card: headline, kicker?, subtitle?), ' +
|
|
983
|
+
'cta (closing card with a glass button: wordmark, tagline?, cta), ' +
|
|
984
|
+
'stats (hero numbers: stats[{label,current,previous?}], title?), ' +
|
|
985
|
+
'bars (bar chart: bars[{label,value,previous?}], title?), ' +
|
|
986
|
+
'ranking (top-N list: items[{label,value}], title?), ' +
|
|
987
|
+
'pie (pie cards: cards[{label,value,total,previous?}], title?). ' +
|
|
988
|
+
'The data scenes (stats/bars/ranking/pie) look best with theme "mux".',
|
|
989
|
+
inputSchema: {
|
|
990
|
+
scenes: z
|
|
991
|
+
.array(sceneSchema)
|
|
992
|
+
.min(1)
|
|
993
|
+
.describe('Ordered scenes; mix types to fit the brief — you do NOT need hero/cta. e.g. a single [{type:"kinetic",text:"…"}], a sequence [{type:"title",headline:"…"},{type:"showcase",screenshot:"…"},{type:"title",headline:"…"}], or a data piece [{type:"ranking",title:"…",items:[…]}]'),
|
|
994
|
+
theme: z.enum(['cinematic', 'mux', 'minimal']).optional().describe('Visual theme. Default "cinematic" (dark, serif, premium).'),
|
|
995
|
+
motion_blur: z.number().int().min(0).max(32).optional().describe('Supersampled motion-blur samples (≥2 enables it — nicer but slower to render). Default off.'),
|
|
996
|
+
width: z.number().int().positive().optional().describe('Default 1920.'),
|
|
997
|
+
height: z.number().int().positive().optional().describe('Default 1080.'),
|
|
998
|
+
project_id: projectIdField,
|
|
999
|
+
},
|
|
1000
|
+
}, async (args) => {
|
|
1001
|
+
const scenes = args.scenes.map((s) => ({
|
|
1002
|
+
duration: s.duration ?? DEFAULT_DURATION[s.type],
|
|
1003
|
+
build: (ctx) => buildSceneElement(s, ctx),
|
|
1004
|
+
}));
|
|
1005
|
+
const source = promo({
|
|
1006
|
+
theme: args.theme ?? 'cinematic',
|
|
1007
|
+
scenes,
|
|
1008
|
+
width: args.width ?? 1920,
|
|
1009
|
+
height: args.height ?? 1080,
|
|
1010
|
+
...(args.motion_blur !== undefined ? { motionBlur: args.motion_blur } : {}),
|
|
1011
|
+
});
|
|
1012
|
+
const id = await store.put(args.project_id, source);
|
|
1013
|
+
const dims = `${source.width}×${source.height}, ${source.duration}s, project_id: ${id}`;
|
|
1014
|
+
const shared = await editorLinkFor(store, id, source);
|
|
1015
|
+
if ('error' in shared) {
|
|
1016
|
+
return {
|
|
1017
|
+
content: [{ type: 'text', text: `Composed a ${scenes.length}-scene promo (${dims}). Could not create a share link (${shared.error}). The full source is available via get_project.` }],
|
|
1018
|
+
structuredContent: {
|
|
1019
|
+
project_id: id,
|
|
1020
|
+
scene_count: scenes.length,
|
|
1021
|
+
width: source.width,
|
|
1022
|
+
height: source.height,
|
|
1023
|
+
duration: source.duration,
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
return {
|
|
1028
|
+
content: [{ type: 'text', text: `Composed a ${scenes.length}-scene promo (${dims}).\n\nOpen it in the editor:\n${shared.url}` }],
|
|
1029
|
+
structuredContent: {
|
|
1030
|
+
project_id: id,
|
|
1031
|
+
scene_count: scenes.length,
|
|
1032
|
+
width: source.width,
|
|
1033
|
+
height: source.height,
|
|
1034
|
+
duration: source.duration,
|
|
1035
|
+
editor_url: shared.url,
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
});
|
|
1039
|
+
// ─── open_in_editor ───────────────────────────────────────────────────────
|
|
1040
|
+
server.registerTool('open_in_editor', {
|
|
1041
|
+
title: 'Create a shareable link that opens the current project in the editor',
|
|
1042
|
+
outputSchema: {
|
|
1043
|
+
editor_url: z.string(),
|
|
1044
|
+
project_id: z.string(),
|
|
1045
|
+
},
|
|
1046
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1047
|
+
description: 'Validate the current project and create a link that opens it in the Clipkit web editor, ' +
|
|
1048
|
+
'where the user can preview and refine it. This shares the PROJECT (nothing is rendered — ' +
|
|
1049
|
+
"that's render_video). Use after composing or editing. Returns a URL.",
|
|
1050
|
+
inputSchema: {
|
|
1051
|
+
project_id: projectIdField,
|
|
1052
|
+
},
|
|
1053
|
+
}, async (args) => {
|
|
1054
|
+
const p = await openProject(store, args.project_id);
|
|
1055
|
+
if (!p.ok) {
|
|
1056
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
1057
|
+
}
|
|
1058
|
+
const src = p.project.source;
|
|
1059
|
+
const result = validate(src);
|
|
1060
|
+
if (!result.valid) {
|
|
1061
|
+
return {
|
|
1062
|
+
isError: true,
|
|
1063
|
+
content: [{ type: 'text', text: 'Cannot open in the editor: the project is invalid. Run validate_project to see the errors.' }],
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
const link = await editorLinkFor(store, p.project.id, src);
|
|
1067
|
+
if ('error' in link) {
|
|
1068
|
+
return { isError: true, content: [{ type: 'text', text: `Could not create an editor link: ${link.error}` }] };
|
|
1069
|
+
}
|
|
1070
|
+
return {
|
|
1071
|
+
content: [{ type: 'text', text: `Open in the editor:\n${link.url}` }],
|
|
1072
|
+
structuredContent: { editor_url: link.url, project_id: p.project.id },
|
|
1073
|
+
};
|
|
1074
|
+
});
|
|
1075
|
+
// ─── load_project ─────────────────────────────────────────────────────────
|
|
1076
|
+
server.registerTool('load_project', {
|
|
1077
|
+
title: 'Load a shared project back into the session',
|
|
1078
|
+
outputSchema: {
|
|
1079
|
+
project_id: z.string(),
|
|
1080
|
+
element_count: z.number(),
|
|
1081
|
+
width: z.number().optional(),
|
|
1082
|
+
height: z.number().optional(),
|
|
1083
|
+
duration: z.number().optional(),
|
|
1084
|
+
},
|
|
1085
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1086
|
+
description: 'Import a previously shared project as the current project, by its share id or its editor ' +
|
|
1087
|
+
'URL (e.g. https://clipkit.dev/editor?id=…), returning its project_id. Use this to continue ' +
|
|
1088
|
+
'working on a project the user opened in the editor or shared earlier — the round-trip for ' +
|
|
1089
|
+
'open_in_editor. Pass an existing project_id to load into that project; omit it to load into ' +
|
|
1090
|
+
'a new one.',
|
|
1091
|
+
inputSchema: {
|
|
1092
|
+
id_or_url: z.string().min(1).describe('A share id, or a clipkit.dev/editor?id=… URL.'),
|
|
1093
|
+
project_id: projectIdField,
|
|
1094
|
+
},
|
|
1095
|
+
}, async (args) => {
|
|
1096
|
+
const loaded = await loadShare(args.id_or_url);
|
|
1097
|
+
if ('error' in loaded) {
|
|
1098
|
+
return { isError: true, content: [{ type: 'text', text: `Could not load project: ${loaded.error}` }] };
|
|
1099
|
+
}
|
|
1100
|
+
const result = validate(loaded.source);
|
|
1101
|
+
if (!result.valid) {
|
|
1102
|
+
const summary = result.errors
|
|
1103
|
+
.slice(0, 3)
|
|
1104
|
+
.map((e) => `${e.path.join('.') || '(root)'}: ${e.message}`)
|
|
1105
|
+
.join('; ');
|
|
1106
|
+
return { isError: true, content: [{ type: 'text', text: `The loaded project did not validate: ${summary}` }] };
|
|
1107
|
+
}
|
|
1108
|
+
const id = await store.put(args.project_id, result.data);
|
|
1109
|
+
const note = unknownNote(unknownKeys(result.data), []);
|
|
1110
|
+
return {
|
|
1111
|
+
content: [
|
|
1112
|
+
{
|
|
1113
|
+
type: 'text',
|
|
1114
|
+
text: `Loaded project (project_id: ${id}). ${result.data.elements.length} elements, ${result.data.duration ?? '?'}s, ${result.data.width ?? '?'}×${result.data.height ?? '?'}.${note}`,
|
|
1115
|
+
},
|
|
1116
|
+
],
|
|
1117
|
+
structuredContent: {
|
|
1118
|
+
project_id: id,
|
|
1119
|
+
element_count: result.data.elements.length,
|
|
1120
|
+
width: result.data.width,
|
|
1121
|
+
height: result.data.height,
|
|
1122
|
+
duration: result.data.duration,
|
|
1123
|
+
},
|
|
1124
|
+
};
|
|
1125
|
+
});
|
|
1126
|
+
// ─── render_video ─────────────────────────────────────────────────────────
|
|
1127
|
+
server.registerTool('render_video', {
|
|
1128
|
+
title: 'Render the current project to an MP4 in the cloud',
|
|
1129
|
+
outputSchema: {
|
|
1130
|
+
download_url: z.string(),
|
|
1131
|
+
credits: z.number(),
|
|
1132
|
+
duration_seconds: z.number(),
|
|
1133
|
+
},
|
|
1134
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1135
|
+
description: 'Validate the current Clipkit project and render it to a finished MP4 on Clipkit\'s servers, ' +
|
|
1136
|
+
'returning a downloadable URL. This is the paid path — it consumes render credits and requires ' +
|
|
1137
|
+
'CLIPKIT_API_KEY to be configured. Use open_in_editor instead to just open the project in the editor ' +
|
|
1138
|
+
'for free. Rendering is synchronous and may take a while for long or high-resolution videos.',
|
|
1139
|
+
inputSchema: {
|
|
1140
|
+
resolution: z
|
|
1141
|
+
.enum(['source', '720p', '1080p', '1440p', '4k'])
|
|
1142
|
+
.optional()
|
|
1143
|
+
.describe('Output resolution. Defaults to the source dimensions. Higher resolutions cost more credits.'),
|
|
1144
|
+
project_id: projectIdField,
|
|
1145
|
+
},
|
|
1146
|
+
}, async ({ resolution, project_id }) => {
|
|
1147
|
+
const p = await openProject(store, project_id);
|
|
1148
|
+
if (!p.ok) {
|
|
1149
|
+
return { isError: true, content: [{ type: 'text', text: p.error }] };
|
|
1150
|
+
}
|
|
1151
|
+
const src = p.project.source;
|
|
1152
|
+
const result = validate(src);
|
|
1153
|
+
if (!result.valid) {
|
|
1154
|
+
return {
|
|
1155
|
+
isError: true,
|
|
1156
|
+
content: [{ type: 'text', text: 'Cannot render: the project is invalid. Run validate_project to see the errors.' }],
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
const rendered = await renderSource(src, resolution ? { resolution } : {});
|
|
1160
|
+
if ('error' in rendered) {
|
|
1161
|
+
return { isError: true, content: [{ type: 'text', text: `Render failed: ${rendered.error}` }] };
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
content: [
|
|
1165
|
+
{
|
|
1166
|
+
type: 'text',
|
|
1167
|
+
text: `Rendered in ${(rendered.durationMs / 1000).toFixed(1)}s for ${rendered.credits} credit${rendered.credits === 1 ? '' : 's'}.\n\nDownload (link valid ~1 hour):\n${rendered.url}`,
|
|
1168
|
+
},
|
|
1169
|
+
],
|
|
1170
|
+
structuredContent: {
|
|
1171
|
+
download_url: rendered.url,
|
|
1172
|
+
credits: rendered.credits,
|
|
1173
|
+
duration_seconds: rendered.durationMs / 1000,
|
|
1174
|
+
},
|
|
1175
|
+
};
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
//# sourceMappingURL=tools.js.map
|