@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,253 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
AbsoluteFill,
|
|
4
|
+
interpolate,
|
|
5
|
+
spring,
|
|
6
|
+
useCurrentFrame,
|
|
7
|
+
useVideoConfig,
|
|
8
|
+
} from "remotion";
|
|
9
|
+
|
|
10
|
+
export type KineticEffect = "typing" | "billboard_flip" | "isometric";
|
|
11
|
+
export type KineticStyle = "terminal" | "cinematic" | "minimal";
|
|
12
|
+
|
|
13
|
+
export interface CameraTransform {
|
|
14
|
+
rotateX: number;
|
|
15
|
+
rotateY: number;
|
|
16
|
+
rotateZ: number;
|
|
17
|
+
translateZ: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface KineticTextProps {
|
|
21
|
+
text: string;
|
|
22
|
+
effect: KineticEffect;
|
|
23
|
+
style: KineticStyle;
|
|
24
|
+
fontSize: number;
|
|
25
|
+
format: "9x16" | "16x9" | "1x1";
|
|
26
|
+
fps: number;
|
|
27
|
+
totalFrames: number;
|
|
28
|
+
camera: CameraTransform;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const EFFECT_CAMERA_DEFAULTS: Record<KineticEffect, CameraTransform> = {
|
|
32
|
+
typing: { rotateX: 8, rotateY: 0, rotateZ: 0, translateZ: -120 },
|
|
33
|
+
billboard_flip: { rotateX: 0, rotateY: 0, rotateZ: 0, translateZ: 0 },
|
|
34
|
+
isometric: { rotateX: 55, rotateY: 0, rotateZ: -45, translateZ: -80 },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const STYLE_THEMES = {
|
|
38
|
+
terminal: { bg: "#0d1117", text: "#00ff88", font: "'Courier New', 'Consolas', monospace", cursor: true, vignette: false, scanlines: true },
|
|
39
|
+
cinematic: { bg: "#000000", text: "#ffffff", font: "'Inter', 'Segoe UI', sans-serif", cursor: false, vignette: true, scanlines: false },
|
|
40
|
+
minimal: { bg: "transparent", text: "#ffffff", font: "'Inter', 'Segoe UI', sans-serif", cursor: false, vignette: false, scanlines: false },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_KINETIC: KineticTextProps = {
|
|
44
|
+
text: "create_video_assembly --aroll talk.mp4",
|
|
45
|
+
effect: "typing",
|
|
46
|
+
style: "terminal",
|
|
47
|
+
fontSize: 72,
|
|
48
|
+
format: "9x16",
|
|
49
|
+
fps: 30,
|
|
50
|
+
totalFrames: 150,
|
|
51
|
+
camera: EFFECT_CAMERA_DEFAULTS.typing,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ─── Typing effect ────────────────────────────────────────────────────────────
|
|
55
|
+
const TypingText: React.FC<{
|
|
56
|
+
text: string;
|
|
57
|
+
frame: number;
|
|
58
|
+
totalFrames: number;
|
|
59
|
+
theme: typeof STYLE_THEMES.terminal;
|
|
60
|
+
fontSize: number;
|
|
61
|
+
}> = ({ text, frame, totalFrames, theme, fontSize }) => {
|
|
62
|
+
const typingFrames = Math.floor(totalFrames * 0.72);
|
|
63
|
+
const visibleChars = Math.min(text.length, Math.floor((frame / typingFrames) * text.length));
|
|
64
|
+
const isTyping = visibleChars < text.length;
|
|
65
|
+
const showCursor = theme.cursor && (isTyping || frame % 30 < 20);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div style={{
|
|
69
|
+
fontFamily: theme.font,
|
|
70
|
+
fontSize,
|
|
71
|
+
fontWeight: 700,
|
|
72
|
+
color: theme.text,
|
|
73
|
+
lineHeight: 1.6,
|
|
74
|
+
whiteSpace: "pre-wrap",
|
|
75
|
+
wordBreak: "break-word",
|
|
76
|
+
maxWidth: "85%",
|
|
77
|
+
textShadow: theme.text === "#00ff88"
|
|
78
|
+
? `0 0 20px ${theme.text}99, 0 0 7px ${theme.text}`
|
|
79
|
+
: "0 2px 14px rgba(0,0,0,0.9)",
|
|
80
|
+
letterSpacing: "0.02em",
|
|
81
|
+
}}>
|
|
82
|
+
{text.slice(0, visibleChars)}
|
|
83
|
+
{showCursor && (
|
|
84
|
+
<span style={{ opacity: frame % 15 < 9 ? 1 : 0 }}>▋</span>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ─── Billboard flip effect ────────────────────────────────────────────────────
|
|
91
|
+
const BillboardFlipText: React.FC<{
|
|
92
|
+
text: string;
|
|
93
|
+
frame: number;
|
|
94
|
+
fps: number;
|
|
95
|
+
theme: typeof STYLE_THEMES.terminal;
|
|
96
|
+
fontSize: number;
|
|
97
|
+
}> = ({ text, frame, fps, theme, fontSize }) => {
|
|
98
|
+
const words = text.split(/\s+/);
|
|
99
|
+
const stagger = 14;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div style={{
|
|
103
|
+
fontFamily: theme.font,
|
|
104
|
+
fontSize,
|
|
105
|
+
fontWeight: 700,
|
|
106
|
+
color: theme.text,
|
|
107
|
+
display: "flex",
|
|
108
|
+
flexWrap: "wrap",
|
|
109
|
+
gap: `${Math.round(fontSize * 0.28)}px`,
|
|
110
|
+
justifyContent: "center",
|
|
111
|
+
maxWidth: "85%",
|
|
112
|
+
textShadow: "0 2px 24px rgba(0,0,0,0.85)",
|
|
113
|
+
}}>
|
|
114
|
+
{words.map((word, i) => {
|
|
115
|
+
const elapsed = Math.max(0, frame - i * stagger);
|
|
116
|
+
const s = spring({ frame: elapsed, fps, config: { damping: 14, stiffness: 140, mass: 0.6 } });
|
|
117
|
+
const tz = interpolate(s, [0, 1], [-800, 0]);
|
|
118
|
+
const ry = interpolate(s, [0, 1], [90, 0]);
|
|
119
|
+
const opacity = interpolate(elapsed, [0, 6], [0, 1], { extrapolateRight: "clamp" });
|
|
120
|
+
return (
|
|
121
|
+
<span key={i} style={{
|
|
122
|
+
display: "inline-block",
|
|
123
|
+
transform: `translateZ(${tz}px) rotateY(${ry}deg)`,
|
|
124
|
+
opacity,
|
|
125
|
+
}}>
|
|
126
|
+
{word}
|
|
127
|
+
</span>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ─── Isometric extrude effect ─────────────────────────────────────────────────
|
|
135
|
+
const IsometricText: React.FC<{
|
|
136
|
+
text: string;
|
|
137
|
+
frame: number;
|
|
138
|
+
fps: number;
|
|
139
|
+
theme: typeof STYLE_THEMES.terminal;
|
|
140
|
+
fontSize: number;
|
|
141
|
+
}> = ({ text, frame, fps, theme, fontSize }) => {
|
|
142
|
+
const chars = text.split("");
|
|
143
|
+
const stagger = 3;
|
|
144
|
+
const shadowColor = theme.text === "#00ff88" ? "#006633" : "rgba(0,0,0,0.55)";
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div style={{
|
|
148
|
+
fontFamily: theme.font,
|
|
149
|
+
fontSize,
|
|
150
|
+
fontWeight: 900,
|
|
151
|
+
color: theme.text,
|
|
152
|
+
display: "flex",
|
|
153
|
+
flexWrap: "wrap",
|
|
154
|
+
justifyContent: "center",
|
|
155
|
+
maxWidth: "85%",
|
|
156
|
+
letterSpacing: "0.06em",
|
|
157
|
+
}}>
|
|
158
|
+
{chars.map((char, i) => {
|
|
159
|
+
const elapsed = Math.max(0, frame - i * stagger);
|
|
160
|
+
const s = spring({ frame: elapsed, fps, config: { damping: 18, stiffness: 200, mass: 0.5 } });
|
|
161
|
+
const ty = interpolate(s, [0, 1], [80, 0]);
|
|
162
|
+
const opacity = interpolate(elapsed, [0, 8], [0, 1], { extrapolateRight: "clamp" });
|
|
163
|
+
return (
|
|
164
|
+
<span key={i} style={{
|
|
165
|
+
display: "inline-block",
|
|
166
|
+
transform: `translateY(${ty}px)`,
|
|
167
|
+
opacity,
|
|
168
|
+
textShadow: `0 5px 0 ${shadowColor}, 0 2px 12px rgba(0,0,0,0.7)`,
|
|
169
|
+
}}>
|
|
170
|
+
{char === " " ? " " : char}
|
|
171
|
+
</span>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// ─── Main composition ─────────────────────────────────────────────────────────
|
|
179
|
+
export const KineticText: React.FC<KineticTextProps> = ({
|
|
180
|
+
text,
|
|
181
|
+
effect,
|
|
182
|
+
style,
|
|
183
|
+
fontSize,
|
|
184
|
+
camera,
|
|
185
|
+
}) => {
|
|
186
|
+
const frame = useCurrentFrame();
|
|
187
|
+
const { fps, durationInFrames } = useVideoConfig();
|
|
188
|
+
const theme = STYLE_THEMES[style];
|
|
189
|
+
|
|
190
|
+
// Camera eases from identity to target over first 60% of duration
|
|
191
|
+
const camP = interpolate(frame, [0, Math.floor(durationInFrames * 0.6)], [0, 1], {
|
|
192
|
+
extrapolateRight: "clamp",
|
|
193
|
+
// cubic ease-in-out
|
|
194
|
+
easing: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
|
|
195
|
+
});
|
|
196
|
+
const cameraTransform = [
|
|
197
|
+
`rotateX(${camera.rotateX * camP}deg)`,
|
|
198
|
+
`rotateY(${camera.rotateY * camP}deg)`,
|
|
199
|
+
`rotateZ(${camera.rotateZ * camP}deg)`,
|
|
200
|
+
`translateZ(${camera.translateZ * camP}px)`,
|
|
201
|
+
].join(" ");
|
|
202
|
+
|
|
203
|
+
const prefix = style === "terminal" ? "$ " : "";
|
|
204
|
+
|
|
205
|
+
const textNode = (() => {
|
|
206
|
+
if (effect === "typing") {
|
|
207
|
+
return (
|
|
208
|
+
<TypingText
|
|
209
|
+
text={prefix + text}
|
|
210
|
+
frame={frame}
|
|
211
|
+
totalFrames={durationInFrames}
|
|
212
|
+
theme={theme}
|
|
213
|
+
fontSize={fontSize}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (effect === "billboard_flip") {
|
|
218
|
+
return <BillboardFlipText text={text} frame={frame} fps={fps} theme={theme} fontSize={fontSize} />;
|
|
219
|
+
}
|
|
220
|
+
return <IsometricText text={text} frame={frame} fps={fps} theme={theme} fontSize={fontSize} />;
|
|
221
|
+
})();
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<AbsoluteFill style={{ background: theme.bg, perspective: "1200px" }}>
|
|
225
|
+
{/* CRT scanlines for terminal style */}
|
|
226
|
+
{theme.scanlines && (
|
|
227
|
+
<AbsoluteFill style={{
|
|
228
|
+
backgroundImage: "repeating-linear-gradient(0deg, rgba(0,0,0,0.07) 0px, rgba(0,0,0,0.07) 1px, transparent 1px, transparent 4px)",
|
|
229
|
+
pointerEvents: "none",
|
|
230
|
+
zIndex: 10,
|
|
231
|
+
}} />
|
|
232
|
+
)}
|
|
233
|
+
{/* Radial vignette for cinematic style */}
|
|
234
|
+
{theme.vignette && (
|
|
235
|
+
<AbsoluteFill style={{
|
|
236
|
+
background: "radial-gradient(ellipse at center, transparent 25%, rgba(0,0,0,0.8) 100%)",
|
|
237
|
+
pointerEvents: "none",
|
|
238
|
+
zIndex: 10,
|
|
239
|
+
}} />
|
|
240
|
+
)}
|
|
241
|
+
{/* 3D camera layer — no overflow:hidden here, that flattens Z-space */}
|
|
242
|
+
<AbsoluteFill style={{
|
|
243
|
+
display: "flex",
|
|
244
|
+
alignItems: "center",
|
|
245
|
+
justifyContent: "center",
|
|
246
|
+
transformStyle: "preserve-3d",
|
|
247
|
+
transform: cameraTransform,
|
|
248
|
+
}}>
|
|
249
|
+
{textNode}
|
|
250
|
+
</AbsoluteFill>
|
|
251
|
+
</AbsoluteFill>
|
|
252
|
+
);
|
|
253
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Composition } from "remotion";
|
|
2
|
+
import { Assembly, DEFAULT_EDIT, EditData } from "./Assembly";
|
|
3
|
+
import { KineticText, DEFAULT_KINETIC, KineticTextProps } from "./KineticText";
|
|
4
|
+
|
|
5
|
+
const dimsFor = (fmt: EditData["format"]) =>
|
|
6
|
+
fmt === "1x1" ? { width: 1080, height: 1080 } : fmt === "16x9" ? { width: 1920, height: 1080 } : { width: 1080, height: 1920 };
|
|
7
|
+
|
|
8
|
+
export const RemotionRoot: React.FC = () => {
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<Composition
|
|
12
|
+
id="Assembly"
|
|
13
|
+
component={Assembly}
|
|
14
|
+
durationInFrames={DEFAULT_EDIT.totalFrames}
|
|
15
|
+
fps={DEFAULT_EDIT.fps}
|
|
16
|
+
{...dimsFor(DEFAULT_EDIT.format)}
|
|
17
|
+
defaultProps={DEFAULT_EDIT}
|
|
18
|
+
calculateMetadata={({ props }) => ({
|
|
19
|
+
durationInFrames: props.totalFrames,
|
|
20
|
+
fps: props.fps,
|
|
21
|
+
...dimsFor(props.format),
|
|
22
|
+
})}
|
|
23
|
+
/>
|
|
24
|
+
<Composition
|
|
25
|
+
id="KineticText"
|
|
26
|
+
component={KineticText}
|
|
27
|
+
durationInFrames={DEFAULT_KINETIC.totalFrames}
|
|
28
|
+
fps={DEFAULT_KINETIC.fps}
|
|
29
|
+
{...dimsFor(DEFAULT_KINETIC.format)}
|
|
30
|
+
defaultProps={DEFAULT_KINETIC}
|
|
31
|
+
calculateMetadata={({ props }: { props: KineticTextProps }) => ({
|
|
32
|
+
durationInFrames: props.totalFrames,
|
|
33
|
+
fps: props.fps,
|
|
34
|
+
...dimsFor(props.format),
|
|
35
|
+
})}
|
|
36
|
+
/>
|
|
37
|
+
</>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"lib": ["ESNext", "DOM"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@viralspin/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"bin": {
|
|
7
|
-
"viralspin-mcp": "dist/index.js"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"build": "tsc",
|
|
11
|
-
"dev": "ts-node src/index.ts",
|
|
12
|
-
"start": "node dist/index.js",
|
|
13
|
-
"prepublishOnly": "npm run build"
|
|
14
|
-
},
|
|
15
|
-
"keywords": ["mcp", "viralspin", "tiktok", "instagram", "youtube", "social-media"],
|
|
16
|
-
"license": "MIT",
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
19
|
-
"form-data": "^4.0.0"
|
|
20
|
-
},
|
|
21
|
-
"devDependencies": {
|
|
22
|
-
"@types/form-data": "^2.5.0",
|
|
23
|
-
"@types/node": "^20.0.0",
|
|
24
|
-
"typescript": "^5.0.0"
|
|
25
|
-
},
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=18"
|
|
28
|
-
}
|
|
29
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@viralspin/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"viralspin-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "ts-node src/index.ts",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["mcp", "viralspin", "tiktok", "instagram", "youtube", "social-media"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
19
|
+
"form-data": "^4.0.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/form-data": "^2.5.0",
|
|
23
|
+
"@types/node": "^20.0.0",
|
|
24
|
+
"typescript": "^5.0.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,80 +1,119 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function
|
|
44
|
-
return apiFetch<T>(endpoint, {
|
|
45
|
-
method: "
|
|
46
|
-
headers:
|
|
47
|
-
body:
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|