@viralspin/mcp 0.2.0 → 0.2.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/client.js +8 -0
  4. package/dist/engine.js +29 -0
  5. package/dist/hosted.js +159 -0
  6. package/dist/index.js +62 -16
  7. package/dist/sanitize.js +22 -0
  8. package/dist/tools/assembly-adjust.js +107 -15
  9. package/dist/tools/carousel-video.js +7 -0
  10. package/dist/tools/color-grade.js +64 -0
  11. package/dist/tools/connections.js +23 -4
  12. package/dist/tools/edit-clip.js +136 -0
  13. package/dist/tools/engage.js +13 -4
  14. package/dist/tools/explainer-index.js +6 -0
  15. package/dist/tools/find-moments.js +140 -0
  16. package/dist/tools/folder-preview.js +71 -0
  17. package/dist/tools/ideas.js +158 -0
  18. package/dist/tools/kinetic-text.js +6 -2
  19. package/dist/tools/local-index.js +84 -0
  20. package/dist/tools/local-mode.js +84 -0
  21. package/dist/tools/profile.js +56 -0
  22. package/dist/tools/publish.js +34 -10
  23. package/dist/tools/relight-clip.js +98 -0
  24. package/dist/tools/timeline-edit.js +367 -0
  25. package/dist/tools/video-assembly.js +131 -4
  26. package/package.json +41 -29
  27. package/BUNDLE_LOCAL_SPEC.md +0 -133
  28. package/engine/assemble.mjs +0 -476
  29. package/engine/gemini.mjs +0 -164
  30. package/engine/kinetic.mjs +0 -84
  31. package/engine/prompts.mjs +0 -94
  32. package/engine/remotion.config.ts +0 -4
  33. package/engine/src/Assembly.tsx +0 -330
  34. package/engine/src/KineticText.tsx +0 -253
  35. package/engine/src/Root.tsx +0 -39
  36. package/engine/src/index.ts +0 -4
  37. package/engine/tsconfig.json +0 -15
  38. package/src/client.ts +0 -119
  39. package/src/index.ts +0 -151
  40. package/src/tools/assembly-adjust.ts +0 -110
  41. package/src/tools/captions.ts +0 -32
  42. package/src/tools/carousel-video.ts +0 -211
  43. package/src/tools/connections.ts +0 -27
  44. package/src/tools/engage.ts +0 -105
  45. package/src/tools/explainer-index.ts +0 -298
  46. package/src/tools/image-ads.ts +0 -192
  47. package/src/tools/kinetic-text.ts +0 -130
  48. package/src/tools/photo-picker.ts +0 -31
  49. package/src/tools/posts.ts +0 -58
  50. package/src/tools/publish.ts +0 -236
  51. package/src/tools/review.ts +0 -126
  52. package/src/tools/video-assembly.ts +0 -205
  53. package/tsconfig.json +0 -15
