@viralspin/mcp 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/client.js +8 -0
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +62 -16
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +107 -15
- package/dist/tools/carousel-video.js +7 -0
- package/dist/tools/color-grade.js +64 -0
- package/dist/tools/connections.js +23 -4
- package/dist/tools/edit-clip.js +136 -0
- package/dist/tools/engage.js +13 -4
- package/dist/tools/explainer-index.js +6 -0
- package/dist/tools/find-moments.js +140 -0
- package/dist/tools/folder-preview.js +71 -0
- package/dist/tools/ideas.js +158 -0
- package/dist/tools/kinetic-text.js +6 -2
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +34 -10
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +131 -4
- package/package.json +41 -29
- package/BUNDLE_LOCAL_SPEC.md +0 -133
- package/engine/assemble.mjs +0 -476
- package/engine/gemini.mjs +0 -164
- package/engine/kinetic.mjs +0 -84
- package/engine/prompts.mjs +0 -94
- package/engine/remotion.config.ts +0 -4
- package/engine/src/Assembly.tsx +0 -330
- package/engine/src/KineticText.tsx +0 -253
- package/engine/src/Root.tsx +0 -39
- package/engine/src/index.ts +0 -4
- package/engine/tsconfig.json +0 -15
- package/src/client.ts +0 -119
- package/src/index.ts +0 -151
- package/src/tools/assembly-adjust.ts +0 -110
- package/src/tools/captions.ts +0 -32
- package/src/tools/carousel-video.ts +0 -211
- package/src/tools/connections.ts +0 -27
- package/src/tools/engage.ts +0 -105
- package/src/tools/explainer-index.ts +0 -298
- package/src/tools/image-ads.ts +0 -192
- package/src/tools/kinetic-text.ts +0 -130
- package/src/tools/photo-picker.ts +0 -31
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -236
- package/src/tools/review.ts +0 -126
- package/src/tools/video-assembly.ts +0 -205
- package/tsconfig.json +0 -15
package/BUNDLE_LOCAL_SPEC.md
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
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/engine/assemble.mjs
DELETED
|
@@ -1,476 +0,0 @@
|
|
|
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); });
|