@viralspin/mcp 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/client.js +8 -0
  4. package/dist/engine.js +29 -0
  5. package/dist/hosted.js +159 -0
  6. package/dist/index.js +62 -16
  7. package/dist/sanitize.js +22 -0
  8. package/dist/tools/assembly-adjust.js +107 -15
  9. package/dist/tools/carousel-video.js +7 -0
  10. package/dist/tools/color-grade.js +64 -0
  11. package/dist/tools/connections.js +23 -4
  12. package/dist/tools/edit-clip.js +136 -0
  13. package/dist/tools/engage.js +13 -4
  14. package/dist/tools/explainer-index.js +6 -0
  15. package/dist/tools/find-moments.js +140 -0
  16. package/dist/tools/folder-preview.js +71 -0
  17. package/dist/tools/ideas.js +158 -0
  18. package/dist/tools/kinetic-text.js +6 -2
  19. package/dist/tools/local-index.js +84 -0
  20. package/dist/tools/local-mode.js +84 -0
  21. package/dist/tools/profile.js +56 -0
  22. package/dist/tools/publish.js +34 -10
  23. package/dist/tools/relight-clip.js +98 -0
  24. package/dist/tools/timeline-edit.js +367 -0
  25. package/dist/tools/video-assembly.js +131 -4
  26. package/package.json +41 -29
  27. package/BUNDLE_LOCAL_SPEC.md +0 -133
  28. package/engine/assemble.mjs +0 -476
  29. package/engine/gemini.mjs +0 -164
  30. package/engine/kinetic.mjs +0 -84
  31. package/engine/prompts.mjs +0 -94
  32. package/engine/remotion.config.ts +0 -4
  33. package/engine/src/Assembly.tsx +0 -330
  34. package/engine/src/KineticText.tsx +0 -253
  35. package/engine/src/Root.tsx +0 -39
  36. package/engine/src/index.ts +0 -4
  37. package/engine/tsconfig.json +0 -15
  38. package/src/client.ts +0 -119
  39. package/src/index.ts +0 -151
  40. package/src/tools/assembly-adjust.ts +0 -110
  41. package/src/tools/captions.ts +0 -32
  42. package/src/tools/carousel-video.ts +0 -211
  43. package/src/tools/connections.ts +0 -27
  44. package/src/tools/engage.ts +0 -105
  45. package/src/tools/explainer-index.ts +0 -298
  46. package/src/tools/image-ads.ts +0 -192
  47. package/src/tools/kinetic-text.ts +0 -130
  48. package/src/tools/photo-picker.ts +0 -31
  49. package/src/tools/posts.ts +0 -58
  50. package/src/tools/publish.ts +0 -236
  51. package/src/tools/review.ts +0 -126
  52. package/src/tools/video-assembly.ts +0 -205
  53. package/tsconfig.json +0 -15
@@ -0,0 +1,64 @@
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.colorGradeTool = void 0;
7
+ /**
8
+ * color_grade — free, instant cinematic LUT looks (ffmpeg, no AI, no cost). The
9
+ * cheap counterpart to relight_clip: it regrades color/contrast but can't change
10
+ * the background or add 3D relighting. Great for the A-roll or any clip.
11
+ */
12
+ const child_process_1 = require("child_process");
13
+ const path_1 = __importDefault(require("path"));
14
+ const engine_js_1 = require("../engine.js");
15
+ const LOOKS = ["teal-orange", "noir", "warm-golden", "cool-cinematic", "vintage", "vibrant", "moody-blue"];
16
+ function runGrade(args) {
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, "grade.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
19
+ let out = "";
20
+ child.stdout.on("data", (d) => { out += d.toString(); });
21
+ child.stderr.on("data", (d) => process.stderr.write(d));
22
+ child.on("error", reject);
23
+ child.on("close", (code) => {
24
+ const m = out.match(/RESULT:([\s\S]+)/);
25
+ if (code === 0 && m)
26
+ resolve(m[1].trim());
27
+ else
28
+ reject(new Error(out.slice(-300) || `grade exited ${code}`));
29
+ });
30
+ });
31
+ }
32
+ exports.colorGradeTool = {
33
+ name: "color_grade",
34
+ description: "Apply a FREE, instant cinematic color grade / LUT look to a LOCAL video (no AI, no cost) — teal-orange, " +
35
+ "noir, warm-golden, cool-cinematic, vintage, vibrant, or moody-blue. Great for the A-roll or any clip; it's " +
36
+ "a toggle-able look (keep the original or the graded file). For true relighting / background change, that's " +
37
+ "the paid relight_clip instead. Omit `look` to list the available looks.",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ video_path: { type: "string", description: "Local path to the video to grade." },
42
+ look: { type: "string", enum: LOOKS, description: "The look to apply. Omit to list all looks." },
43
+ },
44
+ required: ["video_path"],
45
+ },
46
+ async execute(args) {
47
+ if (!(0, engine_js_1.engineAvailable)("grade.mjs")) {
48
+ return "color_grade needs the local engine (ffmpeg). It's unavailable in this install.";
49
+ }
50
+ if (!args.look) {
51
+ const r = JSON.parse(await runGrade(["--list"]));
52
+ const lines = r.looks.map((l) => ` • ${l.key} — ${l.label}`);
53
+ return ["Free cinematic looks (no cost):", ...lines, "", `Apply one: color_grade(video_path: "${args.video_path}", look: "teal-orange").`].join("\n");
54
+ }
55
+ let r;
56
+ try {
57
+ r = JSON.parse(await runGrade([args.video_path, args.look]));
58
+ }
59
+ catch (e) {
60
+ return `Couldn't grade the video: ${e instanceof Error ? e.message : String(e)}`;
61
+ }
62
+ return `Applied the "${r.look}" grade → ${r.out}\nFree + instant (ffmpeg). Swap it in, or try another look.`;
63
+ },
64
+ };
@@ -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
+ };