@viralspin/mcp 0.1.0 → 0.3.1
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 +79 -0
- package/dist/cli.js +40 -0
- package/dist/client.js +56 -9
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +144 -2
- package/dist/local-render/kit.js +217 -0
- package/dist/local-render/login.js +138 -0
- package/dist/local-render/render.js +252 -0
- package/dist/local-render/tokens.js +201 -0
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +194 -0
- package/dist/tools/carousel-video.js +197 -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 +97 -0
- package/dist/tools/explainer-index.js +307 -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/image-ads.js +156 -0
- package/dist/tools/kinetic-text.js +122 -0
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/photo-picker.js +31 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +119 -12
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/render-locally.js +158 -0
- package/dist/tools/review.js +103 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +310 -0
- package/package.json +16 -3
- package/src/client.ts +0 -80
- package/src/index.ts +0 -60
- package/src/tools/captions.ts +0 -32
- package/src/tools/connections.ts +0 -27
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -158
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.BROWSER_HELP = exports.runBrowserInstall = exports.runNpm = exports.MIN_FREE_BYTES = void 0;
|
|
7
|
+
exports.defaultKitDir = defaultKitDir;
|
|
8
|
+
exports.assertSafeKitDir = assertSafeKitDir;
|
|
9
|
+
exports.checkNode = checkNode;
|
|
10
|
+
exports.freeBytes = freeBytes;
|
|
11
|
+
exports.checkDisk = checkDisk;
|
|
12
|
+
exports.sha256File = sha256File;
|
|
13
|
+
exports.downloadVerified = downloadVerified;
|
|
14
|
+
exports.npmInvocation = npmInvocation;
|
|
15
|
+
exports.installKit = installKit;
|
|
16
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const os_1 = __importDefault(require("os"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const child_process_1 = require("child_process");
|
|
21
|
+
exports.MIN_FREE_BYTES = 8 * 2 ** 30;
|
|
22
|
+
function defaultKitDir() {
|
|
23
|
+
return path_1.default.join(os_1.default.homedir(), ".viralspin", "kit");
|
|
24
|
+
}
|
|
25
|
+
/** The kit lives in the user's home, never at a drive root and never inside a git checkout (npm would write there). */
|
|
26
|
+
function assertSafeKitDir(dir, home = os_1.default.homedir()) {
|
|
27
|
+
const abs = path_1.default.resolve(dir);
|
|
28
|
+
if (abs === path_1.default.parse(abs).root)
|
|
29
|
+
throw new Error(`Refusing to install the render kit at a drive root (${abs}).`);
|
|
30
|
+
let cur = abs;
|
|
31
|
+
for (;;) {
|
|
32
|
+
if (fs_1.default.existsSync(path_1.default.join(cur, ".git"))) {
|
|
33
|
+
throw new Error(`Refusing to install the render kit inside a git repository (${cur}). Use the default ${path_1.default.join(home, ".viralspin", "kit")}.`);
|
|
34
|
+
}
|
|
35
|
+
const up = path_1.default.dirname(cur);
|
|
36
|
+
if (up === cur)
|
|
37
|
+
break;
|
|
38
|
+
cur = up;
|
|
39
|
+
}
|
|
40
|
+
const rel = path_1.default.relative(path_1.default.resolve(home), abs);
|
|
41
|
+
if (rel.startsWith("..") || path_1.default.isAbsolute(rel)) {
|
|
42
|
+
throw new Error(`The render kit must live inside your home folder (${home}); got ${abs}.`);
|
|
43
|
+
}
|
|
44
|
+
return abs;
|
|
45
|
+
}
|
|
46
|
+
function checkNode(min, version = process.versions.node) {
|
|
47
|
+
const major = Number(version.split(".")[0]);
|
|
48
|
+
if (!(major >= min)) {
|
|
49
|
+
throw new Error(`Node ${version} is too old for local rendering (needs Node ${min} or newer). Install the current LTS from https://nodejs.org ` +
|
|
50
|
+
`(Windows: winget install OpenJS.NodeJS.LTS; macOS: brew install node), then restart Claude Desktop.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function freeBytes(dir) {
|
|
54
|
+
let probe = path_1.default.resolve(dir);
|
|
55
|
+
while (!fs_1.default.existsSync(probe)) {
|
|
56
|
+
const up = path_1.default.dirname(probe);
|
|
57
|
+
if (up === probe)
|
|
58
|
+
break;
|
|
59
|
+
probe = up;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const s = await fs_1.default.promises.statfs(probe);
|
|
63
|
+
return Number(s.bavail) * Number(s.bsize);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null; // cannot tell: do not block on it
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function checkDisk(dir, need = exports.MIN_FREE_BYTES, free = freeBytes) {
|
|
70
|
+
const f = await free(dir);
|
|
71
|
+
if (f !== null && f < need) {
|
|
72
|
+
throw new Error(`Only ${(f / 2 ** 30).toFixed(1)} GB free on the drive holding ${dir}; local rendering needs at least ${(need / 2 ** 30).toFixed(0)} GB ` +
|
|
73
|
+
`(the render kit is ~0.5 GB, the media and the finished reel the rest). Free some space and try again.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function sha256File(file) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const h = crypto_1.default.createHash("sha256");
|
|
79
|
+
fs_1.default.createReadStream(file).on("data", (d) => h.update(d)).on("error", reject).on("end", () => resolve(h.digest("hex")));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function safeRel(rel) {
|
|
83
|
+
if (!rel || rel.includes("\\") || path_1.default.isAbsolute(rel) || rel.split("/").some((p) => p === ".." || p === "")) {
|
|
84
|
+
throw new Error(`The toolkit manifest names an unsafe path (${rel}); refusing to install it.`);
|
|
85
|
+
}
|
|
86
|
+
return rel;
|
|
87
|
+
}
|
|
88
|
+
/** Download `url` to `dest` (via a .part file), verifying `sha256` when given. */
|
|
89
|
+
async function downloadVerified(url, dest, sha256, fetchImpl = fetch) {
|
|
90
|
+
await fs_1.default.promises.mkdir(path_1.default.dirname(dest), { recursive: true });
|
|
91
|
+
const part = `${dest}.part`;
|
|
92
|
+
const resp = await fetchImpl(url);
|
|
93
|
+
if (!resp.ok || !resp.body)
|
|
94
|
+
throw new Error(`Download failed (${resp.status}) for ${url.slice(0, 120)}`);
|
|
95
|
+
const h = crypto_1.default.createHash("sha256");
|
|
96
|
+
const out = fs_1.default.createWriteStream(part);
|
|
97
|
+
let n = 0;
|
|
98
|
+
try {
|
|
99
|
+
for await (const chunk of resp.body) {
|
|
100
|
+
h.update(chunk);
|
|
101
|
+
n += chunk.length;
|
|
102
|
+
if (!out.write(chunk))
|
|
103
|
+
await new Promise((r) => out.once("drain", () => r()));
|
|
104
|
+
}
|
|
105
|
+
await new Promise((res, rej) => out.end((e) => (e ? rej(e) : res())));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
out.destroy();
|
|
109
|
+
await fs_1.default.promises.rm(part, { force: true });
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
const got = h.digest("hex");
|
|
113
|
+
if (sha256 && got !== sha256) {
|
|
114
|
+
await fs_1.default.promises.rm(part, { force: true });
|
|
115
|
+
throw new Error(`Checksum mismatch for ${url.slice(0, 120)} (expected ${sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…). Not using it.`);
|
|
116
|
+
}
|
|
117
|
+
await fs_1.default.promises.rename(part, dest);
|
|
118
|
+
return n;
|
|
119
|
+
}
|
|
120
|
+
/** npm without a shell where possible: `node <npm-cli.js>` sits next to the node binary on every platform. */
|
|
121
|
+
function npmInvocation(execPath = process.execPath, platform = process.platform) {
|
|
122
|
+
const dir = path_1.default.dirname(execPath);
|
|
123
|
+
const candidates = [
|
|
124
|
+
path_1.default.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), // Windows installer
|
|
125
|
+
path_1.default.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // macOS / Linux
|
|
126
|
+
];
|
|
127
|
+
for (const c of candidates)
|
|
128
|
+
if (fs_1.default.existsSync(c))
|
|
129
|
+
return { cmd: execPath, prefix: [c], shell: false };
|
|
130
|
+
// Last resort: fixed, argument-free-of-user-input commands, so a shell is safe.
|
|
131
|
+
return { cmd: platform === "win32" ? "npm.cmd" : "npm", prefix: [], shell: platform === "win32" };
|
|
132
|
+
}
|
|
133
|
+
const runNpm = (cwd, args, log) => new Promise((resolve, reject) => {
|
|
134
|
+
const inv = npmInvocation();
|
|
135
|
+
const p = (0, child_process_1.spawn)(inv.cmd, [...inv.prefix, ...args], { cwd, shell: inv.shell, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
136
|
+
const onData = (b) => String(b).split(/\r?\n/).filter(Boolean).forEach((l) => log(`[npm] ${l}`));
|
|
137
|
+
p.stdout.on("data", onData);
|
|
138
|
+
p.stderr.on("data", onData);
|
|
139
|
+
p.on("error", (e) => reject(new Error(`could not start npm: ${e.message}`)));
|
|
140
|
+
p.on("close", (c) => resolve(c ?? -1));
|
|
141
|
+
});
|
|
142
|
+
exports.runNpm = runNpm;
|
|
143
|
+
/** ensureBrowser() once; the kit's own node_modules is the cwd so `@remotion/renderer` resolves. */
|
|
144
|
+
const runBrowserInstall = (cwd, _args, log) => new Promise((resolve, reject) => {
|
|
145
|
+
const code = "import('@remotion/renderer').then(m=>m.ensureBrowser()).then(()=>{console.log('browser ready')}).catch(e=>{console.error(String(e&&e.message||e));process.exit(3)})";
|
|
146
|
+
const p = (0, child_process_1.spawn)(process.execPath, ["--input-type=module", "-e", code], { cwd, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
147
|
+
const onData = (b) => String(b).split(/\r?\n/).filter(Boolean).forEach((l) => log(`[browser] ${l}`));
|
|
148
|
+
p.stdout.on("data", onData);
|
|
149
|
+
p.stderr.on("data", onData);
|
|
150
|
+
p.on("error", (e) => reject(e));
|
|
151
|
+
p.on("close", (c) => resolve(c ?? -1));
|
|
152
|
+
});
|
|
153
|
+
exports.runBrowserInstall = runBrowserInstall;
|
|
154
|
+
exports.BROWSER_HELP = "Could not download the Chrome Headless Shell that Remotion draws with. This is almost always antivirus, a corporate proxy or a firewall " +
|
|
155
|
+
"blocking the download. Manual step: allow the download host shown in the lines above (or set HTTPS_PROXY for this computer), then run " +
|
|
156
|
+
"render_locally again - or, in the kit folder, run: node --input-type=module -e \"import('@remotion/renderer').then(m=>m.ensureBrowser())\"";
|
|
157
|
+
async function installKit(o) {
|
|
158
|
+
const log = o.log ?? (() => undefined);
|
|
159
|
+
const kitDir = assertSafeKitDir(o.kitDir, o.home);
|
|
160
|
+
checkNode(o.manifest.min_node);
|
|
161
|
+
await fs_1.default.promises.mkdir(kitDir, { recursive: true });
|
|
162
|
+
const statePath = path_1.default.join(kitDir, ".installed.json");
|
|
163
|
+
let state = { bundle_version: "", remotion: "", files: {} };
|
|
164
|
+
try {
|
|
165
|
+
state = JSON.parse(await fs_1.default.promises.readFile(statePath, "utf8"));
|
|
166
|
+
}
|
|
167
|
+
catch { /* first install */ }
|
|
168
|
+
let updated = 0, skipped = 0, removed = 0;
|
|
169
|
+
const next = {};
|
|
170
|
+
for (const f of o.manifest.files) {
|
|
171
|
+
const rel = safeRel(f.path);
|
|
172
|
+
const dest = path_1.default.join(kitDir, ...rel.split("/"));
|
|
173
|
+
let ok = false;
|
|
174
|
+
if (state.files[rel] === f.sha256 && fs_1.default.existsSync(dest)) {
|
|
175
|
+
ok = (await fs_1.default.promises.stat(dest)).size === f.bytes;
|
|
176
|
+
}
|
|
177
|
+
if (ok) {
|
|
178
|
+
skipped++;
|
|
179
|
+
next[rel] = f.sha256;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
await downloadVerified(f.url, dest, f.sha256, o.fetchImpl);
|
|
183
|
+
updated++;
|
|
184
|
+
next[rel] = f.sha256;
|
|
185
|
+
}
|
|
186
|
+
for (const rel of Object.keys(state.files)) {
|
|
187
|
+
if (!(rel in next)) {
|
|
188
|
+
await fs_1.default.promises.rm(path_1.default.join(kitDir, ...safeRel(rel).split("/")), { force: true });
|
|
189
|
+
removed++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
log(`[kit] ${updated} file(s) updated, ${skipped} already current, ${removed} removed (bundle ${o.manifest.bundle_version})`);
|
|
193
|
+
const pkgHash = next["package.json"] ?? "";
|
|
194
|
+
const haveModules = fs_1.default.existsSync(path_1.default.join(kitDir, "node_modules", "@remotion", "renderer", "package.json"));
|
|
195
|
+
let npmRan = false;
|
|
196
|
+
if (!haveModules || state.npm_hash !== pkgHash) {
|
|
197
|
+
log("[kit] installing Remotion (npm install, one time, a few minutes)...");
|
|
198
|
+
const code = await (o.npm ?? exports.runNpm)(kitDir, ["install", "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error"], log);
|
|
199
|
+
if (code !== 0)
|
|
200
|
+
throw new Error(`npm install failed in ${kitDir} (exit ${code}). Check the [npm] lines above; a proxy or offline network is the usual cause.`);
|
|
201
|
+
npmRan = true;
|
|
202
|
+
}
|
|
203
|
+
let browserRan = false;
|
|
204
|
+
if (npmRan || state.browser !== o.manifest.remotion) {
|
|
205
|
+
log("[kit] checking the Chrome Headless Shell (downloads once, ~100-200 MB)...");
|
|
206
|
+
const code = await (o.browser ?? exports.runBrowserInstall)(kitDir, [], log);
|
|
207
|
+
if (code !== 0)
|
|
208
|
+
throw new Error(exports.BROWSER_HELP);
|
|
209
|
+
browserRan = true;
|
|
210
|
+
}
|
|
211
|
+
const newState = {
|
|
212
|
+
bundle_version: o.manifest.bundle_version, remotion: o.manifest.remotion, files: next,
|
|
213
|
+
npm_hash: pkgHash, browser: o.manifest.remotion,
|
|
214
|
+
};
|
|
215
|
+
await fs_1.default.promises.writeFile(statePath, JSON.stringify(newState, null, 1));
|
|
216
|
+
return { updated, skipped, removed, npmRan, browserRan, bundle_version: o.manifest.bundle_version };
|
|
217
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.saveTokens = void 0;
|
|
7
|
+
exports.pkcePair = pkcePair;
|
|
8
|
+
exports.defaultOpenBrowser = defaultOpenBrowser;
|
|
9
|
+
exports.login = login;
|
|
10
|
+
exports.logout = logout;
|
|
11
|
+
exports.whoami = whoami;
|
|
12
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const http_1 = __importDefault(require("http"));
|
|
15
|
+
const os_1 = __importDefault(require("os"));
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
17
|
+
const tokens_js_1 = require("./tokens.js");
|
|
18
|
+
Object.defineProperty(exports, "saveTokens", { enumerable: true, get: function () { return tokens_js_1.saveTokens; } });
|
|
19
|
+
const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
20
|
+
function pkcePair() {
|
|
21
|
+
const verifier = b64url(crypto_1.default.randomBytes(48));
|
|
22
|
+
return { verifier, challenge: b64url(crypto_1.default.createHash("sha256").update(verifier).digest()) };
|
|
23
|
+
}
|
|
24
|
+
/** Open a URL in the default browser without a shell (an `&` in the URL must not be interpreted). */
|
|
25
|
+
function defaultOpenBrowser(url) {
|
|
26
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
|
|
27
|
+
: process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
28
|
+
: ["xdg-open", [url]];
|
|
29
|
+
const child = (0, child_process_1.spawn)(cmd, args, { stdio: "ignore", detached: true });
|
|
30
|
+
child.on("error", () => undefined); // no browser available: the printed link is the fallback
|
|
31
|
+
child.unref();
|
|
32
|
+
}
|
|
33
|
+
const PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>ViralSpin</title><body style="font:16px system-ui;background:#0e1116;color:#f2f5f7;display:grid;place-items:center;height:100vh;margin:0"><div style="max-width:420px;text-align:center"><h1 style="font-size:22px">${title}</h1><p style="color:#b4bcc8">${body}</p></div>`;
|
|
34
|
+
async function login(o) {
|
|
35
|
+
const log = o.log ?? (() => undefined);
|
|
36
|
+
const fetchImpl = o.fetchImpl ?? fetch;
|
|
37
|
+
const file = o.file ?? (0, tokens_js_1.defaultTokenFile)();
|
|
38
|
+
const { verifier, challenge } = pkcePair();
|
|
39
|
+
const state = b64url(crypto_1.default.randomBytes(16));
|
|
40
|
+
let settle;
|
|
41
|
+
const outcome = new Promise((r) => { settle = r; });
|
|
42
|
+
const server = http_1.default.createServer((req, res) => {
|
|
43
|
+
const u = new URL(req.url || "/", "http://127.0.0.1");
|
|
44
|
+
if (u.pathname !== "/callback") {
|
|
45
|
+
res.writeHead(404).end();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const reply = (code, html) => { res.writeHead(code, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }).end(html); };
|
|
49
|
+
if (u.searchParams.get("state") !== state) {
|
|
50
|
+
reply(400, PAGE("That did not match", "This sign-in link was not started by ViralSpin on this computer. Close this tab and try again."));
|
|
51
|
+
return; // a stray or forged request must not end the real attempt
|
|
52
|
+
}
|
|
53
|
+
const err = u.searchParams.get("error");
|
|
54
|
+
const code = u.searchParams.get("code");
|
|
55
|
+
if (err || !code) {
|
|
56
|
+
reply(200, PAGE("Not connected", "You chose not to connect ViralSpin. You can close this tab."));
|
|
57
|
+
settle({ error: err === "access_denied" || !code ? "You declined the sign-in in the browser." : `The sign-in failed (${err}).` });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
reply(200, PAGE("You are connected", "ViralSpin is connected to this computer. You can close this tab and go back to Claude."));
|
|
61
|
+
settle({ code });
|
|
62
|
+
});
|
|
63
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(o.port ?? 0, "127.0.0.1", resolve); });
|
|
64
|
+
const port = server.address().port;
|
|
65
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
66
|
+
let timer;
|
|
67
|
+
try {
|
|
68
|
+
const reg = await fetchImpl(`${o.apiBase}/v1/oauth/register`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ client_name: o.clientName ?? `ViralSpin on ${os_1.default.hostname()}`, redirect_uris: [redirectUri] }),
|
|
72
|
+
});
|
|
73
|
+
if (!reg.ok)
|
|
74
|
+
throw new Error(`ViralSpin would not register this computer for sign-in (${reg.status}).`);
|
|
75
|
+
const client_id = (await reg.json()).client_id;
|
|
76
|
+
if (!client_id)
|
|
77
|
+
throw new Error("ViralSpin answered the sign-in registration without a client id.");
|
|
78
|
+
const url = `${o.apiBase}/v1/oauth/authorize?` + new URLSearchParams({
|
|
79
|
+
client_id, redirect_uri: redirectUri, response_type: "code",
|
|
80
|
+
code_challenge: challenge, code_challenge_method: "S256", state,
|
|
81
|
+
}).toString();
|
|
82
|
+
log("[login] Opening your browser so you can approve ViralSpin on this computer...");
|
|
83
|
+
log(`[login] If it did not open, open this link yourself: ${url}`);
|
|
84
|
+
try {
|
|
85
|
+
await (o.openBrowser ?? defaultOpenBrowser)(url);
|
|
86
|
+
}
|
|
87
|
+
catch { /* the link above is the fallback */ }
|
|
88
|
+
const timeoutMs = o.timeoutMs ?? 5 * 60000;
|
|
89
|
+
const got = await Promise.race([
|
|
90
|
+
outcome,
|
|
91
|
+
new Promise((r) => { timer = setTimeout(() => r({ error: `Nobody approved the sign-in within ${Math.round(timeoutMs / 60000)} minutes.` }), timeoutMs); }),
|
|
92
|
+
]);
|
|
93
|
+
if ("error" in got)
|
|
94
|
+
throw new Error(got.error);
|
|
95
|
+
const tok = await fetchImpl(`${o.apiBase}/v1/oauth/token`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
98
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code: got.code, redirect_uri: redirectUri, client_id, code_verifier: verifier }).toString(),
|
|
99
|
+
});
|
|
100
|
+
if (!tok.ok)
|
|
101
|
+
throw new Error(`ViralSpin would not finish the sign-in (${tok.status}). Try again.`);
|
|
102
|
+
const j = (await tok.json());
|
|
103
|
+
if (!j.access_token)
|
|
104
|
+
throw new Error("The sign-in finished without an access token. Try again.");
|
|
105
|
+
(0, tokens_js_1.saveTokens)(file, { access_token: j.access_token, refresh_token: j.refresh_token, client_id });
|
|
106
|
+
log("[login] Connected. This computer stays signed in (renews itself for about 30 days of use).");
|
|
107
|
+
return { file, client_id, expires_at: (0, tokens_js_1.jwtExpiry)(j.access_token) };
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (timer)
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
server.close();
|
|
113
|
+
server.closeAllConnections?.();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function logout(file = (0, tokens_js_1.defaultTokenFile)()) {
|
|
117
|
+
try {
|
|
118
|
+
fs_1.default.unlinkSync(file);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Who is signed in, WITHOUT showing a token: the user id from the access token and when it lapses. */
|
|
126
|
+
function whoami(file = (0, tokens_js_1.defaultTokenFile)()) {
|
|
127
|
+
try {
|
|
128
|
+
const j = JSON.parse(fs_1.default.readFileSync(file, "utf8"));
|
|
129
|
+
if (!j || typeof j.access_token !== "string")
|
|
130
|
+
return { signedIn: false };
|
|
131
|
+
const part = j.access_token.split(".")[1];
|
|
132
|
+
const sub = part ? JSON.parse(Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")).sub : undefined;
|
|
133
|
+
return { signedIn: true, user: sub, accessExpiresAt: (0, tokens_js_1.jwtExpiry)(j.access_token), hasRefresh: Boolean(j.refresh_token) };
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { signedIn: false };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.pickJob = pickJob;
|
|
7
|
+
exports.listPendingJobs = listPendingJobs;
|
|
8
|
+
exports.ago = ago;
|
|
9
|
+
exports.defaultConcurrency = defaultConcurrency;
|
|
10
|
+
exports.freePort = freePort;
|
|
11
|
+
exports.rewritePlan = rewritePlan;
|
|
12
|
+
exports.fetchToolkitManifest = fetchToolkitManifest;
|
|
13
|
+
exports.planJob = planJob;
|
|
14
|
+
exports.downloadMedia = downloadMedia;
|
|
15
|
+
exports.uploadOutput = uploadOutput;
|
|
16
|
+
exports.renderLocally = renderLocally;
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const net_1 = __importDefault(require("net"));
|
|
19
|
+
const os_1 = __importDefault(require("os"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const url_1 = require("url");
|
|
22
|
+
const kit_js_1 = require("./kit.js");
|
|
23
|
+
/** Which of my pending jobs to render when nobody said: a reel that is READY and waiting, newest first; failing that one still being
|
|
24
|
+
* prepared (we wait for it). One that a computer already took ("rendering") is never picked on its own. */
|
|
25
|
+
function pickJob(jobs) {
|
|
26
|
+
const byNewest = [...jobs].sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")));
|
|
27
|
+
const waiting = byNewest.filter((j) => j.phase === "waiting");
|
|
28
|
+
const planning = byNewest.filter((j) => j.phase === "planning");
|
|
29
|
+
const busy = byNewest.filter((j) => j.phase === "rendering");
|
|
30
|
+
const ordered = [...waiting, ...planning];
|
|
31
|
+
return { job: ordered[0] ?? null, others: ordered.slice(1), busy };
|
|
32
|
+
}
|
|
33
|
+
async function listPendingJobs(o) {
|
|
34
|
+
const r = await o.tokens.request("GET", `${o.apiBase}/v1/reel/local-jobs?status=pending`);
|
|
35
|
+
return r.jobs ?? [];
|
|
36
|
+
}
|
|
37
|
+
function ago(iso, now = Date.now()) {
|
|
38
|
+
const t = iso ? Date.parse(iso) : NaN;
|
|
39
|
+
if (!Number.isFinite(t))
|
|
40
|
+
return "a while ago";
|
|
41
|
+
const m = Math.max(0, Math.round((now - t) / 60000));
|
|
42
|
+
if (m < 1)
|
|
43
|
+
return "just now";
|
|
44
|
+
if (m < 60)
|
|
45
|
+
return `${m} min ago`;
|
|
46
|
+
const h = Math.round(m / 60);
|
|
47
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
|
|
48
|
+
}
|
|
49
|
+
function defaultConcurrency(cores = os_1.default.cpus().length) {
|
|
50
|
+
return Math.max(1, Math.min(4, Math.floor(cores / 2))); // measured: 6+ was slower than 4
|
|
51
|
+
}
|
|
52
|
+
function freePort() {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const s = net_1.default.createServer();
|
|
55
|
+
s.once("error", reject);
|
|
56
|
+
s.listen(0, "127.0.0.1", () => {
|
|
57
|
+
const p = s.address().port;
|
|
58
|
+
s.close(() => resolve(p));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Replace every media URL in the plan with the loopback address the kit's server will answer on. No public address remains. */
|
|
63
|
+
function rewritePlan(plan, media, port) {
|
|
64
|
+
const map = new Map(media.map((m) => [m.url, `http://127.0.0.1:${port}/${m.name}`]));
|
|
65
|
+
const walk = (n) => {
|
|
66
|
+
if (typeof n === "string")
|
|
67
|
+
return map.get(n) ?? n;
|
|
68
|
+
if (Array.isArray(n))
|
|
69
|
+
return n.map(walk);
|
|
70
|
+
if (n && typeof n === "object")
|
|
71
|
+
return Object.fromEntries(Object.entries(n).map(([k, v]) => [k, walk(v)]));
|
|
72
|
+
return n;
|
|
73
|
+
};
|
|
74
|
+
return walk(plan);
|
|
75
|
+
}
|
|
76
|
+
const dynamicImport = new Function("u", "return import(u)");
|
|
77
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
78
|
+
async function fetchToolkitManifest(o) {
|
|
79
|
+
return o.tokens.request("GET", `${o.apiBase}/v1/toolkit/manifest`);
|
|
80
|
+
}
|
|
81
|
+
async function planJob(o, log) {
|
|
82
|
+
if (!o.job_id && !o.render_id && !o.request)
|
|
83
|
+
throw new Error("Pass a job_id, a render_id (a reel ViralSpin already planned) or a reel request {plan, aroll_url}.");
|
|
84
|
+
const body = o.render_id ? { render_id: o.render_id } : o.request;
|
|
85
|
+
let view = o.job_id
|
|
86
|
+
? await o.tokens.request("GET", `${o.apiBase}/v1/reel/local-jobs/${encodeURIComponent(o.job_id)}`)
|
|
87
|
+
: await o.tokens.request("POST", `${o.apiBase}/v1/reel/local-jobs?wait=50`, body);
|
|
88
|
+
const t0 = Date.now();
|
|
89
|
+
const limit = o.planTimeoutMs ?? 20 * 60000;
|
|
90
|
+
let last = "";
|
|
91
|
+
while (view.status !== "local_ready") {
|
|
92
|
+
if (view.status === "failed" || view.status === "cancelled") {
|
|
93
|
+
throw new Error(`ViralSpin could not prepare this reel (${view.status}): ${view.error_message || "no reason given"}`);
|
|
94
|
+
}
|
|
95
|
+
if (Date.now() - t0 > limit)
|
|
96
|
+
throw new Error(`Preparing the reel took longer than ${Math.round(limit / 60000)} minutes; try again.`);
|
|
97
|
+
if (view.status !== last) {
|
|
98
|
+
log(`[plan] ${view.status}${view.progress ? ` ${Math.round(Number(view.progress) * 100)}%` : ""}`);
|
|
99
|
+
last = view.status;
|
|
100
|
+
}
|
|
101
|
+
await sleep(o.pollMs ?? 3000);
|
|
102
|
+
view = await o.tokens.request("GET", `${o.apiBase}/v1/reel/local-jobs/${view.job_id}`);
|
|
103
|
+
}
|
|
104
|
+
if (!view.manifest)
|
|
105
|
+
throw new Error("The server said the job is ready but sent no manifest.");
|
|
106
|
+
return view.manifest;
|
|
107
|
+
}
|
|
108
|
+
async function downloadMedia(media, dir, fetchImpl, log) {
|
|
109
|
+
await fs_1.default.promises.mkdir(dir, { recursive: true });
|
|
110
|
+
let downloaded = 0, cached = 0;
|
|
111
|
+
let i = 0;
|
|
112
|
+
const lane = async () => {
|
|
113
|
+
for (;;) {
|
|
114
|
+
const m = media[i++];
|
|
115
|
+
if (!m)
|
|
116
|
+
return;
|
|
117
|
+
const dest = path_1.default.join(dir, m.name);
|
|
118
|
+
if (fs_1.default.existsSync(dest) && m.sha256 && (await (0, kit_js_1.sha256File)(dest)) === m.sha256) {
|
|
119
|
+
cached++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (fs_1.default.existsSync(dest) && !m.sha256) {
|
|
123
|
+
cached++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await (0, kit_js_1.downloadVerified)(m.url, dest, m.sha256, fetchImpl);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
await (0, kit_js_1.downloadVerified)(m.url, dest, m.sha256, fetchImpl); // one more try: a dropped connection is the usual cause
|
|
131
|
+
}
|
|
132
|
+
downloaded++;
|
|
133
|
+
if (downloaded % 10 === 0)
|
|
134
|
+
log(`[media] ${downloaded + cached}/${media.length}`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
await Promise.all([lane(), lane(), lane(), lane()]);
|
|
138
|
+
return { downloaded, cached };
|
|
139
|
+
}
|
|
140
|
+
/** Put the finished mp4 in ViralSpin storage through a signed slot (multipart PUT, the shape storage's own client uses). Retried: a home upload drops. */
|
|
141
|
+
async function uploadOutput(o, jobId, file, fetchImpl = fetch, attempts = 3) {
|
|
142
|
+
const slot = await o.tokens.request("POST", `${o.apiBase}/v1/reel/local-jobs/${encodeURIComponent(jobId)}/upload-url`, {});
|
|
143
|
+
let lastErr = "";
|
|
144
|
+
for (let i = 1; i <= attempts; i++) {
|
|
145
|
+
try {
|
|
146
|
+
const blob = typeof fs_1.default.openAsBlob === "function"
|
|
147
|
+
? await fs_1.default.openAsBlob(file, { type: "video/mp4" })
|
|
148
|
+
: new Blob([await fs_1.default.promises.readFile(file)], { type: "video/mp4" });
|
|
149
|
+
const form = new FormData();
|
|
150
|
+
form.append("cacheControl", "31536000");
|
|
151
|
+
form.append("", blob, path_1.default.basename(file));
|
|
152
|
+
const r = await fetchImpl(slot.upload_url, { method: "PUT", body: form, headers: { "x-upsert": "true" } });
|
|
153
|
+
if (r.ok)
|
|
154
|
+
return slot.public_url;
|
|
155
|
+
lastErr = `${r.status} ${(await r.text().catch(() => "")).slice(0, 120)}`;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
lastErr = String(e.message || e);
|
|
159
|
+
}
|
|
160
|
+
if (i < attempts)
|
|
161
|
+
await sleep(1500 * i);
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`The reel was drawn but could not be sent to ViralSpin (${lastErr}). It is still on this computer: ${file}`);
|
|
164
|
+
}
|
|
165
|
+
async function renderLocally(o) {
|
|
166
|
+
const log = o.log ?? (() => undefined);
|
|
167
|
+
const home = o.homeDir ?? os_1.default.homedir();
|
|
168
|
+
const fetchImpl = o.fetchImpl ?? fetch;
|
|
169
|
+
const t0 = Date.now();
|
|
170
|
+
const secs = (a) => Math.round((Date.now() - a) / 100) / 10;
|
|
171
|
+
const kitDir = (0, kit_js_1.assertSafeKitDir)(o.kitDir ?? path_1.default.join(home, ".viralspin", "kit"), home);
|
|
172
|
+
const workRoot = path_1.default.resolve(o.workDir ?? path_1.default.join(home, ".viralspin"));
|
|
173
|
+
(0, kit_js_1.checkNode)(20);
|
|
174
|
+
const envGb = Number(process.env.VIRALSPIN_MIN_FREE_GB);
|
|
175
|
+
await (0, kit_js_1.checkDisk)(workRoot, o.minFreeBytes ?? (envGb > 0 ? envGb * 2 ** 30 : undefined));
|
|
176
|
+
// 1 - the toolkit
|
|
177
|
+
const tk = Date.now();
|
|
178
|
+
const manifest = await fetchToolkitManifest(o);
|
|
179
|
+
(0, kit_js_1.checkNode)(manifest.min_node);
|
|
180
|
+
const inst = await (0, kit_js_1.installKit)({ manifest, kitDir, fetchImpl, log, npm: o.npm, browser: o.browser, home });
|
|
181
|
+
const kitSecs = secs(tk);
|
|
182
|
+
// 2 - the plan passes, on the server
|
|
183
|
+
const tp = Date.now();
|
|
184
|
+
log("[plan] asking ViralSpin to prepare the reel (no cloud draw, no credits)...");
|
|
185
|
+
const job = await planJob(o, log);
|
|
186
|
+
const planSecs = secs(tp);
|
|
187
|
+
if (job.bundle_version !== manifest.bundle_version) {
|
|
188
|
+
throw new Error(`The server is serving render kit ${job.bundle_version} but the toolkit manifest said ${manifest.bundle_version} (a deploy landed between the two calls). Run render_locally again.`);
|
|
189
|
+
}
|
|
190
|
+
if (job.schema_version !== 1)
|
|
191
|
+
throw new Error(`This reel uses plan schema ${job.schema_version}, which this version of the ViralSpin MCP package does not draw. Update @viralspin/mcp.`);
|
|
192
|
+
log(`[plan] ready: ${job.frames} frames at ${job.fps} fps (${job.duration.toFixed(1)} s), ${job.media.length} media file(s), sfx: ${job.sfx.join(", ") || "none"}`);
|
|
193
|
+
// 3 - the media, content-addressed so a second render of the same take downloads nothing
|
|
194
|
+
const td = Date.now();
|
|
195
|
+
const mediaDir = path_1.default.join(workRoot, "cache", "media");
|
|
196
|
+
const dl = await downloadMedia(job.media, mediaDir, fetchImpl, log);
|
|
197
|
+
const dlSecs = secs(td);
|
|
198
|
+
log(`[media] ${job.media.length} file(s): ${dl.downloaded} downloaded, ${dl.cached} already cached`);
|
|
199
|
+
// the app shows "rendering on your computer" from here (best effort: a failed stamp never stops the draw)
|
|
200
|
+
if (!o.dryRun)
|
|
201
|
+
await o.tokens.request("POST", `${o.apiBase}/v1/reel/local-jobs/${encodeURIComponent(job.job_id)}/started`, {}).catch(() => undefined);
|
|
202
|
+
// 4 - the draw, by the installed kit's own run.mjs
|
|
203
|
+
const outDir = path_1.default.join(workRoot, "jobs", job.job_id);
|
|
204
|
+
await fs_1.default.promises.mkdir(outDir, { recursive: true });
|
|
205
|
+
const port = await freePort();
|
|
206
|
+
const propsPath = path_1.default.join(outDir, "props.json");
|
|
207
|
+
await fs_1.default.promises.writeFile(propsPath, JSON.stringify({ composition: "reel", plan: rewritePlan(job.plan, job.media, port) }));
|
|
208
|
+
const takeName = job.media.find((m) => m.url === job.audio_url)?.name;
|
|
209
|
+
if (!takeName)
|
|
210
|
+
throw new Error("The manifest lists no audio take among its media.");
|
|
211
|
+
const outPath = path_1.default.join(outDir, "reel.mp4");
|
|
212
|
+
const concurrency = o.concurrency && o.concurrency > 0 ? Math.floor(o.concurrency) : defaultConcurrency();
|
|
213
|
+
const runKit = o.runKit ?? (await dynamicImport((0, url_1.pathToFileURL)(path_1.default.join(kitDir, "run.mjs")).href)).runKit;
|
|
214
|
+
const dr = Date.now();
|
|
215
|
+
try {
|
|
216
|
+
const r = await runKit({
|
|
217
|
+
kitDir, propsPath, mediaDir, audioPath: path_1.default.join(mediaDir, takeName), outPath, port, concurrency,
|
|
218
|
+
dryRun: Boolean(o.dryRun), log, signal: o.signal,
|
|
219
|
+
});
|
|
220
|
+
const result = {
|
|
221
|
+
job_id: job.job_id, dry_run: Boolean(o.dryRun),
|
|
222
|
+
kit: { dir: kitDir, bundle_version: manifest.bundle_version, updated: inst.updated },
|
|
223
|
+
media: { files: job.media.length, ...dl },
|
|
224
|
+
seconds: { plan: planSecs, download: dlSecs, kit: kitSecs, draw: secs(dr), total: secs(t0) },
|
|
225
|
+
};
|
|
226
|
+
if (!o.dryRun) {
|
|
227
|
+
result.output_path = outPath;
|
|
228
|
+
result.timing = r.timing;
|
|
229
|
+
result.timing_path = path_1.default.join(outDir, "timing.json");
|
|
230
|
+
if (o.complete) {
|
|
231
|
+
let output_url;
|
|
232
|
+
if (o.upload ?? Boolean(o.job_id)) {
|
|
233
|
+
log("[upload] sending the finished reel to ViralSpin so it shows up in the app...");
|
|
234
|
+
output_url = await uploadOutput(o, job.job_id, outPath, fetchImpl);
|
|
235
|
+
log("[upload] done");
|
|
236
|
+
}
|
|
237
|
+
await o.tokens.request("POST", `${o.apiBase}/v1/reel/local-jobs/${job.job_id}/complete`, { status: "done", output_url, timing: { ...r.timing, planSeconds: planSecs, downloadSeconds: dlSecs } });
|
|
238
|
+
result.completed = true;
|
|
239
|
+
result.output_url = output_url;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
if (o.complete && !o.dryRun) {
|
|
246
|
+
await o.tokens
|
|
247
|
+
.request("POST", `${o.apiBase}/v1/reel/local-jobs/${job.job_id}/complete`, { status: "failed", error: String(e.message).slice(0, 800) })
|
|
248
|
+
.catch(() => undefined);
|
|
249
|
+
}
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
}
|