@viralspin/mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/cli.js +77 -0
  4. package/dist/client.js +12 -3
  5. package/dist/engine.js +29 -0
  6. package/dist/hosted.js +159 -0
  7. package/dist/index.js +77 -17
  8. package/dist/local-render/kit.js +217 -0
  9. package/dist/local-render/login.js +138 -0
  10. package/dist/local-render/render.js +252 -0
  11. package/dist/local-render/startup.js +74 -0
  12. package/dist/local-render/tokens.js +201 -0
  13. package/dist/local-render/watch.js +69 -0
  14. package/dist/sanitize.js +22 -0
  15. package/dist/tools/assembly-adjust.js +107 -15
  16. package/dist/tools/carousel-video.js +7 -0
  17. package/dist/tools/color-grade.js +64 -0
  18. package/dist/tools/connections.js +23 -4
  19. package/dist/tools/edit-clip.js +136 -0
  20. package/dist/tools/engage.js +13 -4
  21. package/dist/tools/explainer-index.js +6 -0
  22. package/dist/tools/find-moments.js +140 -0
  23. package/dist/tools/folder-preview.js +71 -0
  24. package/dist/tools/ideas.js +158 -0
  25. package/dist/tools/kinetic-text.js +6 -2
  26. package/dist/tools/local-index.js +84 -0
  27. package/dist/tools/local-mode.js +84 -0
  28. package/dist/tools/profile.js +56 -0
  29. package/dist/tools/publish.js +50 -14
  30. package/dist/tools/relight-clip.js +98 -0
  31. package/dist/tools/render-locally.js +158 -0
  32. package/dist/tools/timeline-edit.js +367 -0
  33. package/dist/tools/video-assembly.js +131 -4
  34. package/package.json +42 -29
  35. package/BUNDLE_LOCAL_SPEC.md +0 -133
  36. package/engine/assemble.mjs +0 -476
  37. package/engine/gemini.mjs +0 -164
  38. package/engine/kinetic.mjs +0 -84
  39. package/engine/prompts.mjs +0 -94
  40. package/engine/remotion.config.ts +0 -4
  41. package/engine/src/Assembly.tsx +0 -330
  42. package/engine/src/KineticText.tsx +0 -253
  43. package/engine/src/Root.tsx +0 -39
  44. package/engine/src/index.ts +0 -4
  45. package/engine/tsconfig.json +0 -15
  46. package/src/client.ts +0 -119
  47. package/src/index.ts +0 -151
  48. package/src/tools/assembly-adjust.ts +0 -110
  49. package/src/tools/captions.ts +0 -32
  50. package/src/tools/carousel-video.ts +0 -211
  51. package/src/tools/connections.ts +0 -27
  52. package/src/tools/engage.ts +0 -105
  53. package/src/tools/explainer-index.ts +0 -298
  54. package/src/tools/image-ads.ts +0 -192
  55. package/src/tools/kinetic-text.ts +0 -130
  56. package/src/tools/photo-picker.ts +0 -31
  57. package/src/tools/posts.ts +0 -58
  58. package/src/tools/publish.ts +0 -236
  59. package/src/tools/review.ts +0 -126
  60. package/src/tools/video-assembly.ts +0 -205
  61. package/tsconfig.json +0 -15
@@ -4,15 +4,34 @@ exports.listConnectionsTool = void 0;
4
4
  const client_js_1 = require("../client.js");
5
5
  exports.listConnectionsTool = {
6
6
  name: "list_connections",
7
- description: "List your connected social media accounts (TikTok, Instagram, YouTube).",
7
+ description: "List your connected social media accounts (TikTok, Instagram, YouTube). " +
8
+ "Returns each account's connection ID — pass the ID to publish_now / schedule_post / send_to_drafts " +
9
+ "via the account_ids param when you have multiple accounts on the same platform.",
8
10
  inputSchema: { type: "object", properties: {}, required: [] },
9
11
  async execute(_args) {
12
+ var _a;
10
13
  const connections = await (0, client_js_1.get)("/v1/publish/connections");
11
14
  if (!connections.length) {
12
15
  return "No platforms connected yet. Visit https://www.viralspin.ai/spin/settings to connect your accounts.";
13
16
  }
14
- return connections
15
- .map((c) => `${c.platform}: ${c.display_name} (${c.username}) — ${c.follower_count.toLocaleString()} followers`)
16
- .join("\n");
17
+ // Group by platform
18
+ const byPlatform = {};
19
+ for (const c of connections) {
20
+ (byPlatform[_a = c.platform] ?? (byPlatform[_a] = [])).push(c);
21
+ }
22
+ const lines = [];
23
+ for (const [platform, accounts] of Object.entries(byPlatform)) {
24
+ lines.push(`${platform.toUpperCase()}:`);
25
+ for (const c of accounts) {
26
+ const label = c.account_label ? ` [${c.account_label}]` : "";
27
+ const handle = c.username ? ` (@${c.username.replace(/^@/, "")})` : "";
28
+ const followers = c.follower_count > 0 ? ` — ${c.follower_count.toLocaleString()} followers` : "";
29
+ lines.push(` • ${c.display_name}${handle}${label}${followers}`);
30
+ lines.push(` id: ${c.id}`);
31
+ }
32
+ }
33
+ lines.push("");
34
+ lines.push("Tip: when publishing to a specific account, pass its id in the account_ids param.");
35
+ return lines.join("\n");
17
36
  },
18
37
  };
