@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/client.js +8 -0
  4. package/dist/engine.js +29 -0
  5. package/dist/hosted.js +159 -0
  6. package/dist/index.js +62 -16
  7. package/dist/sanitize.js +22 -0
  8. package/dist/tools/assembly-adjust.js +107 -15
  9. package/dist/tools/carousel-video.js +7 -0
  10. package/dist/tools/color-grade.js +64 -0
  11. package/dist/tools/connections.js +23 -4
  12. package/dist/tools/edit-clip.js +136 -0
  13. package/dist/tools/engage.js +13 -4
  14. package/dist/tools/explainer-index.js +6 -0
  15. package/dist/tools/find-moments.js +140 -0
  16. package/dist/tools/folder-preview.js +71 -0
  17. package/dist/tools/ideas.js +158 -0
  18. package/dist/tools/kinetic-text.js +6 -2
  19. package/dist/tools/local-index.js +84 -0
  20. package/dist/tools/local-mode.js +84 -0
  21. package/dist/tools/profile.js +56 -0
  22. package/dist/tools/publish.js +34 -10
  23. package/dist/tools/relight-clip.js +98 -0
  24. package/dist/tools/timeline-edit.js +367 -0
  25. package/dist/tools/video-assembly.js +131 -4
  26. package/package.json +41 -29
  27. package/BUNDLE_LOCAL_SPEC.md +0 -133
  28. package/engine/assemble.mjs +0 -476
  29. package/engine/gemini.mjs +0 -164
  30. package/engine/kinetic.mjs +0 -84
  31. package/engine/prompts.mjs +0 -94
  32. package/engine/remotion.config.ts +0 -4
  33. package/engine/src/Assembly.tsx +0 -330
  34. package/engine/src/KineticText.tsx +0 -253
  35. package/engine/src/Root.tsx +0 -39
  36. package/engine/src/index.ts +0 -4
  37. package/engine/tsconfig.json +0 -15
  38. package/src/client.ts +0 -119
  39. package/src/index.ts +0 -151
  40. package/src/tools/assembly-adjust.ts +0 -110
  41. package/src/tools/captions.ts +0 -32
  42. package/src/tools/carousel-video.ts +0 -211
  43. package/src/tools/connections.ts +0 -27
  44. package/src/tools/engage.ts +0 -105
  45. package/src/tools/explainer-index.ts +0 -298
  46. package/src/tools/image-ads.ts +0 -192
  47. package/src/tools/kinetic-text.ts +0 -130
  48. package/src/tools/photo-picker.ts +0 -31
  49. package/src/tools/posts.ts +0 -58
  50. package/src/tools/publish.ts +0 -236
  51. package/src/tools/review.ts +0 -126
  52. package/src/tools/video-assembly.ts +0 -205
  53. package/tsconfig.json +0 -15