@@ -0,0 +1,367 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.keepWindowsTool = exports.removeSpansTool = exports.getTranscriptTool = exports.trimSilencesTool = exports.renderProjectTool = exports.reorderScenesTool = exports.removeSpanTool = exports.trimClipTool = void 0;
4
+ /**
5
+ * Granular timeline edit verbs for a HOSTED Swap & Refine project (a narrative
6
+ * job). These widen Claude's editing vocabulary beyond swap — trim a cutaway,
7
+ * cut a span out of the cut, and reorder the A-roll's backbone scenes — each
8
+ * mapped to an already-deployed narrative endpoint, so the edit lands in the
9
+ * same project the web editor shows (and streams in live via realtime).
10
+ *
11
+ * All are hosted-only: they take a `job_id` (or VIRALSPIN_HOSTED_JOB_ID). There
12
+ * is no local-engine equivalent for these ops.
13
+ */
14
+ const hosted_js_1 = require("../hosted.js");
15
+ function fmt(sec) {
16
+ const m = Math.floor(sec / 60);
17
+ const s = Math.round(sec % 60);
18
+ return `${m}:${s.toString().padStart(2, "0")}`;
19
+ }
20
+ exports.trimClipTool = {
21
+ name: "trim_clip",
22
+ description: "Shorten (or restore) how long ONE B-roll cutaway stays on screen in a hosted Swap & Refine project, " +
23
+ "freeing that time back to the A-roll. Audio-safe — total video length is preserved. " +
24
+ "Get the segment id from suggest_clips. Instant timeline edit by default (no render); call render_project when done.",
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {
28
+ job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
29
+ segment_id: { type: "string", description: "The cutaway to trim (from suggest_clips)." },
30
+ duration_sec: { type: "number", description: "New on-screen duration in seconds (e.g. 1.5)." },
31
+ render: { type: "boolean", description: "Re-render now (costs credits). Default false = instant timeline-only edit." },
32
+ },
33
+ required: ["segment_id", "duration_sec"],
34
+ },
35
+ async execute(args) {
36
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
37
+ if (!jobId)
38
+ return "trim_clip is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
39
+ await (0, hosted_js_1.setClipDuration)(jobId, args.segment_id, args.duration_sec);
40
+ if (args.render) {
41
+ await (0, hosted_js_1.rerender)(jobId);
42
+ return `Trimmed segment ${args.segment_id} to ${args.duration_sec}s and started a re-render. ${(0, hosted_js_1.editorUrl)(jobId)}`;
43
+ }
44
+ return (`Trimmed segment ${args.segment_id} to ${args.duration_sec}s (instant, no render). ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
45
+ `Call render_project(job_id: "${jobId}") when the cut is final.`);
46
+ },
47
+ };
48
+ exports.removeSpanTool = {
49
+ name: "remove_span",
50
+ description: "Cut a span of time [from_sec, to_sec] out of a hosted Swap & Refine video — e.g. a duplicated opening " +
51
+ "sentence or a dead pause. The backbone A-roll is trimmed and the rest shifts up; reuses cached B-roll. " +
52
+ "Re-renders; streams into the editor.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
57
+ from_sec: { type: "number", description: "Start of the span to remove (seconds)." },
58
+ to_sec: { type: "number", description: "End of the span to remove (seconds)." },
59
+ },
60
+ required: ["from_sec", "to_sec"],
61
+ },
62
+ async execute(args) {
63
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
64
+ if (!jobId)
65
+ return "remove_span is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
66
+ if (!(args.to_sec > args.from_sec))
67
+ return "to_sec must be greater than from_sec.";
68
+ await (0, hosted_js_1.removeSpan)(jobId, args.from_sec, args.to_sec, true);
69
+ return (`Removing ${fmt(args.from_sec)}–${fmt(args.to_sec)} and re-rendering.\n` +
70
+ `Updating live: ${(0, hosted_js_1.editorUrl)(jobId)}`);
71
+ },
72
+ };
73
+ exports.reorderScenesTool = {
74
+ name: "reorder_scenes",
75
+ description: "See or reorder the A-roll's backbone scenes in a hosted Swap & Refine project. " +
76
+ "Call with no `order` to LIST the scenes (id + label + time) — the reorderable building blocks. " +
77
+ "Call with `order` (the scene ids in the new running order; omit ids to drop them) to apply it and re-render.",
78
+ inputSchema: {
79
+ type: "object",
80
+ properties: {
81
+ job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
82
+ order: {
83
+ type: "array",
84
+ items: { type: "string" },
85
+ description: "New running order as scene ids. Any scene id omitted here is removed from the cut. Omit `order` entirely to just list the scenes.",
86
+ },
87
+ render: { type: "boolean", description: "Re-render now (costs credits). Default false = instant timeline-only edit." },
88
+ },
89
+ },
90
+ async execute(args) {
91
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
92
+ if (!jobId)
93
+ return "reorder_scenes is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
94
+ const { scenes } = await (0, hosted_js_1.detectScenes)(jobId);
95
+ if (!scenes.length)
96
+ return `No backbone scenes detected for project ${jobId} yet. Open it: ${(0, hosted_js_1.editorUrl)(jobId)}`;
97
+ if (!args.order || !args.order.length) {
98
+ const lines = [...scenes]
99
+ .sort((a, b) => a.order - b.order)
100
+ .map((s) => `- ${s.id}${s.label ? ` — "${s.label}"` : ""}${s.src_start != null ? ` (${fmt(s.src_start)}${s.src_end != null ? `–${fmt(s.src_end)}` : ""})` : ""}`);
101
+ return [
102
+ `Backbone scenes in ${(0, hosted_js_1.editorUrl)(jobId)} :`,
103
+ ...lines,
104
+ ``,
105
+ `Reorder with: reorder_scenes(job_id: "${jobId}", order: ["${scenes[0].id}", …]). Omit a scene id to drop it.`,
106
+ ].join("\n");
107
+ }
108
+ const known = new Set(scenes.map((s) => s.id));
109
+ const wanted = args.order.filter((id) => known.has(id));
110
+ if (!wanted.length)
111
+ return "None of the given ids matched this project's scenes — call reorder_scenes with no `order` to list valid ids.";
112
+ const payload = scenes.map((s) => {
113
+ const idx = wanted.indexOf(s.id);
114
+ return { id: s.id, order: idx === -1 ? 9999 : idx, removed: idx === -1 };
115
+ });
116
+ await (0, hosted_js_1.saveSceneOrder)(jobId, payload);
117
+ const dropped = scenes.length - wanted.length;
118
+ if (args.render) {
119
+ await (0, hosted_js_1.rerender)(jobId);
120
+ return `Reordered to ${wanted.length} scene(s)${dropped ? ` (dropped ${dropped})` : ""} and started a re-render. ${(0, hosted_js_1.editorUrl)(jobId)}`;
121
+ }
122
+ return (`Reordered to ${wanted.length} scene(s)${dropped ? ` (dropped ${dropped})` : ""} (instant, no render). ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
123
+ `Call render_project(job_id: "${jobId}") when the cut is final.`);
124
+ },
125
+ };
126
+ exports.renderProjectTool = {
127
+ name: "render_project",
128
+ description: "Bake the current hosted Swap & Refine timeline into a final MP4 — the ONE cloud render after a batch of " +
129
+ "instant timeline edits (swap_clip / trim_clip / reorder_scenes). Captions, hook, overlays and motion are " +
130
+ "applied here. Costs a render/credits. Returns immediately; the finished preview lands on the project, then " +
131
+ "publish with publish_now or schedule_post.",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: {
135
+ job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
136
+ },
137
+ },
138
+ async execute(args) {
139
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
140
+ if (!jobId)
141
+ return "render_project is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
142
+ await (0, hosted_js_1.rerender)(jobId);
143
+ return (`Rendering the final cut for project ${jobId}.\n` +
144
+ `Watch it in the editor: ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
145
+ `When it finishes, publish with publish_now or schedule_post.`);
146
+ },
147
+ };
148
+ exports.trimSilencesTool = {
149
+ name: "trim_silences",
150
+ description: "Edit out the dead air / empty pauses in a hosted Swap & Refine project. Reads the transcript word " +
151
+ "timings, finds the silent gaps between spoken words (plus a long lead-in / trailing pause), and removes " +
152
+ "them to tighten the cut. It re-cuts the A-roll once per gap, so it can take a couple of minutes on a " +
153
+ "long clip — it does the BIGGEST gaps first (capped by max_cuts). Needs a transcribed talking-head A-roll.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
158
+ min_gap_sec: { type: "number", description: "Smallest pause to treat as dead air (default 0.7s). Lower = tighter." },
159
+ max_cuts: { type: "number", description: "Safety cap on how many gaps to remove this pass (default 12, biggest first)." },
160
+ },
161
+ },
162
+ async execute(args) {
163
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
164
+ if (!jobId)
165
+ return "trim_silences is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
166
+ const minGap = args.min_gap_sec ?? 0.7;
167
+ const maxCuts = args.max_cuts ?? 12;
168
+ const PAD = 0.1;
169
+ const job = await (0, hosted_js_1.getJob)(jobId);
170
+ const tl = job.timeline || {};
171
+ const words = Array.isArray(tl.words) ? tl.words : [];
172
+ if (words.length < 2) {
173
+ return "No transcript word-timings on this project, so I can't detect silences (needs a transcribed talking-head A-roll).";
174
+ }
175
+ const total = Number(tl.total_duration_sec) || Number(words[words.length - 1]?.end) || 0;
176
+ const gaps = [];
177
+ // long lead-in pause
178
+ const firstStart = Number(words[0]?.start) || 0;
179
+ if (firstStart >= minGap + PAD)
180
+ gaps.push({ from: PAD, to: +(firstStart - PAD).toFixed(2), len: firstStart });
181
+ // gaps between words
182
+ for (let i = 0; i < words.length - 1; i++) {
183
+ const a = Number(words[i]?.end), b = Number(words[i + 1]?.start);
184
+ if (!Number.isFinite(a) || !Number.isFinite(b))
185
+ continue;
186
+ const len = b - a;
187
+ if (len >= minGap)
188
+ gaps.push({ from: +(a + PAD).toFixed(2), to: +(b - PAD).toFixed(2), len });
189
+ }
190
+ // trailing pause
191
+ const lastEnd = Number(words[words.length - 1]?.end) || 0;
192
+ if (total - lastEnd >= minGap + PAD)
193
+ gaps.push({ from: +(lastEnd + PAD).toFixed(2), to: +total.toFixed(2), len: total - lastEnd });
194
+ const valid = gaps.filter((g) => g.to - g.from >= 0.2);
195
+ if (!valid.length)
196
+ return `No silences ≥ ${minGap}s found — the cut is already tight.`;
197
+ // biggest gaps first (within the cap), then remove latest→earliest so each
198
+ // removal doesn't invalidate the earlier gaps' timestamps.
199
+ const chosen = [...valid].sort((a, b) => b.len - a.len).slice(0, maxCuts).sort((a, b) => b.from - a.from);
200
+ let removed = 0;
201
+ let ok = 0;
202
+ for (const g of chosen) {
203
+ try {
204
+ await (0, hosted_js_1.removeSpan)(jobId, g.from, g.to, false);
205
+ removed += g.to - g.from;
206
+ ok++;
207
+ }
208
+ catch (e) {
209
+ process.stderr.write(`[trim_silences] span ${g.from}-${g.to} failed: ${e instanceof Error ? e.message : e}\n`);
210
+ }
211
+ }
212
+ if (!ok)
213
+ return "Couldn't trim the silences — the backend rejected the spans. The project may not allow span removal yet.";
214
+ await (0, hosted_js_1.rerender)(jobId);
215
+ const more = valid.length > chosen.length ? ` (${valid.length - chosen.length} smaller pause(s) left — run again or lower min_gap_sec)` : "";
216
+ return (`Trimmed ${ok} silent gap(s) — about ${removed.toFixed(1)}s of dead air${more} — and started a re-render.\n` +
217
+ `Watch it tighten in the editor: ${(0, hosted_js_1.editorUrl)(jobId)}`);
218
+ },
219
+ };
220
+ exports.getTranscriptTool = {
221
+ name: "get_transcript",
222
+ description: "Get the timestamped transcript of a hosted Swap & Refine project so YOU (the agent) can spot what to cut " +
223
+ "— bad takes, restarts ('let me start over'), repeated sentences, filler — or which moments are worth " +
224
+ "highlighting in a long talking/narrated video. Returns spoken segments with start/end times. Then act: " +
225
+ "remove_spans to cut bad parts, or keep_windows to keep only the highlight moments.",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
230
+ },
231
+ },
232
+ async execute(args) {
233
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
234
+ if (!jobId)
235
+ return "get_transcript is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
236
+ const job = await (0, hosted_js_1.getJob)(jobId);
237
+ const tl = job.timeline || {};
238
+ let segs = [];
239
+ if (Array.isArray(tl.transcript) && tl.transcript.length) {
240
+ segs = tl.transcript.map((s) => ({ a: Number(s.start_sec ?? s.start ?? 0), b: Number(s.end_sec ?? s.end ?? 0), text: String(s.text ?? "").trim() }));
241
+ }
242
+ else if (Array.isArray(tl.words) && tl.words.length) {
243
+ // group words into phrases on pauses > 0.5s
244
+ let cur = null;
245
+ for (const w of tl.words) {
246
+ const a = Number(w.start), b = Number(w.end), t = String(w.word ?? w.text ?? "");
247
+ if (!Number.isFinite(a))
248
+ continue;
249
+ if (cur && a - cur.b > 0.5) {
250
+ segs.push(cur);
251
+ cur = null;
252
+ }
253
+ if (!cur)
254
+ cur = { a, b, text: t };
255
+ else {
256
+ cur.b = b;
257
+ cur.text += (t.startsWith("'") ? "" : " ") + t;
258
+ }
259
+ }
260
+ if (cur)
261
+ segs.push(cur);
262
+ }
263
+ if (!segs.length)
264
+ return "No transcript on this project (needs a transcribed talking-head/narrated A-roll).";
265
+ const lines = segs.map((s) => `[${s.a.toFixed(1)}–${s.b.toFixed(1)}] ${s.text}`);
266
+ return [
267
+ `Transcript for ${jobId} (spot restarts / repeats / filler to cut, or highlight moments to keep):`,
268
+ ...lines,
269
+ ``,
270
+ `Cut bad spans: remove_spans(job_id: "${jobId}", spans: [{from_sec, to_sec}, …]).`,
271
+ `Keep only highlights: keep_windows(job_id: "${jobId}", windows: [{from_sec, to_sec}, …]).`,
272
+ ].join("\n");
273
+ },
274
+ };
275
+ /** Remove several spans (latest→earliest so earlier coords stay valid), one render. */
276
+ async function removeMany(jobId, spans) {
277
+ const ordered = [...spans].filter((s) => s.to - s.from >= 0.15).sort((a, b) => b.from - a.from);
278
+ let ok = 0, removed = 0;
279
+ for (const s of ordered) {
280
+ try {
281
+ await (0, hosted_js_1.removeSpan)(jobId, +s.from.toFixed(2), +s.to.toFixed(2), false);
282
+ removed += s.to - s.from;
283
+ ok++;
284
+ }
285
+ catch (e) {
286
+ process.stderr.write(`[removeMany] ${s.from}-${s.to} failed: ${e instanceof Error ? e.message : e}\n`);
287
+ }
288
+ }
289
+ return { ok, removed };
290
+ }
291
+ exports.removeSpansTool = {
292
+ name: "remove_spans",
293
+ description: "Cut SEVERAL time spans out of a hosted project in one pass (e.g. bad takes / restarts you found via " +
294
+ "get_transcript), then re-render once. Spans are seconds of the current cut. Re-cuts the A-roll per span, " +
295
+ "so several takes a minute or two.",
296
+ inputSchema: {
297
+ type: "object",
298
+ properties: {
299
+ job_id: { type: "string", description: "Project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
300
+ spans: {
301
+ type: "array",
302
+ items: { type: "object", properties: { from_sec: { type: "number" }, to_sec: { type: "number" } }, required: ["from_sec", "to_sec"] },
303
+ description: "Spans to cut, each { from_sec, to_sec }.",
304
+ },
305
+ },
306
+ required: ["spans"],
307
+ },
308
+ async execute(args) {
309
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
310
+ if (!jobId)
311
+ return "remove_spans is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
312
+ const spans = (args.spans || []).filter((s) => s && s.to_sec > s.from_sec).map((s) => ({ from: +s.from_sec, to: +s.to_sec }));
313
+ if (!spans.length)
314
+ return "No valid spans (each needs to_sec > from_sec).";
315
+ const { ok, removed } = await removeMany(jobId, spans);
316
+ if (!ok)
317
+ return "Couldn't remove the spans — the backend rejected them.";
318
+ await (0, hosted_js_1.rerender)(jobId);
319
+ return `Removed ${ok} span(s) — about ${removed.toFixed(1)}s — and started a re-render.\n${(0, hosted_js_1.editorUrl)(jobId)}`;
320
+ },
321
+ };
322
+ exports.keepWindowsTool = {
323
+ name: "keep_windows",
324
+ description: "Turn a long video into a highlight reel: KEEP only the given time windows and cut everything else, then " +
325
+ "re-render. Great for 'find the worth-highlighting moments in this 10-min recording' — get_transcript, " +
326
+ "pick the punchy windows, then keep_windows. Windows are seconds of the current cut.",
327
+ inputSchema: {
328
+ type: "object",
329
+ properties: {
330
+ job_id: { type: "string", description: "Project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
331
+ windows: {
332
+ type: "array",
333
+ items: { type: "object", properties: { from_sec: { type: "number" }, to_sec: { type: "number" } }, required: ["from_sec", "to_sec"] },
334
+ description: "The moments to KEEP, each { from_sec, to_sec }. Everything outside them is cut.",
335
+ },
336
+ },
337
+ required: ["windows"],
338
+ },
339
+ async execute(args) {
340
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
341
+ if (!jobId)
342
+ return "keep_windows is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
343
+ const wins = (args.windows || []).filter((w) => w && w.to_sec > w.from_sec).map((w) => ({ from: +w.from_sec, to: +w.to_sec })).sort((a, b) => a.from - b.from);
344
+ if (!wins.length)
345
+ return "No valid windows to keep (each needs to_sec > from_sec).";
346
+ const job = await (0, hosted_js_1.getJob)(jobId);
347
+ const total = Number(job.timeline?.total_duration_sec) || wins[wins.length - 1].to;
348
+ // complement = everything outside the kept windows
349
+ const cuts = [];
350
+ if (wins[0].from > 0.15)
351
+ cuts.push({ from: 0, to: wins[0].from });
352
+ for (let i = 0; i < wins.length - 1; i++) {
353
+ if (wins[i + 1].from - wins[i].to > 0.15)
354
+ cuts.push({ from: wins[i].to, to: wins[i + 1].from });
355
+ }
356
+ if (total - wins[wins.length - 1].to > 0.15)
357
+ cuts.push({ from: wins[wins.length - 1].to, to: total });
358
+ if (!cuts.length)
359
+ return "The windows already cover the whole video — nothing to cut.";
360
+ const { ok, removed } = await removeMany(jobId, cuts);
361
+ if (!ok)
362
+ return "Couldn't trim to the windows — the backend rejected the spans.";
363
+ await (0, hosted_js_1.rerender)(jobId);
364
+ const kept = wins.reduce((s, w) => s + (w.to - w.from), 0);
365
+ return `Kept ${wins.length} highlight window(s) (~${kept.toFixed(1)}s), cut ~${removed.toFixed(1)}s, and started a re-render.\n${(0, hosted_js_1.editorUrl)(jobId)}`;
366
+ },
367
+ };
@@ -5,17 +5,58 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createVideoAssemblyTool = void 0;
7
7
  const child_process_1 = require("child_process");
8
+ const fs_1 = __importDefault(require("fs"));
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const client_js_1 = require("../client.js");
10
- // The assembly engine ships inside this package (mcp/engine). Override with env if needed.
11
- // __dirname at runtime = mcp/dist/tools → ../../engine = mcp/engine.
12
- const ENGINE_DIR = process.env.VIRALSPIN_ASSEMBLY_DIR || path_1.default.resolve(__dirname, "../../engine");
11
+ const hosted_js_1 = require("../hosted.js");
12
+ const engine_js_1 = require("../engine.js");
13
+ const MEDIA_EXT = new Set([".mp4", ".mov", ".webm", ".m4v", ".jpg", ".jpeg", ".png", ".gif", ".webp"]);
14
+ /** Split B-roll entries into local files (expanding folders) and public URLs.
15
+ * Runs on the MCP host, so it can read the user's disk to expand a folder. */
16
+ function partitionBroll(entries) {
17
+ const localFiles = [];
18
+ const urls = [];
19
+ for (const e of entries) {
20
+ if (/^https?:\/\//i.test(e)) {
21
+ urls.push(e);
22
+ continue;
23
+ }
24
+ try {
25
+ const st = fs_1.default.statSync(e);
26
+ if (st.isDirectory()) {
27
+ for (const name of fs_1.default.readdirSync(e)) {
28
+ if (MEDIA_EXT.has(path_1.default.extname(name).toLowerCase()))
29
+ localFiles.push(path_1.default.join(e, name));
30
+ }
31
+ }
32
+ else if (st.isFile()) {
33
+ localFiles.push(e);
34
+ }
35
+ }
36
+ catch { /* unreadable path — skip */ }
37
+ }
38
+ return { localFiles, urls };
39
+ }
40
+ const VIDEO_EXT = /\.(mp4|mov|webm|m4v)(\?|$)/i;
41
+ function brollKind(url) {
42
+ return VIDEO_EXT.test(url) ? "video" : "image";
43
+ }
13
44
  const BROLL_STYLE_KEYS = ["sketchbook", "cinematic_minimal", "dark_studio", "vibrant_illustrated"];
14
45
  const CAPTION_STYLE_KEYS = ["auto", "default", "hormozi", "glow_neon", "minimalist", "whiteboard"];
46
+ /** Baked template recipes (mirrors src/data/videoTemplates.ts). Selecting one from
47
+ * the gallery seeds these defaults so the user doesn't describe the style. */
48
+ const VIDEO_TEMPLATES = {
49
+ screen_demo: {
50
+ output_format: "9x16",
51
+ caption_style: "hormozi",
52
+ direction: "Screen-demo style: fast caption-driven cuts on the beat, punch-in zoom to exactly where the on-screen " +
53
+ "action is each cut, meme captions, brand end card.",
54
+ },
55
+ };
15
56
  /** Run the engine orchestrator, streaming its logs to stderr, and capture RESULT:<path>. */
16
57
  function runEngine(args, extraEnv = {}) {
17
58
  return new Promise((resolve, reject) => {
18
- const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(ENGINE_DIR, "assemble.mjs"), ...args], { cwd: ENGINE_DIR, env: { ...process.env, ...extraEnv } });
59
+ const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "assemble.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env, ...extraEnv } });
19
60
  let stdout = "";
20
61
  child.stdout.on("data", (d) => { stdout += d.toString(); });
21
62
  child.stderr.on("data", (d) => process.stderr.write(d)); // progress → MCP server log
@@ -76,6 +117,14 @@ exports.createVideoAssemblyTool = {
76
117
  enum: ["user_provided", "ai_generated"],
77
118
  description: "B-roll source mode. 'user_provided' (default) = use local files or Google Photos. 'ai_generated' = AI reads the transcript and generates stylized cinematic B-roll images — no local footage needed. Requires the 'style' param.",
78
119
  },
120
+ destination: {
121
+ type: "string",
122
+ enum: ["local", "hosted"],
123
+ description: "Where the project lives. 'local' (default) = render on this machine, return an MP4 path (no upload; reads local disk). " +
124
+ "'hosted' = create a Swap & Refine project on the user's ViralSpin account: local A-roll/B-roll are uploaded, the project " +
125
+ "opens in the web editor, and follow-up swap_clip/suggest_clips (with its job_id) drive that same project. Use 'hosted' " +
126
+ "when the user wants to refine in the browser or keep the project in their account.",
127
+ },
79
128
  style: {
80
129
  type: "string",
81
130
  enum: BROLL_STYLE_KEYS,
@@ -91,6 +140,14 @@ exports.createVideoAssemblyTool = {
91
140
  "When set, long-form videos (>2 min) in the broll folder are searched semantically " +
92
141
  "instead of being clipped from their start. Requires TWELVE_LABS_API_KEY in the MCP env.",
93
142
  },
143
+ template: {
144
+ type: "string",
145
+ enum: ["screen_demo"],
146
+ description: "Optional baked template (from the preview-and-select gallery). 'screen_demo' = the viral build-in-public " +
147
+ "product-demo style: 9x16, fast beat-synced cuts, punch-in zoom to the on-screen action, meme captions, brand " +
148
+ "end card. Seeds output_format + caption_style + creative direction so you don't describe the style; combine " +
149
+ "with find_moments mode:'screen' snippets as the footage.",
150
+ },
94
151
  caption_style: {
95
152
  type: "string",
96
153
  enum: CAPTION_STYLE_KEYS,
@@ -106,6 +163,16 @@ exports.createVideoAssemblyTool = {
106
163
  required: ["aroll"],
107
164
  },
108
165
  async execute(args) {
166
+ // Baked template selected in the gallery → seed its recipe defaults (user's
167
+ // explicit args always win). The style intent rides `prompt`; the beat-grid +
168
+ // preset audio live engine-side (VIRALSPIN_VIDEO_TEMPLATE), consumed by the
169
+ // screen-demo assembly path.
170
+ if (args.template && VIDEO_TEMPLATES[args.template]) {
171
+ const t = VIDEO_TEMPLATES[args.template];
172
+ args.output_format = args.output_format ?? t.output_format;
173
+ args.caption_style = args.caption_style ?? t.caption_style;
174
+ args.prompt = args.prompt ? `${t.direction}\n${args.prompt}` : t.direction;
175
+ }
109
176
  // AI-generated B-roll mode: delegate to the cloud narrative pipeline.
110
177
  if (args.broll_mode === "ai_generated") {
111
178
  if (!args.style) {
@@ -135,6 +202,61 @@ exports.createVideoAssemblyTool = {
135
202
  `Download: ${result.download_url ?? "—"}\n` +
136
203
  `Publish it with publish_now or schedule_post.`);
137
204
  }
205
+ // Hosted project: create a Swap & Refine project (narrative job) on the
206
+ // user's account instead of rendering locally. Local files are uploaded to
207
+ // public URLs, then the web editor + the hosted swap_clip/suggest_clips verbs
208
+ // take over. This is the path that "merges" Claude with the web platform.
209
+ if (args.destination === "hosted") {
210
+ // Upload the A-roll (local path → public URL). A URL passes straight through.
211
+ const arollUrl = /^https?:\/\//i.test(args.aroll)
212
+ ? args.aroll
213
+ : (await (0, client_js_1.uploadFiles)([args.aroll])).urls[0];
214
+ // Resolve local B-roll (expand folders, upload files) + keep URL B-roll.
215
+ const { localFiles, urls } = partitionBroll(args.broll ?? []);
216
+ const uploadedUrls = localFiles.length ? (await (0, client_js_1.uploadFiles)(localFiles)).urls : [];
217
+ const brollItems = [...uploadedUrls, ...urls].map((url) => ({ url, kind: brollKind(url) }));
218
+ // Create the project. NOTE (verified against the live backend): a hosted
219
+ // user_provided job picks its INITIAL B-roll from the user's synced library
220
+ // / Google Photos picker — NOT from ad-hoc local files. (Contextual
221
+ // selection over an arbitrary local folder is the LOCAL engine's job.) So
222
+ // local/URL B-roll here is attached as a refine pool the editor + the
223
+ // swap verbs draw from, once the timeline is built.
224
+ const job = await (0, client_js_1.post)("/v1/narrative/jobs", {
225
+ aroll_paths: [arollUrl],
226
+ prompt: args.prompt,
227
+ output_format: args.output_format ?? "9x16",
228
+ broll_mode: "user_provided",
229
+ ...(args.template ? { template: args.template } : {}),
230
+ ...(args.google_picker_session ? { picker_session_id: args.google_picker_session } : {}),
231
+ });
232
+ // Attach local/URL B-roll once the build produces a timeline (the uploads
233
+ // endpoint 409s before then). Best-effort + bounded so we don't hang.
234
+ let attachNote = "";
235
+ if (brollItems.length) {
236
+ const r = await (0, hosted_js_1.attachUploadsWhenReady)(job.job_id, brollItems);
237
+ attachNote = r.attached
238
+ ? `Attached ${brollItems.length} local/URL B-roll source(s) to the project's refine pool.\n`
239
+ : `Queued ${brollItems.length} local/URL B-roll source(s), but they weren't attached yet (${r.detail}); add them in the editor's Upload tab.\n`;
240
+ }
241
+ const creditNote = job.credits_charged != null ? `Charged ${job.credits_charged} credits (${job.credits_remaining} left).\n` : "";
242
+ return (`Created a hosted Swap & Refine project (job ${job.job_id}).\n` +
243
+ creditNote +
244
+ `Open / refine it in the editor: ${(0, hosted_js_1.editorUrl)(job.job_id)}\n` +
245
+ attachNote +
246
+ (args.google_picker_session ? "" : "Heads up: initial B-roll is picked from your synced library / Google Photos — pass a Google Photos picker session for richer hosted B-roll.\n") +
247
+ `From here: suggest_clips(job_id: "${job.job_id}") to see the cutaways, then ` +
248
+ `swap_clip(job_id: "${job.job_id}", …) to change one — edits show up live in the editor.\n` +
249
+ `Tip: set VIRALSPIN_HOSTED_JOB_ID=${job.job_id} to make the swap verbs target this project by default.`);
250
+ }
251
+ // Local assembly shells out to the bundled engine. The public npm package
252
+ // ships WITHOUT it (the closed magic), so route the user to the hosted cloud
253
+ // pipeline instead of failing with an opaque spawn error.
254
+ if (!(0, engine_js_1.engineAvailable)()) {
255
+ return ("Local video assembly needs the ViralSpin engine, which isn't bundled in this install.\n" +
256
+ "Use the cloud pipeline instead: call create_video_assembly again with destination: \"hosted\" " +
257
+ "— it renders in the cloud and opens in the web Swap & Refine editor.\n" +
258
+ "(Advanced: set VIRALSPIN_ASSEMBLY_DIR to a local engine checkout to enable local rendering.)");
259
+ }
138
260
  const broll = [...(args.broll ?? [])];
