@viralspin/mcp 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/cli.js +40 -0
- package/dist/client.js +56 -9
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +144 -2
- package/dist/local-render/kit.js +217 -0
- package/dist/local-render/login.js +138 -0
- package/dist/local-render/render.js +252 -0
- package/dist/local-render/tokens.js +201 -0
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +194 -0
- package/dist/tools/carousel-video.js +197 -0
- package/dist/tools/color-grade.js +64 -0
- package/dist/tools/connections.js +23 -4
- package/dist/tools/edit-clip.js +136 -0
- package/dist/tools/engage.js +97 -0
- package/dist/tools/explainer-index.js +307 -0
- package/dist/tools/find-moments.js +140 -0
- package/dist/tools/folder-preview.js +71 -0
- package/dist/tools/ideas.js +158 -0
- package/dist/tools/image-ads.js +156 -0
- package/dist/tools/kinetic-text.js +122 -0
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/photo-picker.js +31 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +119 -12
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/render-locally.js +158 -0
- package/dist/tools/review.js +103 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +310 -0
- package/package.json +16 -3
- package/src/client.ts +0 -80
- package/src/index.ts +0 -60
- package/src/tools/captions.ts +0 -32
- package/src/tools/connections.ts +0 -27
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -158
- 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deleteKeywordLinkTool = exports.listKeywordLinksTool = exports.setKeywordLinkTool = void 0;
|
|
4
|
+
const client_js_1 = require("../client.js");
|
|
5
|
+
function describe(k) {
|
|
6
|
+
const scope = k.media_id ? `post ${k.media_id}` : "all posts";
|
|
7
|
+
const platform = k.platform ?? "any platform";
|
|
8
|
+
const reply = k.reply_to_comment
|
|
9
|
+
? (k.comment_reply_text ? `, replies "${k.comment_reply_text}"` : ", + comment reply")
|
|
10
|
+
: ", DM only";
|
|
11
|
+
const status = k.is_active ? "" : " (paused)";
|
|
12
|
+
return `"${k.keyword}" → ${k.link} [${platform}, ${scope}, ${k.match_type}${reply}]${status}`;
|
|
13
|
+
}
|
|
14
|
+
exports.setKeywordLinkTool = {
|
|
15
|
+
name: "set_keyword_link",
|
|
16
|
+
description: "Map a keyword to a resource/destination link for DM automation. When a follower comments/DMs the keyword, " +
|
|
17
|
+
"the link is sent back (delivery activates once Instagram messaging access is approved). " +
|
|
18
|
+
"Re-running with the same keyword + scope updates the link instead of creating a duplicate. " +
|
|
19
|
+
"Omit media_id for a global rule (fires on any post); set media_id for a single post.",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
keyword: { type: "string", description: "The trigger word, e.g. 'guide' or 'link'. Case-insensitive." },
|
|
24
|
+
link: { type: "string", description: "The destination URL to send (e.g. a resource-hub article)." },
|
|
25
|
+
platform: {
|
|
26
|
+
type: "string",
|
|
27
|
+
enum: ["instagram", "tiktok", "youtube"],
|
|
28
|
+
description: "Platform this rule applies to. Omit for any connected platform. Only Instagram delivers today.",
|
|
29
|
+
},
|
|
30
|
+
media_id: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "Optional. A specific post/media id to scope the rule to. Omit for a global rule across all posts.",
|
|
33
|
+
},
|
|
34
|
+
match_type: {
|
|
35
|
+
type: "string",
|
|
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.",
|
|
40
|
+
},
|
|
41
|
+
reply_to_comment: {
|
|
42
|
+
type: "boolean",
|
|
43
|
+
description: "Also post a public reply to the comment (e.g. 'Check your DMs', wording varies). Defaults to true.",
|
|
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
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ["keyword", "link"],
|
|
51
|
+
},
|
|
52
|
+
async execute(args) {
|
|
53
|
+
const created = await (0, client_js_1.post)("/v1/engage/keyword-links", {
|
|
54
|
+
keyword: args.keyword,
|
|
55
|
+
link: args.link,
|
|
56
|
+
platform: args.platform ?? null,
|
|
57
|
+
media_id: args.media_id ?? null,
|
|
58
|
+
match_type: args.match_type ?? "word",
|
|
59
|
+
reply_to_comment: args.reply_to_comment ?? true,
|
|
60
|
+
comment_reply_text: args.comment_reply_text ?? null,
|
|
61
|
+
});
|
|
62
|
+
return `Saved: ${describe(created)}`;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
exports.listKeywordLinksTool = {
|
|
66
|
+
name: "list_keyword_links",
|
|
67
|
+
description: "List your keyword→link DM-automation rules.",
|
|
68
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
69
|
+
async execute(_args) {
|
|
70
|
+
const rules = await (0, client_js_1.get)("/v1/engage/keyword-links");
|
|
71
|
+
if (!rules.length) {
|
|
72
|
+
return "No keyword→link rules yet. Use set_keyword_link to add one (e.g. map 'guide' to your resource link).";
|
|
73
|
+
}
|
|
74
|
+
return rules.map((r) => `• ${describe(r)}`).join("\n");
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
exports.deleteKeywordLinkTool = {
|
|
78
|
+
name: "delete_keyword_link",
|
|
79
|
+
description: "Delete a keyword→link rule by its keyword. If multiple rules share the keyword, all of them are removed.",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
keyword: { type: "string", description: "The keyword whose rule(s) should be deleted." },
|
|
84
|
+
},
|
|
85
|
+
required: ["keyword"],
|
|
86
|
+
},
|
|
87
|
+
async execute(args) {
|
|
88
|
+
const rules = await (0, client_js_1.get)("/v1/engage/keyword-links");
|
|
89
|
+
const matches = rules.filter((r) => r.keyword.toLowerCase() === args.keyword.trim().toLowerCase());
|
|
90
|
+
if (!matches.length)
|
|
91
|
+
return `No rule found for keyword "${args.keyword}".`;
|
|
92
|
+
for (const m of matches) {
|
|
93
|
+
await (0, client_js_1.del)(`/v1/engage/keyword-links/${m.id}`);
|
|
94
|
+
}
|
|
95
|
+
return `Deleted ${matches.length} rule(s) for "${args.keyword}".`;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.indexExplainerSourcesTool = void 0;
|
|
40
|
+
exports.searchAndExtract = searchAndExtract;
|
|
41
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
42
|
+
const node_https_1 = __importDefault(require("node:https"));
|
|
43
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
44
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
45
|
+
const TL_BASE = "https://api.twelvelabs.io/v1.3";
|
|
46
|
+
const VIRALSPIN_HOME = node_path_1.default.join(node_os_1.default.homedir(), ".viralspin");
|
|
47
|
+
const CACHE_FILE = node_path_1.default.join(VIRALSPIN_HOME, "explainer_cache.json");
|
|
48
|
+
function readCache() {
|
|
49
|
+
try {
|
|
50
|
+
if (node_fs_1.default.existsSync(CACHE_FILE))
|
|
51
|
+
return JSON.parse(node_fs_1.default.readFileSync(CACHE_FILE, "utf8"));
|
|
52
|
+
}
|
|
53
|
+
catch { /* ignore */ }
|
|
54
|
+
return { indexes: {}, videos: {} };
|
|
55
|
+
}
|
|
56
|
+
function writeCache(c) {
|
|
57
|
+
node_fs_1.default.mkdirSync(VIRALSPIN_HOME, { recursive: true });
|
|
58
|
+
node_fs_1.default.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2));
|
|
59
|
+
}
|
|
60
|
+
function cacheKey(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
const stat = node_fs_1.default.statSync(filePath);
|
|
63
|
+
return `${filePath}|${stat.size}`;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return filePath;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// ── Twelve Labs REST helpers ───────────────────────────────────────────────
|
|
70
|
+
function tlFetch(method, urlPath, body, apiKey) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const key = apiKey || process.env.TWELVE_LABS_API_KEY || "";
|
|
73
|
+
const bodyBuf = body ? Buffer.from(JSON.stringify(body)) : undefined;
|
|
74
|
+
const req = node_https_1.default.request(`${TL_BASE}${urlPath}`, {
|
|
75
|
+
method,
|
|
76
|
+
headers: {
|
|
77
|
+
"x-api-key": key,
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
...(bodyBuf ? { "Content-Length": bodyBuf.length } : {}),
|
|
80
|
+
},
|
|
81
|
+
}, (res) => {
|
|
82
|
+
const chunks = [];
|
|
83
|
+
res.on("data", (c) => chunks.push(c));
|
|
84
|
+
res.on("end", () => {
|
|
85
|
+
const text = Buffer.concat(chunks).toString();
|
|
86
|
+
try {
|
|
87
|
+
resolve(JSON.parse(text));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
reject(new Error(`TL parse error: ${text.slice(0, 200)}`));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
req.on("error", reject);
|
|
95
|
+
if (bodyBuf)
|
|
96
|
+
req.write(bodyBuf);
|
|
97
|
+
req.end();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async function tlUploadFile(filePath, indexId, apiKey) {
|
|
101
|
+
// Multipart upload using form-data
|
|
102
|
+
const { default: FormData } = await Promise.resolve(`${"form-data"}`).then(s => __importStar(require(s)));
|
|
103
|
+
const form = new FormData();
|
|
104
|
+
form.append("index_id", indexId);
|
|
105
|
+
form.append("language", "en");
|
|
106
|
+
form.append("video_file", node_fs_1.default.createReadStream(filePath), { filename: node_path_1.default.basename(filePath) });
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const headers = { ...form.getHeaders(), "x-api-key": apiKey };
|
|
109
|
+
const req = node_https_1.default.request(`${TL_BASE}/tasks`, { method: "POST", headers }, (res) => {
|
|
110
|
+
const chunks = [];
|
|
111
|
+
res.on("data", (c) => chunks.push(c));
|
|
112
|
+
res.on("end", () => {
|
|
113
|
+
try {
|
|
114
|
+
const d = JSON.parse(Buffer.concat(chunks).toString());
|
|
115
|
+
resolve(d._id || d.id);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
reject(new Error(`TL upload parse error: ${Buffer.concat(chunks).toString().slice(0, 200)}`));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
req.on("error", reject);
|
|
123
|
+
form.pipe(req);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async function waitForTask(taskId, apiKey) {
|
|
127
|
+
const deadline = Date.now() + 30 * 60 * 1000; // 30-min max
|
|
128
|
+
while (Date.now() < deadline) {
|
|
129
|
+
await new Promise((r) => setTimeout(r, 8000));
|
|
130
|
+
const d = await tlFetch("GET", `/tasks/${taskId}`, undefined, apiKey);
|
|
131
|
+
if (d.status === "ready")
|
|
132
|
+
return d.video_id;
|
|
133
|
+
if (d.status === "failed")
|
|
134
|
+
throw new Error(`Twelve Labs indexing failed for task ${taskId}: ${d.error || ""}`);
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Twelve Labs indexing timed out for task ${taskId}`);
|
|
137
|
+
}
|
|
138
|
+
async function getOrCreateIndex(folderPath, indexName, apiKey) {
|
|
139
|
+
const cache = readCache();
|
|
140
|
+
if (cache.indexes[folderPath])
|
|
141
|
+
return cache.indexes[folderPath];
|
|
142
|
+
// Create a new index
|
|
143
|
+
const d = await tlFetch("POST", "/indexes", {
|
|
144
|
+
name: indexName,
|
|
145
|
+
engines: [{ name: "marengo2.6", options: ["visual", "conversation", "text_in_video"] }],
|
|
146
|
+
}, apiKey);
|
|
147
|
+
const indexId = d._id || d.id;
|
|
148
|
+
cache.indexes[folderPath] = indexId;
|
|
149
|
+
writeCache(cache);
|
|
150
|
+
return indexId;
|
|
151
|
+
}
|
|
152
|
+
// ── Folder walking ─────────────────────────────────────────────────────────
|
|
153
|
+
const VIDEO_EXTS = new Set([".mp4", ".mkv", ".mov", ".avi", ".wmv", ".webm"]);
|
|
154
|
+
function walkFolder(folder) {
|
|
155
|
+
const results = [];
|
|
156
|
+
try {
|
|
157
|
+
for (const entry of node_fs_1.default.readdirSync(folder, { withFileTypes: true })) {
|
|
158
|
+
const full = node_path_1.default.join(folder, entry.name);
|
|
159
|
+
if (entry.isDirectory())
|
|
160
|
+
results.push(...walkFolder(full));
|
|
161
|
+
else if (VIDEO_EXTS.has(node_path_1.default.extname(entry.name).toLowerCase()))
|
|
162
|
+
results.push(full);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch { /* ignore permission errors */ }
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
exports.indexExplainerSourcesTool = {
|
|
169
|
+
name: "index_explainer_sources",
|
|
170
|
+
description: "Index a local folder of screen recordings and OBS videos with Twelve Labs so they can be searched semantically " +
|
|
171
|
+
"inside create_video_assembly. Call this once per folder — indexing is cached, so re-running skips already-indexed files. " +
|
|
172
|
+
"Returns an index_id to pass as explainer_index_id in create_video_assembly. " +
|
|
173
|
+
"Requires TWELVE_LABS_API_KEY in the MCP environment.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: {
|
|
177
|
+
sources_folder: {
|
|
178
|
+
type: "string",
|
|
179
|
+
description: "Local folder containing your screen recordings / OBS videos (e.g. C:/obs-recordings).",
|
|
180
|
+
},
|
|
181
|
+
index_name: {
|
|
182
|
+
type: "string",
|
|
183
|
+
description: "Optional name for the Twelve Labs index (defaults to the folder's base name).",
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
required: ["sources_folder"],
|
|
187
|
+
},
|
|
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
|
+
}
|
|
195
|
+
const apiKey = process.env.TWELVE_LABS_API_KEY;
|
|
196
|
+
if (!apiKey) {
|
|
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.";
|
|
198
|
+
}
|
|
199
|
+
const folder = args.sources_folder;
|
|
200
|
+
if (!node_fs_1.default.existsSync(folder)) {
|
|
201
|
+
return `Error: folder not found: ${folder}`;
|
|
202
|
+
}
|
|
203
|
+
const indexName = args.index_name || node_path_1.default.basename(folder) || "viralspin-explainer";
|
|
204
|
+
process.stderr.write(`[explainer-index] Getting/creating Twelve Labs index for ${folder}…\n`);
|
|
205
|
+
const indexId = await getOrCreateIndex(folder, indexName, apiKey);
|
|
206
|
+
process.stderr.write(`[explainer-index] index_id=${indexId}\n`);
|
|
207
|
+
const videos = walkFolder(folder);
|
|
208
|
+
if (videos.length === 0) {
|
|
209
|
+
return `No video files found in ${folder}. Supported formats: ${[...VIDEO_EXTS].join(", ")}`;
|
|
210
|
+
}
|
|
211
|
+
const cache = readCache();
|
|
212
|
+
let indexed = 0;
|
|
213
|
+
let skipped = 0;
|
|
214
|
+
const errors = [];
|
|
215
|
+
for (const vpath of videos) {
|
|
216
|
+
const key = cacheKey(vpath);
|
|
217
|
+
if (cache.videos[key]) {
|
|
218
|
+
skipped++;
|
|
219
|
+
process.stderr.write(`[explainer-index] skip (cached): ${node_path_1.default.basename(vpath)}\n`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
process.stderr.write(`[explainer-index] uploading: ${node_path_1.default.basename(vpath)}…\n`);
|
|
223
|
+
try {
|
|
224
|
+
const taskId = await tlUploadFile(vpath, indexId, apiKey);
|
|
225
|
+
process.stderr.write(`[explainer-index] waiting for task ${taskId}…\n`);
|
|
226
|
+
const videoId = await waitForTask(taskId, apiKey);
|
|
227
|
+
cache.videos[key] = { video_id: videoId, index_id: indexId };
|
|
228
|
+
writeCache(cache);
|
|
229
|
+
indexed++;
|
|
230
|
+
process.stderr.write(`[explainer-index] indexed: ${node_path_1.default.basename(vpath)} → video_id=${videoId}\n`);
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
errors.push(`${node_path_1.default.basename(vpath)}: ${e?.message || e}`);
|
|
234
|
+
process.stderr.write(`[explainer-index] ERROR: ${e?.message || e}\n`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const parts = [
|
|
238
|
+
`Index ready: ${indexId}`,
|
|
239
|
+
` Newly indexed: ${indexed} video${indexed !== 1 ? "s" : ""}`,
|
|
240
|
+
` Already cached: ${skipped}`,
|
|
241
|
+
` Total in folder: ${videos.length}`,
|
|
242
|
+
"",
|
|
243
|
+
`Pass this to create_video_assembly as: explainer_index_id: "${indexId}"`,
|
|
244
|
+
];
|
|
245
|
+
if (errors.length) {
|
|
246
|
+
parts.push("", `Errors (${errors.length}):`, ...errors.map((e) => ` • ${e}`));
|
|
247
|
+
}
|
|
248
|
+
return parts.join("\n");
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
// ── Clip retrieval (used by assemble.mjs via env forwarding) ───────────────
|
|
252
|
+
// Exported so assemble.mjs can also call this logic if needed in future.
|
|
253
|
+
async function searchAndExtract(searchQuery, indexId, outDir, clipIndex) {
|
|
254
|
+
const apiKey = process.env.TWELVE_LABS_API_KEY;
|
|
255
|
+
if (!apiKey)
|
|
256
|
+
return null;
|
|
257
|
+
try {
|
|
258
|
+
const result = await tlFetch("POST", "/search", {
|
|
259
|
+
query_text: searchQuery,
|
|
260
|
+
index_id: indexId,
|
|
261
|
+
search_options: ["visual", "conversation", "text_in_video"],
|
|
262
|
+
page_limit: 1,
|
|
263
|
+
}, apiKey);
|
|
264
|
+
const hit = result?.data?.[0];
|
|
265
|
+
if (!hit)
|
|
266
|
+
return null;
|
|
267
|
+
const videoId = hit.video_id;
|
|
268
|
+
const startSec = hit.start || 0;
|
|
269
|
+
const endSec = hit.end || startSec + 15;
|
|
270
|
+
const confidence = hit.score || 0;
|
|
271
|
+
// Look up local path from cache
|
|
272
|
+
const cache = readCache();
|
|
273
|
+
let localPath = null;
|
|
274
|
+
for (const entry of Object.values(cache.videos)) {
|
|
275
|
+
if (entry.video_id === videoId && entry.index_id === indexId) {
|
|
276
|
+
// Find the key (which is "filePath|size")
|
|
277
|
+
for (const [key, val] of Object.entries(cache.videos)) {
|
|
278
|
+
if (val.video_id === videoId) {
|
|
279
|
+
localPath = key.split("|")[0];
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!localPath || !node_fs_1.default.existsSync(localPath))
|
|
287
|
+
return null;
|
|
288
|
+
const outPath = node_path_1.default.join(outDir, `explainer_clip_${clipIndex}.mp4`);
|
|
289
|
+
const duration = Math.min(endSec - startSec, 30);
|
|
290
|
+
const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
|
|
291
|
+
await new Promise((resolve, reject) => {
|
|
292
|
+
const ff = spawn("ffmpeg", [
|
|
293
|
+
"-y", "-ss", String(startSec), "-t", String(duration),
|
|
294
|
+
"-i", localPath,
|
|
295
|
+
"-c:v", "libx264", "-crf", "26", "-c:a", "aac", "-movflags", "+faststart",
|
|
296
|
+
outPath,
|
|
297
|
+
]);
|
|
298
|
+
ff.on("close", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)));
|
|
299
|
+
ff.on("error", reject);
|
|
300
|
+
});
|
|
301
|
+
return { clip_path: outPath, confidence, start_sec: startSec };
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
process.stderr.write(`[explainer-index] searchAndExtract error: ${e?.message || e}\n`);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|