@viralspin/mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/cli.js +77 -0
  4. package/dist/client.js +12 -3
  5. package/dist/engine.js +29 -0
  6. package/dist/hosted.js +159 -0
  7. package/dist/index.js +77 -17
  8. package/dist/local-render/kit.js +217 -0
  9. package/dist/local-render/login.js +138 -0
  10. package/dist/local-render/render.js +252 -0
  11. package/dist/local-render/startup.js +74 -0
  12. package/dist/local-render/tokens.js +201 -0
  13. package/dist/local-render/watch.js +69 -0
  14. package/dist/sanitize.js +22 -0
  15. package/dist/tools/assembly-adjust.js +107 -15
  16. package/dist/tools/carousel-video.js +7 -0
  17. package/dist/tools/color-grade.js +64 -0
  18. package/dist/tools/connections.js +23 -4
  19. package/dist/tools/edit-clip.js +136 -0
  20. package/dist/tools/engage.js +13 -4
  21. package/dist/tools/explainer-index.js +6 -0
  22. package/dist/tools/find-moments.js +140 -0
  23. package/dist/tools/folder-preview.js +71 -0
  24. package/dist/tools/ideas.js +158 -0
  25. package/dist/tools/kinetic-text.js +6 -2
  26. package/dist/tools/local-index.js +84 -0
  27. package/dist/tools/local-mode.js +84 -0
  28. package/dist/tools/profile.js +56 -0
  29. package/dist/tools/publish.js +50 -14
  30. package/dist/tools/relight-clip.js +98 -0
  31. package/dist/tools/render-locally.js +158 -0
  32. package/dist/tools/timeline-edit.js +367 -0
  33. package/dist/tools/video-assembly.js +131 -4
  34. package/package.json +42 -29
  35. package/BUNDLE_LOCAL_SPEC.md +0 -133
  36. package/engine/assemble.mjs +0 -476
  37. package/engine/gemini.mjs +0 -164
  38. package/engine/kinetic.mjs +0 -84
  39. package/engine/prompts.mjs +0 -94
  40. package/engine/remotion.config.ts +0 -4
  41. package/engine/src/Assembly.tsx +0 -330
  42. package/engine/src/KineticText.tsx +0 -253
  43. package/engine/src/Root.tsx +0 -39
  44. package/engine/src/index.ts +0 -4
  45. package/engine/tsconfig.json +0 -15
  46. package/src/client.ts +0 -119
  47. package/src/index.ts +0 -151
  48. package/src/tools/assembly-adjust.ts +0 -110
  49. package/src/tools/captions.ts +0 -32
  50. package/src/tools/carousel-video.ts +0 -211
  51. package/src/tools/connections.ts +0 -27
  52. package/src/tools/engage.ts +0 -105
  53. package/src/tools/explainer-index.ts +0 -298
  54. package/src/tools/image-ads.ts +0 -192
  55. package/src/tools/kinetic-text.ts +0 -130
  56. package/src/tools/photo-picker.ts +0 -31
  57. package/src/tools/posts.ts +0 -58
  58. package/src/tools/publish.ts +0 -236
  59. package/src/tools/review.ts +0 -126
  60. package/src/tools/video-assembly.ts +0 -205
  61. package/tsconfig.json +0 -15
