@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.
Files changed (40) hide show
  1. package/BUNDLE_LOCAL_SPEC.md +133 -0
  2. package/dist/client.js +47 -9
  3. package/dist/index.js +83 -1
  4. package/dist/tools/assembly-adjust.js +102 -0
  5. package/dist/tools/carousel-video.js +190 -0
  6. package/dist/tools/engage.js +88 -0
  7. package/dist/tools/explainer-index.js +301 -0
  8. package/dist/tools/image-ads.js +156 -0
  9. package/dist/tools/kinetic-text.js +118 -0
  10. package/dist/tools/photo-picker.js +31 -0
  11. package/dist/tools/publish.js +72 -1
  12. package/dist/tools/review.js +103 -0
  13. package/dist/tools/video-assembly.js +183 -0
  14. package/engine/assemble.mjs +476 -0
  15. package/engine/gemini.mjs +164 -0
  16. package/engine/kinetic.mjs +84 -0
  17. package/engine/prompts.mjs +94 -0
  18. package/engine/remotion.config.ts +4 -0
  19. package/engine/src/Assembly.tsx +330 -0
  20. package/engine/src/KineticText.tsx +253 -0
  21. package/engine/src/Root.tsx +39 -0
  22. package/engine/src/index.ts +4 -0
  23. package/engine/tsconfig.json +15 -0
  24. package/package.json +29 -29
  25. package/src/client.ts +119 -80
  26. package/src/index.ts +151 -60
  27. package/src/tools/assembly-adjust.ts +110 -0
  28. package/src/tools/captions.ts +32 -32
  29. package/src/tools/carousel-video.ts +211 -0
  30. package/src/tools/connections.ts +27 -27
  31. package/src/tools/engage.ts +105 -0
  32. package/src/tools/explainer-index.ts +298 -0
  33. package/src/tools/image-ads.ts +192 -0
  34. package/src/tools/kinetic-text.ts +130 -0
  35. package/src/tools/photo-picker.ts +31 -0
  36. package/src/tools/posts.ts +58 -58
  37. package/src/tools/publish.ts +236 -158
  38. package/src/tools/review.ts +126 -0
  39. package/src/tools/video-assembly.ts +205 -0
  40. package/tsconfig.json +15 -15
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local video-assembly engine (Node-only — no Python, bundled ffmpeg).
4
+ * node assemble.mjs --aroll <path|url> [--broll <path|url|folder> ...] [--prompt] [--style-ref]
5
+ * [--format 9x16|1x1|16x9] [--out]
6
+ * node assemble.mjs --suggest <cutIdx> (rank alternatives for a cut)
7
+ * node assemble.mjs --swap <cutIdx> <assetName> (swap a cut + re-render)
8
+ * Pipeline: stage A-roll -> transcribe -> rank albums -> album-weighted B-roll -> analyze ->
9
+ * (rotate) -> editorial -> captions -> face-aware Remotion render. Prints "RESULT:<mp4>".
10
+ */
11
+ import { execFileSync, spawnSync } from "node:child_process";
12
+ import { fileURLToPath } from "node:url";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import ffmpegStatic from "ffmpeg-static";
16
+ import ffprobeStatic from "ffprobe-static";
17
+ import { transcribe, analyzeFrame, rankAlbums, inferStyle, editTimeline, getDirectorDecisions, inferCaptionStyle } from "./gemini.mjs";
18
+ import os from "node:os";
19
+
20
+ const FFMPEG = ffmpegStatic;
21
+ const FFPROBE = ffprobeStatic.path;
22
+ const EXPLAINER_INDEX_ID = process.env.EXPLAINER_INDEX_ID || "";
23
+ const TL_KEY = process.env.TWELVE_LABS_API_KEY || "";
24
+ const TL_BASE = "https://api.twelvelabs.io/v1.2";
25
+ const LONG_VIDEO_SECS = 120;
26
+
27
+ // Read the Twelve Labs video cache written by index_explainer_sources.
28
+ const VIRALSPIN_HOME = path.join(os.homedir(), ".viralspin");
29
+ const EXPLAINER_CACHE_FILE = path.join(VIRALSPIN_HOME, "explainer_cache.json");
30
+ function readExplainerCache() {
31
+ try { return JSON.parse(fs.readFileSync(EXPLAINER_CACHE_FILE, "utf8")); } catch { return { videos: {} }; }
32
+ }
33
+ function localPathFromVideoId(videoId, cache) {
34
+ for (const [key, v] of Object.entries(cache.videos || {})) {
35
+ if (v.video_id === videoId) return key.split("|")[0];
36
+ }
37
+ return null;
38
+ }
39
+
40
+ async function tlSearch(query, indexId) {
41
+ try {
42
+ const r = await fetch(`${TL_BASE}/search`, {
43
+ method: "POST",
44
+ headers: { "x-api-key": TL_KEY, "Content-Type": "application/json" },
45
+ body: JSON.stringify({ index_id: indexId, query_text: query, options: ["visual", "conversation", "text_in_video"] }),
46
+ });
47
+ if (!r.ok) return [];
48
+ const data = await r.json();
49
+ return (data.data || []).slice(0, 3);
50
+ } catch { return []; }
51
+ }
52
+ const ENGINE = path.dirname(fileURLToPath(import.meta.url));
53
+ const PUBLIC = path.join(ENGINE, "public");
54
+ const BROLL = path.join(PUBLIC, "broll");
55
+ const RUN = path.join(ENGINE, "_run");
56
+ const CACHE = path.join(ENGINE, "cache");
57
+ const IMG = new Set([".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"]);
58
+ const VID = new Set([".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv", ".wmv"]);
59
+ const SKIP_DIR = /^(porno|logo|inventory|test)$/i;
60
+ const IMG_BUDGET = 24, VID_BUDGET = 16;
61
+ const MAX_COVER = 2.8; // skip B-roll needing >2.8x upscale to fill the frame (would pixellate)
62
+
63
+ const argv = process.argv.slice(2);
64
+ const one = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
65
+ const many = (f) => { const o = []; for (let i = 0; i < argv.length; i++) if (argv[i] === f) { let j = i + 1; while (j < argv.length && !argv[j].startsWith("--")) o.push(argv[j++]); } return o; };
66
+ const aroll = one("--aroll");
67
+ const brollIn = many("--broll");
68
+ const prompt = one("--prompt", "");
69
+ const format = one("--format", "9x16");
70
+ const styleRef = one("--style-ref", "");
71
+ const captionStyleArg = one("--caption-style", "auto"); // "auto" → AI picks; override with e.g. "hormozi"
72
+ const suggestIdx = one("--suggest", null);
73
+ const swapArgs = many("--swap");
74
+ const captionStyleSwapMode = argv.includes("--swap-caption-style");
75
+ const out = path.resolve(one("--out", path.join(ENGINE, "out", "assembly.mp4")));
76
+ const adjustMode = suggestIdx !== null || swapArgs.length >= 2 || captionStyleSwapMode;
77
+ if (!aroll && !adjustMode) { console.error("ERROR: --aroll is required"); process.exit(1); }
78
+
79
+ const isUrl = (s) => /^https?:\/\//i.test(s);
80
+ const extOf = (f) => (isUrl(f) ? path.extname(new URL(f).pathname) : path.extname(f)).toLowerCase();
81
+ const log = (...a) => console.error("[assemble]", ...a);
82
+ const ff = (args) => execFileSync(FFMPEG, args, { stdio: ["ignore", "ignore", "ignore"] });
83
+ async function download(url, dest) { const r = await fetch(url); if (!r.ok) throw new Error(`download ${r.status} ${url}`); fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer())); }
84
+ function evenSample(arr, n) { if (arr.length <= n) return arr.slice(); const step = arr.length / n, o = []; for (let i = 0; i < n; i++) o.push(arr[Math.floor(i * step)]); return o; }
85
+ function probeDuration(file) { try { return parseFloat(execFileSync(FFPROBE, ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", file]).toString().trim()) || 0; } catch { return 0; } }
86
+ function probeDims(file) {
87
+ try {
88
+ const o = execFileSync(FFPROBE, ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", file]).toString().trim();
89
+ const [w, h] = o.split("x").map(Number);
90
+ return [w || 0, h || 0];
91
+ } catch { return [0, 0]; }
92
+ }
93
+ function render() {
94
+ // pass the entry explicitly — when the engine lives inside another package (mcp/engine),
95
+ // Remotion would otherwise pick that package's src/index.ts as the entry.
96
+ const entry = path.join(ENGINE, "src", "index.ts");
97
+ // --public-dir: when nested in another package, Remotion picks the outer root's public/ by
98
+ // default; point it at the engine's own public/ so staticFile() assets resolve.
99
+ const r = spawnSync(`npx remotion render "${entry}" Assembly "${out}" "--props=${path.join(PUBLIC, "editData.json")}" "--public-dir=${PUBLIC}"`, { cwd: ENGINE, shell: true, stdio: "inherit" });
100
+ if (r.status !== 0) throw new Error(`remotion render failed (${r.status})`);
101
+ }
102
+
103
+ // prep dirs — skip destructive cleanup in swap/adjust modes so staged files survive
104
+ if (!adjustMode) {
105
+ fs.rmSync(BROLL, { recursive: true, force: true });
106
+ fs.rmSync(path.join(PUBLIC, "aroll.mp4"), { force: true });
107
+ }
108
+ fs.mkdirSync(BROLL, { recursive: true });
109
+ fs.mkdirSync(RUN, { recursive: true });
110
+ fs.mkdirSync(CACHE, { recursive: true });
111
+ fs.mkdirSync(path.dirname(out), { recursive: true });
112
+
113
+ async function stageAroll() {
114
+ let src = aroll;
115
+ if (isUrl(aroll)) { src = path.join(RUN, "aroll_src"); await download(aroll, src); }
116
+ else if (!fs.existsSync(src)) throw new Error(`A-roll not found: ${src}`);
117
+ log("transcoding A-roll…");
118
+ ff(["-y", "-i", src, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", path.join(PUBLIC, "aroll.mp4")]);
119
+ }
120
+
121
+ function listAlbums(dir) {
122
+ const albums = {};
123
+ const add = (name, fp) => {
124
+ const e = path.extname(fp).toLowerCase();
125
+ if (!IMG.has(e) && !VID.has(e)) return;
126
+ (albums[name] ??= { imgs: [], vids: [] })[IMG.has(e) ? "imgs" : "vids"].push(fp);
127
+ };
128
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
129
+ for (const e of entries) if (e.isFile()) add("(root)", path.join(dir, e.name));
130
+ for (const e of entries) if (e.isDirectory() && !SKIP_DIR.test(e.name)) {
131
+ const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).forEach((x) => {
132
+ const fp = path.join(d, x.name);
133
+ if (x.isDirectory()) { if (!SKIP_DIR.test(x.name)) walk(fp); } else add(e.name, fp);
134
+ });
135
+ walk(path.join(dir, e.name));
136
+ }
137
+ return albums;
138
+ }
139
+
140
+ async function stageBroll(transcriptText, segments = []) {
141
+ const albums = {};
142
+ const merge = (name, v) => { albums[name] ??= { imgs: [], vids: [] }; albums[name].imgs.push(...v.imgs); albums[name].vids.push(...v.vids); };
143
+ const urls = [];
144
+ // Long-form videos queued for Twelve Labs temporal search (only when index is available).
145
+ const longFormQueue = [];
146
+ for (const p of brollIn) {
147
+ if (isUrl(p)) { urls.push(p); continue; }
148
+ const st = fs.statSync(p, { throwIfNoEntry: false });
149
+ if (!st) { log("skip (missing):", p); continue; }
150
+ if (st.isDirectory()) {
151
+ // Walk directory; intercept long videos.
152
+ const walked = listAlbums(p);
153
+ for (const [k, v] of Object.entries(walked)) {
154
+ const shortVids = [], longVids = [];
155
+ for (const vf of v.vids) {
156
+ if (EXPLAINER_INDEX_ID && TL_KEY && probeDuration(vf) > LONG_VIDEO_SECS) longVids.push(vf);
157
+ else shortVids.push(vf);
158
+ }
159
+ longFormQueue.push(...longVids);
160
+ merge(k, { imgs: v.imgs, vids: shortVids });
161
+ }
162
+ } else {
163
+ const e = extOf(p);
164
+ if (VID.has(e) && EXPLAINER_INDEX_ID && TL_KEY && probeDuration(p) > LONG_VIDEO_SECS) {
165
+ log(`long-form video (${probeDuration(p).toFixed(0)}s), queuing for Twelve Labs search: ${path.basename(p)}`);
166
+ longFormQueue.push(p);
167
+ } else {
168
+ merge("(loose)", { imgs: IMG.has(e) ? [p] : [], vids: VID.has(e) ? [p] : [] });
169
+ }
170
+ }
171
+ }
172
+ const names = Object.keys(albums);
173
+
174
+ // rank albums by name vs transcript
175
+ let weights = Object.fromEntries(names.map((n) => [n, 0.5]));
176
+ if (names.length > 1) {
177
+ try {
178
+ const r = await rankAlbums(transcriptText, names.map((n) => ({ name: n, count: albums[n].imgs.length + albums[n].vids.length })));
179
+ weights = r.weights;
180
+ log("album weights:", JSON.stringify(weights));
181
+ } catch (e) { log("album ranking failed (even weights):", e.message); }
182
+ }
183
+
184
+ const pick = (key, budget) => {
185
+ const avail = names.filter((n) => albums[n][key].length);
186
+ const sumW = avail.reduce((a, n) => a + (weights[n] ?? 0.3), 0) || 1;
187
+ let chosen = [];
188
+ for (const n of avail) chosen.push(...evenSample(albums[n][key], Math.max(1, Math.round(budget * (weights[n] ?? 0.3) / sumW))));
189
+ return evenSample(chosen, budget);
190
+ };
191
+ const files = [...pick("imgs", IMG_BUDGET), ...pick("vids", VID_BUDGET), ...urls];
192
+
193
+ const srcmap = {};
194
+ const keyOf = (f) => { try { const s = fs.statSync(f); return `${path.resolve(f)}|${s.size}|${Math.round(s.mtimeMs)}`; } catch { return `path:${f}`; } };
195
+ const [TW, TH] = format === "1x1" ? [1080, 1080] : format === "16x9" ? [1920, 1080] : [1080, 1920];
196
+ let img = 0, vid = 0;
197
+ for (const f of files) {
198
+ let src = f;
199
+ try {
200
+ if (isUrl(f)) { src = path.join(RUN, "dl_" + (img + vid)); await download(f, src); }
201
+ const [dw, dh] = probeDims(src);
202
+ if (dw && dh && Math.max(TW / dw, TH / dh) > MAX_COVER) { log("skip (low-res):", path.basename(String(f))); continue; }
203
+ let name;
204
+ if (VID.has(extOf(f))) {
205
+ name = `v${String(vid).padStart(2, "0")}.mp4`;
206
+ ff(["-y", "-i", src, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-t", "6", "-vf", "scale='min(1280,iw)':-2", path.join(BROLL, name)]);
207
+ vid++;
208
+ } else {
209
+ name = `${String(img).padStart(2, "0")}.jpg`;
210
+ ff(["-y", "-i", src, "-vf", "scale='min(2048,iw)':-2", path.join(BROLL, name)]);
211
+ img++;
212
+ }
213
+ srcmap[name] = isUrl(f) ? `url:${f}` : keyOf(f);
214
+ } catch { log("skip (convert failed):", path.basename(String(f))); }
215
+ }
216
+ // Twelve Labs temporal search for long-form recordings.
217
+ if (longFormQueue.length > 0 && EXPLAINER_INDEX_ID && TL_KEY) {
218
+ log(`director pass: ${longFormQueue.length} long-form video(s) → Twelve Labs search (index: ${EXPLAINER_INDEX_ID})`);
219
+ const cache = readExplainerCache();
220
+ let decisions = [];
221
+ try { decisions = await getDirectorDecisions(segments); } catch { /* use transcript keywords as fallback */ }
222
+
223
+ if (decisions.length === 0) {
224
+ // Fallback: use the whole transcript as one search query.
225
+ decisions = [{ search_query: transcriptText.slice(0, 200), layout: "interrupt", first_mention_ms: 0 }];
226
+ }
227
+
228
+ for (const decision of decisions) {
229
+ log(` searching: "${decision.search_query}"`);
230
+ const results = await tlSearch(decision.search_query, EXPLAINER_INDEX_ID);
231
+ if (!results.length) { log(" no results for:", decision.search_query); continue; }
232
+
233
+ const top = results[0];
234
+ const localPath = localPathFromVideoId(top.video_id, cache);
235
+ if (!localPath || !fs.existsSync(localPath)) {
236
+ log(` video_id ${top.video_id} not found in local cache — re-run index_explainer_sources`);
237
+ continue;
238
+ }
239
+
240
+ const startSec = typeof top.start === "number" ? top.start : 0;
241
+ const endSec = typeof top.end === "number" ? top.end : startSec + 10;
242
+ const dur = Math.min(endSec - startSec, 30);
243
+ const clipName = `v${String(vid).padStart(2, "0")}.mp4`;
244
+ const clipPath = path.join(BROLL, clipName);
245
+ try {
246
+ ff(["-y", "-ss", String(startSec), "-t", String(dur), "-i", localPath,
247
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
248
+ "-vf", "scale='min(1280,iw)':-2", clipPath]);
249
+ srcmap[clipName] = `tl:${top.video_id}:${startSec}`;
250
+ vid++;
251
+ log(` ✓ extracted ${path.basename(localPath)} @ ${startSec.toFixed(1)}s-${(startSec + dur).toFixed(1)}s → ${clipName}`);
252
+ } catch (e) { log(` ✗ clip extraction failed: ${e.message}`); }
253
+ }
254
+ }
255
+
256
+ fs.writeFileSync(path.join(RUN, "srcmap.json"), JSON.stringify(srcmap));
257
+ log(`B-roll: ${img} images + ${vid} videos staged (from ${names.length} albums${longFormQueue.length ? ` + ${longFormQueue.length} long-form via Twelve Labs` : ""})`);
258
+ return img + vid;
259
+ }
260
+
261
+ // analyze every staged B-roll (cached by source identity → index once)
262
+ async function analyzeBroll() {
263
+ const srcmap = fs.existsSync(path.join(RUN, "srcmap.json")) ? JSON.parse(fs.readFileSync(path.join(RUN, "srcmap.json"))) : {};
264
+ const cacheFile = path.join(CACHE, "analysis.json");
265
+ const cache = fs.existsSync(cacheFile) ? JSON.parse(fs.readFileSync(cacheFile)) : {};
266
+ const res = {};
267
+ for (const name of fs.readdirSync(BROLL).sort()) {
268
+ const ext = path.extname(name).toLowerCase();
269
+ const kind = IMG.has(ext) ? "image" : VID.has(ext) ? "video" : null;
270
+ if (!kind) continue;
271
+ const key = srcmap[name];
272
+ if (key && cache[key]) { res[name] = cache[key]; log(`${name} (cached)`); continue; }
273
+ let frame = path.join(BROLL, name);
274
+ if (kind === "video") {
275
+ frame = path.join(RUN, "f_" + name + ".jpg");
276
+ const d = probeDuration(path.join(BROLL, name)) || 1;
277
+ try { ff(["-y", "-ss", String(Math.max(0.1, d / 2)), "-i", path.join(BROLL, name), "-frames:v", "1", "-vf", "scale=640:-1", frame]); } catch {}
278
+ }
279
+ try { res[name] = await analyzeFrame(frame, kind); if (key) cache[key] = res[name]; log(`${name} ${kind} ${res[name].subject} (${res[name].focal})`); }
280
+ catch (e) { res[name] = { kind, rotate: 0, focal: [0.5, 0.45], secondary: null, subject: "scenery", desc: "", keywords: [] }; log(`${name} analyze error: ${e.message}`); }
281
+ }
282
+ fs.writeFileSync(cacheFile, JSON.stringify(cache));
283
+ fs.writeFileSync(path.join(RUN, "analysis.json"), JSON.stringify(res, null, 2));
284
+ return res;
285
+ }
286
+
287
+ async function inferStyleFromRef(refPath) {
288
+ const [w, h] = probeDims(refPath); const dur = probeDuration(refPath);
289
+ const fmt = w === h ? "1x1" : w > h ? "16x9" : "9x16";
290
+ let cuts = 0;
291
+ try { const r = spawnSync(FFMPEG, ["-i", refPath, "-vf", "select='gt(scene,0.35)',showinfo", "-an", "-f", "null", "-"], { encoding: "utf8" }); cuts = (String(r.stderr).match(/showinfo/g) || []).length; } catch {}
292
+ const frames = [];
293
+ for (let i = 0; i < 8; i++) {
294
+ const t = dur ? Math.max(0.3, (dur * (i + 0.5)) / 8) : 1;
295
+ const fp = path.join(RUN, `sref_${i}.jpg`);
296
+ try { ff(["-y", "-ss", String(t), "-i", refPath, "-frames:v", "1", "-vf", "scale=320:-1", fp]); if (fs.existsSync(fp)) frames.push(fp); } catch {}
297
+ }
298
+ const style = await inferStyle(frames, { fmt, dur: Math.round(dur), cps: dur ? (cuts / dur).toFixed(2) : "0" });
299
+ log("inferred style:", JSON.stringify(style));
300
+ return style;
301
+ }
302
+
303
+ // ── adjustment modes ──────────────────────────────────────────────────
304
+ function beatTextAt(transcript, fromF, durF, fps) {
305
+ const a = (fromF / fps) * 1000, b = ((fromF + durF) / fps) * 1000;
306
+ return transcript.filter((s) => (s.end_ms ?? 0) >= a && (s.start_ms ?? 0) <= b).map((s) => s.text).join(" ");
307
+ }
308
+ function rankCandidates(analysis, beat, usedFiles) {
309
+ const words = new Set(String(beat).toLowerCase().split(/\W+/).filter((w) => w.length > 3));
310
+ const scored = [];
311
+ for (const [name, a] of Object.entries(analysis)) {
312
+ if (usedFiles.has(name)) continue;
313
+ const kws = [...(a.keywords || []), ...String(a.desc || "").toLowerCase().split(/\W+/)];
314
+ let score = kws.reduce((s, k) => s + (words.has(String(k).toLowerCase()) ? 1 : 0), 0);
315
+ if (a.kind === "video") score += 0.5;
316
+ scored.push({ name, score, desc: a.desc, kind: a.kind });
317
+ }
318
+ return scored.sort((x, y) => y.score - x.score).slice(0, 6);
319
+ }
320
+ function suggestMode(idx) {
321
+ const editData = JSON.parse(fs.readFileSync(path.join(PUBLIC, "editData.json")));
322
+ const analysis = JSON.parse(fs.readFileSync(path.join(RUN, "analysis.json")));
323
+ const transcript = JSON.parse(fs.readFileSync(path.join(RUN, "transcript.json"))).segments || [];
324
+ const cut = editData.cuts[idx];
325
+ if (!cut) throw new Error(`no cut #${idx} (have ${editData.cuts.length})`);
326
+ const used = new Set(editData.cuts.map((c) => path.basename(c.file)));
327
+ const beat = beatTextAt(transcript, cut.from, cut.dur, editData.fps);
328
+ const sg = path.join(ENGINE, "suggest"); fs.rmSync(sg, { recursive: true, force: true }); fs.mkdirSync(sg, { recursive: true });
329
+ const options = [];
330
+ for (const t of rankCandidates(analysis, beat, used)) {
331
+ const thumb = path.join(sg, t.name.replace(/\.[^.]+$/, "") + ".jpg");
332
+ try { ff(["-y", "-ss", "0.5", "-i", path.join(BROLL, t.name), "-frames:v", "1", "-vf", "scale=240:-1", thumb]); } catch {}
333
+ options.push({ asset: t.name, kind: t.kind, desc: t.desc, thumb });
334
+ }
335
+ console.log("SUGGEST:" + JSON.stringify({ cut: idx, beat, current: path.basename(cut.file), options }));
336
+ }
337
+ function swapMode(idx, asset) {
338
+ const editData = JSON.parse(fs.readFileSync(path.join(PUBLIC, "editData.json")));
339
+ const analysis = JSON.parse(fs.readFileSync(path.join(RUN, "analysis.json")));
340
+ const a = analysis[asset]; if (!a) throw new Error(`unknown asset ${asset}`);
341
+ const cut = editData.cuts[idx]; if (!cut) throw new Error(`no cut #${idx}`);
342
+ const [w, h] = probeDims(path.join(BROLL, asset));
343
+ Object.assign(cut, { file: "broll/" + asset, focal: a.focal, secondary: a.secondary || null, video: a.kind === "video", srcW: w, srcH: h });
344
+ fs.writeFileSync(path.join(PUBLIC, "editData.json"), JSON.stringify(editData, null, 2));
345
+ render();
346
+ console.log("RESULT:" + out);
347
+ }
348
+
349
+ function swapCaptionStyleMode(styleId) {
350
+ const VALID = ["default", "hormozi", "glow_neon", "minimalist", "whiteboard"];
351
+ if (!VALID.includes(styleId)) throw new Error(`unknown caption style "${styleId}". Choose one of: ${VALID.join(", ")}`);
352
+ const editData = JSON.parse(fs.readFileSync(path.join(PUBLIC, "editData.json")));
353
+ editData.captionStyle = styleId;
354
+ fs.writeFileSync(path.join(PUBLIC, "editData.json"), JSON.stringify(editData, null, 2));
355
+ log(`caption style swapped to: ${styleId}`);
356
+ render();
357
+ console.log("RESULT:" + out);
358
+ }
359
+
360
+ async function main() {
361
+ if (suggestIdx !== null) return suggestMode(parseInt(suggestIdx, 10));
362
+ if (swapArgs.length >= 2) return swapMode(parseInt(swapArgs[0], 10), swapArgs[1]);
363
+ if (captionStyleSwapMode) return swapCaptionStyleMode(captionStyleArg !== "auto" ? captionStyleArg : "default");
364
+
365
+ await stageAroll();
366
+ const dur = probeDuration(path.join(PUBLIC, "aroll.mp4"));
367
+ const fps = 30, totalFrames = Math.max(1, Math.round(dur * fps)), totalMs = Math.round(dur * 1000);
368
+ log(`A-roll ${dur.toFixed(2)}s → ${totalFrames} frames`);
369
+
370
+ // transcribe
371
+ log("transcribing (Gemini)…");
372
+ ff(["-y", "-i", path.join(PUBLIC, "aroll.mp4"), "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", path.join(RUN, "aroll.wav")]);
373
+ const segments = await transcribe(path.join(RUN, "aroll.wav"));
374
+ fs.writeFileSync(path.join(RUN, "transcript.json"), JSON.stringify({ segments }, null, 2));
375
+ const transcriptText = segments.map((s) => s.text).join(" ");
376
+ log(`transcript: ${segments.length} segments`);
377
+
378
+ // style
379
+ let style = null;
380
+ if (styleRef) {
381
+ log("inferring style from reference…");
382
+ let sref = styleRef;
383
+ if (isUrl(styleRef)) { sref = path.join(RUN, "styleref"); await download(styleRef, sref); }
384
+ try { style = await inferStyleFromRef(sref); } catch (e) { log("style inference failed (defaults):", e.message); }
385
+ }
386
+ const effFormat = style?.format || format;
387
+ const wantCaptions = !style || style.captions !== false;
388
+
389
+ // caption style — user override > style_ref inference > AI transcript analysis
390
+ let captionStyle = "default";
391
+ if (captionStyleArg && captionStyleArg !== "auto") {
392
+ captionStyle = captionStyleArg;
393
+ log(`caption style: ${captionStyle} (user-specified)`);
394
+ } else if (style?.caption_style) {
395
+ captionStyle = style.caption_style;
396
+ log(`caption style: ${captionStyle} (inferred from style_ref)`);
397
+ } else {
398
+ log("inferring caption style from transcript…");
399
+ try { captionStyle = await inferCaptionStyle(transcriptText); } catch { captionStyle = "default"; }
400
+ log(`caption style: ${captionStyle} (AI-inferred)`);
401
+ }
402
+
403
+ const nBroll = await stageBroll(transcriptText, segments);
404
+
405
+ let cuts = [], pops = [], hook = null, analysis = {};
406
+ if (nBroll > 0) {
407
+ log("analysing B-roll (Gemini vision)…");
408
+ analysis = await analyzeBroll();
409
+ // correct sideways B-roll
410
+ for (const [name, a] of Object.entries(analysis)) {
411
+ const rot = a.rotate || 0;
412
+ if (rot !== 90 && rot !== 180 && rot !== 270) continue;
413
+ const file = path.join(BROLL, name);
414
+ const isVid = name.toLowerCase().endsWith(".mp4");
415
+ const tmp = file + (isVid ? ".rot.mp4" : ".rot.jpg");
416
+ const tp = rot === 90 ? "transpose=1" : rot === 270 ? "transpose=2" : "transpose=1,transpose=1";
417
+ try { ff(["-y", "-i", file, "-vf", tp, ...(isVid ? ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac"] : []), tmp]); fs.renameSync(tmp, file); log(`rotated ${name} ${rot}°`); } catch { log("rotate failed:", name); }
418
+ }
419
+ log("editorial pass (Gemini)…");
420
+ const edit = await editTimeline(segments, analysis, totalMs, prompt, style);
421
+ cuts = edit.cuts; pops = edit.pops; hook = edit.hook;
422
+ }
423
+
424
+ // captions
425
+ const capsMs = [];
426
+ for (const s of (wantCaptions ? segments : [])) {
427
+ const raw = String(s.text || "").trim();
428
+ if (/^\(.*\)$/.test(raw)) continue;
429
+ const words = raw.split(/\s+/).filter(Boolean);
430
+ if (!words.length) continue;
431
+ const span = Math.max(600, (s.end_ms || 0) - (s.start_ms || 0));
432
+ const chunks = []; for (let i = 0; i < words.length; i += 3) chunks.push(words.slice(i, i + 3).join(" "));
433
+ const totChars = chunks.reduce((a, c) => a + c.length, 0) || 1;
434
+ let t = s.start_ms || 0;
435
+ for (const c of chunks) { const d = Math.max(380, Math.round(span * (c.length / totChars))); capsMs.push({ from_ms: t, dur_ms: d, text: c }); t += d; }
436
+ }
437
+
438
+ // build editData
439
+ const f2 = (ms) => Math.round((ms / 1000) * fps);
440
+ let built = [];
441
+ for (const c of cuts) {
442
+ const a = analysis[c.broll_id]; if (!a) continue;
443
+ const from = Math.max(0, f2(c.from_ms)), d = Math.max(8, f2(c.dur_ms));
444
+ if (from + d > totalFrames) continue;
445
+ const [srcW, srcH] = probeDims(path.join(BROLL, c.broll_id));
446
+ built.push({ from, dur: d, file: "broll/" + c.broll_id, video: a.kind === "video", dir: c.dir ? 1 : 0, focal: a.focal, secondary: a.secondary || null, srcW, srcH });
447
+ }
448
+ built.sort((x, y) => x.from - y.from);
449
+ const cleaned = []; let last = -1;
450
+ for (const c of built) if (c.from >= last) { cleaned.push(c); last = c.from + c.dur; }
451
+
452
+ const popsF = pops.map((p) => ({ from: f2(p.from_ms), dur: 27, text: String(p.text).toUpperCase() })).filter((p) => p.from >= 0 && p.from + p.dur <= totalFrames);
453
+
454
+ const AROLL_FY = 0.38;
455
+ const captionY = (from, durF) => {
456
+ const mid = from + durF / 2;
457
+ const cut = cleaned.find((c) => mid >= c.from && mid < c.from + c.dur);
458
+ const focalY = cut ? cut.focal[1] : AROLL_FY;
459
+ let y = 0.58;
460
+ if (Math.abs(focalY - y) < 0.16) y = focalY < 0.5 ? Math.min(0.82, focalY + 0.3) : Math.max(0.2, focalY - 0.3);
461
+ if (popsF.some((p) => mid >= p.from - 4 && mid < p.from + p.dur + 4)) y = 0.84;
462
+ return Math.max(0.16, Math.min(0.86, y));
463
+ };
464
+ const captions = capsMs
465
+ .map((c) => ({ from: f2(c.from_ms), dur: Math.max(8, f2(c.dur_ms)), text: c.text }))
466
+ .filter((c) => c.from < totalFrames)
467
+ .map((c) => { const durF = Math.min(c.dur, totalFrames - c.from); return { ...c, dur: durF, y: captionY(c.from, durF) }; });
468
+ const hookF = hook && hook.text ? { from: 0, dur: Math.min(110, totalFrames), text: hook.text, keyword: hook.keyword || "" } : null;
469
+
470
+ const editData = { format: effFormat, fps, totalFrames, aroll: "aroll.mp4", arollFocal: [0.5, 0.38], cuts: cleaned, captions, pops: popsF, hook: hookF, captionStyle };
471
+ fs.writeFileSync(path.join(PUBLIC, "editData.json"), JSON.stringify(editData, null, 2));
472
+ log(`${cleaned.length} cutaways, ${captions.length} captions, ${popsF.length} pops, hook=${hookF ? "yes" : "no"}, captionStyle=${captionStyle} → rendering…`);
473
+ render();
474
+ console.log("RESULT:" + out);
475
+ }
476
+ main().catch((e) => { console.error("[assemble] FATAL:", e.message); process.exit(1); });
@@ -0,0 +1,164 @@
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
+ }