package/engine/gemini.mjs DELETED
@@ -1,164 +0,0 @@
1
- // Node port of the Python Gemini scripts (_gem/transcribe/analyze/edit/style/albums) so the
2
- // engine needs only Node — no Python. All calls are Gemini REST (generateContent), thinking off.
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import { prompt as storedPrompt, fill } from "./prompts.mjs";
6
-
7
- const MODEL = "gemini-2.5-flash";
8
-
9
- function getKey() {
10
- if (process.env.VITE_GEMINI_API_KEY) return process.env.VITE_GEMINI_API_KEY;
11
- if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
12
- // local fallback: parse a .env (the repo's, or VIRALSPIN_ENV)
13
- for (const env of [process.env.VIRALSPIN_ENV, "E:/AI test/github/viralspin_new/.env"]) {
14
- if (env && fs.existsSync(env)) {
15
- for (const ln of fs.readFileSync(env, "utf8").split(/\r?\n/)) {
16
- if (ln.trim().startsWith("VITE_GEMINI_API_KEY")) return ln.split("=").slice(1).join("=").trim().replace(/^["']|["']$/g, "");
17
- }
18
- }
19
- }
20
- throw new Error("No Gemini API key (set GEMINI_API_KEY or VITE_GEMINI_API_KEY).");
21
- }
22
- const KEY = getKey();
23
- const URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
24
-
25
- const mimeOf = (p) => {
26
- const e = path.extname(p).toLowerCase();
27
- return e === ".png" ? "image/png" : e === ".wav" ? "audio/wav" : e === ".mp3" ? "audio/mpeg" : "image/jpeg";
28
- };
29
- export const inlinePart = (p) => ({ inline_data: { mime_type: mimeOf(p), data: fs.readFileSync(p).toString("base64") } });
30
-
31
- export async function call(parts, maxTokens = 600) {
32
- const body = {
33
- contents: [{ parts }],
34
- generationConfig: { temperature: 0.2, maxOutputTokens: maxTokens, thinkingConfig: { thinkingBudget: 0 }, responseMimeType: "application/json" },
35
- };
36
- const r = await fetch(URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
37
- const resp = await r.json();
38
- const cand = resp?.candidates?.[0];
39
- const txt = (cand?.content?.parts || []).map((p) => p.text || "").join("");
40
- if (!txt.trim()) throw new Error(`empty Gemini response (finish=${cand?.finishReason} status=${r.status})`);
41
- const m = txt.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
42
- return JSON.parse(m ? m[0] : txt);
43
- }
44
-
45
- export async function transcribe(wavPath) {
46
- const d = await call([inlinePart(wavPath), { text: storedPrompt("local_assembly.transcribe") }], 1200);
47
- return d.segments || (Array.isArray(d) ? d : []);
48
- }
49
-
50
- export async function analyzeFrame(jpgPath, kind) {
51
- const d = await call([inlinePart(jpgPath), { text: storedPrompt("local_assembly.frame_analysis") }], 300);
52
- const sx = d.secondary_x, sy = d.secondary_y;
53
- return {
54
- kind,
55
- rotate: parseInt(d.rotate || 0, 10) || 0,
56
- focal: [+(+d.focal_x || 0.5).toFixed(4), +(+d.focal_y || 0.45).toFixed(4)],
57
- secondary: sx != null && sy != null ? [+(+sx).toFixed(4), +(+sy).toFixed(4)] : null,
58
- subject: d.subject || "scenery",
59
- desc: d.desc || "",
60
- keywords: d.keywords || [],
61
- };
62
- }
63
-
64
- export async function rankAlbums(transcriptText, folders) {
65
- const prompt = fill(storedPrompt("local_assembly.album_ranking"), {
66
- transcript_text: transcriptText,
67
- albums_json: JSON.stringify(folders),
68
- });
69
- try {
70
- const d = await call([{ text: prompt }], 700);
71
- const w = {}; for (const [k, v] of Object.entries(d.weights || {})) w[k] = +v;
72
- return { weights: w, note: d.note || "" };
73
- } catch {
74
- return { weights: Object.fromEntries(folders.map((f) => [f.name, 0.5])), note: "(fallback)" };
75
- }
76
- }
77
-
78
- const VALID_CAPTION_STYLES = ["default", "hormozi", "glow_neon", "minimalist", "whiteboard"];
79
-
80
- export async function inferStyle(framePaths, meta) {
81
- const prompt = fill(storedPrompt("local_assembly.style_inference"), {
82
- frame_count: framePaths.length,
83
- format: meta.fmt,
84
- duration: meta.dur,
85
- cuts_per_sec: meta.cps,
86
- });
87
- const d = await call([...framePaths.map(inlinePart), { text: prompt }], 400);
88
- return {
89
- format: meta.fmt,
90
- captions: d.captions !== false,
91
- hook: d.hook !== false,
92
- pops: d.pops !== false,
93
- pace: d.pace || "fast",
94
- coverage: +(d.coverage ?? 0.5) || 0.5,
95
- caption_style: VALID_CAPTION_STYLES.includes(d.caption_style) ? d.caption_style : null,
96
- note: d.note || "",
97
- };
98
- }
99
-
100
- // Infer caption style from transcript tone when no style_ref is available.
101
- export async function inferCaptionStyle(transcriptText) {
102
- const prompt = `You are a social video editor. Given this A-roll transcript, pick the best caption style for a short-form social video.
103
-
104
- Styles:
105
- - "hormozi": high-energy, motivational, business/hustle content, talking-head, bold claims
106
- - "glow_neon": dark aesthetic, music, nightlife, gaming, cyberpunk, late-night vibes
107
- - "minimalist": calm, educational, soft aesthetic, wellness, thoughtful narration
108
- - "whiteboard": explainer, how-to, tutorial, step-by-step, educational breakdown
109
- - "default": neutral, general purpose, anything else
110
-
111
- Transcript: ${transcriptText.slice(0, 2000)}
112
-
113
- Return JSON: {"caption_style": "<one of the five options>", "reason": "<one sentence>"}`;
114
- try {
115
- const d = await call([{ text: prompt }], 200);
116
- return VALID_CAPTION_STYLES.includes(d.caption_style) ? d.caption_style : "default";
117
- } catch {
118
- return "default";
119
- }
120
- }
121
-
122
- export async function getDirectorDecisions(transcriptSegments) {
123
- if (!transcriptSegments || transcriptSegments.length === 0) return [];
124
- const transcriptText = transcriptSegments.map((s) => s.text || "").join(" ");
125
- const prompt = `Analyze this A-roll transcript. Extract entities that a viewer would expect to SEE visualized on screen — named people, specific products, UI tools, app interfaces, or branded concepts.
126
-
127
- For each entity return:
128
- - name: entity name
129
- - search_query: a 5-10 word Twelve Labs video search query (e.g. "Jensen Huang talking about Blackwell GPU")
130
- - layout: "pip" if it is a named person (keep the speaker visible alongside them), "interrupt" if it is a product/UI/demo (full-screen cutaway)
131
- - first_mention_ms: estimated millisecond offset of the first mention (base on word position in transcript)
132
-
133
- Transcript: ${transcriptText.slice(0, 3000)}
134
-
135
- Return JSON: {"entities": [{"name": "...", "search_query": "...", "layout": "pip", "first_mention_ms": 0}]}`;
136
- try {
137
- const d = await call([{ text: prompt }], 800);
138
- return (d.entities || []).filter((e) => e.name && e.search_query);
139
- } catch (err) {
140
- return [];
141
- }
142
- }
143
-
144
- export async function editTimeline(transcript, analysis, totalMs, userPrompt, style) {
145
- const pace = style?.pace || "fast";
146
- const PACE = {
147
- fast: "Energetic: short cutaways 600-1100 ms and a 3-4 shot montage on numbers/claims. RELEVANCE WINS over coverage — only cut where a clip genuinely fits; otherwise stay on the speaker. Do NOT chase a coverage %.",
148
- medium: "Cutaways 1000-1800 ms, only where a clip genuinely fits; otherwise stay on the speaker.",
149
- slow: "Few, longer cutaways 1800-3000 ms; relevance first.",
150
- }[pace] || "";
151
- const incHook = style ? style.hook !== false : true;
152
- const incPops = style ? style.pops !== false : true;
153
- const candidates = Object.entries(analysis).map(([id, a]) => ({ id, kind: a.kind, subject: a.subject, desc: a.desc, keywords: a.keywords }));
154
- const sys = fill(storedPrompt("local_assembly.timeline_edit"), {
155
- pacing: PACE,
156
- transcript_json: JSON.stringify(transcript),
157
- candidates_json: JSON.stringify(candidates),
158
- total_ms: totalMs,
159
- user_direction: userPrompt || "(none)",
160
- });
161
- const d = await call([{ text: sys }], 2000);
162
- const cuts = (d.cuts || []).filter((c) => analysis[c.broll_id]).sort((a, b) => (a.from_ms || 0) - (b.from_ms || 0));
163
- return { cuts, pops: incPops ? (d.pops || []).filter((p) => p.text) : [], hook: incHook ? d.hook || null : null };
164
- }
@@ -1,84 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Kinetic text renderer — no A-roll, no B-roll, no transcription.
4
- * node kinetic.mjs --text "..." [--effect typing|billboard_flip|isometric]
5
- * [--style terminal|cinematic|minimal] [--format 9x16|16x9|1x1]
6
- * [--font-size <px>] [--duration-sec <n>]
7
- * [--camera-rx <deg>] [--camera-ry <deg>] [--camera-rz <deg>] [--camera-tz <px>]
8
- * [--out <path>]
9
- * Writes props JSON, calls Remotion render for the KineticText composition, prints RESULT:<path>.
10
- */
11
- import { fileURLToPath } from "node:url";
12
- import fs from "node:fs";
13
- import path from "node:path";
14
- import { spawnSync } from "node:child_process";
15
-
16
- const ENGINE = path.dirname(fileURLToPath(import.meta.url));
17
- const PUBLIC = path.join(ENGINE, "public");
18
- const OUT_DIR = path.join(ENGINE, "out");
19
-
20
- fs.mkdirSync(PUBLIC, { recursive: true });
21
- fs.mkdirSync(OUT_DIR, { recursive: true });
22
-
23
- const argv = process.argv.slice(2);
24
- const one = (flag, def) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : def; };
25
-
26
- const text = one("--text", "");
27
- if (!text) { console.error("ERROR: --text is required"); process.exit(1); }
28
-
29
- const effect = one("--effect", "typing");
30
- const style = one("--style", "terminal");
31
- const format = one("--format", "9x16");
32
- const fontSizeArg = one("--font-size", null);
33
- const durationSecArg = one("--duration-sec", null);
34
- const cameraRx = parseFloat(one("--camera-rx", null) ?? "NaN");
35
- const cameraRy = parseFloat(one("--camera-ry", null) ?? "NaN");
36
- const cameraRz = parseFloat(one("--camera-rz", null) ?? "NaN");
37
- const cameraTz = parseFloat(one("--camera-tz", null) ?? "NaN");
38
- const outArg = one("--out", null);
39
-
40
- const fps = 30;
41
-
42
- // Auto-size: ~3 frames per char + 45 frame tail (1.5s hold at end)
43
- const totalFrames = durationSecArg
44
- ? Math.round(parseFloat(durationSecArg) * fps)
45
- : Math.max(60, Math.ceil(text.length * 3 + 45));
46
-
47
- // Default font size by format
48
- const defaultFontSize = format === "16x9" ? 96 : 72;
49
- const fontSize = fontSizeArg ? parseInt(fontSizeArg, 10) : defaultFontSize;
50
-
51
- // Per-effect camera defaults
52
- const CAMERA_DEFAULTS = {
53
- typing: { rotateX: 8, rotateY: 0, rotateZ: 0, translateZ: -120 },
54
- billboard_flip: { rotateX: 0, rotateY: 0, rotateZ: 0, translateZ: 0 },
55
- isometric: { rotateX: 55, rotateY: 0, rotateZ: -45, translateZ: -80 },
56
- };
57
- const defaults = CAMERA_DEFAULTS[effect] ?? CAMERA_DEFAULTS.typing;
58
-
59
- const camera = {
60
- rotateX: isNaN(cameraRx) ? defaults.rotateX : cameraRx,
61
- rotateY: isNaN(cameraRy) ? defaults.rotateY : cameraRy,
62
- rotateZ: isNaN(cameraRz) ? defaults.rotateZ : cameraRz,
63
- translateZ: isNaN(cameraTz) ? defaults.translateZ : cameraTz,
64
- };
65
-
66
- const timestamp = Date.now();
67
- const out = path.resolve(outArg ?? path.join(OUT_DIR, `kinetic_${timestamp}.mp4`));
68
- fs.mkdirSync(path.dirname(out), { recursive: true });
69
-
70
- const props = { text, effect, style, fontSize, format, fps, totalFrames, camera };
71
- const propsFile = path.join(PUBLIC, "kineticProps.json");
72
- fs.writeFileSync(propsFile, JSON.stringify(props, null, 2));
73
-
74
- process.stderr.write(`[kinetic] text="${text.slice(0, 60)}" effect=${effect} style=${style} format=${format} frames=${totalFrames}\n`);
75
-
76
- const entry = path.join(ENGINE, "src", "index.ts");
77
- const cmd = `npx remotion render "${entry}" KineticText "${out}" "--props=${propsFile}" "--public-dir=${PUBLIC}"`;
78
- const r = spawnSync(cmd, { cwd: ENGINE, shell: true, stdio: "inherit" });
79
- if (r.status !== 0) {
80
- console.error(`[kinetic] remotion render failed (exit ${r.status})`);
81
- process.exit(1);
82
- }
83
-
84
- console.log("RESULT:" + out);
@@ -1,94 +0,0 @@
1
- // "Secret sauce" prompts for the local video-assembly engine.
2
- //
3
- // These BUILTIN strings are the editable source of truth surfaced in the admin
4
- // dashboard LLM Prompt Store under the "LOCAL VIDEO ASSEMBLY" section
5
- // (keys local_assembly.*). At runtime the MCP tool fetches the active rows from
6
- // the backend and hands them to the engine in the VIRALSPIN_ASSEMBLY_PROMPTS env
7
- // var (a JSON map key→value_text). Anything missing, blank, or malformed falls
8
- // back to the BUILTIN below, so an offline run — or a misnamed/deactivated row —
9
- // still produces a video.
10
- //
11
- // Templated prompts use {{double_brace}} tokens (filled by fill() in the engine)
12
- // so the literal single-brace JSON in their output spec is never disturbed.
13
-
14
- export const BUILTIN = {
15
- "local_assembly.transcribe":
16
- "Transcribe this short spoken clip accurately (a creator talking to camera). " +
17
- 'Return ONLY JSON: {"segments":[{"start_ms":int,"end_ms":int,"text":str}]}. ' +
18
- "Break into natural phrases. Best-guess unclear words; do not invent content. " +
19
- "ALSO include significant NON-SPEECH audio as their own segments, with the sound in parentheses — " +
20
- "e.g. (coughing), (laughing), (sighing), (music), (silence). These give the editor context.",
21
-
22
- "local_assembly.frame_analysis":
23
- "Framing a vertical 9:16 social video from this (often horizontal) source. Return ONLY JSON: " +
24
- '{"subject":"person|product|scenery","rotate":0,"focal_x":0-1,"focal_y":0-1,' +
25
- '"secondary_x":0-1|null,"secondary_y":0-1|null,"desc":"<=8 words","keywords":["..."]}. ' +
26
- "rotate = CLOCKWISE degrees (0/90/180/270) to make it UPRIGHT (0 if already upright); give focal/secondary " +
27
- "in coords AFTER that rotation. focal_x/y = center of the MAIN subject (a person's FACE) to keep in a tall 9:16 " +
28
- "crop. secondary_x/y = a second notable element to reveal with a pan (sign/text/landmark/product) or null. " +
29
- "keywords = 2-4 concrete nouns/themes.",
30
-
31
- "local_assembly.album_ranking":
32
- `A short video's narration says:\n"{{transcript_text}}"\n\nWe have these B-roll ALBUMS (folder name + file count):\n` +
33
- `{{albums_json}}\n\nRank how relevant each album is as B-roll for this narration, judging mainly by NAME. ` +
34
- 'Return ONLY JSON: {"weights":{"<album>":0.1-1.0},"note":"<one sentence>"}. Every album gets a weight.',
35
-
36
- "local_assembly.style_inference":
37
- `These are {{frame_count}} sampled frames from a short-form social video (aspect {{format}}, {{duration}}s, ` +
38
- `~{{cuts_per_sec}} hard scene-cuts/sec). Infer its EDITING STYLE. Return ONLY JSON: ` +
39
- '{"captions":bool,"hook":bool,"pops":bool,"pace":"fast|medium|slow","coverage":0-1,"note":"..."}. ' +
40
- "captions=bold spoken-word subtitles burned in? hook=persistent headline/title overlay near top? " +
41
- "pops=big animated keyword/number callouts? pace=overall energy. coverage=fraction showing B-roll vs talking head.",
42
-
43
- "local_assembly.timeline_edit":
44
- `You are a senior short-form editor cutting a HIGH-ENERGY vertical 9:16 Reel (bold captions, hook, pops, quick cuts).\n` +
45
- `The A-roll is a person talking (their voice is the soundtrack). B-roll is a MIX of (a) GENERIC ` +
46
- `lifestyle/aspirational footage (beach/travel/product) that does NOT literally depict the topic — ` +
47
- `energetic punctuation; and (b) the CREATOR'S OWN PERSONAL photos that may genuinely depict the real ` +
48
- `moments she describes (sick, recovering, hospital, pregnancy/childbirth, family). Match by content.\n\n` +
49
- `TRANSCRIPT (ms):\n{{transcript_json}}\n\nB-ROLL CANDIDATES:\n{{candidates_json}}\n\n` +
50
- `TOTAL: {{total_ms}} ms. USER DIRECTION: {{user_direction}}\n\n` +
51
- `Produce THREE things:\n` +
52
- `1. "hook": a punchy 4-8 word headline for the first ~3.5s + ONE "keyword" within it to highlight.\n` +
53
- `2. "pops": 2-4 big keyword/number callouts that punch in ON the beat said. Each {"from_ms":int,"text":"SHORT UPPERCASE"}.\n` +
54
- `3. "cuts": B-roll cutaways. RULES:\n` +
55
- ` - PACING: {{pacing}}\n` +
56
- ` - STRONGLY prefer video-kind assets; alternate dir 0/1. A cutaway must GENUINELY relate to the beat.\n` +
57
- ` - TWO kinds of B-roll, match each candidate's CONTENT (desc/keywords) to the beat:\n` +
58
- ` • GENERIC lifestyle/beach/travel/product → ONLY on posting/content/success/going-viral/lifestyle/aspiration lines. NEVER a generic clip on a specific physical-state line (sick, recovering, surgery, giving birth, coughing) — looks broken.\n` +
59
- ` • The creator's OWN PERSONAL photos that GENUINELY depict the moment (visibly sick/in bed, hospital, pregnant/newborn, recovering, real family) → DO use these ON the matching personal line (a real sick photo on "you're sick, recovering" is GREAT; a beach photo is not).\n` +
60
- ` - KEEP THE SPEAKER (no cutaway) on: non-speech beats like "(coughing)", the final line, abstract lines, and ANY beat where NO candidate (generic OR personal) genuinely fits. Staying on the speaker beats an irrelevant clip; don't force a cutaway to fill time.\n` +
61
- ` - The clip opens with a COUGH before she speaks — keep the speaker there.\n` +
62
- ` - Each broll_id at most twice; no overlaps; within total duration.\n` +
63
- ` Each cut: {"broll_id":str,"from_ms":int,"dur_ms":int,"dir":0,"video":bool}.\n\n` +
64
- `Return ONLY JSON: {"hook":{"text":str,"keyword":str},"pops":[...],"cuts":[...]}`,
65
- };
66
-
67
- let _overrides = null;
68
- /** Parse VIRALSPIN_ASSEMBLY_PROMPTS once. Any failure → empty (use builtins). */
69
- function overrides() {
70
- if (_overrides) return _overrides;
71
- _overrides = {};
72
- const raw = process.env.VIRALSPIN_ASSEMBLY_PROMPTS;
73
- if (raw) {
74
- try {
75
- const m = JSON.parse(raw);
76
- for (const [k, v] of Object.entries(m)) {
77
- if (typeof v === "string" && v.trim()) _overrides[k] = v;
78
- }
79
- } catch { /* malformed override blob → keep builtins */ }
80
- }
81
- return _overrides;
82
- }
83
-
84
- /** Admin-store override for `key` if present and non-empty, else the builtin. */
85
- export function prompt(key) {
86
- return overrides()[key] ?? BUILTIN[key];
87
- }
88
-
89
- /** Replace {{token}} placeholders. Missing keys are left untouched. */
90
- export function fill(template, vars) {
91
- let out = template;
92
- for (const [k, v] of Object.entries(vars)) out = out.split(`{{${k}}}`).join(String(v));
93
- return out;
94
- }
@@ -1,4 +0,0 @@
1
- import { Config } from "@remotion/cli/config";
2
-
3
- Config.setVideoImageFormat("jpeg");
4
- Config.setOverwriteOutput(true);