@viralspin/mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUNDLE_LOCAL_SPEC.md +133 -0
- package/dist/client.js +47 -9
- package/dist/index.js +83 -1
- package/dist/tools/assembly-adjust.js +102 -0
- package/dist/tools/carousel-video.js +190 -0
- package/dist/tools/engage.js +88 -0
- package/dist/tools/explainer-index.js +301 -0
- package/dist/tools/image-ads.js +156 -0
- package/dist/tools/kinetic-text.js +118 -0
- package/dist/tools/photo-picker.js +31 -0
- package/dist/tools/publish.js +72 -1
- package/dist/tools/review.js +103 -0
- package/dist/tools/video-assembly.js +183 -0
- package/engine/assemble.mjs +476 -0
- package/engine/gemini.mjs +164 -0
- package/engine/kinetic.mjs +84 -0
- package/engine/prompts.mjs +94 -0
- package/engine/remotion.config.ts +4 -0
- package/engine/src/Assembly.tsx +330 -0
- package/engine/src/KineticText.tsx +253 -0
- package/engine/src/Root.tsx +39 -0
- package/engine/src/index.ts +4 -0
- package/engine/tsconfig.json +15 -0
- package/package.json +29 -29
- package/src/client.ts +119 -80
- package/src/index.ts +151 -60
- package/src/tools/assembly-adjust.ts +110 -0
- package/src/tools/captions.ts +32 -32
- package/src/tools/carousel-video.ts +211 -0
- package/src/tools/connections.ts +27 -27
- package/src/tools/engage.ts +105 -0
- package/src/tools/explainer-index.ts +298 -0
- package/src/tools/image-ads.ts +192 -0
- package/src/tools/kinetic-text.ts +130 -0
- package/src/tools/photo-picker.ts +31 -0
- package/src/tools/posts.ts +58 -58
- package/src/tools/publish.ts +236 -158
- package/src/tools/review.ts +126 -0
- package/src/tools/video-assembly.ts +205 -0
- package/tsconfig.json +15 -15
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Bundle-Local Spec — `@viralspin/mcp` local video-assembly
|
|
2
|
+
|
|
3
|
+
Status: proposed. Owner: Allen. Last updated: 2026-05-24.
|
|
4
|
+
|
|
5
|
+
## 1. Goal
|
|
6
|
+
|
|
7
|
+
Let any ViralSpin user produce a polished vertical short from **their own local files**
|
|
8
|
+
(local A-roll + local B-roll folders) and/or Google Photos, with **zero key management**
|
|
9
|
+
and a **one-time ~10-minute install** — driven entirely by natural language.
|
|
10
|
+
|
|
11
|
+
Target UX (the literal command from day one of this work):
|
|
12
|
+
|
|
13
|
+
> "Create a video with A-roll `E:\...\test.mp4`, B-roll from `E:\PickedByCarissa` and my Google Photos."
|
|
14
|
+
|
|
15
|
+
The agent recognizes the local paths, realizes the cloud connector can't read local disk,
|
|
16
|
+
offers a one-time local install (~10 min, with consent), then runs the assembly. Gemini /
|
|
17
|
+
AI cost runs on **ViralSpin's** key server-side and is metered as **credits**. The user
|
|
18
|
+
never sees or pastes an API key.
|
|
19
|
+
|
|
20
|
+
## 2. Current state (2026-05-24)
|
|
21
|
+
|
|
22
|
+
- **Engine** (`E:\AI test\carissa bot\assembly-engine\`) — Node orchestrator (`assemble.mjs`)
|
|
23
|
+
+ Python Gemini scripts (`transcribe.py`, `analyze.py`, `edit.py`, `albums.py`, `style.py`)
|
|
24
|
+
+ parameterized Remotion composition (`src/Assembly.tsx` / `Root.tsx`). Produces the
|
|
25
|
+
cindyzhu-style edit (captions + hook + keyword pops + fast focal-anchored cuts).
|
|
26
|
+
**Not in the repo**; referenced by `VIRALSPIN_ASSEMBLY_DIR` (defaults to the carissa path).
|
|
27
|
+
- **MCP** (`mcp/`, this package) — stdio server. `create_video_assembly` + `start_photo_picker`
|
|
28
|
+
shell out to the engine. **Merged to main (PR #93)** but the engine is still external.
|
|
29
|
+
- **Backend** — `/v1/assembly/picker` (POST create picker session, GET poll → re-host picks to
|
|
30
|
+
public `narrative` bucket URLs) is **live**. Hosted `viralspin_create_video_assembly`
|
|
31
|
+
(URL-only, older pipeline) is unchanged.
|
|
32
|
+
- **Two credentials today** (both need to disappear from the user's view):
|
|
33
|
+
1. `VITE_GEMINI_API_KEY` — engine calls Gemini directly from a local `.env`. **Blocker for
|
|
34
|
+
distribution** (would be baked into shipped code).
|
|
35
|
+
2. `vs_…` token — per-user ViralSpin auth, currently hand-pasted into the MCP config env.
|
|
36
|
+
|
|
37
|
+
## 3. Target architecture
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
natural-language command
|
|
41
|
+
│
|
|
42
|
+
▼
|
|
43
|
+
Agent (Claude Code / Desktop, local shell)
|
|
44
|
+
│ detects local E:\ paths → cloud can't read disk
|
|
45
|
+
│ one-time consent → installs local helper (.mcpb / npx) ── §4F
|
|
46
|
+
▼
|
|
47
|
+
Local @viralspin/mcp (stdio, self-contained: Node + ffmpeg-static + Remotion/Chromium) ── §4B
|
|
48
|
+
│ transcribe / analyze / edit / style ── all Node ── §4A
|
|
49
|
+
│ AI calls → ViralSpin backend proxy (no Gemini key locally) ── §4C
|
|
50
|
+
│ auth via login-provisioned token (no pasted vs_) ── §4D
|
|
51
|
+
│ persistent analysis cache (path+size+mtime) ── §4E
|
|
52
|
+
▼
|
|
53
|
+
Remotion render (local CPU) → out.mp4 → publish_now / schedule_post
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 4. Workstreams
|
|
57
|
+
|
|
58
|
+
### A. Port Python → Node (remove the Python dependency)
|
|
59
|
+
Reimplement `transcribe.py`, `analyze.py`, `edit.py`, `albums.py`, `style.py` in TypeScript.
|
|
60
|
+
- Keep the exact contracts (focal `{subject,focal_x,focal_y,secondary,desc,keywords}`;
|
|
61
|
+
editorial output `{hook,pops,cuts}`; album weights; style `{format,captions,hook,pops,pace,coverage}`).
|
|
62
|
+
- Preserve the hard-won Gemini settings: `thinkingConfig.thinkingBudget=0` +
|
|
63
|
+
`responseMimeType:application/json` (gemini-2.5-flash is a thinking model — without this it
|
|
64
|
+
burns the token budget on hidden thinking and returns empty text).
|
|
65
|
+
- Keep "transcribe FIRST → drives album ranking + B-roll selection + captions" ordering.
|
|
66
|
+
|
|
67
|
+
### B. Bundle the runtime into the package
|
|
68
|
+
- `ffmpeg-static` (transcode A-roll, stage B-roll, extract audio, mid-frame for video analysis).
|
|
69
|
+
- Remotion + its headless Chromium (the ~hundreds-of-MB download → the bulk of the 10 min).
|
|
70
|
+
- Vendor the composition (`Assembly.tsx`/`Root.tsx`) inside the package.
|
|
71
|
+
- Result: `@viralspin/mcp` runs with only Node present; no external engine dir,
|
|
72
|
+
no system ffmpeg, no Python.
|
|
73
|
+
|
|
74
|
+
### C. Proxy AI through the backend (credits, no user Gemini key) ← enables distribution
|
|
75
|
+
- New backend endpoints (X-API-Key / token authed) that wrap the Gemini calls the engine makes:
|
|
76
|
+
transcription, per-asset vision analysis, editorial pass, style inference.
|
|
77
|
+
- Server holds the Gemini key. Each call **debits credits** against the user's account.
|
|
78
|
+
- Local engine calls these instead of Gemini directly → no key ships to clients.
|
|
79
|
+
- Decide credit pricing per render (≈ #assets analyzed + 1 transcript + 1 editorial + optional style).
|
|
80
|
+
|
|
81
|
+
### D. Token provisioning via login (no pasted `vs_`)
|
|
82
|
+
- A connect/login flow writes the user's token into the MCP config automatically
|
|
83
|
+
(or the helper fetches it post-login). User funds credits; never handles a key.
|
|
84
|
+
- Token identifies whose credits to charge and whose Google account the picker uses.
|
|
85
|
+
|
|
86
|
+
### E. Persistent analysis cache
|
|
87
|
+
- Key each analyzed asset by `path + size + mtime` (and a content hash for URLs).
|
|
88
|
+
- A 1000-item folder is indexed **once**; re-runs / edits skip re-analysis (huge cost + time win).
|
|
89
|
+
- Cache lives under the package's data dir; survives across renders.
|
|
90
|
+
|
|
91
|
+
### F. Packaging, distribution, auto-install
|
|
92
|
+
- Primary: **`.mcpb` Claude Desktop extension** — double-click install, bundles Node.
|
|
93
|
+
- Secondary: `npx @viralspin/mcp` / install script for Claude Code (`claude mcp add`).
|
|
94
|
+
- **Client-side auto-install trigger**: the agent (not the cloud connector — it has no disk
|
|
95
|
+
access) detects local paths in the request and offers the one-time setup **with explicit
|
|
96
|
+
consent**. Never install silently.
|
|
97
|
+
- Note the **reconnect seam**: adding a stdio server still requires the client to spawn it;
|
|
98
|
+
today that means "restart once" after first install. (`/mcp` reconnect re-attaches to the
|
|
99
|
+
existing child — it does NOT pick up a freshly installed/rebuilt server.)
|
|
100
|
+
|
|
101
|
+
### G. Adjustment tools (post-MVP)
|
|
102
|
+
On a persisted "project" (editData + cached candidate pool + transcript), conversational edits:
|
|
103
|
+
`suggest_clips(cut)` → ranked alternatives w/ thumbnails, `swap_clip(cut,choice)` → re-render
|
|
104
|
+
(~1 min local), plus remove / add / reframe / set_hook / edit_caption / set_pace.
|
|
105
|
+
|
|
106
|
+
### H. Optional server-side render (zero-install fallback)
|
|
107
|
+
For users who can't/won't install: render on ViralSpin infra (your compute → more credits).
|
|
108
|
+
Trade-off: no local-file capability (must upload), but truly zero-setup.
|
|
109
|
+
|
|
110
|
+
## 5. End-to-end UX (target)
|
|
111
|
+
|
|
112
|
+
1. User signs in to ViralSpin, funds credits (one time). Token provisioned silently.
|
|
113
|
+
2. Adds the ViralSpin connector — enough on its own for URL / Google-Photos → server-side render.
|
|
114
|
+
3. Types a command referencing local files. Agent: *"This needs a one-time ~10-min local setup
|
|
115
|
+
to use your local files — install it?"* → installs the `.mcpb` → restart once.
|
|
116
|
+
4. Thereafter, local-file commands "just work": transcribe → AI picks B-roll by context
|
|
117
|
+
(local folders + Google Photos pooled) → render locally → publish. AI cost = credits; no keys shown.
|
|
118
|
+
|
|
119
|
+
## 6. Open questions / caveats
|
|
120
|
+
|
|
121
|
+
- **Render on user hardware** — slow on weak machines. §4H is the escape hatch.
|
|
122
|
+
- **Baseline prereqs** — OS + disk space even with a bundle; confirm Node bundling in `.mcpb`.
|
|
123
|
+
- **Reconnect seam** — see §4F; need "restart once" UX until clients hot-load MCP servers.
|
|
124
|
+
- **Credit metering granularity** — per-asset analysis vs per-render flat fee.
|
|
125
|
+
- **Cache invalidation** — mtime is good enough locally; URLs need a content hash.
|
|
126
|
+
|
|
127
|
+
## 7. Suggested sequencing
|
|
128
|
+
|
|
129
|
+
1. **C + D** (Gemini→backend proxy + token-via-login) — unblocks distribution; do first.
|
|
130
|
+
2. **A** (Python→Node) — removes the Python prereq.
|
|
131
|
+
3. **B + E** (bundle runtime + analysis cache) — the actual "10-min install, then fast" payoff.
|
|
132
|
+
4. **F** (.mcpb + client-side auto-install with consent).
|
|
133
|
+
5. **G** (adjustment tools), **H** (server-side render) — after the local MVP ships.
|
package/dist/client.js
CHANGED
|
@@ -5,22 +5,32 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.get = get;
|
|
7
7
|
exports.post = post;
|
|
8
|
+
exports.pyGet = pyGet;
|
|
9
|
+
exports.pyPost = pyPost;
|
|
8
10
|
exports.del = del;
|
|
9
11
|
exports.uploadFiles = uploadFiles;
|
|
10
12
|
exports.deleteFiles = deleteFiles;
|
|
11
|
-
const form_data_1 = __importDefault(require("form-data"));
|
|
12
13
|
const fs_1 = __importDefault(require("fs"));
|
|
13
14
|
const path_1 = __importDefault(require("path"));
|
|
14
15
|
const API_URL = (process.env.VIRALSPIN_API_URL || "https://viralspinbackend.onrender.com").replace(/\/$/, "");
|
|
16
|
+
// Python backend — Higgsfield, review-sessions, OAuth, narrative-render
|
|
17
|
+
const PY_API_URL = (process.env.VIRALSPIN_PY_API_URL || "https://viralspin-api.onrender.com").replace(/\/$/, "");
|
|
15
18
|
const API_KEY = process.env.VIRALSPIN_API_KEY || "";
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
// OAuth token takes precedence over API key (set after "Sign in with ViralSpin" flow)
|
|
20
|
+
const OAUTH_TOKEN = process.env.VIRALSPIN_OAUTH_TOKEN || "";
|
|
21
|
+
if (!API_KEY && !OAUTH_TOKEN) {
|
|
22
|
+
process.stderr.write("[viralspin-mcp] Warning: Neither VIRALSPIN_API_KEY nor VIRALSPIN_OAUTH_TOKEN is set\n");
|
|
18
23
|
}
|
|
19
24
|
function headers(extra = {}) {
|
|
25
|
+
// OAuth Bearer token (from "Sign in with ViralSpin" flow) takes priority
|
|
26
|
+
if (OAUTH_TOKEN) {
|
|
27
|
+
return { "Authorization": `Bearer ${OAUTH_TOKEN}`, ...extra };
|
|
28
|
+
}
|
|
29
|
+
// Legacy API key — retained for backward compatibility with personal installs
|
|
20
30
|
return { "X-API-Key": API_KEY, ...extra };
|
|
21
31
|
}
|
|
22
|
-
async function apiFetch(endpoint, init = {}) {
|
|
23
|
-
const url = `${
|
|
32
|
+
async function apiFetch(endpoint, init = {}, base = API_URL) {
|
|
33
|
+
const url = `${base}${endpoint}`;
|
|
24
34
|
const resp = await fetch(url, {
|
|
25
35
|
...init,
|
|
26
36
|
headers: { ...(init.headers ?? {}), ...headers() },
|
|
@@ -46,6 +56,17 @@ function post(endpoint, body) {
|
|
|
46
56
|
body: JSON.stringify(body),
|
|
47
57
|
});
|
|
48
58
|
}
|
|
59
|
+
/** Python backend helpers — for routes that live in viralspin-api (Higgsfield, OAuth, etc.) */
|
|
60
|
+
function pyGet(endpoint) {
|
|
61
|
+
return apiFetch(endpoint, { method: "GET" }, PY_API_URL);
|
|
62
|
+
}
|
|
63
|
+
function pyPost(endpoint, body) {
|
|
64
|
+
return apiFetch(endpoint, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
}, PY_API_URL);
|
|
69
|
+
}
|
|
49
70
|
function del(endpoint, body) {
|
|
50
71
|
return apiFetch(endpoint, {
|
|
51
72
|
method: "DELETE",
|
|
@@ -55,15 +76,32 @@ function del(endpoint, body) {
|
|
|
55
76
|
}
|
|
56
77
|
/** Upload one or more local files. Returns { url, storage_path } or { urls, storage_paths } */
|
|
57
78
|
async function uploadFiles(filePaths) {
|
|
58
|
-
|
|
79
|
+
// Use the native Web FormData + Blob (Node 18+). The npm 'form-data' package
|
|
80
|
+
// streams in a way native fetch can't serialize correctly, which previously
|
|
81
|
+
// caused Busboy on the backend to fail with "Failed to parse upload".
|
|
82
|
+
const form = new FormData();
|
|
59
83
|
const fieldName = filePaths.length === 1 ? "file" : "files";
|
|
60
84
|
for (const fp of filePaths) {
|
|
61
|
-
|
|
85
|
+
const buffer = await fs_1.default.promises.readFile(fp);
|
|
86
|
+
const ext = path_1.default.extname(fp).toLowerCase();
|
|
87
|
+
const mime = ext === ".png" ? "image/png" :
|
|
88
|
+
ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" :
|
|
89
|
+
ext === ".gif" ? "image/gif" :
|
|
90
|
+
ext === ".webp" ? "image/webp" :
|
|
91
|
+
ext === ".mp4" ? "video/mp4" :
|
|
92
|
+
ext === ".mov" ? "video/quicktime" :
|
|
93
|
+
ext === ".webm" ? "video/webm" :
|
|
94
|
+
"application/octet-stream";
|
|
95
|
+
const blob = new Blob([buffer], { type: mime });
|
|
96
|
+
form.append(fieldName, blob, path_1.default.basename(fp));
|
|
62
97
|
}
|
|
98
|
+
const authHeader = OAUTH_TOKEN
|
|
99
|
+
? { "Authorization": `Bearer ${OAUTH_TOKEN}` }
|
|
100
|
+
: { "X-API-Key": API_KEY };
|
|
63
101
|
const resp = await fetch(`${API_URL}/v1/media/upload`, {
|
|
64
102
|
method: "POST",
|
|
65
|
-
|
|
66
|
-
|
|
103
|
+
// Don't set Content-Type manually — fetch will set it with the correct multipart boundary.
|
|
104
|
+
headers: authHeader,
|
|
67
105
|
body: form,
|
|
68
106
|
});
|
|
69
107
|
if (!resp.ok) {
|
package/dist/index.js
CHANGED
|
@@ -6,17 +6,99 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
|
6
6
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
7
7
|
const connections_js_1 = require("./tools/connections.js");
|
|
8
8
|
const publish_js_1 = require("./tools/publish.js");
|
|
9
|
+
const carousel_video_js_1 = require("./tools/carousel-video.js");
|
|
10
|
+
const video_assembly_js_1 = require("./tools/video-assembly.js");
|
|
11
|
+
const photo_picker_js_1 = require("./tools/photo-picker.js");
|
|
12
|
+
const assembly_adjust_js_1 = require("./tools/assembly-adjust.js");
|
|
9
13
|
const posts_js_1 = require("./tools/posts.js");
|
|
10
14
|
const captions_js_1 = require("./tools/captions.js");
|
|
15
|
+
const engage_js_1 = require("./tools/engage.js");
|
|
16
|
+
const explainer_index_js_1 = require("./tools/explainer-index.js");
|
|
17
|
+
const kinetic_text_js_1 = require("./tools/kinetic-text.js");
|
|
18
|
+
const review_js_1 = require("./tools/review.js");
|
|
19
|
+
const image_ads_js_1 = require("./tools/image-ads.js");
|
|
11
20
|
const tools = [
|
|
12
21
|
connections_js_1.listConnectionsTool,
|
|
13
22
|
publish_js_1.publishNowTool,
|
|
14
23
|
publish_js_1.schedulePostTool,
|
|
24
|
+
publish_js_1.sendToDraftsTool,
|
|
25
|
+
carousel_video_js_1.publishCarouselAsVideoTool,
|
|
26
|
+
video_assembly_js_1.createVideoAssemblyTool,
|
|
27
|
+
photo_picker_js_1.startPhotoPickerTool,
|
|
28
|
+
assembly_adjust_js_1.suggestClipsTool,
|
|
29
|
+
assembly_adjust_js_1.swapClipTool,
|
|
30
|
+
assembly_adjust_js_1.swapCaptionStyleTool,
|
|
15
31
|
posts_js_1.listPostsTool,
|
|
16
32
|
posts_js_1.cancelPostTool,
|
|
17
33
|
captions_js_1.generateCaptionTool,
|
|
34
|
+
engage_js_1.setKeywordLinkTool,
|
|
35
|
+
engage_js_1.listKeywordLinksTool,
|
|
36
|
+
engage_js_1.deleteKeywordLinkTool,
|
|
37
|
+
explainer_index_js_1.indexExplainerSourcesTool,
|
|
38
|
+
kinetic_text_js_1.createKineticTextTool,
|
|
39
|
+
review_js_1.createReviewLinkTool,
|
|
40
|
+
review_js_1.checkReviewStatusTool,
|
|
41
|
+
image_ads_js_1.prepareProductAssetTool,
|
|
42
|
+
image_ads_js_1.assembleImageAdTool,
|
|
43
|
+
image_ads_js_1.publishToMetaTool,
|
|
18
44
|
];
|
|
19
|
-
|
|
45
|
+
// Shown to the user as the welcome / getting-started message. The agent fills in
|
|
46
|
+
// <picker link> by calling start_photo_picker (per ONBOARDING below).
|
|
47
|
+
const SAMPLE_COMMANDS = [
|
|
48
|
+
'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."',
|
|
49
|
+
'2. Publish it: "Publish that video to TikTok and Instagram with the caption: posting through the chaos."',
|
|
50
|
+
].join("\n");
|
|
51
|
+
const WELCOME = [
|
|
52
|
+
"Welcome to ViralSpin. I turn a talking-head A-roll into a polished vertical short —",
|
|
53
|
+
"B-roll cutaways matched to what you say, captions, a hook headline, and keyword pops —",
|
|
54
|
+
"then publish it to TikTok, Instagram, or YouTube. B-roll can be local files, a local",
|
|
55
|
+
"folder (auto-indexed), public URLs, and/or your Google Photos.",
|
|
56
|
+
"",
|
|
57
|
+
"Two commands to get you started:",
|
|
58
|
+
SAMPLE_COMMANDS,
|
|
59
|
+
].join("\n");
|
|
60
|
+
// Auto-injected into the agent's context when the server connects. Drives proactive onboarding.
|
|
61
|
+
const INSTRUCTIONS = [
|
|
62
|
+
"ViralSpin assembles A-roll + B-roll into polished vertical shorts and publishes them to TikTok/Instagram/YouTube.",
|
|
63
|
+
"",
|
|
64
|
+
"ONBOARDING: The FIRST time you help the user with ViralSpin in a session, proactively onboard them:",
|
|
65
|
+
"(1) give the short intro below, (2) CALL start_photo_picker so they get a live Google Photos B-roll picker link, and",
|
|
66
|
+
"(3) show these two sample commands verbatim:",
|
|
67
|
+
SAMPLE_COMMANDS,
|
|
68
|
+
"",
|
|
69
|
+
"HOW B-ROLL WORKS: create_video_assembly accepts local file paths, a local FOLDER (auto-expanded and indexed),",
|
|
70
|
+
"public URLs, and Google Photos. For Google Photos: call start_photo_picker → it returns a browser link + sessionId;",
|
|
71
|
+
"the user picks in their browser (Google forbids listing albums programmatically), then call create_video_assembly",
|
|
72
|
+
"with google_picker_session set to that sessionId. Pass style_ref (a reference video) to auto-infer captions/hook/pace/aspect.",
|
|
73
|
+
"After rendering, publish with publish_now or schedule_post.",
|
|
74
|
+
].join("\n");
|
|
75
|
+
const server = new index_js_1.Server({ name: "viralspin", version: "0.1.0" }, { capabilities: { tools: {}, prompts: {} }, instructions: INSTRUCTIONS });
|
|
76
|
+
server.setRequestHandler(types_js_1.ListPromptsRequestSchema, async () => ({
|
|
77
|
+
prompts: [
|
|
78
|
+
{
|
|
79
|
+
name: "getting_started",
|
|
80
|
+
description: "Welcome + how to use ViralSpin: intro, a Google Photos B-roll picker link, and two sample commands.",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
}));
|
|
84
|
+
server.setRequestHandler(types_js_1.GetPromptRequestSchema, async (req) => {
|
|
85
|
+
if (req.params.name !== "getting_started") {
|
|
86
|
+
throw new Error(`Unknown prompt: ${req.params.name}`);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
messages: [
|
|
90
|
+
{
|
|
91
|
+
role: "user",
|
|
92
|
+
content: {
|
|
93
|
+
type: "text",
|
|
94
|
+
text: WELCOME +
|
|
95
|
+
"\n\nNow call start_photo_picker and include the returned Google Photos picker link in your reply, " +
|
|
96
|
+
"so I can pick B-roll. Keep the two sample commands above in your reply.",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
});
|
|
20
102
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
21
103
|
tools: tools.map((t) => ({
|
|
22
104
|
name: t.name,
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
// 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";
|
|
13
|
+
function runEngine(args) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(ENGINE_DIR, "assemble.mjs"), ...args], { cwd: ENGINE_DIR, env: { ...process.env } });
|
|
16
|
+
let stdout = "";
|
|
17
|
+
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
18
|
+
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
19
|
+
child.on("error", reject);
|
|
20
|
+
child.on("close", (code) => (code === 0 ? resolve(stdout) : reject(new Error(`engine exited ${code}. ${stdout.slice(-300)}`))));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
exports.suggestClipsTool = {
|
|
24
|
+
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.",
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
cut: { type: "number", description: "Index of the cutaway to replace (0-based; from the cut list create_video_assembly returns)." },
|
|
32
|
+
},
|
|
33
|
+
required: ["cut"],
|
|
34
|
+
},
|
|
35
|
+
async execute(args) {
|
|
36
|
+
const out = await runEngine(["--suggest", String(args.cut)]);
|
|
37
|
+
const m = out.match(/SUGGEST:(.+)/);
|
|
38
|
+
if (!m)
|
|
39
|
+
throw new Error("no suggestions returned");
|
|
40
|
+
const d = JSON.parse(m[1]);
|
|
41
|
+
const lines = (d.options || []).map((o, i) => `${i + 1}. ${o.asset} (${o.kind}) — ${o.desc || "no description"}\n thumbnail: ${o.thumb}`);
|
|
42
|
+
return [
|
|
43
|
+
`Cut #${d.cut} (currently: ${d.current})${d.beat ? ` · line: "${d.beat}"` : ""}`,
|
|
44
|
+
`Ranked alternatives:`,
|
|
45
|
+
...lines,
|
|
46
|
+
``,
|
|
47
|
+
`Apply one with: swap_clip(cut: ${d.cut}, asset: "<asset>").`,
|
|
48
|
+
].join("\n");
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
exports.swapClipTool = {
|
|
52
|
+
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.",
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
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\")." },
|
|
60
|
+
},
|
|
61
|
+
required: ["cut", "asset"],
|
|
62
|
+
},
|
|
63
|
+
async execute(args) {
|
|
64
|
+
const out = await runEngine(["--swap", String(args.cut), args.asset]);
|
|
65
|
+
const m = out.match(/RESULT:(.+)/);
|
|
66
|
+
if (!m)
|
|
67
|
+
throw new Error(`swap failed. ${out.slice(-300)}`);
|
|
68
|
+
return `Swapped cut #${args.cut} → ${args.asset} and re-rendered.\nOutput: ${m[1].trim()}`;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
exports.swapCaptionStyleTool = {
|
|
72
|
+
name: "swap_caption_style",
|
|
73
|
+
description: "Change the caption style of the most recently assembled video and re-render. " +
|
|
74
|
+
"Uses the cached edit data — much faster than a full re-assembly (no re-transcription, no B-roll re-analysis). " +
|
|
75
|
+
"Styles: default (bold white spring pop), hormozi (word-by-word yellow highlight), glow_neon (neon cyan pulse), " +
|
|
76
|
+
"minimalist (lowercase clean fade), whiteboard (left-to-right reveal).",
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
caption_style: {
|
|
81
|
+
type: "string",
|
|
82
|
+
enum: ["default", "hormozi", "glow_neon", "minimalist", "whiteboard"],
|
|
83
|
+
description: "Caption style to apply. " +
|
|
84
|
+
"default = bold white pop-in (universal). " +
|
|
85
|
+
"hormozi = word-by-word yellow highlight on black pill (high-energy talking-head). " +
|
|
86
|
+
"glow_neon = neon cyan pulsing text (dark aesthetic, music, gaming). " +
|
|
87
|
+
"minimalist = lowercase thin font fade-in (calm, educational). " +
|
|
88
|
+
"whiteboard = text revealed left-to-right (explainers, tutorials).",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
required: ["caption_style"],
|
|
92
|
+
},
|
|
93
|
+
async execute(args) {
|
|
94
|
+
const out = await runEngine(["--caption-style", args.caption_style, "--swap-caption-style"]);
|
|
95
|
+
const m = out.match(/RESULT:(.+)/);
|
|
96
|
+
if (!m)
|
|
97
|
+
throw new Error(`caption style swap failed. ${out.slice(-300)}`);
|
|
98
|
+
return (`Caption style changed to "${args.caption_style}" and re-rendered.\n` +
|
|
99
|
+
`Output: ${m[1].trim()}\n` +
|
|
100
|
+
`Publish with publish_now or schedule_post.`);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
caption: { type: "string", description: "Post caption." },
|
|
75
|
+
hashtags: {
|
|
76
|
+
type: "array",
|
|
77
|
+
items: { type: "string" },
|
|
78
|
+
description: "Hashtags without # prefix.",
|
|
79
|
+
},
|
|
80
|
+
audio_path: {
|
|
81
|
+
type: "string",
|
|
82
|
+
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').",
|
|
83
|
+
},
|
|
84
|
+
duration_per_slide: {
|
|
85
|
+
type: "number",
|
|
86
|
+
description: "Optional. Seconds each slide is displayed. Defaults to 3.",
|
|
87
|
+
},
|
|
88
|
+
mode: {
|
|
89
|
+
type: "string",
|
|
90
|
+
enum: ["publish", "draft"],
|
|
91
|
+
description: "Optional. 'publish' (default) publishes immediately. 'draft' sends to TikTok inbox / YouTube as private — user finishes in mobile app.",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
required: ["paths", "platforms", "caption"],
|
|
95
|
+
},
|
|
96
|
+
async execute(args) {
|
|
97
|
+
ensureFfmpeg();
|
|
98
|
+
const imagePaths = expandImagePaths(args.paths);
|
|
99
|
+
if (!imagePaths.length)
|
|
100
|
+
throw new Error("No image files found in the provided paths.");
|
|
101
|
+
const mode = args.mode ?? "publish";
|
|
102
|
+
const duration = Math.max(0.5, args.duration_per_slide ?? 3);
|
|
103
|
+
if (mode === "draft" && args.platforms.includes("instagram")) {
|
|
104
|
+
throw new Error("Instagram does not support drafts via API. Remove 'instagram' from platforms or use mode='publish'.");
|
|
105
|
+
}
|
|
106
|
+
if (args.audio_path && !fs_1.default.existsSync(args.audio_path)) {
|
|
107
|
+
throw new Error(`Audio file not found: ${args.audio_path}`);
|
|
108
|
+
}
|
|
109
|
+
// Build slideshow MP4 via ffmpeg concat demuxer.
|
|
110
|
+
const tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "viralspin-slideshow-"));
|
|
111
|
+
const concatFile = path_1.default.join(tmpDir, "concat.txt");
|
|
112
|
+
const outputPath = path_1.default.join(tmpDir, "slideshow.mp4");
|
|
113
|
+
// concat demuxer format: file '<path>' + duration N per entry;
|
|
114
|
+
// last image must be re-listed without a duration so ffmpeg holds the final frame.
|
|
115
|
+
const escape = (p) => p.replace(/\\/g, "/").replace(/'/g, "'\\''");
|
|
116
|
+
const concatLines = [];
|
|
117
|
+
for (const img of imagePaths) {
|
|
118
|
+
concatLines.push(`file '${escape(img)}'`);
|
|
119
|
+
concatLines.push(`duration ${duration}`);
|
|
120
|
+
}
|
|
121
|
+
concatLines.push(`file '${escape(imagePaths[imagePaths.length - 1])}'`);
|
|
122
|
+
fs_1.default.writeFileSync(concatFile, concatLines.join("\n"));
|
|
123
|
+
const ffmpegArgs = [
|
|
124
|
+
"-y",
|
|
125
|
+
"-f", "concat",
|
|
126
|
+
"-safe", "0",
|
|
127
|
+
"-i", concatFile,
|
|
128
|
+
];
|
|
129
|
+
if (args.audio_path) {
|
|
130
|
+
ffmpegArgs.push("-i", args.audio_path);
|
|
131
|
+
}
|
|
132
|
+
// Scale slides to fit 1080×1920 (TikTok/Reels native), pad edges black to preserve aspect.
|
|
133
|
+
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");
|
|
134
|
+
if (args.audio_path) {
|
|
135
|
+
ffmpegArgs.push("-c:a", "aac", "-b:a", "192k", "-shortest");
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
ffmpegArgs.push("-an");
|
|
139
|
+
}
|
|
140
|
+
ffmpegArgs.push(outputPath);
|
|
141
|
+
const ffmpeg = (0, child_process_1.spawnSync)("ffmpeg", ffmpegArgs, { encoding: "utf8" });
|
|
142
|
+
if (ffmpeg.status !== 0) {
|
|
143
|
+
try {
|
|
144
|
+
fs_1.default.unlinkSync(concatFile);
|
|
145
|
+
fs_1.default.rmdirSync(tmpDir);
|
|
146
|
+
}
|
|
147
|
+
catch { /* ignore */ }
|
|
148
|
+
const stderr = (ffmpeg.stderr || "").split("\n").slice(-10).join("\n");
|
|
149
|
+
throw new Error(`ffmpeg failed (exit ${ffmpeg.status}):\n${stderr}`);
|
|
150
|
+
}
|
|
151
|
+
// Upload + publish.
|
|
152
|
+
let result;
|
|
153
|
+
let storagePaths = [];
|
|
154
|
+
try {
|
|
155
|
+
const upload = await (0, client_js_1.uploadFiles)([outputPath]);
|
|
156
|
+
storagePaths = upload.storage_paths;
|
|
157
|
+
result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
158
|
+
media_urls: upload.urls,
|
|
159
|
+
media_type: "video",
|
|
160
|
+
platforms: args.platforms,
|
|
161
|
+
caption: args.caption,
|
|
162
|
+
hashtags: args.hashtags ?? [],
|
|
163
|
+
publish_now: true,
|
|
164
|
+
mode,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
try {
|
|
169
|
+
fs_1.default.unlinkSync(concatFile);
|
|
170
|
+
fs_1.default.unlinkSync(outputPath);
|
|
171
|
+
fs_1.default.rmdirSync(tmpDir);
|
|
172
|
+
}
|
|
173
|
+
catch { /* ignore */ }
|
|
174
|
+
if (storagePaths.length)
|
|
175
|
+
await (0, client_js_1.deleteFiles)(storagePaths).catch(() => { });
|
|
176
|
+
}
|
|
177
|
+
const { status, platform_post_ids, failed_reason } = result.post;
|
|
178
|
+
const targets = Object.keys(platform_post_ids);
|
|
179
|
+
const totalSeconds = (imagePaths.length * duration).toFixed(1);
|
|
180
|
+
let summary = `Slideshow video built: ${imagePaths.length} images × ${duration}s = ${totalSeconds}s, 1080×1920, ${args.audio_path ? "with audio" : "silent"}.\nStatus: ${status}\n`;
|
|
181
|
+
if (targets.length) {
|
|
182
|
+
summary += mode === "draft"
|
|
183
|
+
? `Sent to drafts on: ${targets.join(", ")}\nOpen the platform's mobile app to finish — add trending audio (if silent), adjust caption, then publish.`
|
|
184
|
+
: `Published to: ${targets.join(", ")}`;
|
|
185
|
+
}
|
|
186
|
+
if (failed_reason)
|
|
187
|
+
summary += `\nErrors: ${failed_reason}`;
|
|
188
|
+
return summary.trim();
|
|
189
|
+
},
|
|
190
|
+
};
|