@@ -0,0 +1,74 @@
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.defaultEnv = void 0;
7
+ exports.startupCommand = startupCommand;
8
+ exports.plistFor = plistFor;
9
+ exports.installStartup = installStartup;
10
+ exports.uninstallStartup = uninstallStartup;
11
+ const child_process_1 = require("child_process");
12
+ const fs_1 = __importDefault(require("fs"));
13
+ const os_1 = __importDefault(require("os"));
14
+ const path_1 = __importDefault(require("path"));
15
+ /**
16
+ * Start the connector when this person signs in to their computer (`viralspin-mcp watch --install`), so "Render on my computer" just works.
17
+ * Windows: a Run key under HKEY_CURRENT_USER (no admin). macOS: a LaunchAgent plist (written, not loaded: it starts at the next sign-in, like
18
+ * the Windows key, so a second copy never collides with one already running). Other systems: said plainly, nothing is changed.
19
+ */
20
+ const NAME = "ViralSpinConnector";
21
+ const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
22
+ const defaultEnv = () => ({ platform: process.platform, node: process.execPath, script: process.argv[1] ?? "", home: os_1.default.homedir() });
23
+ exports.defaultEnv = defaultEnv;
24
+ /** The command line the system runs at sign-in. Pure so it is tested. */
25
+ function startupCommand(e) {
26
+ return `"${e.node}" "${e.script}" watch`;
27
+ }
28
+ function plistFor(e) {
29
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
30
+ return `<?xml version="1.0" encoding="UTF-8"?>
31
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
32
+ <plist version="1.0"><dict>
33
+ <key>Label</key><string>ai.viralspin.connector</string>
34
+ <key>ProgramArguments</key><array><string>${esc(e.node)}</string><string>${esc(e.script)}</string><string>watch</string></array>
35
+ <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
36
+ <key>StandardOutPath</key><string>${esc(path_1.default.join(e.home, ".viralspin", "watch.log"))}</string>
37
+ <key>StandardErrorPath</key><string>${esc(path_1.default.join(e.home, ".viralspin", "watch.log"))}</string>
38
+ </dict></plist>
39
+ `;
40
+ }
41
+ const plistPath = (e) => path_1.default.join(e.home, "Library", "LaunchAgents", "ai.viralspin.connector.plist");
42
+ function installStartup(e = (0, exports.defaultEnv)(), run = child_process_1.execFileSync) {
43
+ if (!e.script)
44
+ return "Could not tell where this program is installed, so nothing was changed. Install it with: npm i -g @viralspin/mcp";
45
+ if (e.platform === "win32") {
46
+ run("reg", ["add", RUN_KEY, "/v", NAME, "/t", "REG_SZ", "/d", startupCommand(e), "/f"], { stdio: "ignore" });
47
+ return "The connector will start when you sign in to this computer. Start it now with: viralspin-mcp watch";
48
+ }
49
+ if (e.platform === "darwin") {
50
+ const p = plistPath(e);
51
+ fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
52
+ fs_1.default.mkdirSync(path_1.default.join(e.home, ".viralspin"), { recursive: true });
53
+ fs_1.default.writeFileSync(p, plistFor(e));
54
+ return `The connector will start at your next sign-in (${p}). Start it now with: viralspin-mcp watch`;
55
+ }
56
+ return "Start-at-sign-in is set up for Windows and macOS only. On this system, run: viralspin-mcp watch";
57
+ }
58
+ function uninstallStartup(e = (0, exports.defaultEnv)(), run = child_process_1.execFileSync) {
59
+ if (e.platform === "win32") {
60
+ try {
61
+ run("reg", ["delete", RUN_KEY, "/v", NAME, "/f"], { stdio: "ignore" });
62
+ }
63
+ catch { /* it was not there */ }
64
+ return "The connector will no longer start when you sign in.";
65
+ }
66
+ if (e.platform === "darwin") {
67
+ try {
68
+ fs_1.default.unlinkSync(plistPath(e));
69
+ }
70
+ catch { /* it was not there */ }
71
+ return "The connector will no longer start when you sign in.";
72
+ }
73
+ return "Nothing to remove on this system.";
74
+ }
@@ -0,0 +1,201 @@
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.TokenManager = exports.RefreshRefused = void 0;
7
+ exports.jwtExpiry = jwtExpiry;
8
+ exports.saveTokens = saveTokens;
9
+ exports.defaultTokenFile = defaultTokenFile;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const os_1 = __importDefault(require("os"));
12
+ const path_1 = __importDefault(require("path"));
13
+ function jwtExpiry(token) {
14
+ try {
15
+ const part = token.split(".")[1];
16
+ if (!part)
17
+ return null;
18
+ const json = JSON.parse(Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
19
+ return typeof json.exp === "number" ? json.exp : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ /** Write the token file owner-only (dir 0700, file 0600), atomically. chmod even if the file existed with looser bits. */
26
+ function saveTokens(file, data) {
27
+ const dir = path_1.default.dirname(file);
28
+ fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
29
+ try {
30
+ fs_1.default.chmodSync(dir, 0o700);
31
+ }
32
+ catch { /* not ours to chmod (a shared home on Windows) */ }
33
+ const tmp = `${file}.${process.pid}.tmp`;
34
+ fs_1.default.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
35
+ try {
36
+ fs_1.default.chmodSync(tmp, 0o600);
37
+ }
38
+ catch { /* Windows */ }
39
+ fs_1.default.renameSync(tmp, file);
40
+ }
41
+ function defaultTokenFile() {
42
+ return path_1.default.join(os_1.default.homedir(), ".viralspin", "oauth.json");
43
+ }
44
+ /** The server said the refresh token is no good (expired, revoked): a new sign-in is the only way forward. */
45
+ class RefreshRefused extends Error {
46
+ }
47
+ exports.RefreshRefused = RefreshRefused;
48
+ class TokenManager {
49
+ constructor(opts) {
50
+ this.opts = opts;
51
+ this.mem = null;
52
+ this.refreshCount = 0;
53
+ this.signing = null;
54
+ this.fetchImpl = opts.fetchImpl ?? fetch;
55
+ this.file = opts.file ?? defaultTokenFile();
56
+ this.env = opts.env ?? process.env;
57
+ this.now = opts.now ?? Date.now;
58
+ this.skew = opts.skewSec ?? 120;
59
+ }
60
+ readFile() {
61
+ try {
62
+ const j = JSON.parse(fs_1.default.readFileSync(this.file, "utf8"));
63
+ return j && typeof j.access_token === "string" ? j : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ fromEnv() {
70
+ const a = this.env.VIRALSPIN_OAUTH_TOKEN;
71
+ if (!a && !this.env.VIRALSPIN_OAUTH_REFRESH_TOKEN)
72
+ return null;
73
+ return {
74
+ access_token: a || "",
75
+ refresh_token: this.env.VIRALSPIN_OAUTH_REFRESH_TOKEN || undefined,
76
+ client_id: this.env.VIRALSPIN_OAUTH_CLIENT_ID || undefined,
77
+ };
78
+ }
79
+ expiry(p) {
80
+ if (!p.access_token)
81
+ return 0;
82
+ return jwtExpiry(p.access_token) ?? Number.POSITIVE_INFINITY; // not a JWT with exp: trust until the server says 401
83
+ }
84
+ /** The candidate with the latest expiry (re-read from the file every call). */
85
+ best() {
86
+ const all = [this.mem, this.readFile(), this.fromEnv()].filter(Boolean);
87
+ if (!all.length)
88
+ return null;
89
+ return all.sort((a, b) => this.expiry(b) - this.expiry(a))[0];
90
+ }
91
+ refreshToken() {
92
+ for (const p of [this.mem, this.readFile(), this.fromEnv()]) {
93
+ if (p?.refresh_token)
94
+ return { rt: p.refresh_token, client_id: p.client_id };
95
+ }
96
+ return null;
97
+ }
98
+ /** Run the browser sign-in, one at a time, and forget anything stale held in memory. */
99
+ async signInNow() {
100
+ if (!this.opts.signIn)
101
+ return false;
102
+ this.signing ?? (this.signing = this.opts.signIn().finally(() => { this.signing = null; }));
103
+ await this.signing;
104
+ this.mem = null;
105
+ return true;
106
+ }
107
+ async getAccessToken() {
108
+ let cur = this.best();
109
+ if (!cur && (await this.signInNow()))
110
+ cur = this.best();
111
+ if (!cur) {
112
+ throw new Error("Not signed in to ViralSpin on this computer. Run `npx -y @viralspin/mcp login` once (it opens your browser), " +
113
+ "or set VIRALSPIN_OAUTH_TOKEN (+ VIRALSPIN_OAUTH_REFRESH_TOKEN) in the MCP server's env.");
114
+ }
115
+ const nowSec = this.now() / 1000;
116
+ if (cur.access_token && this.expiry(cur) - nowSec > this.skew)
117
+ return cur.access_token;
118
+ if (this.refreshToken()) {
119
+ try {
120
+ return await this.refresh();
121
+ }
122
+ catch (e) {
123
+ // the 30-day refresh token ran out (or was revoked): sign in again in the browser, if we can
124
+ if (!(e instanceof RefreshRefused) || !(await this.signInNow()))
125
+ throw e;
126
+ const fresh = this.best();
127
+ if (fresh?.access_token)
128
+ return fresh.access_token;
129
+ throw e;
130
+ }
131
+ }
132
+ if (cur.access_token)
133
+ return cur.access_token; // nothing to refresh with: let the server judge it
134
+ throw new Error("The ViralSpin sign-in has expired and there is no refresh token. Sign in again.");
135
+ }
136
+ async refresh() {
137
+ const r = this.refreshToken();
138
+ if (!r)
139
+ throw new Error("The ViralSpin sign-in expired and there is no refresh token (VIRALSPIN_OAUTH_REFRESH_TOKEN). Sign in again.");
140
+ const form = new URLSearchParams({ grant_type: "refresh_token", refresh_token: r.rt });
141
+ if (r.client_id)
142
+ form.set("client_id", r.client_id);
143
+ const resp = await this.fetchImpl(`${this.opts.apiBase}/v1/oauth/token`, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
146
+ body: form.toString(),
147
+ });
148
+ if (!resp.ok) {
149
+ const text = await resp.text().catch(() => "");
150
+ const msg = `Could not renew the ViralSpin sign-in (${resp.status}). The refresh token lasts 30 days; sign in again. ${text.slice(0, 160)}`;
151
+ throw resp.status === 400 || resp.status === 401 ? new RefreshRefused(msg) : new Error(msg);
152
+ }
153
+ const j = (await resp.json());
154
+ if (!j.access_token)
155
+ throw new Error("The token endpoint answered without an access_token.");
156
+ this.mem = { access_token: j.access_token, refresh_token: j.refresh_token || r.rt, client_id: r.client_id };
157
+ this.refreshCount++;
158
+ try {
159
+ saveTokens(this.file, { ...this.mem });
160
+ }
161
+ catch {
162
+ /* the file is a convenience; memory is enough for this process */
163
+ }
164
+ return j.access_token;
165
+ }
166
+ /** Authenticated JSON request; one retry after a refresh when the server says 401. */
167
+ async request(method, url, body) {
168
+ const go = async (token) => this.fetchImpl(url, {
169
+ method,
170
+ headers: { Authorization: `Bearer ${token}`, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
171
+ body: body !== undefined ? JSON.stringify(body) : undefined,
172
+ });
173
+ let resp = await go(await this.getAccessToken());
174
+ if (resp.status === 401 && (this.refreshToken() || this.opts.signIn)) {
175
+ let fresh;
176
+ try {
177
+ fresh = this.refreshToken() ? await this.refresh() : (await this.signInNow(), await this.getAccessToken());
178
+ }
179
+ catch (e) {
180
+ if (!(e instanceof RefreshRefused) || !(await this.signInNow()))
181
+ throw e;
182
+ fresh = await this.getAccessToken();
183
+ }
184
+ resp = await go(fresh);
185
+ }
186
+ if (!resp.ok) {
187
+ const text = await resp.text().catch(() => "");
188
+ let detail = text;
189
+ try {
190
+ const d = JSON.parse(text).detail;
191
+ detail = typeof d === "string" ? d : JSON.stringify(d ?? text);
192
+ }
193
+ catch {
194
+ /* keep text */
195
+ }
196
+ throw new Error(`API error ${resp.status} on ${method} ${url.replace(this.opts.apiBase, "")}: ${detail.slice(0, 400)}`);
197
+ }
198
+ return (await resp.json());
199
+ }
200
+ }
201
+ exports.TokenManager = TokenManager;
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.newWatchState = exports.POLL_MS = exports.PING_EVERY_MS = void 0;
4
+ exports.watchTick = watchTick;
5
+ exports.nextDelay = nextDelay;
6
+ exports.watchLoop = watchLoop;
7
+ const render_js_1 = require("./render.js");
8
+ exports.PING_EVERY_MS = 15000;
9
+ exports.POLL_MS = 4000;
10
+ const MAX_BACKOFF_MS = 60000;
11
+ const newWatchState = () => ({ lastPing: 0, failed: new Set(), errors: 0, pingWarned: false });
12
+ exports.newWatchState = newWatchState;
13
+ async function watchTick(d, st) {
14
+ if (d.now() - st.lastPing >= exports.PING_EVERY_MS) {
15
+ st.lastPing = d.now();
16
+ try {
17
+ await d.ping();
18
+ st.pingWarned = false;
19
+ }
20
+ catch (e) {
21
+ if (!st.pingWarned) {
22
+ d.log(`[watch] could not tell ViralSpin this computer is online (${e.message}); will keep trying`);
23
+ st.pingWarned = true;
24
+ }
25
+ }
26
+ }
27
+ let jobs;
28
+ try {
29
+ jobs = await d.list();
30
+ st.errors = 0;
31
+ }
32
+ catch (e) {
33
+ st.errors++;
34
+ d.log(`[watch] could not ask ViralSpin for jobs (${e.message})`);
35
+ return { did: "error" };
36
+ }
37
+ const { job } = (0, render_js_1.pickJob)(jobs.filter((j) => !st.failed.has(j.job_id)));
38
+ // a job ViralSpin is still preparing is waited for by the render itself (it polls); starting it now only means this computer holds it from here
39
+ if (!job)
40
+ return { did: "idle" };
41
+ const name = job.title ? `"${job.title}"` : job.job_id.slice(0, 8);
42
+ d.log(`[watch] drawing ${name}${job.duration_sec ? ` (${Math.round(Number(job.duration_sec))} s)` : ""}`);
43
+ try {
44
+ await d.render(job.job_id);
45
+ d.log(`[watch] finished ${name}`);
46
+ return { did: "rendered", jobId: job.job_id };
47
+ }
48
+ catch (e) {
49
+ st.failed.add(job.job_id);
50
+ d.log(`[watch] ${name} failed on this computer: ${e.message}`);
51
+ return { did: "failed", jobId: job.job_id };
52
+ }
53
+ }
54
+ /** Wait before the next pass: soon after work, a little longer when idle, much longer while ViralSpin is unreachable. */
55
+ function nextDelay(r, st) {
56
+ if (r.did === "rendered" || r.did === "failed")
57
+ return 500;
58
+ if (r.did === "error")
59
+ return Math.min(MAX_BACKOFF_MS, exports.POLL_MS * 2 ** Math.min(st.errors, 4));
60
+ return exports.POLL_MS;
61
+ }
62
+ async function watchLoop(d, o = {}) {
63
+ const st = (0, exports.newWatchState)();
64
+ d.log("[watch] ViralSpin connector is running. Send a reel to your computer from the app and it draws here. Ctrl+C to stop.");
65
+ while (!o.signal?.aborted) {
66
+ const r = await watchTick(d, st);
67
+ await d.sleep(nextDelay(r, st));
68
+ }
69
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ // Strips unpaired UTF-16 surrogates from strings so MCP responses can be
3
+ // safely JSON-serialized and written to the stdio transport. Lone surrogates
4
+ // (e.g. an emoji's surrogate pair cut in half by a fixed-length .slice) cause
5
+ // the transport's UTF-8 encoder to throw "no low surrogate in string".
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.sanitizeSurrogates = sanitizeSurrogates;
8
+ exports.sanitizeObject = sanitizeObject;
9
+ const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
10
+ function sanitizeSurrogates(str) {
11
+ return str.replace(UNPAIRED_SURROGATE, "");
12
+ }
13
+ function sanitizeObject(obj) {
14
+ if (typeof obj === "string")
15
+ return sanitizeSurrogates(obj);
16
+ if (Array.isArray(obj))
17
+ return obj.map(sanitizeObject);
18
+ if (obj && typeof obj === "object") {
19
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, sanitizeObject(v)]));
20
+ }
21
+ return obj;
22
+ }
@@ -6,13 +6,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.swapCaptionStyleTool = exports.swapClipTool = exports.suggestClipsTool = void 0;
7
7
  const child_process_1 = require("child_process");
8
8
  const path_1 = __importDefault(require("path"));
9
- // Adjustment tools operate on the most recent create_video_assembly project (the engine keeps the
10
- // timeline + analyzed candidate pool + staged clips on disk), so suggesting is instant and swapping
11
- // just re-renders. Same engine dir as create_video_assembly.
12
- const ENGINE_DIR = process.env.VIRALSPIN_ASSEMBLY_DIR || "E:/AI test/carissa bot/assembly-engine";
9
+ const hosted_js_1 = require("../hosted.js");
10
+ const engine_js_1 = require("../engine.js");
11
+ // Adjustment tools operate on the most recent create_video_assembly project. By
12
+ // default that's the LOCAL engine project (the engine keeps the timeline +
13
+ // analyzed candidate pool + staged clips on disk), so suggesting is instant and
14
+ // swapping just re-renders locally. When a hosted project is targeted instead —
15
+ // a `job_id` arg, or the VIRALSPIN_HOSTED_JOB_ID env set by a hosted
16
+ // create_video_assembly — the same verbs drive the web Swap & Refine project via
17
+ // the deployed narrative endpoints, so the edit shows up in the browser editor.
18
+ // Shown when a local action is requested but no engine is bundled (the public
19
+ // npm package) and no hosted project is targeted.
20
+ const NO_LOCAL_ENGINE = "This local action needs the ViralSpin engine, which isn't bundled in this install.\n" +
21
+ "Target a hosted Swap & Refine project instead — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID) — " +
22
+ "or set VIRALSPIN_ASSEMBLY_DIR to a local engine checkout.";
13
23
  function runEngine(args) {
14
24
  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, "assemble.mjs"), ...args], { cwd: ENGINE_DIR, env: { ...process.env } });
25
+ const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "assemble.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
16
26
  let stdout = "";
17
27
  child.stdout.on("data", (d) => { stdout += d.toString(); });
18
28
  child.stderr.on("data", (d) => process.stderr.write(d));
@@ -22,17 +32,56 @@ function runEngine(args) {
22
32
  }
23
33
  exports.suggestClipsTool = {
24
34
  name: "suggest_clips",
25
- description: "Get ranked alternative B-roll for one cutaway in the most recently assembled video (from create_video_assembly). " +
26
- "Returns options ranked by how well they fit that moment's transcript, each with a thumbnail image path you can open. " +
27
- "Then call swap_clip to apply a choice. Instant — reuses the cached analysis, no re-render.",
35
+ description: "See a project's cutaways and find better B-roll for one of them. " +
36
+ "LOCAL project (default): pass `cut` (0-based) to get ranked alternatives for that cutaway, each with a thumbnail. " +
37
+ "HOSTED Swap & Refine project (pass `job_id`, or set VIRALSPIN_HOSTED_JOB_ID): " +
38
+ "call with no `query` to list the timeline's cutaways (segment id + the beat each covers + current clip); " +
39
+ "call with `query` (e.g. \"newborn in hospital\") to search the user's B-roll library — each result includes the " +
40
+ "library tags it matched on, which is the 'why this clip fits' to show the user. " +
41
+ "Then call swap_clip to apply a choice.",
28
42
  inputSchema: {
29
43
  type: "object",
30
44
  properties: {
31
- cut: { type: "number", description: "Index of the cutaway to replace (0-based; from the cut list create_video_assembly returns)." },
45
+ cut: { type: "number", description: "LOCAL only: index of the cutaway to replace (0-based; from the cut list create_video_assembly returns)." },
46
+ job_id: { type: "string", description: "HOSTED: the Swap & Refine project (narrative job) to inspect. Omit to use VIRALSPIN_HOSTED_JOB_ID, or the local project." },
47
+ query: { type: "string", description: "HOSTED: theme to search the B-roll library for (the moment's subject). Omit to list the timeline's cutaways instead." },
32
48
  },
33
- required: ["cut"],
34
49
  },
35
50
  async execute(args) {
51
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
52
+ if (jobId) {
53
+ // Hosted project: either list the timeline's cutaways, or search the
54
+ // library for alternatives (with the matched tags = the "why").
55
+ if (args.query) {
56
+ const r = await (0, hosted_js_1.searchLibrary)(args.query);
57
+ if (!r.items.length)
58
+ return `No library B-roll matched "${args.query}". Try a broader term.`;
59
+ const lines = r.items.map((it, i) => `${i + 1}. ${it.url}\n ${it.kind}${it.album ? ` · from "${it.album}"` : ""} — matched on: "${args.query}"`);
60
+ return [
61
+ `B-roll matching "${args.query}" (best fit first):`,
62
+ ...lines,
63
+ ``,
64
+ `Apply one with: swap_clip(job_id: "${jobId}", segment_id: "<from the cutaway list>", asset_url: "<url above>").`,
65
+ ].join("\n");
66
+ }
67
+ const job = await (0, hosted_js_1.getJob)(jobId);
68
+ const segs = (0, hosted_js_1.listSegments)(job.timeline);
69
+ if (!segs.length) {
70
+ return `Project ${jobId} has no listable cutaways yet (status: ${job.status}). Open it: ${(0, hosted_js_1.editorUrl)(jobId)}`;
71
+ }
72
+ const lines = segs.map((s, i) => `${i + 1}. segment ${s.id}${s.beat ? ` — "${s.beat}"` : ""}${s.asset_url ? `\n current: ${s.asset_url}` : ""}`);
73
+ return [
74
+ `Cutaways in ${(0, hosted_js_1.editorUrl)(jobId)} :`,
75
+ ...lines,
76
+ ``,
77
+ `Find alternatives for one: suggest_clips(job_id: "${jobId}", query: "<what that moment is about>").`,
78
+ ].join("\n");
79
+ }
80
+ if (!(0, engine_js_1.engineAvailable)())
81
+ return NO_LOCAL_ENGINE;
82
+ if (args.cut === undefined) {
83
+ return "For a local project, pass `cut` (the 0-based cutaway index). For a hosted Swap & Refine project, pass `job_id`.";
84
+ }
36
85
  const out = await runEngine(["--suggest", String(args.cut)]);
37
86
  const m = out.match(/SUGGEST:(.+)/);
38
87
  if (!m)
@@ -50,17 +99,49 @@ exports.suggestClipsTool = {
50
99
  };
51
100
  exports.swapClipTool = {
52
101
  name: "swap_clip",
53
- description: "Replace one cutaway in the most recently assembled video with a chosen B-roll asset (from suggest_clips), then re-render. " +
54
- "Returns the path to the updated MP4.",
102
+ description: "Replace one cutaway with a chosen B-roll asset (from suggest_clips), then re-render. " +
103
+ "LOCAL project (default): pass `cut` (0-based) + `asset` (filename) → re-renders locally, returns the MP4 path. " +
104
+ "HOSTED Swap & Refine project (pass `job_id`, or set VIRALSPIN_HOSTED_JOB_ID): pass `segment_id` + `asset_url` → " +
105
+ "patches the same timeline the browser editor shows and kicks off a cloud re-render; the change appears live in " +
106
+ "Swap & Refine. Returns immediately — the new preview lands on the project shortly.",
55
107
  inputSchema: {
56
108
  type: "object",
57
109
  properties: {
58
- cut: { type: "number", description: "Index of the cutaway to replace (0-based)." },
59
- asset: { type: "string", description: "Asset filename from suggest_clips (e.g. \"v03.mp4\" or \"07.jpg\")." },
110
+ cut: { type: "number", description: "LOCAL: index of the cutaway to replace (0-based)." },
111
+ asset: { type: "string", description: "LOCAL: asset filename from suggest_clips (e.g. \"v03.mp4\" or \"07.jpg\")." },
112
+ job_id: { type: "string", description: "HOSTED: the Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
113
+ segment_id: { type: "string", description: "HOSTED: the cutaway's segment id (from suggest_clips)." },
114
+ asset_url: { type: "string", description: "HOSTED: public URL of the replacement B-roll (from suggest_clips)." },
115
+ asset_kind: { type: "string", enum: ["broll_video", "broll_photo"], description: "HOSTED: optional — kind of the replacement asset." },
116
+ reason: { type: "string", description: "HOSTED: optional one-line rationale for WHY this clip fits here (e.g. \"the line says 'the day she arrived'\"). Stored as provenance + shown on the timeline." },
117
+ render: { type: "boolean", description: "HOSTED: re-render now (costs a render/credits). Default false = instant timeline-only edit; iterate freely, then call render_project once to bake & publish." },
60
118
  },
61
- required: ["cut", "asset"],
62
119
  },
63
120
  async execute(args) {
121
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
122
+ if (jobId) {
123
+ if (!args.segment_id || !args.asset_url) {
124
+ return "For a hosted project, pass `segment_id` and `asset_url` (both from suggest_clips).";
125
+ }
126
+ let kind = args.asset_kind;
127
+ if (!kind)
128
+ kind = /\.(mp4|mov|webm|m4v)(\?|$)/i.test(args.asset_url) ? "broll_video" : "broll_photo";
129
+ await (0, hosted_js_1.swapSegment)(jobId, args.segment_id, args.asset_url, kind, args.reason);
130
+ if (args.render) {
131
+ await (0, hosted_js_1.rerender)(jobId);
132
+ return (`Swapped segment ${args.segment_id} → ${args.asset_url}${args.reason ? ` (${args.reason})` : ""} and started a cloud re-render.\n` +
133
+ `Updating live in the editor: ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
134
+ `Publish it with publish_now or schedule_post once it finishes.`);
135
+ }
136
+ return (`Swapped segment ${args.segment_id} → ${args.asset_url}${args.reason ? ` (${args.reason})` : ""} (instant, no render).\n` +
137
+ `Keep editing freely — it's all on the timeline. Open: ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
138
+ `When the cut is right, call render_project(job_id: "${jobId}") to bake captions/overlays and get the final MP4.`);
139
+ }
140
+ if (!(0, engine_js_1.engineAvailable)())
141
+ return NO_LOCAL_ENGINE;
142
+ if (args.cut === undefined || !args.asset) {
143
+ return "For a local project, pass `cut` and `asset`. For a hosted Swap & Refine project, pass `job_id`, `segment_id`, and `asset_url`.";
144
+ }
64
145
  const out = await runEngine(["--swap", String(args.cut), args.asset]);
65
146
  const m = out.match(/RESULT:(.+)/);
66
147
  if (!m)
@@ -87,10 +168,21 @@ exports.swapCaptionStyleTool = {
87
168
  "minimalist = lowercase thin font fade-in (calm, educational). " +
88
169
  "whiteboard = text revealed left-to-right (explainers, tutorials).",
89
170
  },
171
+ job_id: { type: "string", description: "HOSTED: the Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID or the local project." },
90
172
  },
91
173
  required: ["caption_style"],
92
174
  },
93
175
  async execute(args) {
176
+ // Hosted projects set caption style via the editor's render options; there's
177
+ // no standalone caption-swap endpoint to drive from here yet. Be explicit
178
+ // rather than hit a guessed route.
179
+ const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
180
+ if (jobId) {
181
+ return (`Caption style isn't swappable from here for hosted Swap & Refine projects yet — ` +
182
+ `set it in the editor: ${(0, hosted_js_1.editorUrl)(jobId)} (this is the next slice to wire up).`);
183
+ }
184
+ if (!(0, engine_js_1.engineAvailable)())
185
+ return NO_LOCAL_ENGINE;
94
186
  const out = await runEngine(["--caption-style", args.caption_style, "--swap-caption-style"]);
95
187
  const m = out.match(/RESULT:(.+)/);
96
188
  if (!m)
@@ -71,6 +71,12 @@ exports.publishCarouselAsVideoTool = {
71
71
  items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
72
72
  description: "Platforms to publish to. Must be connected. For mode='draft', Instagram is not allowed (no IG drafts API).",
73
73
  },
74
+ account_ids: {
75
+ type: "array",
76
+ items: { type: "string" },
77
+ description: "Specific connection IDs (from list_connections) to target. REQUIRED when you have multiple accounts on a platform — " +
78
+ "without it the post fans out to EVERY connected account on the platform (e.g. a draft landing on the wrong handle).",
79
+ },
74
80
  caption: { type: "string", description: "Post caption." },
75
81
  hashtags: {
76
82
  type: "array",
@@ -158,6 +164,7 @@ exports.publishCarouselAsVideoTool = {
158
164
  media_urls: upload.urls,
159
165
  media_type: "video",
160
166
  platforms: args.platforms,
167
+ ...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
161
168
  caption: args.caption,
162
169
  hashtags: args.hashtags ?? [],
163
170
  publish_now: true,
@@ -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
+ };