@viralspin/mcp 0.1.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.
@@ -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
+ }
@@ -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
+ };