@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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/client.js +55 -9
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +129 -1
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +194 -0
- package/dist/tools/carousel-video.js +197 -0
- package/dist/tools/color-grade.js +64 -0
- package/dist/tools/connections.js +23 -4
- package/dist/tools/edit-clip.js +136 -0
- package/dist/tools/engage.js +97 -0
- package/dist/tools/explainer-index.js +307 -0
- package/dist/tools/find-moments.js +140 -0
- package/dist/tools/folder-preview.js +71 -0
- package/dist/tools/ideas.js +158 -0
- package/dist/tools/image-ads.js +156 -0
- package/dist/tools/kinetic-text.js +122 -0
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/photo-picker.js +31 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +104 -9
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/review.js +103 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +310 -0
- package/package.json +14 -2
- package/src/client.ts +0 -80
- package/src/index.ts +0 -60
- package/src/tools/captions.ts +0 -32
- package/src/tools/connections.ts +0 -27
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -158
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,194 @@
|
|
|
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.swapCaptionStyleTool = exports.swapClipTool = exports.suggestClipsTool = void 0;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
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.";
|
|
23
|
+
function runEngine(args) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
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 } });
|
|
26
|
+
let stdout = "";
|
|
27
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
28
|
+
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
29
|
+
child.on("error", reject);
|
|
30
|
+
child.on("close", (code) => (code === 0 ? resolve(stdout) : reject(new Error(`engine exited ${code}. ${stdout.slice(-300)}`))));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
exports.suggestClipsTool = {
|
|
34
|
+
name: "suggest_clips",
|
|
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.",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
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." },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
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
|
+
}
|
|
85
|
+
const out = await runEngine(["--suggest", String(args.cut)]);
|
|
86
|
+
const m = out.match(/SUGGEST:(.+)/);
|
|
87
|
+
if (!m)
|
|
88
|
+
throw new Error("no suggestions returned");
|
|
89
|
+
const d = JSON.parse(m[1]);
|
|
90
|
+
const lines = (d.options || []).map((o, i) => `${i + 1}. ${o.asset} (${o.kind}) — ${o.desc || "no description"}\n thumbnail: ${o.thumb}`);
|
|
91
|
+
return [
|
|
92
|
+
`Cut #${d.cut} (currently: ${d.current})${d.beat ? ` · line: "${d.beat}"` : ""}`,
|
|
93
|
+
`Ranked alternatives:`,
|
|
94
|
+
...lines,
|
|
95
|
+
``,
|
|
96
|
+
`Apply one with: swap_clip(cut: ${d.cut}, asset: "<asset>").`,
|
|
97
|
+
].join("\n");
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
exports.swapClipTool = {
|
|
101
|
+
name: "swap_clip",
|
|
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.",
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
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." },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
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
|
+
}
|
|
145
|
+
const out = await runEngine(["--swap", String(args.cut), args.asset]);
|
|
146
|
+
const m = out.match(/RESULT:(.+)/);
|
|
147
|
+
if (!m)
|
|
148
|
+
throw new Error(`swap failed. ${out.slice(-300)}`);
|
|
149
|
+
return `Swapped cut #${args.cut} → ${args.asset} and re-rendered.\nOutput: ${m[1].trim()}`;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
exports.swapCaptionStyleTool = {
|
|
153
|
+
name: "swap_caption_style",
|
|
154
|
+
description: "Change the caption style of the most recently assembled video and re-render. " +
|
|
155
|
+
"Uses the cached edit data — much faster than a full re-assembly (no re-transcription, no B-roll re-analysis). " +
|
|
156
|
+
"Styles: default (bold white spring pop), hormozi (word-by-word yellow highlight), glow_neon (neon cyan pulse), " +
|
|
157
|
+
"minimalist (lowercase clean fade), whiteboard (left-to-right reveal).",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
caption_style: {
|
|
162
|
+
type: "string",
|
|
163
|
+
enum: ["default", "hormozi", "glow_neon", "minimalist", "whiteboard"],
|
|
164
|
+
description: "Caption style to apply. " +
|
|
165
|
+
"default = bold white pop-in (universal). " +
|
|
166
|
+
"hormozi = word-by-word yellow highlight on black pill (high-energy talking-head). " +
|
|
167
|
+
"glow_neon = neon cyan pulsing text (dark aesthetic, music, gaming). " +
|
|
168
|
+
"minimalist = lowercase thin font fade-in (calm, educational). " +
|
|
169
|
+
"whiteboard = text revealed left-to-right (explainers, tutorials).",
|
|
170
|
+
},
|
|
171
|
+
job_id: { type: "string", description: "HOSTED: the Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID or the local project." },
|
|
172
|
+
},
|
|
173
|
+
required: ["caption_style"],
|
|
174
|
+
},
|
|
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;
|
|
186
|
+
const out = await runEngine(["--caption-style", args.caption_style, "--swap-caption-style"]);
|
|
187
|
+
const m = out.match(/RESULT:(.+)/);
|
|
188
|
+
if (!m)
|
|
189
|
+
throw new Error(`caption style swap failed. ${out.slice(-300)}`);
|
|
190
|
+
return (`Caption style changed to "${args.caption_style}" and re-rendered.\n` +
|
|
191
|
+
`Output: ${m[1].trim()}\n` +
|
|
192
|
+
`Publish with publish_now or schedule_post.`);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
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.publishCarouselAsVideoTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* publish_carousel_as_video — convert image slides into a slideshow MP4 and publish.
|
|
9
|
+
*
|
|
10
|
+
* Solves two real platform constraints:
|
|
11
|
+
* 1. TikTok rejects PHOTO carousels in MEDIA_UPLOAD (draft) mode — but accepts
|
|
12
|
+
* VIDEO drafts via the inbox endpoint. Converting first lets users send a
|
|
13
|
+
* slideshow to TikTok drafts and add trending music in-app before publishing.
|
|
14
|
+
* 2. Neither TikTok nor Instagram allows attaching a custom audio file to a
|
|
15
|
+
* PHOTO carousel via API. Baking the audio into a video sidesteps that.
|
|
16
|
+
*
|
|
17
|
+
* Requires ffmpeg on PATH locally.
|
|
18
|
+
*/
|
|
19
|
+
const fs_1 = __importDefault(require("fs"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const os_1 = __importDefault(require("os"));
|
|
22
|
+
const child_process_1 = require("child_process");
|
|
23
|
+
const client_js_1 = require("../client.js");
|
|
24
|
+
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
|
25
|
+
function expandImagePaths(inputs) {
|
|
26
|
+
const result = [];
|
|
27
|
+
for (const p of inputs) {
|
|
28
|
+
const stat = fs_1.default.statSync(p, { throwIfNoEntry: false });
|
|
29
|
+
if (!stat)
|
|
30
|
+
throw new Error(`Path not found: ${p}`);
|
|
31
|
+
if (stat.isDirectory()) {
|
|
32
|
+
const files = fs_1.default.readdirSync(p)
|
|
33
|
+
.filter((f) => IMAGE_EXTS.has(path_1.default.extname(f).toLowerCase()))
|
|
34
|
+
.sort()
|
|
35
|
+
.map((f) => path_1.default.join(p, f));
|
|
36
|
+
result.push(...files);
|
|
37
|
+
}
|
|
38
|
+
else if (IMAGE_EXTS.has(path_1.default.extname(p).toLowerCase())) {
|
|
39
|
+
result.push(p);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function ensureFfmpeg() {
|
|
45
|
+
const r = (0, child_process_1.spawnSync)("ffmpeg", ["-version"], { encoding: "utf8" });
|
|
46
|
+
if (r.error || r.status !== 0) {
|
|
47
|
+
throw new Error("ffmpeg is required for slideshow generation but was not found on PATH.\n" +
|
|
48
|
+
"Install instructions:\n" +
|
|
49
|
+
" Windows : winget install Gyan.FFmpeg (or download from https://ffmpeg.org/download.html)\n" +
|
|
50
|
+
" macOS : brew install ffmpeg\n" +
|
|
51
|
+
" Linux : sudo apt install ffmpeg\n" +
|
|
52
|
+
"After installing, restart Claude Code so the new PATH is picked up.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.publishCarouselAsVideoTool = {
|
|
56
|
+
name: "publish_carousel_as_video",
|
|
57
|
+
description: "Convert a folder of images into a 1080×1920 slideshow MP4 and publish it. " +
|
|
58
|
+
"Use this when (a) you want the carousel as a TikTok DRAFT (TikTok's API rejects PHOTO carousels for drafts but accepts VIDEO drafts so the user can add trending music in-app), " +
|
|
59
|
+
"or (b) you want to attach a specific MP3 to the carousel (TikTok/IG don't allow audio attachment to photo posts via API, but a video with baked-in audio works). " +
|
|
60
|
+
"Requires ffmpeg installed locally. Returns a single video post, not a carousel post.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
paths: {
|
|
65
|
+
type: "array",
|
|
66
|
+
items: { type: "string" },
|
|
67
|
+
description: "Folder containing slide images, or a list of image file paths. Files are sorted alphabetically — name them 01_, 02_, etc. for ordering.",
|
|
68
|
+
},
|
|
69
|
+
platforms: {
|
|
70
|
+
type: "array",
|
|
71
|
+
items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
|
|
72
|
+
description: "Platforms to publish to. Must be connected. For mode='draft', Instagram is not allowed (no IG drafts API).",
|
|
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
|
+
},
|
|
80
|
+
caption: { type: "string", description: "Post caption." },
|
|
81
|
+
hashtags: {
|
|
82
|
+
type: "array",
|
|
83
|
+
items: { type: "string" },
|
|
84
|
+
description: "Hashtags without # prefix.",
|
|
85
|
+
},
|
|
86
|
+
audio_path: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "Optional. Local path to an MP3/WAV/M4A/AAC file to use as soundtrack. If omitted, the video has no audio (creator can add trending music in-app if mode='draft').",
|
|
89
|
+
},
|
|
90
|
+
duration_per_slide: {
|
|
91
|
+
type: "number",
|
|
92
|
+
description: "Optional. Seconds each slide is displayed. Defaults to 3.",
|
|
93
|
+
},
|
|
94
|
+
mode: {
|
|
95
|
+
type: "string",
|
|
96
|
+
enum: ["publish", "draft"],
|
|
97
|
+
description: "Optional. 'publish' (default) publishes immediately. 'draft' sends to TikTok inbox / YouTube as private — user finishes in mobile app.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
required: ["paths", "platforms", "caption"],
|
|
101
|
+
},
|
|
102
|
+
async execute(args) {
|
|
103
|
+
ensureFfmpeg();
|
|
104
|
+
const imagePaths = expandImagePaths(args.paths);
|
|
105
|
+
if (!imagePaths.length)
|
|
106
|
+
throw new Error("No image files found in the provided paths.");
|
|
107
|
+
const mode = args.mode ?? "publish";
|
|
108
|
+
const duration = Math.max(0.5, args.duration_per_slide ?? 3);
|
|
109
|
+
if (mode === "draft" && args.platforms.includes("instagram")) {
|
|
110
|
+
throw new Error("Instagram does not support drafts via API. Remove 'instagram' from platforms or use mode='publish'.");
|
|
111
|
+
}
|
|
112
|
+
if (args.audio_path && !fs_1.default.existsSync(args.audio_path)) {
|
|
113
|
+
throw new Error(`Audio file not found: ${args.audio_path}`);
|
|
114
|
+
}
|
|
115
|
+
// Build slideshow MP4 via ffmpeg concat demuxer.
|
|
116
|
+
const tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "viralspin-slideshow-"));
|
|
117
|
+
const concatFile = path_1.default.join(tmpDir, "concat.txt");
|
|
118
|
+
const outputPath = path_1.default.join(tmpDir, "slideshow.mp4");
|
|
119
|
+
// concat demuxer format: file '<path>' + duration N per entry;
|
|
120
|
+
// last image must be re-listed without a duration so ffmpeg holds the final frame.
|
|
121
|
+
const escape = (p) => p.replace(/\\/g, "/").replace(/'/g, "'\\''");
|
|
122
|
+
const concatLines = [];
|
|
123
|
+
for (const img of imagePaths) {
|
|
124
|
+
concatLines.push(`file '${escape(img)}'`);
|
|
125
|
+
concatLines.push(`duration ${duration}`);
|
|
126
|
+
}
|
|
127
|
+
concatLines.push(`file '${escape(imagePaths[imagePaths.length - 1])}'`);
|
|
128
|
+
fs_1.default.writeFileSync(concatFile, concatLines.join("\n"));
|
|
129
|
+
const ffmpegArgs = [
|
|
130
|
+
"-y",
|
|
131
|
+
"-f", "concat",
|
|
132
|
+
"-safe", "0",
|
|
133
|
+
"-i", concatFile,
|
|
134
|
+
];
|
|
135
|
+
if (args.audio_path) {
|
|
136
|
+
ffmpegArgs.push("-i", args.audio_path);
|
|
137
|
+
}
|
|
138
|
+
// Scale slides to fit 1080×1920 (TikTok/Reels native), pad edges black to preserve aspect.
|
|
139
|
+
ffmpegArgs.push("-vf", "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,format=yuv420p,fps=30", "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-pix_fmt", "yuv420p");
|
|
140
|
+
if (args.audio_path) {
|
|
141
|
+
ffmpegArgs.push("-c:a", "aac", "-b:a", "192k", "-shortest");
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
ffmpegArgs.push("-an");
|
|
145
|
+
}
|
|
146
|
+
ffmpegArgs.push(outputPath);
|
|
147
|
+
const ffmpeg = (0, child_process_1.spawnSync)("ffmpeg", ffmpegArgs, { encoding: "utf8" });
|
|
148
|
+
if (ffmpeg.status !== 0) {
|
|
149
|
+
try {
|
|
150
|
+
fs_1.default.unlinkSync(concatFile);
|
|
151
|
+
fs_1.default.rmdirSync(tmpDir);
|
|
152
|
+
}
|
|
153
|
+
catch { /* ignore */ }
|
|
154
|
+
const stderr = (ffmpeg.stderr || "").split("\n").slice(-10).join("\n");
|
|
155
|
+
throw new Error(`ffmpeg failed (exit ${ffmpeg.status}):\n${stderr}`);
|
|
156
|
+
}
|
|
157
|
+
// Upload + publish.
|
|
158
|
+
let result;
|
|
159
|
+
let storagePaths = [];
|
|
160
|
+
try {
|
|
161
|
+
const upload = await (0, client_js_1.uploadFiles)([outputPath]);
|
|
162
|
+
storagePaths = upload.storage_paths;
|
|
163
|
+
result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
164
|
+
media_urls: upload.urls,
|
|
165
|
+
media_type: "video",
|
|
166
|
+
platforms: args.platforms,
|
|
167
|
+
...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
|
|
168
|
+
caption: args.caption,
|
|
169
|
+
hashtags: args.hashtags ?? [],
|
|
170
|
+
publish_now: true,
|
|
171
|
+
mode,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
try {
|
|
176
|
+
fs_1.default.unlinkSync(concatFile);
|
|
177
|
+
fs_1.default.unlinkSync(outputPath);
|
|
178
|
+
fs_1.default.rmdirSync(tmpDir);
|
|
179
|
+
}
|
|
180
|
+
catch { /* ignore */ }
|
|
181
|
+
if (storagePaths.length)
|
|
182
|
+
await (0, client_js_1.deleteFiles)(storagePaths).catch(() => { });
|
|
183
|
+
}
|
|
184
|
+
const { status, platform_post_ids, failed_reason } = result.post;
|
|
185
|
+
const targets = Object.keys(platform_post_ids);
|
|
186
|
+
const totalSeconds = (imagePaths.length * duration).toFixed(1);
|
|
187
|
+
let summary = `Slideshow video built: ${imagePaths.length} images × ${duration}s = ${totalSeconds}s, 1080×1920, ${args.audio_path ? "with audio" : "silent"}.\nStatus: ${status}\n`;
|
|
188
|
+
if (targets.length) {
|
|
189
|
+
summary += mode === "draft"
|
|
190
|
+
? `Sent to drafts on: ${targets.join(", ")}\nOpen the platform's mobile app to finish — add trending audio (if silent), adjust caption, then publish.`
|
|
191
|
+
: `Published to: ${targets.join(", ")}`;
|
|
192
|
+
}
|
|
193
|
+
if (failed_reason)
|
|
194
|
+
summary += `\nErrors: ${failed_reason}`;
|
|
195
|
+
return summary.trim();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.colorGradeTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* color_grade — free, instant cinematic LUT looks (ffmpeg, no AI, no cost). The
|
|
9
|
+
* cheap counterpart to relight_clip: it regrades color/contrast but can't change
|
|
10
|
+
* the background or add 3D relighting. Great for the A-roll or any clip.
|
|
11
|
+
*/
|
|
12
|
+
const child_process_1 = require("child_process");
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const engine_js_1 = require("../engine.js");
|
|
15
|
+
const LOOKS = ["teal-orange", "noir", "warm-golden", "cool-cinematic", "vintage", "vibrant", "moody-blue"];
|
|
16
|
+
function runGrade(args) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "grade.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
|
|
19
|
+
let out = "";
|
|
20
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
21
|
+
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
22
|
+
child.on("error", reject);
|
|
23
|
+
child.on("close", (code) => {
|
|
24
|
+
const m = out.match(/RESULT:([\s\S]+)/);
|
|
25
|
+
if (code === 0 && m)
|
|
26
|
+
resolve(m[1].trim());
|
|
27
|
+
else
|
|
28
|
+
reject(new Error(out.slice(-300) || `grade exited ${code}`));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
exports.colorGradeTool = {
|
|
33
|
+
name: "color_grade",
|
|
34
|
+
description: "Apply a FREE, instant cinematic color grade / LUT look to a LOCAL video (no AI, no cost) — teal-orange, " +
|
|
35
|
+
"noir, warm-golden, cool-cinematic, vintage, vibrant, or moody-blue. Great for the A-roll or any clip; it's " +
|
|
36
|
+
"a toggle-able look (keep the original or the graded file). For true relighting / background change, that's " +
|
|
37
|
+
"the paid relight_clip instead. Omit `look` to list the available looks.",
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
video_path: { type: "string", description: "Local path to the video to grade." },
|
|
42
|
+
look: { type: "string", enum: LOOKS, description: "The look to apply. Omit to list all looks." },
|
|
43
|
+
},
|
|
44
|
+
required: ["video_path"],
|
|
45
|
+
},
|
|
46
|
+
async execute(args) {
|
|
47
|
+
if (!(0, engine_js_1.engineAvailable)("grade.mjs")) {
|
|
48
|
+
return "color_grade needs the local engine (ffmpeg). It's unavailable in this install.";
|
|
49
|
+
}
|
|
50
|
+
if (!args.look) {
|
|
51
|
+
const r = JSON.parse(await runGrade(["--list"]));
|
|
52
|
+
const lines = r.looks.map((l) => ` • ${l.key} — ${l.label}`);
|
|
53
|
+
return ["Free cinematic looks (no cost):", ...lines, "", `Apply one: color_grade(video_path: "${args.video_path}", look: "teal-orange").`].join("\n");
|
|
54
|
+
}
|
|
55
|
+
let r;
|
|
56
|
+
try {
|
|
57
|
+
r = JSON.parse(await runGrade([args.video_path, args.look]));
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return `Couldn't grade the video: ${e instanceof Error ? e.message : String(e)}`;
|
|
61
|
+
}
|
|
62
|
+
return `Applied the "${r.look}" grade → ${r.out}\nFree + instant (ffmpeg). Swap it in, or try another look.`;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -4,15 +4,34 @@ exports.listConnectionsTool = void 0;
|
|
|
4
4
|
const client_js_1 = require("../client.js");
|
|
5
5
|
exports.listConnectionsTool = {
|
|
6
6
|
name: "list_connections",
|
|
7
|
-
description: "List your connected social media accounts (TikTok, Instagram, YouTube)."
|
|
7
|
+
description: "List your connected social media accounts (TikTok, Instagram, YouTube). " +
|
|
8
|
+
"Returns each account's connection ID — pass the ID to publish_now / schedule_post / send_to_drafts " +
|
|
9
|
+
"via the account_ids param when you have multiple accounts on the same platform.",
|
|
8
10
|
inputSchema: { type: "object", properties: {}, required: [] },
|
|
9
11
|
async execute(_args) {
|
|
12
|
+
var _a;
|
|
10
13
|
const connections = await (0, client_js_1.get)("/v1/publish/connections");
|
|
11
14
|
if (!connections.length) {
|
|
12
15
|
return "No platforms connected yet. Visit https://www.viralspin.ai/spin/settings to connect your accounts.";
|
|
13
16
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
// Group by platform
|
|
18
|
+
const byPlatform = {};
|
|
19
|
+
for (const c of connections) {
|
|
20
|
+
(byPlatform[_a = c.platform] ?? (byPlatform[_a] = [])).push(c);
|
|
21
|
+
}
|
|
22
|
+
const lines = [];
|
|
23
|
+
for (const [platform, accounts] of Object.entries(byPlatform)) {
|
|
24
|
+
lines.push(`${platform.toUpperCase()}:`);
|
|
25
|
+
for (const c of accounts) {
|
|
26
|
+
const label = c.account_label ? ` [${c.account_label}]` : "";
|
|
27
|
+
const handle = c.username ? ` (@${c.username.replace(/^@/, "")})` : "";
|
|
28
|
+
const followers = c.follower_count > 0 ? ` — ${c.follower_count.toLocaleString()} followers` : "";
|
|
29
|
+
lines.push(` • ${c.display_name}${handle}${label}${followers}`);
|
|
30
|
+
lines.push(` id: ${c.id}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push("Tip: when publishing to a specific account, pass its id in the account_ids param.");
|
|
35
|
+
return lines.join("\n");
|
|
17
36
|
},
|
|
18
37
|
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.editRawClipTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* edit_raw_clip — "edit this" for a RAW local talking-head clip. Transcribes it, cuts the dead
|
|
9
|
+
* space + filler words, burns captions, and drops RICH on-beat motion-graphics (an LLM plans
|
|
10
|
+
* varied stat / logo tile / step / quote cards from the transcript) → a finished 9:16 short.
|
|
11
|
+
*
|
|
12
|
+
* Runs on the user's machine via the bundled intake engine (mcp/engine/intake.py). Only the
|
|
13
|
+
* transcript (not the footage) is sent to the cloud graphics planner, so the video stays local.
|
|
14
|
+
* Falls back to on-device deterministic graphics if the planner isn't reachable.
|
|
15
|
+
*/
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const os_1 = __importDefault(require("os"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const engine_js_1 = require("../engine.js");
|
|
21
|
+
const client_js_1 = require("../client.js");
|
|
22
|
+
/** Spawn the local Python intake engine, trying the usual interpreter names. */
|
|
23
|
+
function runPy(args) {
|
|
24
|
+
const bins = process.platform === "win32" ? ["python", "py", "python3"] : ["python3", "python"];
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const tryBin = (i) => {
|
|
27
|
+
if (i >= bins.length) {
|
|
28
|
+
reject(new Error("Python 3 not found on PATH — install it to use edit_raw_clip."));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const child = (0, child_process_1.spawn)(bins[i], [path_1.default.join(engine_js_1.ENGINE_DIR, "intake.py"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
|
|
32
|
+
let out = "", err = "";
|
|
33
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
34
|
+
child.stderr.on("data", (d) => { err += d.toString(); });
|
|
35
|
+
child.on("error", (e) => { if (e.code === "ENOENT")
|
|
36
|
+
tryBin(i + 1);
|
|
37
|
+
else
|
|
38
|
+
reject(e); });
|
|
39
|
+
child.on("close", (code) => {
|
|
40
|
+
if (code === 0)
|
|
41
|
+
resolve(out);
|
|
42
|
+
else
|
|
43
|
+
reject(new Error((err || out).slice(-500) || `intake exited ${code}`));
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
tryBin(0);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
exports.editRawClipTool = {
|
|
50
|
+
name: "edit_raw_clip",
|
|
51
|
+
description: "EDIT a raw local talking-head video into a finished 9:16 short — the natural tool for \"edit this: " +
|
|
52
|
+
"<path>\", \"dress this clip\", or \"clean up my talking-head recording\". It transcribes the clip, cuts " +
|
|
53
|
+
"the dead space and filler words (um/uh/…), burns captions, and drops RICH on-beat motion-graphics — " +
|
|
54
|
+
"varied stat cards, brand/logo tiles, step/pipeline cards, and quote cards, chosen and filled by an LLM " +
|
|
55
|
+
"from what's actually said (like a polished explainer, not just captions). Runs on the user's machine; " +
|
|
56
|
+
"the footage stays local (only the transcript is used to plan graphics). Give it the video_path; returns " +
|
|
57
|
+
"the finished file path (and a preview URL). For a pure color/LUT look instead, that's color_grade.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
video_path: { type: "string", description: "Local path to the raw talking-head video to edit (e.g. G:/clips/talk.mov)." },
|
|
62
|
+
captions: { type: "boolean", description: "Burn captions onto the reel (default true)." },
|
|
63
|
+
graphics: { type: "boolean", description: "Add on-beat motion-graphics (default true)." },
|
|
64
|
+
sensitivity: { type: "number", description: "Dead-space cut threshold in seconds (default 0.5; lower = tighter, more cuts)." },
|
|
65
|
+
out_path: { type: "string", description: "Where to write the finished reel (default: next to the input as <name>_edited.mp4)." },
|
|
66
|
+
},
|
|
67
|
+
required: ["video_path"],
|
|
68
|
+
},
|
|
69
|
+
async execute(args) {
|
|
70
|
+
if (!(0, engine_js_1.engineAvailable)("intake.py")) {
|
|
71
|
+
return "edit_raw_clip needs the local editing engine (Python + ffmpeg). It's unavailable in this install.";
|
|
72
|
+
}
|
|
73
|
+
const vp = (args.video_path || "").trim().replace(/^["']|["']$/g, "");
|
|
74
|
+
if (!vp || !fs_1.default.existsSync(vp)) {
|
|
75
|
+
return `Couldn't find that video: ${vp || "(no path given)"}. Pass a local path, e.g. edit_raw_clip(video_path: "G:/clips/talk.mov").`;
|
|
76
|
+
}
|
|
77
|
+
const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "vs-edit-"));
|
|
78
|
+
const wordsPath = path_1.default.join(tmp, "words.json");
|
|
79
|
+
const outPath = args.out_path || vp.replace(/\.[^./\\]+$/, "") + "_edited.mp4";
|
|
80
|
+
const wantGraphics = args.graphics !== false;
|
|
81
|
+
try {
|
|
82
|
+
// 1) transcribe → word timestamps (reused by the cut + captions + planner, so no double work)
|
|
83
|
+
await runPy(["transcribe", "--clip", vp, "--out", wordsPath]);
|
|
84
|
+
const words = JSON.parse(fs_1.default.readFileSync(wordsPath, "utf8"));
|
|
85
|
+
// 2) plan RICH graphics from the transcript (LLM, best-effort). Only the transcript leaves the
|
|
86
|
+
// machine. If it fails, the engine falls back to on-device deterministic graphics.
|
|
87
|
+
const planArgs = [];
|
|
88
|
+
let plannerNote = "on-device graphics";
|
|
89
|
+
if (wantGraphics && words.length) {
|
|
90
|
+
try {
|
|
91
|
+
const dur = words[words.length - 1]?.end || 0;
|
|
92
|
+
const { graphics } = await (0, client_js_1.pyPost)("/v1/spin/plan-graphics", { words, duration: dur });
|
|
93
|
+
if (Array.isArray(graphics) && graphics.length) {
|
|
94
|
+
const pp = path_1.default.join(tmp, "plan.json");
|
|
95
|
+
fs_1.default.writeFileSync(pp, JSON.stringify(graphics));
|
|
96
|
+
planArgs.push("--graphics", pp);
|
|
97
|
+
plannerNote = `${graphics.length} LLM-planned graphics`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { /* planner unreachable → on-device fallback */ }
|
|
101
|
+
}
|
|
102
|
+
// 3) dress: cut dead space + filler → captions → graphics (reusing the transcript)
|
|
103
|
+
const dressArgs = ["dress", "--clip", vp, "--out", outPath, "--words", wordsPath, "--max-gap", String(args.sensitivity ?? 0.5), ...planArgs];
|
|
104
|
+
if (args.captions === false)
|
|
105
|
+
dressArgs.push("--no-captions");
|
|
106
|
+
if (!wantGraphics)
|
|
107
|
+
dressArgs.push("--no-graphics");
|
|
108
|
+
const dressOut = await runPy(dressArgs);
|
|
109
|
+
const rm = dressOut.match(/REPORT:(\{.*\})/);
|
|
110
|
+
const report = rm ? JSON.parse(rm[1]) : {};
|
|
111
|
+
// 4) best-effort upload for a shareable preview URL (local file is the real deliverable)
|
|
112
|
+
let url = "";
|
|
113
|
+
try {
|
|
114
|
+
url = (await (0, client_js_1.uploadFiles)([outPath])).urls[0] || "";
|
|
115
|
+
}
|
|
116
|
+
catch { /* local-only is fine */ }
|
|
117
|
+
const bits = [
|
|
118
|
+
`Edited **${path_1.default.basename(vp)}** → \`${outPath}\``,
|
|
119
|
+
`• ${report.original_sec ?? "?"}s → ${report.cut_sec ?? "?"}s (${report.pct_removed ?? 0}% tighter), ${report.n_cuts ?? 0} cuts`,
|
|
120
|
+
`• ${report.n_graphics ?? 0} on-beat graphics (${plannerNote})${args.captions === false ? "" : " + captions"}`,
|
|
121
|
+
];
|
|
122
|
+
if (url)
|
|
123
|
+
bits.push(`• Preview: ${url}`);
|
|
124
|
+
return bits.join("\n");
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
return `Couldn't edit the clip: ${e instanceof Error ? e.message : String(e)}`;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
try {
|
|
131
|
+
fs_1.default.rmSync(tmp, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
catch { /* ignore */ }
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|