@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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/client.js +8 -0
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +62 -16
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +107 -15
- package/dist/tools/carousel-video.js +7 -0
- package/dist/tools/color-grade.js +64 -0
- package/dist/tools/connections.js +23 -4
- package/dist/tools/edit-clip.js +136 -0
- package/dist/tools/engage.js +13 -4
- package/dist/tools/explainer-index.js +6 -0
- package/dist/tools/find-moments.js +140 -0
- package/dist/tools/folder-preview.js +71 -0
- package/dist/tools/ideas.js +158 -0
- package/dist/tools/kinetic-text.js +6 -2
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +34 -10
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +131 -4
- package/package.json +41 -29
- package/BUNDLE_LOCAL_SPEC.md +0 -133
- package/engine/assemble.mjs +0 -476
- package/engine/gemini.mjs +0 -164
- package/engine/kinetic.mjs +0 -84
- package/engine/prompts.mjs +0 -94
- package/engine/remotion.config.ts +0 -4
- package/engine/src/Assembly.tsx +0 -330
- package/engine/src/KineticText.tsx +0 -253
- package/engine/src/Root.tsx +0 -39
- package/engine/src/index.ts +0 -4
- package/engine/tsconfig.json +0 -15
- package/src/client.ts +0 -119
- package/src/index.ts +0 -151
- package/src/tools/assembly-adjust.ts +0 -110
- package/src/tools/captions.ts +0 -32
- package/src/tools/carousel-video.ts +0 -211
- package/src/tools/connections.ts +0 -27
- package/src/tools/engage.ts +0 -105
- package/src/tools/explainer-index.ts +0 -298
- package/src/tools/image-ads.ts +0 -192
- package/src/tools/kinetic-text.ts +0 -130
- package/src/tools/photo-picker.ts +0 -31
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -236
- package/src/tools/review.ts +0 -126
- package/src/tools/video-assembly.ts +0 -205
- 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
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
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.
|
|
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
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|