@@ -0,0 +1,136 @@
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.editRawClipTool = void 0;
7
+ /**
8
+ * edit_raw_clip — "edit this" for a RAW local talking-head clip. Transcribes it, cuts the dead
9
+ * space + filler words, burns captions, and drops RICH on-beat motion-graphics (an LLM plans
10
+ * varied stat / logo tile / step / quote cards from the transcript) → a finished 9:16 short.
11
+ *
12
+ * Runs on the user's machine via the bundled intake engine (mcp/engine/intake.py). Only the
13
+ * transcript (not the footage) is sent to the cloud graphics planner, so the video stays local.
14
+ * Falls back to on-device deterministic graphics if the planner isn't reachable.
15
+ */
16
+ const child_process_1 = require("child_process");
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const os_1 = __importDefault(require("os"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const engine_js_1 = require("../engine.js");
21
+ const client_js_1 = require("../client.js");
22
+ /** Spawn the local Python intake engine, trying the usual interpreter names. */
23
+ function runPy(args) {
24
+ const bins = process.platform === "win32" ? ["python", "py", "python3"] : ["python3", "python"];
25
+ return new Promise((resolve, reject) => {
26
+ const tryBin = (i) => {
27
+ if (i >= bins.length) {
28
+ reject(new Error("Python 3 not found on PATH — install it to use edit_raw_clip."));
29
+ return;
30
+ }
31
+ const child = (0, child_process_1.spawn)(bins[i], [path_1.default.join(engine_js_1.ENGINE_DIR, "intake.py"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
32
+ let out = "", err = "";
33
+ child.stdout.on("data", (d) => { out += d.toString(); });
34
+ child.stderr.on("data", (d) => { err += d.toString(); });
35
+ child.on("error", (e) => { if (e.code === "ENOENT")
36
+ tryBin(i + 1);
37
+ else
38
+ reject(e); });
39
+ child.on("close", (code) => {
40
+ if (code === 0)
41
+ resolve(out);
42
+ else
43
+ reject(new Error((err || out).slice(-500) || `intake exited ${code}`));
44
+ });
45
+ };
46
+ tryBin(0);
47
+ });
48
+ }
49
+ exports.editRawClipTool = {
50
+ name: "edit_raw_clip",
51
+ description: "EDIT a raw local talking-head video into a finished 9:16 short — the natural tool for \"edit this: " +
52
+ "<path>\", \"dress this clip\", or \"clean up my talking-head recording\". It transcribes the clip, cuts " +
53
+ "the dead space and filler words (um/uh/…), burns captions, and drops RICH on-beat motion-graphics — " +
54
+ "varied stat cards, brand/logo tiles, step/pipeline cards, and quote cards, chosen and filled by an LLM " +
55
+ "from what's actually said (like a polished explainer, not just captions). Runs on the user's machine; " +
56
+ "the footage stays local (only the transcript is used to plan graphics). Give it the video_path; returns " +
57
+ "the finished file path (and a preview URL). For a pure color/LUT look instead, that's color_grade.",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ video_path: { type: "string", description: "Local path to the raw talking-head video to edit (e.g. G:/clips/talk.mov)." },
62
+ captions: { type: "boolean", description: "Burn captions onto the reel (default true)." },
63
+ graphics: { type: "boolean", description: "Add on-beat motion-graphics (default true)." },
64
+ sensitivity: { type: "number", description: "Dead-space cut threshold in seconds (default 0.5; lower = tighter, more cuts)." },
65
+ out_path: { type: "string", description: "Where to write the finished reel (default: next to the input as <name>_edited.mp4)." },
66
+ },
67
+ required: ["video_path"],
68
+ },
69
+ async execute(args) {
70
+ if (!(0, engine_js_1.engineAvailable)("intake.py")) {
71
+ return "edit_raw_clip needs the local editing engine (Python + ffmpeg). It's unavailable in this install.";
72
+ }
73
+ const vp = (args.video_path || "").trim().replace(/^["']|["']$/g, "");
74
+ if (!vp || !fs_1.default.existsSync(vp)) {
75
+ return `Couldn't find that video: ${vp || "(no path given)"}. Pass a local path, e.g. edit_raw_clip(video_path: "G:/clips/talk.mov").`;
76
+ }
77
+ const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "vs-edit-"));
78
+ const wordsPath = path_1.default.join(tmp, "words.json");
79
+ const outPath = args.out_path || vp.replace(/\.[^./\\]+$/, "") + "_edited.mp4";
80
+ const wantGraphics = args.graphics !== false;
81
+ try {
82
+ // 1) transcribe → word timestamps (reused by the cut + captions + planner, so no double work)
83
+ await runPy(["transcribe", "--clip", vp, "--out", wordsPath]);
84
+ const words = JSON.parse(fs_1.default.readFileSync(wordsPath, "utf8"));
85
+ // 2) plan RICH graphics from the transcript (LLM, best-effort). Only the transcript leaves the
86
+ // machine. If it fails, the engine falls back to on-device deterministic graphics.
87
+ const planArgs = [];
88
+ let plannerNote = "on-device graphics";
89
+ if (wantGraphics && words.length) {
90
+ try {
91
+ const dur = words[words.length - 1]?.end || 0;
92
+ const { graphics } = await (0, client_js_1.pyPost)("/v1/spin/plan-graphics", { words, duration: dur });
93
+ if (Array.isArray(graphics) && graphics.length) {
94
+ const pp = path_1.default.join(tmp, "plan.json");
95
+ fs_1.default.writeFileSync(pp, JSON.stringify(graphics));
96
+ planArgs.push("--graphics", pp);
97
+ plannerNote = `${graphics.length} LLM-planned graphics`;
98
+ }
99
+ }
100
+ catch { /* planner unreachable → on-device fallback */ }
101
+ }
102
+ // 3) dress: cut dead space + filler → captions → graphics (reusing the transcript)
103
+ const dressArgs = ["dress", "--clip", vp, "--out", outPath, "--words", wordsPath, "--max-gap", String(args.sensitivity ?? 0.5), ...planArgs];
104
+ if (args.captions === false)
105
+ dressArgs.push("--no-captions");
106
+ if (!wantGraphics)
107
+ dressArgs.push("--no-graphics");
108
+ const dressOut = await runPy(dressArgs);
109
+ const rm = dressOut.match(/REPORT:(\{.*\})/);
110
+ const report = rm ? JSON.parse(rm[1]) : {};
111
+ // 4) best-effort upload for a shareable preview URL (local file is the real deliverable)
112
+ let url = "";
113
+ try {
114
+ url = (await (0, client_js_1.uploadFiles)([outPath])).urls[0] || "";
115
+ }
116
+ catch { /* local-only is fine */ }
117
+ const bits = [
118
+ `Edited **${path_1.default.basename(vp)}** → \`${outPath}\``,
119
+ `• ${report.original_sec ?? "?"}s → ${report.cut_sec ?? "?"}s (${report.pct_removed ?? 0}% tighter), ${report.n_cuts ?? 0} cuts`,
120
+ `• ${report.n_graphics ?? 0} on-beat graphics (${plannerNote})${args.captions === false ? "" : " + captions"}`,
121
+ ];
122
+ if (url)
123
+ bits.push(`• Preview: ${url}`);
124
+ return bits.join("\n");
125
+ }
126
+ catch (e) {
127
+ return `Couldn't edit the clip: ${e instanceof Error ? e.message : String(e)}`;
128
+ }
129
+ finally {
130
+ try {
131
+ fs_1.default.rmSync(tmp, { recursive: true, force: true });
132
+ }
133
+ catch { /* ignore */ }
134
+ }
135
+ },
136
+ };
@@ -5,7 +5,9 @@ const client_js_1 = require("../client.js");
5
5
  function describe(k) {
6
6
  const scope = k.media_id ? `post ${k.media_id}` : "all posts";
7
7
  const platform = k.platform ?? "any platform";
8
- const reply = k.reply_to_comment ? ", + comment reply" : ", DM only";
8
+ const reply = k.reply_to_comment
9
+ ? (k.comment_reply_text ? `, replies "${k.comment_reply_text}"` : ", + comment reply")
10
+ : ", DM only";
9
11
  const status = k.is_active ? "" : " (paused)";
10
12
  return `"${k.keyword}" → ${k.link} [${platform}, ${scope}, ${k.match_type}${reply}]${status}`;
11
13
  }
@@ -31,13 +33,19 @@ exports.setKeywordLinkTool = {
31
33
  },
32
34
  match_type: {
33
35
  type: "string",
34
- enum: ["exact", "contains"],
35
- description: "'contains' (default) fires if the comment includes the keyword; 'exact' requires a full match.",
36
+ enum: ["exact", "contains", "word"],
37
+ description: "'word' (default, recommended) fires when the keyword appears as a whole word — 'the app.' matches, " +
38
+ "'appreciate' does not. 'contains' fires on any substring, which misfires on short keywords. " +
39
+ "'exact' requires the comment to be nothing but the keyword.",
36
40
  },
37
41
  reply_to_comment: {
38
42
  type: "boolean",
39
43
  description: "Also post a public reply to the comment (e.g. 'Check your DMs', wording varies). Defaults to true.",
40
44
  },
45
+ comment_reply_text: {
46
+ type: "string",
47
+ description: "Custom wording for that public reply. Omit to rotate through the built-in phrases.",
48
+ },
41
49
  },
42
50
  required: ["keyword", "link"],
43
51
  },
@@ -47,8 +55,9 @@ exports.setKeywordLinkTool = {
47
55
  link: args.link,
48
56
  platform: args.platform ?? null,
49
57
  media_id: args.media_id ?? null,
50
- match_type: args.match_type ?? "contains",
58
+ match_type: args.match_type ?? "word",
51
59
  reply_to_comment: args.reply_to_comment ?? true,
60
+ comment_reply_text: args.comment_reply_text ?? null,
52
61
  });