139
261
  // Pull in Google Photos picks (re-hosted to public URLs by the backend), if any.
140
262
  if (args.google_picker_session) {
@@ -175,6 +297,11 @@ exports.createVideoAssemblyTool = {
175
297
  if (process.env.TWELVE_LABS_API_KEY) {
176
298
  extraEnv.TWELVE_LABS_API_KEY = process.env.TWELVE_LABS_API_KEY;
177
299
  }
300
+ if (args.template) {
301
+ // Engine reads this to apply the baked recipe (beat grid + preset audio +
302
+ // punch-in) in the screen-demo assembly path.
303
+ extraEnv.VIRALSPIN_VIDEO_TEMPLATE = args.template;
304
+ }
178
305
  const outPath = await runEngine(cli, extraEnv);
179
306
  return (`Video assembly complete.\n` +
180
307
  `Output: ${outPath}\n` +
package/package.json CHANGED
@@ -1,29 +1,41 @@
1
- {
2
- "name": "@viralspin/mcp",
3
- "version": "0.2.0",
4
- "description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
5
- "main": "dist/index.js",
6
- "bin": {
7
- "viralspin-mcp": "dist/index.js"
8
- },
9
- "scripts": {
10
- "build": "tsc",
11
- "dev": "ts-node src/index.ts",
12
- "start": "node dist/index.js",
13
- "prepublishOnly": "npm run build"
14
- },
15
- "keywords": ["mcp", "viralspin", "tiktok", "instagram", "youtube", "social-media"],
16
- "license": "MIT",
17
- "dependencies": {
18
- "@modelcontextprotocol/sdk": "^1.0.0",
19
- "form-data": "^4.0.0"
20
- },
21
- "devDependencies": {
22
- "@types/form-data": "^2.5.0",
23
- "@types/node": "^20.0.0",
24
- "typescript": "^5.0.0"
25
- },
26
- "engines": {
27
- "node": ">=18"
28
- }
29
- }
1
+ {
2
+ "name": "@viralspin/mcp",
3
+ "version": "0.2.2",
4
+ "description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "viralspin-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "ts-node src/index.ts",
17
+ "start": "node dist/index.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "viralspin",
23
+ "tiktok",
24
+ "instagram",
25
+ "youtube",
26
+ "social-media"
27
+ ],
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.0.0",
31
+ "form-data": "^4.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/form-data": "^2.5.0",
35
+ "@types/node": "^20.0.0",
36
+ "typescript": "^5.0.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }