@viralspin/mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUNDLE_LOCAL_SPEC.md +133 -0
- package/dist/client.js +47 -9
- package/dist/index.js +83 -1
- package/dist/tools/assembly-adjust.js +102 -0
- package/dist/tools/carousel-video.js +190 -0
- package/dist/tools/engage.js +88 -0
- package/dist/tools/explainer-index.js +301 -0
- package/dist/tools/image-ads.js +156 -0
- package/dist/tools/kinetic-text.js +118 -0
- package/dist/tools/photo-picker.js +31 -0
- package/dist/tools/publish.js +72 -1
- package/dist/tools/review.js +103 -0
- package/dist/tools/video-assembly.js +183 -0
- package/engine/assemble.mjs +476 -0
- package/engine/gemini.mjs +164 -0
- package/engine/kinetic.mjs +84 -0
- package/engine/prompts.mjs +94 -0
- package/engine/remotion.config.ts +4 -0
- package/engine/src/Assembly.tsx +330 -0
- package/engine/src/KineticText.tsx +253 -0
- package/engine/src/Root.tsx +39 -0
- package/engine/src/index.ts +4 -0
- package/engine/tsconfig.json +15 -0
- package/package.json +29 -29
- package/src/client.ts +119 -80
- package/src/index.ts +151 -60
- package/src/tools/assembly-adjust.ts +110 -0
- package/src/tools/captions.ts +32 -32
- package/src/tools/carousel-video.ts +211 -0
- package/src/tools/connections.ts +27 -27
- package/src/tools/engage.ts +105 -0
- package/src/tools/explainer-index.ts +298 -0
- package/src/tools/image-ads.ts +192 -0
- package/src/tools/kinetic-text.ts +130 -0
- package/src/tools/photo-picker.ts +31 -0
- package/src/tools/posts.ts +58 -58
- package/src/tools/publish.ts +236 -158
- package/src/tools/review.ts +126 -0
- package/src/tools/video-assembly.ts +205 -0
- package/tsconfig.json +15 -15
|
@@ -0,0 +1,88 @@
|
|
|
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 ? ", + comment reply" : ", DM only";
|
|
9
|
+
const status = k.is_active ? "" : " (paused)";
|
|
10
|
+
return `"${k.keyword}" → ${k.link} [${platform}, ${scope}, ${k.match_type}${reply}]${status}`;
|
|
11
|
+
}
|
|
12
|
+
exports.setKeywordLinkTool = {
|
|
13
|
+
name: "set_keyword_link",
|
|
14
|
+
description: "Map a keyword to a resource/destination link for DM automation. When a follower comments/DMs the keyword, " +
|
|
15
|
+
"the link is sent back (delivery activates once Instagram messaging access is approved). " +
|
|
16
|
+
"Re-running with the same keyword + scope updates the link instead of creating a duplicate. " +
|
|
17
|
+
"Omit media_id for a global rule (fires on any post); set media_id for a single post.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
keyword: { type: "string", description: "The trigger word, e.g. 'guide' or 'link'. Case-insensitive." },
|
|
22
|
+
link: { type: "string", description: "The destination URL to send (e.g. a resource-hub article)." },
|
|
23
|
+
platform: {
|
|
24
|
+
type: "string",
|
|
25
|
+
enum: ["instagram", "tiktok", "youtube"],
|
|
26
|
+
description: "Platform this rule applies to. Omit for any connected platform. Only Instagram delivers today.",
|
|
27
|
+
},
|
|
28
|
+
media_id: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Optional. A specific post/media id to scope the rule to. Omit for a global rule across all posts.",
|
|
31
|
+
},
|
|
32
|
+
match_type: {
|
|
33
|
+
type: "string",
|
|
34
|
+
enum: ["exact", "contains"],
|
|
35
|
+
description: "'contains' (default) fires if the comment includes the keyword; 'exact' requires a full match.",
|
|
36
|
+
},
|
|
37
|
+
reply_to_comment: {
|
|
38
|
+
type: "boolean",
|
|
39
|
+
description: "Also post a public reply to the comment (e.g. 'Check your DMs', wording varies). Defaults to true.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ["keyword", "link"],
|
|
43
|
+
},
|
|
44
|
+
async execute(args) {
|
|
45
|
+
const created = await (0, client_js_1.post)("/v1/engage/keyword-links", {
|
|
46
|
+
keyword: args.keyword,
|
|
47
|
+
link: args.link,
|
|
48
|
+
platform: args.platform ?? null,
|
|
49
|
+
media_id: args.media_id ?? null,
|
|
50
|
+
match_type: args.match_type ?? "contains",
|
|
51
|
+
reply_to_comment: args.reply_to_comment ?? true,
|
|
52
|
+
});
|
|
53
|
+
return `Saved: ${describe(created)}`;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
exports.listKeywordLinksTool = {
|
|
57
|
+
name: "list_keyword_links",
|
|
58
|
+
description: "List your keyword→link DM-automation rules.",
|
|
59
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
60
|
+
async execute(_args) {
|
|
61
|
+
const rules = await (0, client_js_1.get)("/v1/engage/keyword-links");
|
|
62
|
+
if (!rules.length) {
|
|
63
|
+
return "No keyword→link rules yet. Use set_keyword_link to add one (e.g. map 'guide' to your resource link).";
|
|
64
|
+
}
|
|
65
|
+
return rules.map((r) => `• ${describe(r)}`).join("\n");
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
exports.deleteKeywordLinkTool = {
|
|
69
|
+
name: "delete_keyword_link",
|
|
70
|
+
description: "Delete a keyword→link rule by its keyword. If multiple rules share the keyword, all of them are removed.",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
keyword: { type: "string", description: "The keyword whose rule(s) should be deleted." },
|
|
75
|
+
},
|
|
76
|
+
required: ["keyword"],
|
|
77
|
+
},
|
|
78
|
+
async execute(args) {
|
|
79
|
+
const rules = await (0, client_js_1.get)("/v1/engage/keyword-links");
|
|
80
|
+
const matches = rules.filter((r) => r.keyword.toLowerCase() === args.keyword.trim().toLowerCase());
|
|
81
|
+
if (!matches.length)
|
|
82
|
+
return `No rule found for keyword "${args.keyword}".`;
|
|
83
|
+
for (const m of matches) {
|
|
84
|
+
await (0, client_js_1.del)(`/v1/engage/keyword-links/${m.id}`);
|
|
85
|
+
}
|
|
86
|
+
return `Deleted ${matches.length} rule(s) for "${args.keyword}".`;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,301 @@
|
|
|
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
|
+
const apiKey = process.env.TWELVE_LABS_API_KEY;
|
|
190
|
+
if (!apiKey) {
|
|
191
|
+
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.";
|
|
192
|
+
}
|
|
193
|
+
const folder = args.sources_folder;
|
|
194
|
+
if (!node_fs_1.default.existsSync(folder)) {
|
|
195
|
+
return `Error: folder not found: ${folder}`;
|
|
196
|
+
}
|
|
197
|
+
const indexName = args.index_name || node_path_1.default.basename(folder) || "viralspin-explainer";
|
|
198
|
+
process.stderr.write(`[explainer-index] Getting/creating Twelve Labs index for ${folder}…\n`);
|
|
199
|
+
const indexId = await getOrCreateIndex(folder, indexName, apiKey);
|
|
200
|
+
process.stderr.write(`[explainer-index] index_id=${indexId}\n`);
|
|
201
|
+
const videos = walkFolder(folder);
|
|
202
|
+
if (videos.length === 0) {
|
|
203
|
+
return `No video files found in ${folder}. Supported formats: ${[...VIDEO_EXTS].join(", ")}`;
|
|
204
|
+
}
|
|
205
|
+
const cache = readCache();
|
|
206
|
+
let indexed = 0;
|
|
207
|
+
let skipped = 0;
|
|
208
|
+
const errors = [];
|
|
209
|
+
for (const vpath of videos) {
|
|
210
|
+
const key = cacheKey(vpath);
|
|
211
|
+
if (cache.videos[key]) {
|
|
212
|
+
skipped++;
|
|
213
|
+
process.stderr.write(`[explainer-index] skip (cached): ${node_path_1.default.basename(vpath)}\n`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
process.stderr.write(`[explainer-index] uploading: ${node_path_1.default.basename(vpath)}…\n`);
|
|
217
|
+
try {
|
|
218
|
+
const taskId = await tlUploadFile(vpath, indexId, apiKey);
|
|
219
|
+
process.stderr.write(`[explainer-index] waiting for task ${taskId}…\n`);
|
|
220
|
+
const videoId = await waitForTask(taskId, apiKey);
|
|
221
|
+
cache.videos[key] = { video_id: videoId, index_id: indexId };
|
|
222
|
+
writeCache(cache);
|
|
223
|
+
indexed++;
|
|
224
|
+
process.stderr.write(`[explainer-index] indexed: ${node_path_1.default.basename(vpath)} → video_id=${videoId}\n`);
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
errors.push(`${node_path_1.default.basename(vpath)}: ${e?.message || e}`);
|
|
228
|
+
process.stderr.write(`[explainer-index] ERROR: ${e?.message || e}\n`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const parts = [
|
|
232
|
+
`Index ready: ${indexId}`,
|
|
233
|
+
` Newly indexed: ${indexed} video${indexed !== 1 ? "s" : ""}`,
|
|
234
|
+
` Already cached: ${skipped}`,
|
|
235
|
+
` Total in folder: ${videos.length}`,
|
|
236
|
+
"",
|
|
237
|
+
`Pass this to create_video_assembly as: explainer_index_id: "${indexId}"`,
|
|
238
|
+
];
|
|
239
|
+
if (errors.length) {
|
|
240
|
+
parts.push("", `Errors (${errors.length}):`, ...errors.map((e) => ` • ${e}`));
|
|
241
|
+
}
|
|
242
|
+
return parts.join("\n");
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
// ── Clip retrieval (used by assemble.mjs via env forwarding) ───────────────
|
|
246
|
+
// Exported so assemble.mjs can also call this logic if needed in future.
|
|
247
|
+
async function searchAndExtract(searchQuery, indexId, outDir, clipIndex) {
|
|
248
|
+
const apiKey = process.env.TWELVE_LABS_API_KEY;
|
|
249
|
+
if (!apiKey)
|
|
250
|
+
return null;
|
|
251
|
+
try {
|
|
252
|
+
const result = await tlFetch("POST", "/search", {
|
|
253
|
+
query_text: searchQuery,
|
|
254
|
+
index_id: indexId,
|
|
255
|
+
search_options: ["visual", "conversation", "text_in_video"],
|
|
256
|
+
page_limit: 1,
|
|
257
|
+
}, apiKey);
|
|
258
|
+
const hit = result?.data?.[0];
|
|
259
|
+
if (!hit)
|
|
260
|
+
return null;
|
|
261
|
+
const videoId = hit.video_id;
|
|
262
|
+
const startSec = hit.start || 0;
|
|
263
|
+
const endSec = hit.end || startSec + 15;
|
|
264
|
+
const confidence = hit.score || 0;
|
|
265
|
+
// Look up local path from cache
|
|
266
|
+
const cache = readCache();
|
|
267
|
+
let localPath = null;
|
|
268
|
+
for (const entry of Object.values(cache.videos)) {
|
|
269
|
+
if (entry.video_id === videoId && entry.index_id === indexId) {
|
|
270
|
+
// Find the key (which is "filePath|size")
|
|
271
|
+
for (const [key, val] of Object.entries(cache.videos)) {
|
|
272
|
+
if (val.video_id === videoId) {
|
|
273
|
+
localPath = key.split("|")[0];
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!localPath || !node_fs_1.default.existsSync(localPath))
|
|
281
|
+
return null;
|
|
282
|
+
const outPath = node_path_1.default.join(outDir, `explainer_clip_${clipIndex}.mp4`);
|
|
283
|
+
const duration = Math.min(endSec - startSec, 30);
|
|
284
|
+
const { spawn } = await Promise.resolve().then(() => __importStar(require("child_process")));
|
|
285
|
+
await new Promise((resolve, reject) => {
|
|
286
|
+
const ff = spawn("ffmpeg", [
|
|
287
|
+
"-y", "-ss", String(startSec), "-t", String(duration),
|
|
288
|
+
"-i", localPath,
|
|
289
|
+
"-c:v", "libx264", "-crf", "26", "-c:a", "aac", "-movflags", "+faststart",
|
|
290
|
+
outPath,
|
|
291
|
+
]);
|
|
292
|
+
ff.on("close", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}`)));
|
|
293
|
+
ff.on("error", reject);
|
|
294
|
+
});
|
|
295
|
+
return { clip_path: outPath, confidence, start_sec: startSec };
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
process.stderr.write(`[explainer-index] searchAndExtract error: ${e?.message || e}\n`);
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.publishToMetaTool = exports.assembleImageAdTool = exports.prepareProductAssetTool = void 0;
|
|
4
|
+
const client_js_1 = require("../client.js");
|
|
5
|
+
const APP_BASE = process.env.VIRALSPIN_APP_URL || "https://app.viralspin.ai";
|
|
6
|
+
exports.prepareProductAssetTool = {
|
|
7
|
+
name: "prepare_product_asset",
|
|
8
|
+
description: "Download and verify a product image from a local path or public URL, upload it to ViralSpin " +
|
|
9
|
+
"cloud storage, and return a stable cloud URL ready for Higgsfield image ad generation.",
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
required: ["image_url"],
|
|
13
|
+
properties: {
|
|
14
|
+
image_url: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Local file path (e.g. C:/photos/product.jpg) or public URL of the product image.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
async execute({ image_url }) {
|
|
21
|
+
if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
|
|
22
|
+
return `✅ Asset ready (already a URL):\n\n ${image_url}\n\nPass this to assemble_image_ad.`;
|
|
23
|
+
}
|
|
24
|
+
const { urls } = await (0, client_js_1.uploadFiles)([image_url]);
|
|
25
|
+
return [
|
|
26
|
+
`✅ Product asset uploaded:`,
|
|
27
|
+
``,
|
|
28
|
+
` ${urls[0]}`,
|
|
29
|
+
``,
|
|
30
|
+
`Pass this URL to assemble_image_ad.`,
|
|
31
|
+
].join("\n");
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
exports.assembleImageAdTool = {
|
|
35
|
+
name: "assemble_image_ad",
|
|
36
|
+
description: "Generate image ad variants from a product photo using Higgsfield AI. " +
|
|
37
|
+
"Uploads the product image, generates styled variants with your prompt, then creates a " +
|
|
38
|
+
"client review link showing all variants in a carousel. " +
|
|
39
|
+
"Requires Higgsfield credentials saved in ViralSpin Settings.",
|
|
40
|
+
inputSchema: {
|
|
41
|
+
type: "object",
|
|
42
|
+
required: ["product_image_path", "prompt"],
|
|
43
|
+
properties: {
|
|
44
|
+
product_image_path: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "Local file path (e.g. C:/photos/product.jpg) or public URL of the product image.",
|
|
47
|
+
},
|
|
48
|
+
prompt: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "Higgsfield styling prompt describing the desired ad aesthetic. " +
|
|
51
|
+
"Example: 'luxury product shot on white marble, soft studio lighting, premium brand aesthetic'",
|
|
52
|
+
},
|
|
53
|
+
pin: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Optional 4-digit PIN to protect the client review link.",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
async execute({ product_image_path, prompt, pin }) {
|
|
60
|
+
// Upload local files; pass through URLs unchanged
|
|
61
|
+
let imageUrl = product_image_path;
|
|
62
|
+
if (!product_image_path.startsWith("http://") && !product_image_path.startsWith("https://")) {
|
|
63
|
+
const { urls } = await (0, client_js_1.uploadFiles)([product_image_path]);
|
|
64
|
+
imageUrl = urls[0];
|
|
65
|
+
}
|
|
66
|
+
// Start async generation job — Higgsfield lives on Python backend
|
|
67
|
+
const { job_id } = await (0, client_js_1.pyPost)("/v1/higgsfield/generate", {
|
|
68
|
+
product_image_url: imageUrl,
|
|
69
|
+
prompt,
|
|
70
|
+
});
|
|
71
|
+
// Poll until done (~3 min timeout)
|
|
72
|
+
let variants = [];
|
|
73
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
74
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
75
|
+
const status = await (0, client_js_1.pyGet)(`/v1/higgsfield/status/${job_id}`);
|
|
76
|
+
if (status.status === "complete") {
|
|
77
|
+
variants = status.variant_urls ?? [];
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (status.status === "failed") {
|
|
81
|
+
throw new Error(`Higgsfield generation failed: ${status.error ?? "unknown error"}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!variants.length) {
|
|
85
|
+
throw new Error("Generation timed out after ~3 minutes — try again or check Higgsfield credentials");
|
|
86
|
+
}
|
|
87
|
+
// Create a review session with image variants displayed as a carousel
|
|
88
|
+
const { token } = await (0, client_js_1.post)("/v1/review-sessions", {
|
|
89
|
+
output_path: variants[0],
|
|
90
|
+
metadata: {
|
|
91
|
+
media_type: "image",
|
|
92
|
+
variants,
|
|
93
|
+
caption: prompt,
|
|
94
|
+
platform_targets: ["instagram", "facebook"],
|
|
95
|
+
},
|
|
96
|
+
pin: pin ?? null,
|
|
97
|
+
expires_hours: 168,
|
|
98
|
+
});
|
|
99
|
+
const reviewUrl = `${APP_BASE}/review/${token}`;
|
|
100
|
+
return [
|
|
101
|
+
`✅ ${variants.length} image ad variant${variants.length > 1 ? "s" : ""} generated!`,
|
|
102
|
+
``,
|
|
103
|
+
`Review link: ${reviewUrl}`,
|
|
104
|
+
``,
|
|
105
|
+
`Send this to your client — they'll see all ${variants.length} variants as a carousel`,
|
|
106
|
+
`and can Approve or Request Changes.`,
|
|
107
|
+
pin ? `PIN: ${pin}` : ``,
|
|
108
|
+
``,
|
|
109
|
+
`To publish an approved variant:`,
|
|
110
|
+
` publish_to_meta with media_url="${variants[0]}" and your caption.`,
|
|
111
|
+
]
|
|
112
|
+
.filter((l) => l !== undefined)
|
|
113
|
+
.join("\n");
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
exports.publishToMetaTool = {
|
|
117
|
+
name: "publish_to_meta",
|
|
118
|
+
description: "Publish an image (or video) to Instagram and/or Facebook. " +
|
|
119
|
+
"Use after a client approves an image ad variant from assemble_image_ad.",
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: "object",
|
|
122
|
+
required: ["media_url", "platforms", "caption"],
|
|
123
|
+
properties: {
|
|
124
|
+
media_url: {
|
|
125
|
+
type: "string",
|
|
126
|
+
description: "Public URL of the approved image or video to publish.",
|
|
127
|
+
},
|
|
128
|
+
platforms: {
|
|
129
|
+
type: "array",
|
|
130
|
+
items: { type: "string", enum: ["instagram", "facebook"] },
|
|
131
|
+
description: "Target platforms: 'instagram', 'facebook', or both.",
|
|
132
|
+
},
|
|
133
|
+
caption: {
|
|
134
|
+
type: "string",
|
|
135
|
+
description: "Post caption (include hashtags here if desired).",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
async execute({ media_url, platforms, caption }) {
|
|
140
|
+
const result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
141
|
+
video_url: media_url,
|
|
142
|
+
platforms,
|
|
143
|
+
caption,
|
|
144
|
+
publish_now: true,
|
|
145
|
+
});
|
|
146
|
+
const lines = ["📸 Published to Meta:"];
|
|
147
|
+
const results = result.results ?? {};
|
|
148
|
+
for (const [platform, res] of Object.entries(results)) {
|
|
149
|
+
lines.push(` ${res.success ? "✅" : "❌"} ${platform}${res.error ? `: ${res.error}` : ""}`);
|
|
150
|
+
}
|
|
151
|
+
if (!Object.keys(results).length) {
|
|
152
|
+
lines.push(" (no platform results returned — check your connected accounts in Settings)");
|
|
153
|
+
}
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
},
|
|
156
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
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.createKineticTextTool = void 0;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const ENGINE_DIR = process.env.VIRALSPIN_ASSEMBLY_DIR || path_1.default.resolve(__dirname, "../../engine");
|
|
10
|
+
const EFFECTS = ["typing", "billboard_flip", "isometric"];
|
|
11
|
+
const STYLES = ["terminal", "cinematic", "minimal"];
|
|
12
|
+
const FORMATS = ["9x16", "16x9", "1x1"];
|
|
13
|
+
function runKinetic(args) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(ENGINE_DIR, "kinetic.mjs"), ...args], { cwd: ENGINE_DIR, env: { ...process.env } });
|
|
16
|
+
let stdout = "";
|
|
17
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
18
|
+
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
19
|
+
child.on("error", reject);
|
|
20
|
+
child.on("close", (code) => {
|
|
21
|
+
const m = stdout.match(/RESULT:(.+)/);
|
|
22
|
+
if (code === 0 && m)
|
|
23
|
+
resolve(m[1].trim());
|
|
24
|
+
else
|
|
25
|
+
reject(new Error(`Kinetic render failed (exit ${code}). ${stdout.slice(-300)}`));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
exports.createKineticTextTool = {
|
|
30
|
+
name: "create_kinetic_text",
|
|
31
|
+
description: "Render a standalone cinematic typing-animation video — great for explainer intros that visually demo " +
|
|
32
|
+
"what a user types into the prompt. Text is typed out character-by-character with a blinking cursor " +
|
|
33
|
+
"while a 3D camera slowly pans, giving a cinematic 'Star Wars pull-back' feel. " +
|
|
34
|
+
"Three effects: 'typing' (character-by-character with 3D camera), " +
|
|
35
|
+
"'billboard_flip' (words fly from Z-depth and snap into place), " +
|
|
36
|
+
"'isometric' (letters extrude upward on a tilted plane). " +
|
|
37
|
+
"Three styles: 'terminal' (green-on-dark, monospace, scanlines), " +
|
|
38
|
+
"'cinematic' (white-on-black, serif, vignette), " +
|
|
39
|
+
"'minimal' (transparent background, for overlay use). " +
|
|
40
|
+
"Returns the path to a finished MP4 — feed it to publish_now or as A-roll in create_video_assembly.",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
text: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "The text to animate — e.g. the prompt the user would type.",
|
|
47
|
+
},
|
|
48
|
+
effect: {
|
|
49
|
+
type: "string",
|
|
50
|
+
enum: EFFECTS,
|
|
51
|
+
description: "Animation style. Default: typing.",
|
|
52
|
+
},
|
|
53
|
+
style: {
|
|
54
|
+
type: "string",
|
|
55
|
+
enum: STYLES,
|
|
56
|
+
description: "Visual theme. terminal = green monospace; cinematic = white serif; minimal = transparent BG. Default: terminal.",
|
|
57
|
+
},
|
|
58
|
+
format: {
|
|
59
|
+
type: "string",
|
|
60
|
+
enum: FORMATS,
|
|
61
|
+
description: "Output aspect ratio. Default: 9x16 (vertical).",
|
|
62
|
+
},
|
|
63
|
+
font_size: {
|
|
64
|
+
type: "number",
|
|
65
|
+
description: "Font size in px. Auto-sized by format if omitted (72px for 9x16, 96px for 16x9).",
|
|
66
|
+
},
|
|
67
|
+
camera: {
|
|
68
|
+
type: "object",
|
|
69
|
+
description: "Override the 3D camera end position. Each field defaults to the effect's cinematic preset.",
|
|
70
|
+
properties: {
|
|
71
|
+
rotateX: { type: "number", description: "X-axis tilt in degrees (positive = tilt top toward viewer)." },
|
|
72
|
+
rotateY: { type: "number", description: "Y-axis turn in degrees." },
|
|
73
|
+
rotateZ: { type: "number", description: "Z-axis roll in degrees." },
|
|
74
|
+
translateZ: { type: "number", description: "Z-depth offset in px (negative = pull back/zoom out)." },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
duration_sec: {
|
|
78
|
+
type: "number",
|
|
79
|
+
description: "Override clip length in seconds. Default: auto (≈ text.length × 0.1s + 1.5s hold).",
|
|
80
|
+
},
|
|
81
|
+
out: {
|
|
82
|
+
type: "string",
|
|
83
|
+
description: "Output MP4 path. Defaults to engine/out/kinetic_<timestamp>.mp4.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: ["text"],
|
|
87
|
+
},
|
|
88
|
+
async execute(args) {
|
|
89
|
+
const cli = ["--text", args.text];
|
|
90
|
+
if (args.effect)
|
|
91
|
+
cli.push("--effect", args.effect);
|
|
92
|
+
if (args.style)
|
|
93
|
+
cli.push("--style", args.style);
|
|
94
|
+
if (args.format)
|
|
95
|
+
cli.push("--format", args.format);
|
|
96
|
+
if (args.font_size)
|
|
97
|
+
cli.push("--font-size", String(args.font_size));
|
|
98
|
+
if (args.duration_sec)
|
|
99
|
+
cli.push("--duration-sec", String(args.duration_sec));
|
|
100
|
+
if (args.out)
|
|
101
|
+
cli.push("--out", args.out);
|
|
102
|
+
if (args.camera) {
|
|
103
|
+
const c = args.camera;
|
|
104
|
+
if (c.rotateX != null)
|
|
105
|
+
cli.push("--camera-rx", String(c.rotateX));
|
|
106
|
+
if (c.rotateY != null)
|
|
107
|
+
cli.push("--camera-ry", String(c.rotateY));
|
|
108
|
+
if (c.rotateZ != null)
|
|
109
|
+
cli.push("--camera-rz", String(c.rotateZ));
|
|
110
|
+
if (c.translateZ != null)
|
|
111
|
+
cli.push("--camera-tz", String(c.translateZ));
|
|
112
|
+
}
|
|
113
|
+
const outPath = await runKinetic(cli);
|
|
114
|
+
return (`Kinetic text render complete.\n` +
|
|
115
|
+
`Output: ${outPath}\n` +
|
|
116
|
+
`Use this as a hook intro by passing it as A-roll to create_video_assembly, or publish directly with publish_now.`);
|
|
117
|
+
},
|
|
118
|
+
};
|