53
62
  return `Saved: ${describe(created)}`;
54
63
  },
@@ -186,6 +186,12 @@ exports.indexExplainerSourcesTool = {
186
186
  required: ["sources_folder"],
187
187
  },
188
188
  async execute(args) {
189
+ // Credit gate: Twelve Labs indexing makes paid API calls that are not yet
190
+ // metered to a user's credit balance, so it is OFF by default for public
191
+ // MCP deployments. Internal/power users can enable it explicitly.
192
+ if (process.env.EXPLAINER_INDEX_ENABLED !== "true") {
193
+ return "Twelve Labs explainer indexing is disabled. This feature calls a paid API that is not yet billed to credits, so it is off by default. To enable it for internal use, set EXPLAINER_INDEX_ENABLED=true in the MCP environment.";
194
+ }
189
195
  const apiKey = process.env.TWELVE_LABS_API_KEY;
190
196
  if (!apiKey) {
191
197
  return "Error: TWELVE_LABS_API_KEY is not set in the MCP environment. Add it to your ~/.claude.json MCP config under env.TWELVE_LABS_API_KEY.";
@@ -0,0 +1,140 @@
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.findMomentsTool = void 0;
7
+ /**
8
+ * find_moments — surface highlight windows inside a long/silent local video so
9
+ * the agent can build a reel without the user pre-splitting it. Shells out to the
10
+ * engine's moments.mjs (ffmpeg frame sampling + vision scoring via the configured
11
+ * model — local Ollama when VIRALSPIN_VLM is set, else cloud Gemini).
12
+ */
13
+ const child_process_1 = require("child_process");
14
+ const path_1 = __importDefault(require("path"));
15
+ const engine_js_1 = require("../engine.js");
16
+ function runMoments(args, script = "moments.mjs") {
17
+ return new Promise((resolve, reject) => {
18
+ const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, script), ...args], {
19
+ cwd: engine_js_1.ENGINE_DIR,
20
+ env: { ...process.env },
21
+ });
22
+ let out = "";
23
+ child.stdout.on("data", (d) => { out += d.toString(); });
24
+ child.stderr.on("data", (d) => process.stderr.write(d));
25
+ child.on("error", reject);
26
+ child.on("close", (code) => {
27
+ const m = out.match(/RESULT:([\s\S]+)/);
28
+ if (code === 0 && m)
29
+ resolve(m[1].trim());
30
+ else
31
+ reject(new Error(out.slice(-400) || `moments engine exited ${code}`));
32
+ });
33
+ });
34
+ }
35
+ exports.findMomentsTool = {
36
+ name: "find_moments",
37
+ description: "Find the highlight-worthy moments INSIDE a long or silent LOCAL video (e.g. a screen recording or B-roll " +
38
+ "reel) — no need to split it first. Samples frames and scores them with the configured vision model " +
39
+ "(free + private local Ollama when local mode is on, else cloud), and returns 0.6–3s windows with " +
40
+ "descriptions. Pass `query` to bias toward a theme. Set `extract: true` to ALSO cut each highlight into " +
41
+ "its own small clip LOCALLY — then you upload only those snippets (not the long source). Build a reel from " +
42
+ "them with create_video_assembly: destination:'hosted' uploads ONLY the snippets + opens the web editor; " +
43
+ "destination:'local' keeps everything on-device (no upload). Same analysis, two destinations.",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ video_path: { type: "string", description: "Local path to the long video to scan." },
48
+ query: { type: "string", description: "Optional theme to bias toward (e.g. 'surprise reveal', 'on-screen editing')." },
49
+ threshold: { type: "number", description: "Score cutoff 0..1 for a 'highlight' (default 0.55; lower = more windows)." },
50
+ extract: { type: "boolean", description: "Also cut each highlight into its own small clip locally (so you upload only the snippets). Default false = just list the windows." },
51
+ mode: { type: "string", enum: ["highlight", "screen"], description: "'highlight' (default) = VLM-scored striking moments for any footage. 'screen' = SCREEN-RECORDING mode: frame-diff finds where the on-screen ACTION is and returns a focal point (x,y) + suggested punch-in zoom per snippet, so the reel can auto-zoom to exactly where things happen (cursor, typing, output). Use for app/product demos." },
52
+ },
53
+ required: ["video_path"],
54
+ },
55
+ async execute(args) {
56
+ if (args.mode === "screen")
57
+ return runScreenMode(args);
58
+ if (!(0, engine_js_1.engineAvailable)("moments.mjs")) {
59
+ return "find_moments needs the local engine (it runs ffmpeg + the vision model). It's unavailable in this install — set VIRALSPIN_ASSEMBLY_DIR, or use the hosted transcript path (get_transcript + keep_windows) for talking/narrated video instead.";
60
+ }
61
+ const cli = [args.video_path];
62
+ if (args.query)
63
+ cli.push("--query", args.query);
64
+ if (args.threshold != null)
65
+ cli.push("--threshold", String(args.threshold));
66
+ if (args.extract)
67
+ cli.push("--extract");
68
+ let json;
69
+ try {
70
+ json = JSON.parse(await runMoments(cli));
71
+ }
72
+ catch (e) {
73
+ return `Couldn't scan the video: ${e instanceof Error ? e.message : String(e)}`;
74
+ }
75
+ if (!json.windows?.length) {
76
+ return `Scanned ${json.sampled} frames over ${Math.round(json.duration)}s but found no standout moments — lower the threshold or add a query.`;
77
+ }
78
+ const head = `Found ${json.windows.length} highlight window(s) in ${path_1.default.basename(args.video_path)} (${Math.round(json.duration)}s, ${json.sampled} frames sampled, analyzed locally if local mode is on):`;
79
+ if (json.clips?.length) {
80
+ const lines = json.clips.map((c, i) => `${i + 1}. ${c.path} (${c.from_sec.toFixed(1)}–${c.to_sec.toFixed(1)}s)${c.desc ? ` — ${c.desc}` : ""}`);
81
+ return [
82
+ head,
83
+ `Extracted ${json.clips.length} clip(s) → ${json.out_dir}`,
84
+ ...lines,
85
+ ``,
86
+ `Build a reel from these snippets with create_video_assembly:`,
87
+ ` • destination:"hosted" → uploads ONLY these snippets (not the long source) + opens the web editor.`,
88
+ ` • destination:"local" → keeps everything on-device, no upload (fully private).`,
89
+ ].join("\n");
90
+ }
91
+ const lines = json.windows.map((w, i) => `${i + 1}. ${w.from_sec.toFixed(1)}–${w.to_sec.toFixed(1)}s (score ${w.score})${w.desc ? ` — ${w.desc}` : ""}`);
92
+ return [
93
+ head,
94
+ ...lines,
95
+ ``,
96
+ `Re-run with extract:true to cut these into clips (then upload only those), or run keep_windows on a hosted project to cut to just these moments.`,
97
+ ].join("\n");
98
+ },
99
+ };
100
+ // SCREEN mode — shells to screen-moments.mjs (frame-diff focal + zoom, VLM label).
101
+ // Returns snippets that carry focal_x/focal_y + zoom so the assembly can punch-in to
102
+ // exactly where the on-screen action is. Each screen recording is a B-roll SOURCE;
103
+ // call once per recording and let the story arc stitch the snippets together.
104
+ async function runScreenMode(args) {
105
+ if (!(0, engine_js_1.engineAvailable)("screen-moments.mjs")) {
106
+ return "Screen mode needs the local engine (ffmpeg + Python opencv). It's unavailable in this install — set VIRALSPIN_ASSEMBLY_DIR and `pip install opencv-python numpy`.";
107
+ }
108
+ const cli = [args.video_path];
109
+ if (args.threshold != null)
110
+ cli.push("--threshold", String(args.threshold));
111
+ if (args.extract)
112
+ cli.push("--extract");
113
+ let json;
114
+ try {
115
+ json = JSON.parse(await runMoments(cli, "screen-moments.mjs"));
116
+ }
117
+ catch (e) {
118
+ return `Couldn't scan the screen recording: ${e instanceof Error ? e.message : String(e)}`;
119
+ }
120
+ if (!json.snippets?.length) {
121
+ return `Scanned ${json.sampled} frames over ${Math.round(json.duration)}s of ${path_1.default.basename(args.video_path)} but found no clear on-screen action — the recording may be mostly static.`;
122
+ }
123
+ const head = `Found ${json.snippets.length} on-screen action moment(s) in ${path_1.default.basename(args.video_path)} (${json.width}×${json.height}, ${Math.round(json.duration)}s). Each carries a focal point + punch-in zoom so the reel can zoom to exactly where the action is:`;
124
+ const fmt = (s, i) => `${i + 1}. ${s.from_sec.toFixed(1)}–${s.to_sec.toFixed(1)}s — focal (${s.focal_x.toFixed(2)}, ${s.focal_y.toFixed(2)}), zoom ${s.zoom}×${s.label ? ` — “${s.label}”` : ""}`;
125
+ if (json.clips?.length) {
126
+ return [
127
+ head,
128
+ ...json.snippets.map(fmt),
129
+ ``,
130
+ `Extracted ${json.clips.length} snippet clip(s) → ${json.out_dir}`,
131
+ `These are B-roll candidates: build a screen-demo reel with create_video_assembly (they stitch together with other B-roll; the focal+zoom drive the auto punch-in).`,
132
+ ].join("\n");
133
+ }
134
+ return [
135
+ head,
136
+ ...json.snippets.map(fmt),
137
+ ``,
138
+ `Re-run with extract:true to cut these into snippet clips, then assemble a screen-demo reel from them (each snippet's focal+zoom drives the punch-in).`,
139
+ ].join("\n");
140
+ }
@@ -0,0 +1,71 @@
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.previewFolderTool = void 0;
7
+ /**
8
+ * preview_folder — a pre-flight cost gate for assembling a local folder.
9
+ *
10
+ * create_video_assembly analyzes every clip (Gemini tags) and charges credits on
11
+ * create. For a big folder that adds up, so the agent should call this FIRST and
12
+ * confirm with the user before assembling. Counting is local (reads the disk),
13
+ * so it's instant and free; the exact credit charge is finalized server-side in
14
+ * the create response. An optional per-asset rate (env) gives a rough estimate.
15
+ */
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const path_1 = __importDefault(require("path"));
18
+ const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
19
+ const VIDEO_EXT = new Set([".mp4", ".mov", ".webm", ".m4v"]);
20
+ exports.previewFolderTool = {
21
+ name: "preview_folder",
22
+ description: "Before assembling from a LOCAL folder, count its media so you can warn the user about cost. Each clip is " +
23
+ "analyzed (tagged for matching) and credits are charged when create_video_assembly runs — so for a big " +
24
+ "folder, call this first and CONFIRM before proceeding. Counting is instant + free; the exact charge is " +
25
+ "finalized in the create response. Returns video/image counts (+ a rough credit estimate if a per-asset " +
26
+ "rate is configured via VIRALSPIN_ANALYZE_CREDITS_PER_ASSET).",
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ folder: { type: "string", description: "Local folder path to inspect." },
31
+ },
32
+ required: ["folder"],
33
+ },
34
+ async execute(args) {
35
+ const folder = args.folder;
36
+ if (!fs_1.default.existsSync(folder))
37
+ return `Folder not found: ${folder}`;
38
+ let names;
39
+ try {
40
+ const st = fs_1.default.statSync(folder);
41
+ if (!st.isDirectory())
42
+ return `${folder} is a file, not a folder.`;
43
+ names = fs_1.default.readdirSync(folder);
44
+ }
45
+ catch (e) {
46
+ return `Can't read folder: ${e instanceof Error ? e.message : e}`;
47
+ }
48
+ let images = 0;
49
+ let videos = 0;
50
+ for (const n of names) {
51
+ const ext = path_1.default.extname(n).toLowerCase();
52
+ if (VIDEO_EXT.has(ext))
53
+ videos++;
54
+ else if (IMAGE_EXT.has(ext))
55
+ images++;
56
+ }
57
+ const total = images + videos;
58
+ if (!total)
59
+ return `No images or videos found in ${folder}.`;
60
+ const rate = Number(process.env.VIRALSPIN_ANALYZE_CREDITS_PER_ASSET || "");
61
+ const est = Number.isFinite(rate) && rate > 0 ? ` Rough estimate: ~${Math.round(total * rate)} credits (${rate}/clip).` : "";
62
+ const warnAt = Number(process.env.VIRALSPIN_FOLDER_WARN || "40");
63
+ const big = total > warnAt;
64
+ return (`${folder}\n` +
65
+ `${total} media file(s): ${videos} video(s), ${images} image(s).\n` +
66
+ `Each is analyzed when you assemble; credits are charged on create_video_assembly and finalized in its response.${est}\n` +
67
+ (big
68
+ ? `⚠️ Large folder — CONFIRM with the user before assembling (it will cost credits). Consider a sub-folder or only the best clips.`
69
+ : `Small enough to assemble directly.`));
70
+ },
71
+ };
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listIdeasTool = exports.queueIdeaTool = void 0;
4
+ /**
5
+ * queue_idea / list_ideas — the Prompt/Idea History bridge. Claude brainstorms
6
+ * with the user, then queues each idea (hook + script + an asset plan) here; it
7
+ * persists in Supabase (via /v1/spin/ideas) and shows up in the user's web
8
+ * /spin/history, cross-session and cross-browser. The asset_plan tells the user
9
+ * what's needed to execute (existing clips, AI B-roll, web images, kinetic text,
10
+ * or footage to film).
11
+ */
12
+ const client_js_1 = require("../client.js");
13
+ exports.queueIdeaTool = {
14
+ name: "queue_idea",
15
+ description: "Queue a content idea into the user's persistent Prompt/Idea History (shows in their web /spin/history, " +
16
+ "cross-browser). Use after brainstorming or after analyzing a saved/pasted post the user wants to replicate. " +
17
+ "Give it a `hook`, a ready-to-film `script` (they read it off the teleprompter at /create), the `topic`, and " +
18
+ "an `asset_plan` — per shot, say what to use: existing clip / AI-generated B-roll / web image (logo, person) / " +
19
+ "kinetic text overlay / footage to film. Set `source_url` if it came from a saved post. " +
20
+ "For a punchy 5-7s relatable joke where the VALUE IS THE OVERLAY CAPTION (not narration) — e.g. a video of " +
21
+ "someone on a plane captioned 'Flight attendant: OMG is there anyone onboard who can align cross-functional " +
22
+ "stakeholders while navigating ambiguity?' — use format:'reel_7s' and put the line in `caption`. " +
23
+ "GROUND every reel_7s in what is CURRENTLY GOING VIRAL in the creator's niche: call get_creator_profile " +
24
+ "for their niche + handles + topics, research the caption-reel formats + in-group jokes trending in that " +
25
+ "niche right now (their and adjacent creators' recent hits), and adapt a PROVEN pattern to their audience — " +
26
+ "don't invent a random joke. When generating a WEEK's batch of ideas, include AT LEAST ONE reel_7s modeled " +
27
+ "on that week's viral trend in the niche (set `source_url` to the post it's modeled on when there is one). " +
28
+ "reel_7s ASSET PLAN — if the caption has a SETUP → PAYOFF shape (before/after, question → reaction, 'four " +
29
+ "hours later:'), SPLIT the asset_plan into ONE BEAT PER SEGMENT so the shoot app can guide the creator " +
30
+ "part-by-part; a single-line caption = a single beat. Give each segment its own overlay line QUOTED in " +
31
+ "`note` (e.g. note: 'Overlay: \"Four hours later:\". Same framing as segment 1 - the contrast IS the joke.'), " +
32
+ "its own `search_terms`, and kind:'existing' — so the app can AI-pick that segment from the creator's camera " +
33
+ "roll (a 7s clip OR a photo) and they can still film/overwrite it themselves. " +
34
+ "HOOK PATTERN — a strong attention-grab is to OPEN ON A SURPRISED REACTION, then demonstrate the payoff " +
35
+ "('wait, what?!' → here's the thing). For ideas that PROMOTE VIRALSPIN (which are encouraged), lead the " +
36
+ "asset_plan with a reaction beat: first shot kind:'film' note the creator's surprised/delighted reaction " +
37
+ "(e.g. 'reacts stunned to how fast the reel was made'), THEN the demonstration beats. The mobile shoot flow " +
38
+ "(Take → React → Show → Build) mirrors this, so a react-first plan maps cleanly to how it's executed. " +
39
+ "SHOT PHRASING — write each `shot` as a SHORT, PLAIN, second-person creator ACTION they can just DO, not a " +
40
+ "cinematic third-person screenplay direction. GOOD: 'Show/gesture a perfect prompt', 'Sift through a bunch of AI " +
41
+ "output and pick a few good ones', 'React impressed at the polished result'. BAD (too abstract/wordy): 'Creator " +
42
+ "looking thoughtful, gesturing beyond the camera to a conceptual future', 'Hands sifting through a grid of " +
43
+ "AI-generated text/images on a tablet, selecting a few'. If a beat is really an EMOTIONAL/impressed MOMENT (e.g. " +
44
+ "'confidently presenting a refined high-quality result'), make it a REACTION the creator performs to camera — an " +
45
+ "impressed facial reaction — NOT a staged 'show' of holding up a screen. " +
46
+ "TAKE vs SHOW — the creator ON CAMERA (talking, gesturing, reacting) belongs to the Take + React steps, so those " +
47
+ "are kind:'film'. The 'SHOW IT' beats are footage of the SUBJECT — a screen recording, a demo, product/B-roll — " +
48
+ "NOT the creator; make them kind:'existing' (a clip the user records/provides) or kind:'ai_broll', and describe " +
49
+ "WHAT'S ON SCREEN, not the person. NEVER write a show/demo beat as 'creator looking thoughtful' or 'creator " +
50
+ "pointing at a screen' — that's a talking-head (Take). E.g. a demo beat = existing 'Screen-record the bad-vs-good " +
51
+ "prompt output', not film 'Creator points to the screen'. " +
52
+ "NO REDUNDANT TAKE BEATS — the creator reads the WHOLE script off the teleprompter in step 1, so NEVER add a " +
53
+ "kind:'film' beat that is just 'talk/gesture to camera as you tee up the idea': that IS the Take, and it orphans " +
54
+ "in the shoot UI. A kind:'film' beat must be a PERFORMED MOMENT — a face or a GESTURE — because those become " +
55
+ "real tasks in the React step (step 2). Reach for a gesture when a phrase invites one: 'the real secret…' → film " +
56
+ "'Shhh - index finger to your lips as you tease the secret'. " +
57
+ "EVERY LINE SHOULD HAVE SOMETHING ON SCREEN — the shoot app shows the script beside an 'on screen' column, so " +
58
+ "cover the script with beats, and lean on the NON-FILM kinds so the creator isn't asked to shoot everything: " +
59
+ "kind:'web_image' = a logo/graphic/image PULLED FROM THE WEB, floated in a card beside the creator's head (use it " +
60
+ "when a concept has an obvious visual — 'AI success' → a trending-up chart / trophy); kind:'kinetic' = text " +
61
+ "overlays AND animated motion graphics. Describe a motion graphic in PLAIN TERMS of what the viewer sees ('a " +
62
+ "terminal window that types the prompt out') — never name an implementation (don't say 'SVG', 'Lottie' or " +
63
+ "'Remotion'): these are drawn natively on the phone, so an implementation name is both wrong and meaningless to " +
64
+ "the creator reading the plan. A terminal/typing animation is the one bespoke motion graphic that renders today " +
65
+ "(say 'terminal' or 'typing' in the shot and it's picked up); any other kinetic beat renders as its text overlay, " +
66
+ "so put the words you want burned on screen IN QUOTES. " +
67
+ "For any web_image/kinetic overlay DON'T pin an exact position — say it floats above the creator's head " +
68
+ "or wherever there's space, and note that placement is resolved at render so it won't collide with the captions.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ hook: { type: "string", description: "The scroll-stopping opening line / concept." },
73
+ script: { type: "string", description: "The full narration script to film (teleprompter-ready)." },
74
+ topic: { type: "string", description: "The core topic / pillar this idea belongs to." },
75
+ tags: { type: "array", items: { type: "string" }, description: "Optional tags." },
76
+ asset_plan: {
77
+ type: "array",
78
+ description: "Per-shot plan: [{shot, kind:'existing'|'ai_broll'|'web_image'|'kinetic'|'film', note, narration, search_terms, asset_url?}]. " +
79
+ "ALWAYS set `search_terms` on any SHOW beat (existing / ai_broll) AND on every `web_image` beat — on a " +
80
+ "web_image it's the WEB SEARCH we run to find the graphic, and a result is only kept if its title mentions " +
81
+ "one of these terms, so an abstract concept with no concrete nouns finds nothing and the card falls back to " +
82
+ "plain text (e.g. 'AI success' → search_terms 'trophy, winner podium, chart going up, success'). " +
83
+ "Set `asset_url` instead when a SPECIFIC image is wanted; then no search runs. search_terms are " +
84
+ "CONCRETE VISUAL NOUNS, comma-separated, " +
85
+ "that could literally appear in a photo — the shoot app matches these against on-device Vision labels of " +
86
+ "the creator's camera roll. `shot` is written for a HUMAN to read and is often abstract, which matches " +
87
+ "nothing; `search_terms` is what the matcher actually searches. E.g. shot 'Sift through a bunch of AI " +
88
+ "output and pick a few good ones' → search_terms 'tablet, laptop screen, computer monitor, grid of images, " +
89
+ "hands typing'. Use plain everyday objects/places/people (laptop, screen, desk, phone, coffee, office, " +
90
+ "whiteboard) — NOT abstractions ('AI output', 'workflow', 'the future'). " +
91
+ "ALWAYS set `narration` to the EXACT word(s) from `script` the creator is SPEAKING while this beat is on " +
92
+ "screen — the beats run in order and together cover the whole script, so each beat's narration is its slice of " +
93
+ "the script verbatim. A slice does NOT have to be a whole sentence: when an overlay is triggered by SPECIFIC " +
94
+ "WORDS, CUT THE SLICE AT THOSE WORDS so the beat lands on the row it belongs to. E.g. for 'The real secret to " +
95
+ "AI success isn't learning to prompt.' with a shush gesture on 'the real secret' and a web graphic on 'AI " +
96
+ "success', emit TWO beats — narration 'The real secret' then 'to AI success isn't learning to prompt.' — " +
97
+ "NOT one sentence-beat plus a graphic pushed onto the NEXT sentence, which mis-times the graphic. This is what the shoot UI shows in quotes under each shot, so the creator knows what " +
98
+ "they're saying during it. (A kinetic text-overlay beat still has narration playing under it.)",
99
+ items: {
100
+ type: "object",
101
+ properties: {
102
+ shot: { type: "string" },
103
+ kind: { type: "string", enum: ["existing", "ai_broll", "web_image", "kinetic", "film"] },
104
+ note: { type: "string" },
105
+ narration: { type: "string", description: "The verbatim script line(s) spoken during this beat." },
106
+ search_terms: { type: "string", description: "Concrete visual nouns (comma-separated) to match the creator's camera roll, e.g. 'tablet, laptop screen, grid of images'." },
107
+ asset_url: { type: "string" },
108
+ },
109
+ required: ["shot", "kind"],
110
+ },
111
+ },
112
+ source_url: { type: "string", description: "The saved/pasted post URL this idea is modeled on, if any." },
113
+ format: { type: "string", enum: ["video", "carousel", "image", "reel_7s"], description: "How it's executed: 'video' (film off the teleprompter) | 'carousel' (image carousel) | 'image' | 'reel_7s' (a 5-7s caption reel — see below). Default video." },
114
+ caption: { type: "string", description: "For format 'reel_7s' ONLY: the single punchy OVERLAY CAPTION that IS the joke/value (e.g. \"Flight attendant: OMG is there anyone onboard who can align cross-functional stakeholders while navigating ambiguity?\"). The user films 5-7s of themselves reacting / doing a light action (or any B-roll — it barely matters); the caption carries it. Put the caption here AND in `hook`." },
115
+ week: { type: "string", description: "ISO week tag for weekly batches, e.g. '2026-W27'. Group a week's 5 ideas under the same value." },
116
+ },
117
+ },
118
+ async execute(args) {
119
+ // For a 7s caption reel the overlay caption IS the content — use it as the hook/script if not given.
120
+ const hook = args.hook ?? args.caption;
121
+ const script = args.script ?? (args.format === "reel_7s" ? args.caption : undefined);
122
+ if (!hook && !script)
123
+ return "Give the idea at least a `hook` or a `script` (or a `caption` for reel_7s).";
124
+ const r = await (0, client_js_1.post)("/v1/spin/ideas", {
125
+ hook,
126
+ script,
127
+ topic: args.topic,
128
+ tags: args.tags,
129
+ asset_plan: args.asset_plan,
130
+ source_url: args.source_url,
131
+ format: args.format,
132
+ caption: args.caption,
133
+ week: args.week,
134
+ });
135
+ const planNote = args.asset_plan?.length ? ` with a ${args.asset_plan.length}-shot asset plan` : "";
136
+ return (`Queued idea ${r.id}${planNote} → it's in the user's Prompt/Idea History (/spin/history), saved across sessions + browsers.\n` +
137
+ `They can open it, film the script on the teleprompter at /create, and the plan says what B-roll to use vs film.`);
138
+ },
139
+ };
140
+ exports.listIdeasTool = {
141
+ name: "list_ideas",
142
+ description: "List the user's queued content ideas (their Prompt/Idea History). Optionally filter by status " +
143
+ "(queued / scripted / filmed / done / dismissed). Use to resume planning or pick the next idea to execute.",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ status: { type: "string", enum: ["queued", "scripted", "filmed", "done", "dismissed"], description: "Optional status filter." },
148
+ },
149
+ },
150
+ async execute(args) {
151
+ const q = args.status ? `?status=${encodeURIComponent(args.status)}` : "";
152
+ const r = await (0, client_js_1.get)(`/v1/spin/ideas${q}`);
153
+ if (!r.ideas.length)
154
+ return args.status ? `No ideas with status "${args.status}".` : "No ideas queued yet — brainstorm some and queue_idea them.";
155
+ const lines = r.ideas.map((i) => `- [${i.status}] ${i.hook || "(no hook)"}${i.topic ? ` · ${i.topic}` : ""}${i.source_url ? ` · from ${i.source_url}` : ""} (id ${i.id})`);
156
+ return [`${r.ideas.length} idea(s) in the Prompt/Idea History:`, ...lines].join("\n");
157
+ },
158
+ };
@@ -6,13 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createKineticTextTool = void 0;
7
7
  const child_process_1 = require("child_process");
8
8
  const path_1 = __importDefault(require("path"));
9
- const ENGINE_DIR = process.env.VIRALSPIN_ASSEMBLY_DIR || path_1.default.resolve(__dirname, "../../engine");
9
+ const engine_js_1 = require("../engine.js");
10
10
  const EFFECTS = ["typing", "billboard_flip", "isometric"];
11
11
  const STYLES = ["terminal", "cinematic", "minimal"];
12
12
  const FORMATS = ["9x16", "16x9", "1x1"];
13
13
  function runKinetic(args) {
14
14
  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, "kinetic.mjs"), ...args], { cwd: ENGINE_DIR, env: { ...process.env } });
15
+ const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "kinetic.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
16
16
  let stdout = "";
17
17
  child.stdout.on("data", (d) => { stdout += d.toString(); });
18
18
  child.stderr.on("data", (d) => process.stderr.write(d));
@@ -86,6 +86,10 @@ exports.createKineticTextTool = {
86
86
  required: ["text"],
87
87
  },
88
88
  async execute(args) {
89
+ if (!(0, engine_js_1.engineAvailable)("kinetic.mjs")) {
90
+ return ("create_kinetic_text needs the ViralSpin engine, which isn't bundled in this install.\n" +
91
+ "Set VIRALSPIN_ASSEMBLY_DIR to a local engine checkout to enable it.");
92
+ }
89
93
  const cli = ["--text", args.text];
90
94
  if (args.effect)
91
95
  cli.push("--effect", args.effect);