@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ViralSpin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # ViralSpin MCP — the video editor your AI agent operates
2
+
3
+ [![MCP Registry](https://img.shields.io/badge/MCP-registry-blue)](https://registry.modelcontextprotocol.io/v0/servers?search=viralspin)
4
+ [![npm](https://img.shields.io/badge/npm-%40viralspin%2Fmcp-red)](https://www.npmjs.com/package/@viralspin/mcp)
5
+ [![license](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
6
+
7
+ Turn a talking-head clip + a folder of B-roll into a polished vertical short — and **let Claude do the edit**. Point it at your footage in plain English; it transcribes, picks B-roll cutaways matched to what's said, adds captions + a hook, and renders a 9:16 short. Then publishes to TikTok, Instagram, or YouTube. No timeline-wrangling required.
8
+
9
+ > **The agent operates a real timeline.** Swap a clip, trim it, reorder scenes, cut a span — all by asking. Edits are instant; you render once when the cut is right.
10
+
11
+ ![demo](docs/demo.gif) <!-- TODO: the split-screen "Claude edits, clips stream onto the timeline" clip -->
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ # Claude Code
17
+ claude mcp add viralspin -- npx -y @viralspin/mcp
18
+
19
+ # or Claude Desktop — add to claude_desktop_config.json:
20
+ {
21
+ "mcpServers": {
22
+ "viralspin": { "command": "npx", "args": ["-y", "@viralspin/mcp"] }
23
+ }
24
+ }
25
+ ```
26
+
27
+ Then just ask:
28
+
29
+ ```
30
+ Turn my A-roll at C:/clips/talk.mp4 into a 9:16 short, using B-roll
31
+ from C:/my-broll and my Google Photos.
32
+
33
+ Publish that to TikTok and Instagram with the caption: posting through the chaos.
34
+ ```
35
+
36
+ ## What the agent can do
37
+
38
+ | Tool | What it does |
39
+ |---|---|
40
+ | `create_video_assembly` | A-roll + B-roll (local files, a folder, URLs, or Google Photos) → a rendered vertical short. Local or hosted. |
41
+ | `suggest_clips` / `swap_clip` | See the cutaways; swap one for better-fitting B-roll (with a `reason`). |
42
+ | `trim_clip` / `remove_span` / `reorder_scenes` | Granular timeline edits — instant, no re-render. |
43
+ | `render_project` | Bake captions/overlays into the final MP4 (the one render). |
44
+ | `publish_now` / `schedule_post` / `send_to_drafts` | Post to TikTok / Instagram / YouTube. |
45
+ | `generate_caption`, `create_kinetic_text`, carousels, image ads | The rest of the content pipeline. |
46
+
47
+ Edits are **instant and free** (they patch the timeline); `render_project` is the single cloud render. Iterate the cut as much as you like, then finalize once.
48
+
49
+ ## Open source
50
+
51
+ The MCP server, the editor, and the agent verbs are **MIT** — use them free. Cloud generation (AI B-roll, rendering, publishing) runs on ViralSpin's infrastructure and is metered as credits; sign in at [viralspin.ai](https://www.viralspin.ai). You never paste an API key for the AI — the heavy lifting is server-side.
52
+
53
+ - Listed in the official **MCP registry**: `ai.viralspin/viralspin`
54
+ - Hosted connector (no install, for URL / Google Photos workflows): `https://mcp.viralspin.ai/mcp`
55
+
56
+ ## Local vs hosted
57
+
58
+ - **Hosted (the default):** the cloud pipeline does the heavy lifting — assembly, AI B-roll, rendering — and every edit opens in the web Swap & Refine editor. B-roll from Google Photos / URLs / your synced library. Needs a [viralspin.ai](https://www.viralspin.ai) account (generation is metered as credits); no API keys to paste. The `mcp.viralspin.ai/mcp` hosted connector is the same, zero-install.
59
+ - **Local (advanced):** point `VIRALSPIN_ASSEMBLY_DIR` at a local engine checkout to read your disk — index a folder of clips, select B-roll contextually, and render locally. The npm package ships without the engine, so the local-render tools route you to the hosted path until that env var is set.
60
+
61
+ Either way, the agent reads local files *to upload them* (A-roll, a B-roll folder); only on-disk rendering needs the local engine.
62
+
63
+ ## License
64
+
65
+ MIT — see [LICENSE](./LICENSE).
package/dist/client.js CHANGED
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.get = get;
7
7
  exports.post = post;
8
+ exports.patch = patch;
8
9
  exports.pyGet = pyGet;
9
10
  exports.pyPost = pyPost;
10
11
  exports.del = del;
@@ -56,6 +57,13 @@ function post(endpoint, body) {
56
57
  body: JSON.stringify(body),
57
58
  });
58
59
  }
60
+ function patch(endpoint, body) {
61
+ return apiFetch(endpoint, {
62
+ method: "PATCH",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify(body),
65
+ });
66
+ }
59
67
  /** Python backend helpers — for routes that live in viralspin-api (Higgsfield, OAuth, etc.) */
60
68
  function pyGet(endpoint) {
61
69
  return apiFetch(endpoint, { method: "GET" }, PY_API_URL);
package/dist/engine.js ADDED
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ENGINE_DIR = void 0;
7
+ exports.engineAvailable = engineAvailable;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ /**
11
+ * The local Remotion assembly engine ships INSIDE this package (mcp/engine).
12
+ * At runtime __dirname = mcp/dist, so ../engine = mcp/engine. Override with
13
+ * VIRALSPIN_ASSEMBLY_DIR to point at a different engine checkout.
14
+ *
15
+ * NOTE: the published npm package ships WITHOUT the engine (it's the closed
16
+ * render/selection "magic" — see the OSS launch plan). Local-engine tools must
17
+ * therefore check engineAvailable() and degrade to a friendly message rather
18
+ * than failing with an opaque spawn error.
19
+ */
20
+ exports.ENGINE_DIR = process.env.VIRALSPIN_ASSEMBLY_DIR || path_1.default.resolve(__dirname, "../engine");
21
+ /** True when the local engine (with the given entry script) is present. */
22
+ function engineAvailable(entry = "assemble.mjs") {
23
+ try {
24
+ return fs_1.default.existsSync(path_1.default.join(exports.ENGINE_DIR, entry));
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
package/dist/hosted.js ADDED
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.editorUrl = editorUrl;
4
+ exports.hostedJobId = hostedJobId;
5
+ exports.getJob = getJob;
6
+ exports.pollJob = pollJob;
7
+ exports.attachUploadsWhenReady = attachUploadsWhenReady;
8
+ exports.swapSegment = swapSegment;
9
+ exports.setClipDuration = setClipDuration;
10
+ exports.removeSpan = removeSpan;
11
+ exports.detectScenes = detectScenes;
12
+ exports.saveSceneOrder = saveSceneOrder;
13
+ exports.rerender = rerender;
14
+ exports.searchLibrary = searchLibrary;
15
+ exports.listSegments = listSegments;
16
+ /**
17
+ * Hosted Swap & Refine integration.
18
+ *
19
+ * The web Swap & Refine editor is the *narrative jobs* system: a "project" is a
20
+ * narrative job (`job_id`), its edit state lives in `job.timeline`, and B-roll
21
+ * swaps + re-renders go through endpoints that are ALREADY deployed and used by
22
+ * the browser editor (`/v1/narrative/jobs/...`). These helpers let the MCP edit
23
+ * verbs drive that same hosted project instead of the local on-disk engine, so a
24
+ * swap done from Claude shows up in the web editor (and the render lands in the
25
+ * user's Swap & Refine list). Local-engine behavior is untouched — the hosted
26
+ * path is opt-in (a `job_id` arg or the VIRALSPIN_HOSTED_JOB_ID env).
27
+ */
28
+ const client_js_1 = require("./client.js");
29
+ const APP_URL = (process.env.VIRALSPIN_APP_URL || "https://app.viralspin.ai").replace(/\/$/, "");
30
+ /** Deep link to the project in the web Swap & Refine editor. */
31
+ function editorUrl(jobId) {
32
+ return `${APP_URL}/swap-refine/${jobId}`;
33
+ }
34
+ /**
35
+ * The hosted project the edit verbs act on: an explicit `job_id` wins, else the
36
+ * VIRALSPIN_HOSTED_JOB_ID env (set after a hosted create_video_assembly), else
37
+ * null → the caller falls back to the local engine. This is the single switch
38
+ * that keeps existing local behavior the default.
39
+ */
40
+ function hostedJobId(explicit) {
41
+ return explicit || process.env.VIRALSPIN_HOSTED_JOB_ID || null;
42
+ }
43
+ function getJob(jobId) {
44
+ return (0, client_js_1.get)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}`);
45
+ }
46
+ /** Poll until the job reaches a terminal state (complete/failed) or times out. */
47
+ async function pollJob(jobId, timeoutMs = 15 * 60 * 1000) {
48
+ const deadline = Date.now() + timeoutMs;
49
+ let job = { id: jobId, status: "queued" };
50
+ while (Date.now() < deadline) {
51
+ await new Promise((r) => setTimeout(r, 6000));
52
+ job = await getJob(jobId);
53
+ if (job.status === "complete" || job.status === "failed")
54
+ break;
55
+ }
56
+ return job;
57
+ }
58
+ /**
59
+ * Attach B-roll uploads to a hosted project's refine pool, waiting for the
60
+ * pipeline to build a timeline first. The uploads endpoint 409s with
61
+ * "job has no timeline yet" until the build stage finishes (verified against the
62
+ * live backend 2026-06-22), so a fresh create can't attach immediately. Bounded
63
+ * poll; returns whether the attach landed.
64
+ */
65
+ async function attachUploadsWhenReady(jobId, items, timeoutMs = 90000) {
66
+ if (!items.length)
67
+ return { attached: false, detail: "no items" };
68
+ const deadline = Date.now() + timeoutMs;
69
+ let lastDetail = "timed out before the project's timeline was ready";
70
+ while (Date.now() < deadline) {
71
+ const job = await getJob(jobId);
72
+ if (job.status === "failed")
73
+ return { attached: false, detail: "job failed before attach" };
74
+ const hasTimeline = !!(job.timeline && (job.timeline.aroll_url || (job.timeline.timeline?.length ?? 0)));
75
+ if (hasTimeline) {
76
+ try {
77
+ await (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/uploads`, { items });
78
+ return { attached: true };
79
+ }
80
+ catch (e) {
81
+ lastDetail = e instanceof Error ? e.message : String(e);
82
+ if (!/no timeline yet/i.test(lastDetail))
83
+ return { attached: false, detail: lastDetail };
84
+ }
85
+ }
86
+ await new Promise((r) => setTimeout(r, 5000));
87
+ }
88
+ return { attached: false, detail: lastDetail };
89
+ }
90
+ /** Replace one cutaway's B-roll in the stored timeline (same PATCH the editor
91
+ * uses). `reason` is optional provenance — why the agent placed this clip; the
92
+ * backend persists it when supported, and it's echoed back regardless. */
93
+ function swapSegment(jobId, segmentId, assetUrl, assetKind, reason) {
94
+ return (0, client_js_1.patch)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/swap`, {
95
+ segment_id: segmentId,
96
+ asset_url: assetUrl,
97
+ asset_kind: assetKind,
98
+ ...(reason ? { reason } : {}),
99
+ });
100
+ }
101
+ /** Shorten/restore a cutaway's on-screen duration (audio-safe — the backend
102
+ * pairs the trim with A-roll filler so total length is preserved). */
103
+ function setClipDuration(jobId, segmentId, durationSec) {
104
+ return (0, client_js_1.patch)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/segment-duration`, {
105
+ segment_id: segmentId,
106
+ duration_sec: durationSec,
107
+ });
108
+ }
109
+ /** Cut a boundary-aligned span [fromSec,toSec] out of the output (e.g. a
110
+ * duplicate opening line); backend trims the A-roll bed + re-renders. */
111
+ function removeSpan(jobId, fromSec, toSec, render = true) {
112
+ return (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/remove-span`, {
113
+ from_sec: fromSec,
114
+ to_sec: toSec,
115
+ render,
116
+ });
117
+ }
118
+ /** Detect (cached) the A-roll's backbone scenes — the reorderable building blocks. */
119
+ function detectScenes(jobId) {
120
+ return (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/scenes/detect`, {});
121
+ }
122
+ /** Persist a new running order / soft-removals for the backbone scenes. */
123
+ function saveSceneOrder(jobId, scenes) {
124
+ return (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/scenes/order`, { scenes });
125
+ }
126
+ /** Re-render the stored timeline after one or more swaps. Returns immediately. */
127
+ function rerender(jobId) {
128
+ return (0, client_js_1.post)(`/v1/narrative/jobs/${encodeURIComponent(jobId)}/rerender`, {});
129
+ }
130
+ /**
131
+ * Text-search the user's analyzed B-roll library (matches each asset's Gemini
132
+ * tags — notes/subjects/emotion). The match is the "why this clip fits" signal
133
+ * we surface to the user when proposing a swap.
134
+ */
135
+ function searchLibrary(q, limit = 12) {
136
+ return (0, client_js_1.get)(`/v1/assembly/search?q=${encodeURIComponent(q)}&limit=${limit}`);
137
+ }
138
+ /**
139
+ * Best-effort list of the timeline's B-roll cutaways (id + current asset + the
140
+ * beat text it covers) so the user/agent can see what to swap. Tolerant of the
141
+ * exact timeline JSON shape: tries the common array fields and degrades to [].
142
+ */
143
+ function listSegments(timeline) {
144
+ if (!timeline || typeof timeline !== "object")
145
+ return [];
146
+ // The live shape stores the cut at `timeline.timeline` (an array that mixes the
147
+ // A-roll bed + B-roll cutaways); older/other shapes use segments/brolls/overlays.
148
+ const arr = timeline.timeline || timeline.segments || timeline.brolls || timeline.overlays || timeline.broll_overlays || [];
149
+ if (!Array.isArray(arr))
150
+ return [];
151
+ return arr
152
+ .filter((s) => s && (s.id || s.segment_id) && String(s.type ?? "") !== "aroll")
153
+ .map((s) => ({
154
+ id: String(s.id ?? s.segment_id),
155
+ beat: s.beat ?? s.label ?? s.caption ?? s.transcript ?? s.phrase ?? undefined,
156
+ asset_url: s.asset_url ?? s.url ?? undefined,
157
+ kind: s.type ?? s.kind ?? undefined,
158
+ }));
159
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,16 @@ const carousel_video_js_1 = require("./tools/carousel-video.js");
10
10
  const video_assembly_js_1 = require("./tools/video-assembly.js");
11
11
  const photo_picker_js_1 = require("./tools/photo-picker.js");
12
12
  const assembly_adjust_js_1 = require("./tools/assembly-adjust.js");
13
+ const timeline_edit_js_1 = require("./tools/timeline-edit.js");
14
+ const folder_preview_js_1 = require("./tools/folder-preview.js");
15
+ const find_moments_js_1 = require("./tools/find-moments.js");
16
+ const local_mode_js_1 = require("./tools/local-mode.js");
17
+ const color_grade_js_1 = require("./tools/color-grade.js");
18
+ const edit_clip_js_1 = require("./tools/edit-clip.js");
19
+ const relight_clip_js_1 = require("./tools/relight-clip.js");
20
+ const ideas_js_1 = require("./tools/ideas.js");
21
+ const local_index_js_1 = require("./tools/local-index.js");
22
+ const profile_js_1 = require("./tools/profile.js");
13
23
  const posts_js_1 = require("./tools/posts.js");
14
24
  const captions_js_1 = require("./tools/captions.js");
15
25
  const engage_js_1 = require("./tools/engage.js");
@@ -28,6 +38,26 @@ const tools = [
28
38
  assembly_adjust_js_1.suggestClipsTool,
29
39
  assembly_adjust_js_1.swapClipTool,
30
40
  assembly_adjust_js_1.swapCaptionStyleTool,
41
+ timeline_edit_js_1.trimClipTool,
42
+ timeline_edit_js_1.removeSpanTool,
43
+ timeline_edit_js_1.reorderScenesTool,
44
+ timeline_edit_js_1.renderProjectTool,
45
+ timeline_edit_js_1.trimSilencesTool,
46
+ timeline_edit_js_1.getTranscriptTool,
47
+ timeline_edit_js_1.removeSpansTool,
48
+ timeline_edit_js_1.keepWindowsTool,
49
+ folder_preview_js_1.previewFolderTool,
50
+ find_moments_js_1.findMomentsTool,
51
+ local_mode_js_1.setLocalModeTool,
52
+ color_grade_js_1.colorGradeTool,
53
+ edit_clip_js_1.editRawClipTool,
54
+ relight_clip_js_1.relightClipTool,
55
+ ideas_js_1.queueIdeaTool,
56
+ ideas_js_1.listIdeasTool,
57
+ profile_js_1.setCreatorProfileTool,
58
+ profile_js_1.getCreatorProfileTool,
59
+ local_index_js_1.syncLocalIndexTool,
60
+ local_index_js_1.searchLocalIndexTool,
31
61
  posts_js_1.listPostsTool,
32
62
  posts_js_1.cancelPostTool,
33
63
  captions_js_1.generateCaptionTool,
@@ -45,32 +75,48 @@ const tools = [
45
75
  // Shown to the user as the welcome / getting-started message. The agent fills in
46
76
  // <picker link> by calling start_photo_picker (per ONBOARDING below).
47
77
  const SAMPLE_COMMANDS = [
48
- '1. Make a video: "Turn my A-roll at C:/clips/talk.mp4 into a 9:16 short, using B-roll from C:/my-broll and my Google Photos."',
49
- '2. Publish it: "Publish that video to TikTok and Instagram with the caption: posting through the chaos."',
78
+ "Try one of these — I'll open a DRAFT in your ViralSpin editor for you to review side-by-side:",
79
+ "",
80
+ 'SIMPLE — a quick short:',
81
+ ' "Turn my talking-head at C:/clips/talk.mp4 into a 9:16 TikTok short — pull B-roll from',
82
+ ' C:/my-broll and my Google Photos, add captions, and show me the draft."',
83
+ "",
84
+ 'COMPREHENSIVE — hook + story + scheduled publish (the full loop):',
85
+ ' "Make a hosted reel. Open on the strongest surprise/reveal clip from C:/footage/hooks with the',
86
+ ' title \'Claude just killed video editors\', then tell the story of how AI is replacing video editors',
87
+ ' using the clips in C:/footage/story. Build a story arc that fits the footage, caption it, and show',
88
+ ' me the draft to review. Once I approve, schedule it to Instagram and TikTok next Tuesday at 8pm."',
50
89
  ].join("\n");
51
90
  const WELCOME = [
52
- "Welcome to ViralSpin. I turn a talking-head A-roll into a polished vertical short —",
53
- "B-roll cutaways matched to what you say, captions, a hook headline, and keyword pops —",
54
- "then publish it to TikTok, Instagram, or YouTube. B-roll can be local files, a local",
55
- "folder (auto-indexed), public URLs, and/or your Google Photos.",
91
+ "Welcome to ViralSpin. I turn your footage into polished vertical shorts — B-roll matched to",
92
+ "what's said, captions, a hook headline — then PUBLISH or SCHEDULE them to TikTok, Instagram,",
93
+ "or YouTube. I open every project as a DRAFT in your web editor so you can watch it come together",
94
+ "and tweak it side-by-side. B-roll can be local files, a local folder (auto-indexed), URLs, or Google Photos.",
56
95
  "",
57
- "Two commands to get you started:",
58
96
  SAMPLE_COMMANDS,
59
97
  ].join("\n");
60
98
  // Auto-injected into the agent's context when the server connects. Drives proactive onboarding.
61
99
  const INSTRUCTIONS = [
62
- "ViralSpin assembles A-roll + B-roll into polished vertical shorts and publishes them to TikTok/Instagram/YouTube.",
100
+ "ViralSpin assembles footage into polished vertical shorts and PUBLISHES/SCHEDULES them to TikTok/Instagram/YouTube — edit AND ship, all from chat, with a live web editor the user watches side-by-side.",
63
101
  "",
64
- "ONBOARDING: The FIRST time you help the user with ViralSpin in a session, proactively onboard them:",
65
- "(1) give the short intro below, (2) CALL start_photo_picker so they get a live Google Photos B-roll picker link, and",
66
- "(3) show these two sample commands verbatim:",
102
+ "ONBOARDING: The FIRST time you help the user in a session: (1) give the intro below, (2) CALL start_photo_picker for a Google Photos B-roll picker link, and (3) show the TWO ready-to-run use cases verbatim (a SIMPLE one and a COMPREHENSIVE one):",
67
103
  SAMPLE_COMMANDS,
68
104
  "",
69
- "HOW B-ROLL WORKS: create_video_assembly accepts local file paths, a local FOLDER (auto-expanded and indexed),",
70
- "public URLs, and Google Photos. For Google Photos: call start_photo_picker → it returns a browser link + sessionId;",
71
- "the user picks in their browser (Google forbids listing albums programmatically), then call create_video_assembly",
72
- "with google_picker_session set to that sessionId. Pass style_ref (a reference video) to auto-infer captions/hook/pace/aspect.",
73
- "After rendering, publish with publish_now or schedule_post.",
105
+ "DRAFT-FIRST WORKFLOW: For anything beyond a trivial one-off, create a HOSTED project (create_video_assembly destination:\"hosted\") so it opens in the web Swap & Refine editor — the user reviews it side-by-side while you work. You HAVE FREEDOM to pick the clips that best match the user's intent: use suggest_clips to search their analyzed library by MEANING (e.g. a 'surprise/reveal' hook), and build a story arc + hook title that fits the AVAILABLE B-roll. Return the editor link as a DRAFT and invite review. Refine on request with swap_clip / trim_clip / reorder_scenes / remove_span (these stream live into the editor), then render_project. NEVER auto-publish — call publish_now / schedule_post ONLY after the user approves the draft. For 'schedule … next Tuesday 8pm' use schedule_post with that time.",
106
+ "",
107
+ "HOW B-ROLL WORKS: create_video_assembly accepts local file paths, a local FOLDER (auto-expanded + indexed), public URLs, and Google Photos. Hosted user_provided B-roll comes from the synced library / Google Photos picker (call start_photo_picker → user picks → pass google_picker_session). Pass style_ref (a reference video) to auto-infer captions/hook/pace/aspect.",
108
+ "",
109
+ "COST GATE: before assembling a LARGE local folder, call preview_folder first — it counts the clips so you can warn the user (each is analyzed + charged on create) and CONFIRM before spending credits.",
110
+ "",
111
+ "SMART CUTS (you read the transcript, you decide what to cut — no extra vendor): 'edit out the dead air' → trim_silences. 'Remove the bad takes / restarts / repeats' → get_transcript, identify the bad spans yourself, then remove_spans. 'Find the best moments in this long recording / make a highlight reel' → get_transcript, pick the punchy windows, then keep_windows (keeps only those, cuts the rest). This is the cheap moment-search path for talking/narrated long videos — uses the Whisper transcript + your judgment, no Twelve Labs.",
112
+ "",
113
+ "LONG / SILENT VIDEO → REEL: for a long or silent local video (e.g. a screen recording), run find_moments(extract:true) — it scores frames with the vision model (free on the user's GPU when local mode is on) and cuts the highlights into small clips LOCALLY. Then build the reel with create_video_assembly using those clips: destination:'hosted' uploads ONLY the snippets (not the long source) + opens the web editor (DEFAULT); destination:'local' keeps everything on-device with no upload (fully private). Same analysis, two destinations — let the user pick privacy vs the web editor.",
114
+ "",
115
+ "LOOK / LIGHTING: 'make it cinematic' / 'add a teal-orange (or noir / warm / moody) look' → color_grade — FREE, instant ffmpeg LUT looks, no cost, great on the A-roll or any clip (call with no `look` to list them). 'Relight this / change the lighting / put me in [a scene] / re-scene it' → relight_clip — PAID Fal.ai (best on B-roll + short 3–10s hero shots; can change background + add real directional light). ALWAYS show the cost and get explicit confirmation before relight_clip's paid video step (it's two-call: preview the frame first, then confirm:true). Default to the free color_grade unless the user wants a true relight / new background.",
116
+ "",
117
+ "IDEA PIPELINE (brainstorm → execute, persistent): when the user wants content ideas or pastes a saved/liked post URL they want to replicate — (1) analyze it + identify their core topics, (2) write a teleprompter-ready SCRIPT, (3) find what they ALREADY have: search_local_index for their local footage (run sync_local_index on their folders first so it's catalogued) + suggest_clips for cloud library, (4) build a per-shot ASSET PLAN — reuse an existing clip, else AI-generate B-roll (theme), pull a web image (logo/person), add a kinetic-text overlay, or tell them to FILM the missing shot, (5) queue_idea it (hook + script + asset_plan + source_url). It persists cross-browser in /spin/history; the user films the script on the teleprompter at /create and the plan says what B-roll to use vs shoot. Recommend the BEST B-roll option per shot. Use list_ideas to resume / pick the next idea to execute.",
118
+ "",
119
+ "WEEKLY IDEA ENGINE: at setup, learn the user's context — call get_creator_profile; if empty, read their connected accounts via list_connections and/or ASK for their niche + handles, then set_creator_profile. To seed a week's ideas: research what's WORKING in their niche — top-performing short-form on TikTok / Instagram / YouTube Shorts (YouTube Shorts has the cleanest 'top by views' signal; for IG/TikTok use WebSearch + the Discover feature), and factor in the user's OWN account insights — then generate 5 ideas and queue_idea each with the SAME `week` tag (e.g. '2026-W27') and a `format` ('video' = film off the teleprompter at /create; 'carousel' = image carousel for a niche news item). They appear as a week-tab set in /spin/history. Offer to make this a WEEKLY ROUTINE (a scheduled run that re-seeds 5 ideas each week) and set weekly_routine via set_creator_profile.",
74
120
  ].join("\n");
75
121
  const server = new index_js_1.Server({ name: "viralspin", version: "0.1.0" }, { capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS });
76
122
  server.setRequestHandler(types_js_1.ListPromptsRequestSchema, async () => ({
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ // Strips unpaired UTF-16 surrogates from strings so MCP responses can be
3
+ // safely JSON-serialized and written to the stdio transport. Lone surrogates
4
+ // (e.g. an emoji's surrogate pair cut in half by a fixed-length .slice) cause
5
+ // the transport's UTF-8 encoder to throw "no low surrogate in string".
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.sanitizeSurrogates = sanitizeSurrogates;
8
+ exports.sanitizeObject = sanitizeObject;
9
+ const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
10
+ function sanitizeSurrogates(str) {
11
+ return str.replace(UNPAIRED_SURROGATE, "");
12
+ }
13
+ function sanitizeObject(obj) {
14
+ if (typeof obj === "string")
15
+ return sanitizeSurrogates(obj);
16
+ if (Array.isArray(obj))
17
+ return obj.map(sanitizeObject);
18
+ if (obj && typeof obj === "object") {
19
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, sanitizeObject(v)]));
20
+ }
21
+ return obj;
22
+ }
@@ -6,13 +6,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.swapCaptionStyleTool = exports.swapClipTool = exports.suggestClipsTool = void 0;
7
7
  const child_process_1 = require("child_process");
8
8
  const path_1 = __importDefault(require("path"));
9
- // Adjustment tools operate on the most recent create_video_assembly project (the engine keeps the
10
- // timeline + analyzed candidate pool + staged clips on disk), so suggesting is instant and swapping
11
- // just re-renders. Same engine dir as create_video_assembly.
12
- const ENGINE_DIR = process.env.VIRALSPIN_ASSEMBLY_DIR || "E:/AI test/carissa bot/assembly-engine";
9
+ const hosted_js_1 = require("../hosted.js");
10
+ const engine_js_1 = require("../engine.js");
11
+ // Adjustment tools operate on the most recent create_video_assembly project. By
12
+ // default that's the LOCAL engine project (the engine keeps the timeline +
13
+ // analyzed candidate pool + staged clips on disk), so suggesting is instant and
14
+ // swapping just re-renders locally. When a hosted project is targeted instead —
15
+ // a `job_id` arg, or the VIRALSPIN_HOSTED_JOB_ID env set by a hosted
16
+ // create_video_assembly — the same verbs drive the web Swap & Refine project via
17
+ // the deployed narrative endpoints, so the edit shows up in the browser editor.
18
+ // Shown when a local action is requested but no engine is bundled (the public
19
+ // npm package) and no hosted project is targeted.
20
+ const NO_LOCAL_ENGINE = "This local action needs the ViralSpin engine, which isn't bundled in this install.\n" +
21
+ "Target a hosted Swap & Refine project instead — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID) — " +
22
+ "or set VIRALSPIN_ASSEMBLY_DIR to a local engine checkout.";
13
23
  function runEngine(args) {
14
24
  return new Promise((resolve, reject) => {
15
- 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 } });
25
+ 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 } });
16
26
  let stdout = "";
17
27
  child.stdout.on("data", (d) => { stdout += d.toString(); });
18
28
  child.stderr.on("data", (d) => process.stderr.write(d));
@@ -22,17 +32,56 @@ function runEngine(args) {
22
32
  }
23
33
  exports.suggestClipsTool = {
24
34
  name: "suggest_clips",
25
- description: "Get ranked alternative B-roll for one cutaway in the most recently assembled video (from create_video_assembly). " +
26
- "Returns options ranked by how well they fit that moment's transcript, each with a thumbnail image path you can open. " +
27
- "Then call swap_clip to apply a choice. Instant — reuses the cached analysis, no re-render.",
35
+ description: "See a project's cutaways and find better B-roll for one of them. " +
36
+ "LOCAL project (default): pass `cut` (0-based) to get ranked alternatives for that cutaway, each with a thumbnail. " +
37
+ "HOSTED Swap & Refine project (pass `job_id`, or set VIRALSPIN_HOSTED_JOB_ID): " +
38
+ "call with no `query` to list the timeline's cutaways (segment id + the beat each covers + current clip); " +
39
+ "call with `query` (e.g. \"newborn in hospital\") to search the user's B-roll library — each result includes the " +
40
+ "library tags it matched on, which is the 'why this clip fits' to show the user. " +
41
+ "Then call swap_clip to apply a choice.",
28
42
  inputSchema: {
29
43
  type: "object",
30
44
  properties: {
31
- cut: { type: "number", description: "Index of the cutaway to replace (0-based; from the cut list create_video_assembly returns)." },
45
+ cut: { type: "number", description: "LOCAL only: index of the cutaway to replace (0-based; from the cut list create_video_assembly returns)." },
46
+ job_id: { type: "string", description: "HOSTED: the Swap & Refine project (narrative job) to inspect. Omit to use VIRALSPIN_HOSTED_JOB_ID, or the local project." },
47
+ query: { type: "string", description: "HOSTED: theme to search the B-roll library for (the moment's subject). Omit to list the timeline's cutaways instead." },
32
48
  },
33
- required: ["cut"],
34
49
  },
35
50
  async execute(args) {
51
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
52
+ if (jobId) {
53
+ // Hosted project: either list the timeline's cutaways, or search the
54
+ // library for alternatives (with the matched tags = the "why").
55
+ if (args.query) {
56
+ const r = await (0, hosted_js_1.searchLibrary)(args.query);
57
+ if (!r.items.length)
58
+ return `No library B-roll matched "${args.query}". Try a broader term.`;
59
+ const lines = r.items.map((it, i) => `${i + 1}. ${it.url}\n ${it.kind}${it.album ? ` · from "${it.album}"` : ""} — matched on: "${args.query}"`);
60
+ return [
61
+ `B-roll matching "${args.query}" (best fit first):`,
62
+ ...lines,
63
+ ``,
64
+ `Apply one with: swap_clip(job_id: "${jobId}", segment_id: "<from the cutaway list>", asset_url: "<url above>").`,
65
+ ].join("\n");
66
+ }
67
+ const job = await (0, hosted_js_1.getJob)(jobId);
68
+ const segs = (0, hosted_js_1.listSegments)(job.timeline);
69
+ if (!segs.length) {
70
+ return `Project ${jobId} has no listable cutaways yet (status: ${job.status}). Open it: ${(0, hosted_js_1.editorUrl)(jobId)}`;
71
+ }
72
+ const lines = segs.map((s, i) => `${i + 1}. segment ${s.id}${s.beat ? ` — "${s.beat}"` : ""}${s.asset_url ? `\n current: ${s.asset_url}` : ""}`);
73
+ return [
74
+ `Cutaways in ${(0, hosted_js_1.editorUrl)(jobId)} :`,
75
+ ...lines,
76
+ ``,
77
+ `Find alternatives for one: suggest_clips(job_id: "${jobId}", query: "<what that moment is about>").`,
78
+ ].join("\n");
79
+ }
80
+ if (!(0, engine_js_1.engineAvailable)())
81
+ return NO_LOCAL_ENGINE;
82
+ if (args.cut === undefined) {
83
+ return "For a local project, pass `cut` (the 0-based cutaway index). For a hosted Swap & Refine project, pass `job_id`.";
84
+ }
36
85
  const out = await runEngine(["--suggest", String(args.cut)]);
37
86
  const m = out.match(/SUGGEST:(.+)/);
38
87
  if (!m)
@@ -50,17 +99,49 @@ exports.suggestClipsTool = {
50
99
  };
51
100
  exports.swapClipTool = {
52
101
  name: "swap_clip",
53
- description: "Replace one cutaway in the most recently assembled video with a chosen B-roll asset (from suggest_clips), then re-render. " +
54
- "Returns the path to the updated MP4.",
102
+ description: "Replace one cutaway with a chosen B-roll asset (from suggest_clips), then re-render. " +
103
+ "LOCAL project (default): pass `cut` (0-based) + `asset` (filename) → re-renders locally, returns the MP4 path. " +
104
+ "HOSTED Swap & Refine project (pass `job_id`, or set VIRALSPIN_HOSTED_JOB_ID): pass `segment_id` + `asset_url` → " +
105
+ "patches the same timeline the browser editor shows and kicks off a cloud re-render; the change appears live in " +
106
+ "Swap & Refine. Returns immediately — the new preview lands on the project shortly.",
55
107
  inputSchema: {
56
108
  type: "object",
57
109
  properties: {
58
- cut: { type: "number", description: "Index of the cutaway to replace (0-based)." },
59
- asset: { type: "string", description: "Asset filename from suggest_clips (e.g. \"v03.mp4\" or \"07.jpg\")." },
110
+ cut: { type: "number", description: "LOCAL: index of the cutaway to replace (0-based)." },
111
+ asset: { type: "string", description: "LOCAL: asset filename from suggest_clips (e.g. \"v03.mp4\" or \"07.jpg\")." },
112
+ job_id: { type: "string", description: "HOSTED: the Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
113
+ segment_id: { type: "string", description: "HOSTED: the cutaway's segment id (from suggest_clips)." },
114
+ asset_url: { type: "string", description: "HOSTED: public URL of the replacement B-roll (from suggest_clips)." },
115
+ asset_kind: { type: "string", enum: ["broll_video", "broll_photo"], description: "HOSTED: optional — kind of the replacement asset." },
116
+ reason: { type: "string", description: "HOSTED: optional one-line rationale for WHY this clip fits here (e.g. \"the line says 'the day she arrived'\"). Stored as provenance + shown on the timeline." },
117
+ render: { type: "boolean", description: "HOSTED: re-render now (costs a render/credits). Default false = instant timeline-only edit; iterate freely, then call render_project once to bake & publish." },
60
118
  },
61
- required: ["cut", "asset"],
62
119
  },
63
120
  async execute(args) {
121
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
122
+ if (jobId) {
123
+ if (!args.segment_id || !args.asset_url) {
124
+ return "For a hosted project, pass `segment_id` and `asset_url` (both from suggest_clips).";
125
+ }
126
+ let kind = args.asset_kind;
127
+ if (!kind)
128
+ kind = /\.(mp4|mov|webm|m4v)(\?|$)/i.test(args.asset_url) ? "broll_video" : "broll_photo";
129
+ await (0, hosted_js_1.swapSegment)(jobId, args.segment_id, args.asset_url, kind, args.reason);
130
+ if (args.render) {
131
+ await (0, hosted_js_1.rerender)(jobId);
132
+ return (`Swapped segment ${args.segment_id} → ${args.asset_url}${args.reason ? ` (${args.reason})` : ""} and started a cloud re-render.\n` +
133
+ `Updating live in the editor: ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
134
+ `Publish it with publish_now or schedule_post once it finishes.`);
135
+ }
136
+ return (`Swapped segment ${args.segment_id} → ${args.asset_url}${args.reason ? ` (${args.reason})` : ""} (instant, no render).\n` +
137
+ `Keep editing freely — it's all on the timeline. Open: ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
138
+ `When the cut is right, call render_project(job_id: "${jobId}") to bake captions/overlays and get the final MP4.`);
139
+ }
140
+ if (!(0, engine_js_1.engineAvailable)())
141
+ return NO_LOCAL_ENGINE;
142
+ if (args.cut === undefined || !args.asset) {
143
+ return "For a local project, pass `cut` and `asset`. For a hosted Swap & Refine project, pass `job_id`, `segment_id`, and `asset_url`.";
144
+ }
64
145
  const out = await runEngine(["--swap", String(args.cut), args.asset]);
65
146
  const m = out.match(/RESULT:(.+)/);
66
147
  if (!m)
@@ -87,10 +168,21 @@ exports.swapCaptionStyleTool = {
87
168
  "minimalist = lowercase thin font fade-in (calm, educational). " +
88
169
  "whiteboard = text revealed left-to-right (explainers, tutorials).",
89
170
  },
171
+ job_id: { type: "string", description: "HOSTED: the Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID or the local project." },
90
172
  },
91
173
  required: ["caption_style"],
92
174
  },
93
175
  async execute(args) {
176
+ // Hosted projects set caption style via the editor's render options; there's
177
+ // no standalone caption-swap endpoint to drive from here yet. Be explicit
178
+ // rather than hit a guessed route.
179
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
180
+ if (jobId) {
181
+ return (`Caption style isn't swappable from here for hosted Swap & Refine projects yet — ` +
182
+ `set it in the editor: ${(0, hosted_js_1.editorUrl)(jobId)} (this is the next slice to wire up).`);
183
+ }
184
+ if (!(0, engine_js_1.engineAvailable)())
185
+ return NO_LOCAL_ENGINE;
94
186
  const out = await runEngine(["--caption-style", args.caption_style, "--swap-caption-style"]);
95
187
  const m = out.match(/RESULT:(.+)/);
96
188
  if (!m)
@@ -71,6 +71,12 @@ exports.publishCarouselAsVideoTool = {
71
71
  items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
72
72
  description: "Platforms to publish to. Must be connected. For mode='draft', Instagram is not allowed (no IG drafts API).",
73
73
  },
74
+ account_ids: {
75
+ type: "array",
76
+ items: { type: "string" },
77
+ description: "Specific connection IDs (from list_connections) to target. REQUIRED when you have multiple accounts on a platform — " +
78
+ "without it the post fans out to EVERY connected account on the platform (e.g. a draft landing on the wrong handle).",
79
+ },
74
80
  caption: { type: "string", description: "Post caption." },
75
81
  hashtags: {
76
82
  type: "array",
@@ -158,6 +164,7 @@ exports.publishCarouselAsVideoTool = {
158
164
  media_urls: upload.urls,
159
165
  media_type: "video",
160
166
  platforms: args.platforms,
167
+ ...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
161
168
  caption: args.caption,
162
169
  hashtags: args.hashtags ?? [],
163
170
  publish_now: true,