@viralspin/mcp 0.2.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 +8 -0
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +62 -16
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +107 -15
- package/dist/tools/carousel-video.js +7 -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 +13 -4
- package/dist/tools/explainer-index.js +6 -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/kinetic-text.js +6 -2
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +34 -10
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +131 -4
- package/package.json +41 -29
- package/BUNDLE_LOCAL_SPEC.md +0 -133
- package/engine/assemble.mjs +0 -476
- package/engine/gemini.mjs +0 -164
- package/engine/kinetic.mjs +0 -84
- package/engine/prompts.mjs +0 -94
- package/engine/remotion.config.ts +0 -4
- package/engine/src/Assembly.tsx +0 -330
- package/engine/src/KineticText.tsx +0 -253
- package/engine/src/Root.tsx +0 -39
- package/engine/src/index.ts +0 -4
- package/engine/tsconfig.json +0 -15
- package/src/client.ts +0 -119
- package/src/index.ts +0 -151
- package/src/tools/assembly-adjust.ts +0 -110
- package/src/tools/captions.ts +0 -32
- package/src/tools/carousel-video.ts +0 -211
- package/src/tools/connections.ts +0 -27
- package/src/tools/engage.ts +0 -105
- package/src/tools/explainer-index.ts +0 -298
- package/src/tools/image-ads.ts +0 -192
- package/src/tools/kinetic-text.ts +0 -130
- package/src/tools/photo-picker.ts +0 -31
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -236
- package/src/tools/review.ts +0 -126
- package/src/tools/video-assembly.ts +0 -205
- package/tsconfig.json +0 -15
package/src/client.ts
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
|
|
4
|
-
const API_URL = (process.env.VIRALSPIN_API_URL || "https://viralspinbackend.onrender.com").replace(/\/$/, "");
|
|
5
|
-
// Python backend — Higgsfield, review-sessions, OAuth, narrative-render
|
|
6
|
-
const PY_API_URL = (process.env.VIRALSPIN_PY_API_URL || "https://viralspin-api.onrender.com").replace(/\/$/, "");
|
|
7
|
-
const API_KEY = process.env.VIRALSPIN_API_KEY || "";
|
|
8
|
-
// OAuth token takes precedence over API key (set after "Sign in with ViralSpin" flow)
|
|
9
|
-
const OAUTH_TOKEN = process.env.VIRALSPIN_OAUTH_TOKEN || "";
|
|
10
|
-
|
|
11
|
-
if (!API_KEY && !OAUTH_TOKEN) {
|
|
12
|
-
process.stderr.write("[viralspin-mcp] Warning: Neither VIRALSPIN_API_KEY nor VIRALSPIN_OAUTH_TOKEN is set\n");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
16
|
-
// OAuth Bearer token (from "Sign in with ViralSpin" flow) takes priority
|
|
17
|
-
if (OAUTH_TOKEN) {
|
|
18
|
-
return { "Authorization": `Bearer ${OAUTH_TOKEN}`, ...extra };
|
|
19
|
-
}
|
|
20
|
-
// Legacy API key — retained for backward compatibility with personal installs
|
|
21
|
-
return { "X-API-Key": API_KEY, ...extra };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function apiFetch<T>(endpoint: string, init: RequestInit = {}, base = API_URL): Promise<T> {
|
|
25
|
-
const url = `${base}${endpoint}`;
|
|
26
|
-
const resp = await fetch(url, {
|
|
27
|
-
...init,
|
|
28
|
-
headers: { ...(init.headers as Record<string, string> ?? {}), ...headers() },
|
|
29
|
-
});
|
|
30
|
-
if (!resp.ok) {
|
|
31
|
-
const text = await resp.text().catch(() => "");
|
|
32
|
-
let detail = text;
|
|
33
|
-
try { detail = JSON.parse(text).detail ?? text; } catch {}
|
|
34
|
-
throw new Error(`API error ${resp.status}: ${detail}`);
|
|
35
|
-
}
|
|
36
|
-
return resp.json() as Promise<T>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function get<T>(endpoint: string): Promise<T> {
|
|
40
|
-
return apiFetch<T>(endpoint, { method: "GET" });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function post<T>(endpoint: string, body: unknown): Promise<T> {
|
|
44
|
-
return apiFetch<T>(endpoint, {
|
|
45
|
-
method: "POST",
|
|
46
|
-
headers: { "Content-Type": "application/json" },
|
|
47
|
-
body: JSON.stringify(body),
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Python backend helpers — for routes that live in viralspin-api (Higgsfield, OAuth, etc.) */
|
|
52
|
-
export function pyGet<T>(endpoint: string): Promise<T> {
|
|
53
|
-
return apiFetch<T>(endpoint, { method: "GET" }, PY_API_URL);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function pyPost<T>(endpoint: string, body: unknown): Promise<T> {
|
|
57
|
-
return apiFetch<T>(endpoint, {
|
|
58
|
-
method: "POST",
|
|
59
|
-
headers: { "Content-Type": "application/json" },
|
|
60
|
-
body: JSON.stringify(body),
|
|
61
|
-
}, PY_API_URL);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function del<T>(endpoint: string, body?: unknown): Promise<T> {
|
|
65
|
-
return apiFetch<T>(endpoint, {
|
|
66
|
-
method: "DELETE",
|
|
67
|
-
headers: body ? { "Content-Type": "application/json" } : {},
|
|
68
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Upload one or more local files. Returns { url, storage_path } or { urls, storage_paths } */
|
|
73
|
-
export async function uploadFiles(filePaths: string[]): Promise<{ urls: string[]; storage_paths: string[] }> {
|
|
74
|
-
// Use the native Web FormData + Blob (Node 18+). The npm 'form-data' package
|
|
75
|
-
// streams in a way native fetch can't serialize correctly, which previously
|
|
76
|
-
// caused Busboy on the backend to fail with "Failed to parse upload".
|
|
77
|
-
const form = new FormData();
|
|
78
|
-
const fieldName = filePaths.length === 1 ? "file" : "files";
|
|
79
|
-
for (const fp of filePaths) {
|
|
80
|
-
const buffer = await fs.promises.readFile(fp);
|
|
81
|
-
const ext = path.extname(fp).toLowerCase();
|
|
82
|
-
const mime =
|
|
83
|
-
ext === ".png" ? "image/png" :
|
|
84
|
-
ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
|
|
85
|
-
ext === ".gif" ? "image/gif" :
|
|
86
|
-
ext === ".webp" ? "image/webp" :
|
|
87
|
-
ext === ".mp4" ? "video/mp4" :
|
|
88
|
-
ext === ".mov" ? "video/quicktime" :
|
|
89
|
-
ext === ".webm" ? "video/webm" :
|
|
90
|
-
"application/octet-stream";
|
|
91
|
-
const blob = new Blob([buffer], { type: mime });
|
|
92
|
-
form.append(fieldName, blob, path.basename(fp));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const authHeader: Record<string, string> = OAUTH_TOKEN
|
|
96
|
-
? { "Authorization": `Bearer ${OAUTH_TOKEN}` }
|
|
97
|
-
: { "X-API-Key": API_KEY };
|
|
98
|
-
const resp = await fetch(`${API_URL}/v1/media/upload`, {
|
|
99
|
-
method: "POST",
|
|
100
|
-
// Don't set Content-Type manually — fetch will set it with the correct multipart boundary.
|
|
101
|
-
headers: authHeader,
|
|
102
|
-
body: form,
|
|
103
|
-
});
|
|
104
|
-
if (!resp.ok) {
|
|
105
|
-
const text = await resp.text().catch(() => "");
|
|
106
|
-
let detail = text;
|
|
107
|
-
try { detail = JSON.parse(text).detail ?? text; } catch {}
|
|
108
|
-
throw new Error(`Upload failed ${resp.status}: ${detail}`);
|
|
109
|
-
}
|
|
110
|
-
const data = await resp.json() as { url?: string; storage_path?: string; urls?: string[]; storage_paths?: string[] };
|
|
111
|
-
if (data.urls) return { urls: data.urls, storage_paths: data.storage_paths! };
|
|
112
|
-
return { urls: [data.url!], storage_paths: [data.storage_path!] };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Delete uploaded files from storage (cleanup after publish) */
|
|
116
|
-
export async function deleteFiles(storagePaths: string[]): Promise<void> {
|
|
117
|
-
if (!storagePaths.length) return;
|
|
118
|
-
await del(`/v1/media/${storagePaths[0]}`, storagePaths.length > 1 ? { storage_paths: storagePaths } : undefined);
|
|
119
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import {
|
|
5
|
-
CallToolRequestSchema,
|
|
6
|
-
ListToolsRequestSchema,
|
|
7
|
-
ListPromptsRequestSchema,
|
|
8
|
-
GetPromptRequestSchema,
|
|
9
|
-
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
-
|
|
11
|
-
import { listConnectionsTool } from "./tools/connections.js";
|
|
12
|
-
import { publishNowTool, schedulePostTool, sendToDraftsTool } from "./tools/publish.js";
|
|
13
|
-
import { publishCarouselAsVideoTool } from "./tools/carousel-video.js";
|
|
14
|
-
import { createVideoAssemblyTool } from "./tools/video-assembly.js";
|
|
15
|
-
import { startPhotoPickerTool } from "./tools/photo-picker.js";
|
|
16
|
-
import { suggestClipsTool, swapClipTool, swapCaptionStyleTool } from "./tools/assembly-adjust.js";
|
|
17
|
-
import { listPostsTool, cancelPostTool } from "./tools/posts.js";
|
|
18
|
-
import { generateCaptionTool } from "./tools/captions.js";
|
|
19
|
-
import { setKeywordLinkTool, listKeywordLinksTool, deleteKeywordLinkTool } from "./tools/engage.js";
|
|
20
|
-
import { indexExplainerSourcesTool } from "./tools/explainer-index.js";
|
|
21
|
-
import { createKineticTextTool } from "./tools/kinetic-text.js";
|
|
22
|
-
import { createReviewLinkTool, checkReviewStatusTool } from "./tools/review.js";
|
|
23
|
-
import { prepareProductAssetTool, assembleImageAdTool, publishToMetaTool } from "./tools/image-ads.js";
|
|
24
|
-
|
|
25
|
-
const tools = [
|
|
26
|
-
listConnectionsTool,
|
|
27
|
-
publishNowTool,
|
|
28
|
-
schedulePostTool,
|
|
29
|
-
sendToDraftsTool,
|
|
30
|
-
publishCarouselAsVideoTool,
|
|
31
|
-
createVideoAssemblyTool,
|
|
32
|
-
startPhotoPickerTool,
|
|
33
|
-
suggestClipsTool,
|
|
34
|
-
swapClipTool,
|
|
35
|
-
swapCaptionStyleTool,
|
|
36
|
-
listPostsTool,
|
|
37
|
-
cancelPostTool,
|
|
38
|
-
generateCaptionTool,
|
|
39
|
-
setKeywordLinkTool,
|
|
40
|
-
listKeywordLinksTool,
|
|
41
|
-
deleteKeywordLinkTool,
|
|
42
|
-
indexExplainerSourcesTool,
|
|
43
|
-
createKineticTextTool,
|
|
44
|
-
createReviewLinkTool,
|
|
45
|
-
checkReviewStatusTool,
|
|
46
|
-
prepareProductAssetTool,
|
|
47
|
-
assembleImageAdTool,
|
|
48
|
-
publishToMetaTool,
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
// Shown to the user as the welcome / getting-started message. The agent fills in
|
|
52
|
-
// <picker link> by calling start_photo_picker (per ONBOARDING below).
|
|
53
|
-
const SAMPLE_COMMANDS = [
|
|
54
|
-
'1. Make a video: "Turn my A-roll at C:/clips/talk.mp4 into a 9:16 short, using B-roll from C:/my-broll and my Google Photos."',
|
|
55
|
-
'2. Publish it: "Publish that video to TikTok and Instagram with the caption: posting through the chaos."',
|
|
56
|
-
].join("\n");
|
|
57
|
-
|
|
58
|
-
const WELCOME = [
|
|
59
|
-
"Welcome to ViralSpin. I turn a talking-head A-roll into a polished vertical short —",
|
|
60
|
-
"B-roll cutaways matched to what you say, captions, a hook headline, and keyword pops —",
|
|
61
|
-
"then publish it to TikTok, Instagram, or YouTube. B-roll can be local files, a local",
|
|
62
|
-
"folder (auto-indexed), public URLs, and/or your Google Photos.",
|
|
63
|
-
"",
|
|
64
|
-
"Two commands to get you started:",
|
|
65
|
-
SAMPLE_COMMANDS,
|
|
66
|
-
].join("\n");
|
|
67
|
-
|
|
68
|
-
// Auto-injected into the agent's context when the server connects. Drives proactive onboarding.
|
|
69
|
-
const INSTRUCTIONS = [
|
|
70
|
-
"ViralSpin assembles A-roll + B-roll into polished vertical shorts and publishes them to TikTok/Instagram/YouTube.",
|
|
71
|
-
"",
|
|
72
|
-
"ONBOARDING: The FIRST time you help the user with ViralSpin in a session, proactively onboard them:",
|
|
73
|
-
"(1) give the short intro below, (2) CALL start_photo_picker so they get a live Google Photos B-roll picker link, and",
|
|
74
|
-
"(3) show these two sample commands verbatim:",
|
|
75
|
-
SAMPLE_COMMANDS,
|
|
76
|
-
"",
|
|
77
|
-
"HOW B-ROLL WORKS: create_video_assembly accepts local file paths, a local FOLDER (auto-expanded and indexed),",
|
|
78
|
-
"public URLs, and Google Photos. For Google Photos: call start_photo_picker → it returns a browser link + sessionId;",
|
|
79
|
-
"the user picks in their browser (Google forbids listing albums programmatically), then call create_video_assembly",
|
|
80
|
-
"with google_picker_session set to that sessionId. Pass style_ref (a reference video) to auto-infer captions/hook/pace/aspect.",
|
|
81
|
-
"After rendering, publish with publish_now or schedule_post.",
|
|
82
|
-
].join("\n");
|
|
83
|
-
|
|
84
|
-
const server = new Server(
|
|
85
|
-
{ name: "viralspin", version: "0.1.0" },
|
|
86
|
-
{ capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS }
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
90
|
-
prompts: [
|
|
91
|
-
{
|
|
92
|
-
name: "getting_started",
|
|
93
|
-
description:
|
|
94
|
-
"Welcome + how to use ViralSpin: intro, a Google Photos B-roll picker link, and two sample commands.",
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
}));
|
|
98
|
-
|
|
99
|
-
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
100
|
-
if (req.params.name !== "getting_started") {
|
|
101
|
-
throw new Error(`Unknown prompt: ${req.params.name}`);
|
|
102
|
-
}
|
|
103
|
-
return {
|
|
104
|
-
messages: [
|
|
105
|
-
{
|
|
106
|
-
role: "user",
|
|
107
|
-
content: {
|
|
108
|
-
type: "text",
|
|
109
|
-
text:
|
|
110
|
-
WELCOME +
|
|
111
|
-
"\n\nNow call start_photo_picker and include the returned Google Photos picker link in your reply, " +
|
|
112
|
-
"so I can pick B-roll. Keep the two sample commands above in your reply.",
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
],
|
|
116
|
-
};
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
120
|
-
tools: tools.map((t) => ({
|
|
121
|
-
name: t.name,
|
|
122
|
-
description: t.description,
|
|
123
|
-
inputSchema: t.inputSchema,
|
|
124
|
-
})),
|
|
125
|
-
}));
|
|
126
|
-
|
|
127
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
128
|
-
const tool = tools.find((t) => t.name === req.params.name);
|
|
129
|
-
if (!tool) {
|
|
130
|
-
return { content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], isError: true };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
const result = await tool.execute(req.params.arguments as never);
|
|
135
|
-
return { content: [{ type: "text", text: String(result) }] };
|
|
136
|
-
} catch (err) {
|
|
137
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
138
|
-
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
async function main() {
|
|
143
|
-
const transport = new StdioServerTransport();
|
|
144
|
-
await server.connect(transport);
|
|
145
|
-
process.stderr.write("[viralspin-mcp] Server started\n");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
main().catch((err) => {
|
|
149
|
-
process.stderr.write(`[viralspin-mcp] Fatal: ${err}\n`);
|
|
150
|
-
process.exit(1);
|
|
151
|
-
});
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
|
-
import path from "path";
|
|
3
|
-
|
|
4
|
-
// Adjustment tools operate on the most recent create_video_assembly project (the engine keeps the
|
|
5
|
-
// timeline + analyzed candidate pool + staged clips on disk), so suggesting is instant and swapping
|
|
6
|
-
// just re-renders. Same engine dir as create_video_assembly.
|
|
7
|
-
const ENGINE_DIR =
|
|
8
|
-
process.env.VIRALSPIN_ASSEMBLY_DIR || "E:/AI test/carissa bot/assembly-engine";
|
|
9
|
-
|
|
10
|
-
function runEngine(args: string[]): Promise<string> {
|
|
11
|
-
return new Promise((resolve, reject) => {
|
|
12
|
-
const child = spawn(process.platform === "win32" ? "node.exe" : "node",
|
|
13
|
-
[path.join(ENGINE_DIR, "assemble.mjs"), ...args],
|
|
14
|
-
{ cwd: ENGINE_DIR, env: { ...process.env } });
|
|
15
|
-
let stdout = "";
|
|
16
|
-
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
17
|
-
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
18
|
-
child.on("error", reject);
|
|
19
|
-
child.on("close", (code) => (code === 0 ? resolve(stdout) : reject(new Error(`engine exited ${code}. ${stdout.slice(-300)}`))));
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export const suggestClipsTool = {
|
|
24
|
-
name: "suggest_clips",
|
|
25
|
-
description:
|
|
26
|
-
"Get ranked alternative B-roll for one cutaway in the most recently assembled video (from create_video_assembly). " +
|
|
27
|
-
"Returns options ranked by how well they fit that moment's transcript, each with a thumbnail image path you can open. " +
|
|
28
|
-
"Then call swap_clip to apply a choice. Instant — reuses the cached analysis, no re-render.",
|
|
29
|
-
inputSchema: {
|
|
30
|
-
type: "object",
|
|
31
|
-
properties: {
|
|
32
|
-
cut: { type: "number", description: "Index of the cutaway to replace (0-based; from the cut list create_video_assembly returns)." },
|
|
33
|
-
},
|
|
34
|
-
required: ["cut"],
|
|
35
|
-
},
|
|
36
|
-
async execute(args: { cut: number }): Promise<string> {
|
|
37
|
-
const out = await runEngine(["--suggest", String(args.cut)]);
|
|
38
|
-
const m = out.match(/SUGGEST:(.+)/);
|
|
39
|
-
if (!m) throw new Error("no suggestions returned");
|
|
40
|
-
const d = JSON.parse(m[1]);
|
|
41
|
-
const lines = (d.options || []).map(
|
|
42
|
-
(o: { asset: string; kind: string; desc: string; thumb: string }, i: number) =>
|
|
43
|
-
`${i + 1}. ${o.asset} (${o.kind}) — ${o.desc || "no description"}\n thumbnail: ${o.thumb}`
|
|
44
|
-
);
|
|
45
|
-
return [
|
|
46
|
-
`Cut #${d.cut} (currently: ${d.current})${d.beat ? ` · line: "${d.beat}"` : ""}`,
|
|
47
|
-
`Ranked alternatives:`,
|
|
48
|
-
...lines,
|
|
49
|
-
``,
|
|
50
|
-
`Apply one with: swap_clip(cut: ${d.cut}, asset: "<asset>").`,
|
|
51
|
-
].join("\n");
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const swapClipTool = {
|
|
56
|
-
name: "swap_clip",
|
|
57
|
-
description:
|
|
58
|
-
"Replace one cutaway in the most recently assembled video with a chosen B-roll asset (from suggest_clips), then re-render. " +
|
|
59
|
-
"Returns the path to the updated MP4.",
|
|
60
|
-
inputSchema: {
|
|
61
|
-
type: "object",
|
|
62
|
-
properties: {
|
|
63
|
-
cut: { type: "number", description: "Index of the cutaway to replace (0-based)." },
|
|
64
|
-
asset: { type: "string", description: "Asset filename from suggest_clips (e.g. \"v03.mp4\" or \"07.jpg\")." },
|
|
65
|
-
},
|
|
66
|
-
required: ["cut", "asset"],
|
|
67
|
-
},
|
|
68
|
-
async execute(args: { cut: number; asset: string }): Promise<string> {
|
|
69
|
-
const out = await runEngine(["--swap", String(args.cut), args.asset]);
|
|
70
|
-
const m = out.match(/RESULT:(.+)/);
|
|
71
|
-
if (!m) throw new Error(`swap failed. ${out.slice(-300)}`);
|
|
72
|
-
return `Swapped cut #${args.cut} → ${args.asset} and re-rendered.\nOutput: ${m[1].trim()}`;
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export const swapCaptionStyleTool = {
|
|
77
|
-
name: "swap_caption_style",
|
|
78
|
-
description:
|
|
79
|
-
"Change the caption style of the most recently assembled video and re-render. " +
|
|
80
|
-
"Uses the cached edit data — much faster than a full re-assembly (no re-transcription, no B-roll re-analysis). " +
|
|
81
|
-
"Styles: default (bold white spring pop), hormozi (word-by-word yellow highlight), glow_neon (neon cyan pulse), " +
|
|
82
|
-
"minimalist (lowercase clean fade), whiteboard (left-to-right reveal).",
|
|
83
|
-
inputSchema: {
|
|
84
|
-
type: "object",
|
|
85
|
-
properties: {
|
|
86
|
-
caption_style: {
|
|
87
|
-
type: "string",
|
|
88
|
-
enum: ["default", "hormozi", "glow_neon", "minimalist", "whiteboard"],
|
|
89
|
-
description:
|
|
90
|
-
"Caption style to apply. " +
|
|
91
|
-
"default = bold white pop-in (universal). " +
|
|
92
|
-
"hormozi = word-by-word yellow highlight on black pill (high-energy talking-head). " +
|
|
93
|
-
"glow_neon = neon cyan pulsing text (dark aesthetic, music, gaming). " +
|
|
94
|
-
"minimalist = lowercase thin font fade-in (calm, educational). " +
|
|
95
|
-
"whiteboard = text revealed left-to-right (explainers, tutorials).",
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
|
-
required: ["caption_style"],
|
|
99
|
-
},
|
|
100
|
-
async execute(args: { caption_style: string }): Promise<string> {
|
|
101
|
-
const out = await runEngine(["--caption-style", args.caption_style, "--swap-caption-style"]);
|
|
102
|
-
const m = out.match(/RESULT:(.+)/);
|
|
103
|
-
if (!m) throw new Error(`caption style swap failed. ${out.slice(-300)}`);
|
|
104
|
-
return (
|
|
105
|
-
`Caption style changed to "${args.caption_style}" and re-rendered.\n` +
|
|
106
|
-
`Output: ${m[1].trim()}\n` +
|
|
107
|
-
`Publish with publish_now or schedule_post.`
|
|
108
|
-
);
|
|
109
|
-
},
|
|
110
|
-
};
|
package/src/tools/captions.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { post } from "../client.js";
|
|
2
|
-
|
|
3
|
-
interface CaptionResponse {
|
|
4
|
-
caption: string;
|
|
5
|
-
hashtags: string[];
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export const generateCaptionTool = {
|
|
9
|
-
name: "generate_caption",
|
|
10
|
-
description: "Generate an AI caption and hashtags for a post on a specific platform.",
|
|
11
|
-
inputSchema: {
|
|
12
|
-
type: "object",
|
|
13
|
-
properties: {
|
|
14
|
-
title: { type: "string", description: "Video or content title/description." },
|
|
15
|
-
platform: {
|
|
16
|
-
type: "string",
|
|
17
|
-
enum: ["tiktok", "instagram", "youtube"],
|
|
18
|
-
description: "Target platform.",
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
required: ["title", "platform"],
|
|
22
|
-
},
|
|
23
|
-
|
|
24
|
-
async execute(args: { title: string; platform: string }) {
|
|
25
|
-
const data = await post<CaptionResponse>("/v1/publish/generate-caption", {
|
|
26
|
-
video_title: args.title,
|
|
27
|
-
platform: args.platform,
|
|
28
|
-
});
|
|
29
|
-
const tags = data.hashtags.map((h) => `#${h.replace(/^#/, "")}`).join(" ");
|
|
30
|
-
return `Caption:\n${data.caption}\n\nHashtags:\n${tags}`;
|
|
31
|
-
},
|
|
32
|
-
};
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* publish_carousel_as_video — convert image slides into a slideshow MP4 and publish.
|
|
3
|
-
*
|
|
4
|
-
* Solves two real platform constraints:
|
|
5
|
-
* 1. TikTok rejects PHOTO carousels in MEDIA_UPLOAD (draft) mode — but accepts
|
|
6
|
-
* VIDEO drafts via the inbox endpoint. Converting first lets users send a
|
|
7
|
-
* slideshow to TikTok drafts and add trending music in-app before publishing.
|
|
8
|
-
* 2. Neither TikTok nor Instagram allows attaching a custom audio file to a
|
|
9
|
-
* PHOTO carousel via API. Baking the audio into a video sidesteps that.
|
|
10
|
-
*
|
|
11
|
-
* Requires ffmpeg on PATH locally.
|
|
12
|
-
*/
|
|
13
|
-
import fs from "fs";
|
|
14
|
-
import path from "path";
|
|
15
|
-
import os from "os";
|
|
16
|
-
import { spawnSync } from "child_process";
|
|
17
|
-
import { post, uploadFiles, deleteFiles } from "../client.js";
|
|
18
|
-
|
|
19
|
-
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
|
20
|
-
|
|
21
|
-
function expandImagePaths(inputs: string[]): string[] {
|
|
22
|
-
const result: string[] = [];
|
|
23
|
-
for (const p of inputs) {
|
|
24
|
-
const stat = fs.statSync(p, { throwIfNoEntry: false });
|
|
25
|
-
if (!stat) throw new Error(`Path not found: ${p}`);
|
|
26
|
-
if (stat.isDirectory()) {
|
|
27
|
-
const files = fs.readdirSync(p)
|
|
28
|
-
.filter((f) => IMAGE_EXTS.has(path.extname(f).toLowerCase()))
|
|
29
|
-
.sort()
|
|
30
|
-
.map((f) => path.join(p, f));
|
|
31
|
-
result.push(...files);
|
|
32
|
-
} else if (IMAGE_EXTS.has(path.extname(p).toLowerCase())) {
|
|
33
|
-
result.push(p);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return result;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function ensureFfmpeg(): void {
|
|
40
|
-
const r = spawnSync("ffmpeg", ["-version"], { encoding: "utf8" });
|
|
41
|
-
if (r.error || r.status !== 0) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
"ffmpeg is required for slideshow generation but was not found on PATH.\n" +
|
|
44
|
-
"Install instructions:\n" +
|
|
45
|
-
" Windows : winget install Gyan.FFmpeg (or download from https://ffmpeg.org/download.html)\n" +
|
|
46
|
-
" macOS : brew install ffmpeg\n" +
|
|
47
|
-
" Linux : sudo apt install ffmpeg\n" +
|
|
48
|
-
"After installing, restart Claude Code so the new PATH is picked up."
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface PublishResult {
|
|
54
|
-
post: { id: string; status: string; platform_post_ids: Record<string, string>; failed_reason?: string };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export const publishCarouselAsVideoTool = {
|
|
58
|
-
name: "publish_carousel_as_video",
|
|
59
|
-
description:
|
|
60
|
-
"Convert a folder of images into a 1080×1920 slideshow MP4 and publish it. " +
|
|
61
|
-
"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), " +
|
|
62
|
-
"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). " +
|
|
63
|
-
"Requires ffmpeg installed locally. Returns a single video post, not a carousel post.",
|
|
64
|
-
inputSchema: {
|
|
65
|
-
type: "object",
|
|
66
|
-
properties: {
|
|
67
|
-
paths: {
|
|
68
|
-
type: "array",
|
|
69
|
-
items: { type: "string" },
|
|
70
|
-
description: "Folder containing slide images, or a list of image file paths. Files are sorted alphabetically — name them 01_, 02_, etc. for ordering.",
|
|
71
|
-
},
|
|
72
|
-
platforms: {
|
|
73
|
-
type: "array",
|
|
74
|
-
items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
|
|
75
|
-
description: "Platforms to publish to. Must be connected. For mode='draft', Instagram is not allowed (no IG drafts API).",
|
|
76
|
-
},
|
|
77
|
-
caption: { type: "string", description: "Post caption." },
|
|
78
|
-
hashtags: {
|
|
79
|
-
type: "array",
|
|
80
|
-
items: { type: "string" },
|
|
81
|
-
description: "Hashtags without # prefix.",
|
|
82
|
-
},
|
|
83
|
-
audio_path: {
|
|
84
|
-
type: "string",
|
|
85
|
-
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').",
|
|
86
|
-
},
|
|
87
|
-
duration_per_slide: {
|
|
88
|
-
type: "number",
|
|
89
|
-
description: "Optional. Seconds each slide is displayed. Defaults to 3.",
|
|
90
|
-
},
|
|
91
|
-
mode: {
|
|
92
|
-
type: "string",
|
|
93
|
-
enum: ["publish", "draft"],
|
|
94
|
-
description: "Optional. 'publish' (default) publishes immediately. 'draft' sends to TikTok inbox / YouTube as private — user finishes in mobile app.",
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
required: ["paths", "platforms", "caption"],
|
|
98
|
-
},
|
|
99
|
-
|
|
100
|
-
async execute(args: {
|
|
101
|
-
paths: string[];
|
|
102
|
-
platforms: string[];
|
|
103
|
-
caption: string;
|
|
104
|
-
hashtags?: string[];
|
|
105
|
-
audio_path?: string;
|
|
106
|
-
duration_per_slide?: number;
|
|
107
|
-
mode?: "publish" | "draft";
|
|
108
|
-
}) {
|
|
109
|
-
ensureFfmpeg();
|
|
110
|
-
|
|
111
|
-
const imagePaths = expandImagePaths(args.paths);
|
|
112
|
-
if (!imagePaths.length) throw new Error("No image files found in the provided paths.");
|
|
113
|
-
|
|
114
|
-
const mode = args.mode ?? "publish";
|
|
115
|
-
const duration = Math.max(0.5, args.duration_per_slide ?? 3);
|
|
116
|
-
|
|
117
|
-
if (mode === "draft" && args.platforms.includes("instagram")) {
|
|
118
|
-
throw new Error(
|
|
119
|
-
"Instagram does not support drafts via API. Remove 'instagram' from platforms or use mode='publish'."
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (args.audio_path && !fs.existsSync(args.audio_path)) {
|
|
124
|
-
throw new Error(`Audio file not found: ${args.audio_path}`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Build slideshow MP4 via ffmpeg concat demuxer.
|
|
128
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "viralspin-slideshow-"));
|
|
129
|
-
const concatFile = path.join(tmpDir, "concat.txt");
|
|
130
|
-
const outputPath = path.join(tmpDir, "slideshow.mp4");
|
|
131
|
-
|
|
132
|
-
// concat demuxer format: file '<path>' + duration N per entry;
|
|
133
|
-
// last image must be re-listed without a duration so ffmpeg holds the final frame.
|
|
134
|
-
const escape = (p: string) => p.replace(/\\/g, "/").replace(/'/g, "'\\''");
|
|
135
|
-
const concatLines: string[] = [];
|
|
136
|
-
for (const img of imagePaths) {
|
|
137
|
-
concatLines.push(`file '${escape(img)}'`);
|
|
138
|
-
concatLines.push(`duration ${duration}`);
|
|
139
|
-
}
|
|
140
|
-
concatLines.push(`file '${escape(imagePaths[imagePaths.length - 1])}'`);
|
|
141
|
-
fs.writeFileSync(concatFile, concatLines.join("\n"));
|
|
142
|
-
|
|
143
|
-
const ffmpegArgs: string[] = [
|
|
144
|
-
"-y",
|
|
145
|
-
"-f", "concat",
|
|
146
|
-
"-safe", "0",
|
|
147
|
-
"-i", concatFile,
|
|
148
|
-
];
|
|
149
|
-
if (args.audio_path) {
|
|
150
|
-
ffmpegArgs.push("-i", args.audio_path);
|
|
151
|
-
}
|
|
152
|
-
// Scale slides to fit 1080×1920 (TikTok/Reels native), pad edges black to preserve aspect.
|
|
153
|
-
ffmpegArgs.push(
|
|
154
|
-
"-vf", "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,format=yuv420p,fps=30",
|
|
155
|
-
"-c:v", "libx264",
|
|
156
|
-
"-preset", "fast",
|
|
157
|
-
"-crf", "23",
|
|
158
|
-
"-pix_fmt", "yuv420p",
|
|
159
|
-
);
|
|
160
|
-
if (args.audio_path) {
|
|
161
|
-
ffmpegArgs.push(
|
|
162
|
-
"-c:a", "aac",
|
|
163
|
-
"-b:a", "192k",
|
|
164
|
-
"-shortest",
|
|
165
|
-
);
|
|
166
|
-
} else {
|
|
167
|
-
ffmpegArgs.push("-an");
|
|
168
|
-
}
|
|
169
|
-
ffmpegArgs.push(outputPath);
|
|
170
|
-
|
|
171
|
-
const ffmpeg = spawnSync("ffmpeg", ffmpegArgs, { encoding: "utf8" });
|
|
172
|
-
if (ffmpeg.status !== 0) {
|
|
173
|
-
try { fs.unlinkSync(concatFile); fs.rmdirSync(tmpDir); } catch { /* ignore */ }
|
|
174
|
-
const stderr = (ffmpeg.stderr || "").split("\n").slice(-10).join("\n");
|
|
175
|
-
throw new Error(`ffmpeg failed (exit ${ffmpeg.status}):\n${stderr}`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Upload + publish.
|
|
179
|
-
let result: PublishResult;
|
|
180
|
-
let storagePaths: string[] = [];
|
|
181
|
-
try {
|
|
182
|
-
const upload = await uploadFiles([outputPath]);
|
|
183
|
-
storagePaths = upload.storage_paths;
|
|
184
|
-
|
|
185
|
-
result = await post<PublishResult>("/v1/publish/schedule", {
|
|
186
|
-
media_urls: upload.urls,
|
|
187
|
-
media_type: "video",
|
|
188
|
-
platforms: args.platforms,
|
|
189
|
-
caption: args.caption,
|
|
190
|
-
hashtags: args.hashtags ?? [],
|
|
191
|
-
publish_now: true,
|
|
192
|
-
mode,
|
|
193
|
-
});
|
|
194
|
-
} finally {
|
|
195
|
-
try { fs.unlinkSync(concatFile); fs.unlinkSync(outputPath); fs.rmdirSync(tmpDir); } catch { /* ignore */ }
|
|
196
|
-
if (storagePaths.length) await deleteFiles(storagePaths).catch(() => {});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const { status, platform_post_ids, failed_reason } = result.post;
|
|
200
|
-
const targets = Object.keys(platform_post_ids);
|
|
201
|
-
const totalSeconds = (imagePaths.length * duration).toFixed(1);
|
|
202
|
-
let summary = `Slideshow video built: ${imagePaths.length} images × ${duration}s = ${totalSeconds}s, 1080×1920, ${args.audio_path ? "with audio" : "silent"}.\nStatus: ${status}\n`;
|
|
203
|
-
if (targets.length) {
|
|
204
|
-
summary += mode === "draft"
|
|
205
|
-
? `Sent to drafts on: ${targets.join(", ")}\nOpen the platform's mobile app to finish — add trending audio (if silent), adjust caption, then publish.`
|
|
206
|
-
: `Published to: ${targets.join(", ")}`;
|
|
207
|
-
}
|
|
208
|
-
if (failed_reason) summary += `\nErrors: ${failed_reason}`;
|
|
209
|
-
return summary.trim();
|
|
210
|
-
},
|
|
211
|
-
};
|
package/src/tools/connections.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { get } from "../client.js";
|
|
2
|
-
|
|
3
|
-
interface Connection {
|
|
4
|
-
id: string;
|
|
5
|
-
platform: string;
|
|
6
|
-
display_name: string;
|
|
7
|
-
username: string;
|
|
8
|
-
follower_count: number;
|
|
9
|
-
is_active: boolean;
|
|
10
|
-
connected_at: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const listConnectionsTool = {
|
|
14
|
-
name: "list_connections",
|
|
15
|
-
description: "List your connected social media accounts (TikTok, Instagram, YouTube).",
|
|
16
|
-
inputSchema: { type: "object", properties: {}, required: [] },
|
|
17
|
-
|
|
18
|
-
async execute(_args: Record<string, unknown>) {
|
|
19
|
-
const connections = await get<Connection[]>("/v1/publish/connections");
|
|
20
|
-
if (!connections.length) {
|
|
21
|
-
return "No platforms connected yet. Visit https://www.viralspin.ai/spin/settings to connect your accounts.";
|
|
22
|
-
}
|
|
23
|
-
return connections
|
|
24
|
-
.map((c) => `${c.platform}: ${c.display_name} (${c.username}) — ${c.follower_count.toLocaleString()} followers`)
|
|
25
|
-
.join("\n");
|
|
26
|
-
},
|
|
27
|
-
};
|