@viralspin/mcp 0.4.0 → 0.5.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 -21
- package/README.md +79 -79
- package/dist/cli.js +11 -1
- package/dist/client.js +4 -4
- package/dist/index.js +7 -0
- package/dist/local-render/cwd.js +29 -0
- package/dist/local-render/media-fix.js +71 -0
- package/dist/local-render/render.js +25 -3
- package/dist/local-render/tokens.js +4 -0
- package/dist/local-render/watch.js +49 -13
- package/dist/organize-watch.js +699 -0
- package/dist/organizer/edit.js +148 -0
- package/dist/organizer/images.js +231 -0
- package/dist/organizer/plan.js +204 -0
- package/dist/organizer/scan.js +331 -0
- package/dist/organizer/topics.js +204 -0
- package/dist/tools/organize.js +171 -0
- package/package.json +11 -2
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 ViralSpin
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ViralSpin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
# ViralSpin MCP — the video editor your AI agent operates
|
|
2
|
-
|
|
3
|
-
[](https://registry.modelcontextprotocol.io/v0/servers?search=viralspin)
|
|
4
|
-
[](https://www.npmjs.com/package/@viralspin/mcp)
|
|
5
|
-
[](./LICENSE)
|
|
6
|
-
|
|
7
|
-
Turn a talking-head clip + a folder of B-roll into a polished vertical short — and **let Claude do the edit**. Point it at your footage in plain English; it transcribes, picks B-roll cutaways matched to what's said, adds captions + a hook, and renders a 9:16 short. Then publishes to TikTok, Instagram, or YouTube. No timeline-wrangling required.
|
|
8
|
-
|
|
9
|
-
> **The agent operates a real timeline.** Swap a clip, trim it, reorder scenes, cut a span — all by asking. Edits are instant; you render once when the cut is right.
|
|
10
|
-
|
|
11
|
-
 <!-- TODO: the split-screen "Claude edits, clips stream onto the timeline" clip -->
|
|
12
|
-
|
|
13
|
-
## Quick start
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
# Claude Code
|
|
17
|
-
claude mcp add viralspin -- npx -y @viralspin/mcp
|
|
18
|
-
|
|
19
|
-
# or Claude Desktop — add to claude_desktop_config.json:
|
|
20
|
-
{
|
|
21
|
-
"mcpServers": {
|
|
22
|
-
"viralspin": { "command": "npx", "args": ["-y", "@viralspin/mcp"] }
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Then just ask:
|
|
28
|
-
|
|
29
|
-
```
|
|
30
|
-
Turn my A-roll at C:/clips/talk.mp4 into a 9:16 short, using B-roll
|
|
31
|
-
from C:/my-broll and my Google Photos.
|
|
32
|
-
|
|
33
|
-
Publish that to TikTok and Instagram with the caption: posting through the chaos.
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## Render a reel on your own computer (`render_locally`)
|
|
37
|
-
|
|
38
|
-
ViralSpin plans the reel and hands over the media; **your Mac or PC does the draw**, nothing is published, 0 credits.
|
|
39
|
-
|
|
40
|
-
1. Install Node 20+ (macOS: `brew install node` - Windows: `winget install OpenJS.NodeJS.LTS`) and keep 8 GB of disk free.
|
|
41
|
-
2. Add the server to Claude Desktop (`claude_desktop_config.json`; on Windows use `"command": "npx.cmd"` if `npx` is not found):
|
|
42
|
-
`"viralspin": { "command": "npx", "args": ["-y", "@viralspin/mcp"] }` - no tokens to paste.
|
|
43
|
-
3. Connect once: in a terminal run `npx -y @viralspin/mcp login` (your browser opens, you click **Authorize**). Skipping this is fine too: the first time you ask Claude to render, the browser opens by itself. Credentials live in `~/.viralspin/oauth.json` (Windows: `%USERPROFILE%\.viralspin`), readable only by you, and renew themselves silently for about 30 days of use. `npx -y @viralspin/mcp whoami` / `logout` show / remove them; nothing ever prints a token.
|
|
44
|
-
4. In the ViralSpin app: open a reel's **style picker**, choose **On my computer**, tap **Send to my computer**.
|
|
45
|
-
5. In Claude Desktop say: *"Render my ViralSpin reel"* - with no arguments it picks your newest waiting reel and says which. The first run downloads a ~0.5 GB kit into `~/.viralspin/kit` and Chrome Headless Shell once. The finished reel is uploaded back and shows up in the app; a copy stays in `~/.viralspin/jobs/<id>/`.
|
|
46
|
-
|
|
47
|
-
Tools: `render_locally` (no arguments, or `job_id`, `render_id`, `request`, `concurrency`, `dry_run`, `complete`, `upload`, `background`) and `render_locally_status` (no arguments lists your waiting / rendering reels).
|
|
48
|
-
If the Chrome download is blocked by antivirus or a proxy the error says so and names the manual step.
|
|
49
|
-
|
|
50
|
-
## What the agent can do
|
|
51
|
-
|
|
52
|
-
| Tool | What it does |
|
|
53
|
-
|---|---|
|
|
54
|
-
| `create_video_assembly` | A-roll + B-roll (local files, a folder, URLs, or Google Photos) → a rendered vertical short. Local or hosted. |
|
|
55
|
-
| `suggest_clips` / `swap_clip` | See the cutaways; swap one for better-fitting B-roll (with a `reason`). |
|
|
56
|
-
| `trim_clip` / `remove_span` / `reorder_scenes` | Granular timeline edits — instant, no re-render. |
|
|
57
|
-
| `render_project` | Bake captions/overlays into the final MP4 (the one render). |
|
|
58
|
-
| `publish_now` / `schedule_post` / `send_to_drafts` | Post to TikTok / Instagram / YouTube. |
|
|
59
|
-
| `generate_caption`, `create_kinetic_text`, carousels, image ads | The rest of the content pipeline. |
|
|
60
|
-
|
|
61
|
-
Edits are **instant and free** (they patch the timeline); `render_project` is the single cloud render. Iterate the cut as much as you like, then finalize once.
|
|
62
|
-
|
|
63
|
-
## Open source
|
|
64
|
-
|
|
65
|
-
The MCP server, the editor, and the agent verbs are **MIT** — use them free. Cloud generation (AI B-roll, rendering, publishing) runs on ViralSpin's infrastructure and is metered as credits; sign in at [viralspin.ai](https://www.viralspin.ai). You never paste an API key for the AI — the heavy lifting is server-side.
|
|
66
|
-
|
|
67
|
-
- Listed in the official **MCP registry**: `ai.viralspin/viralspin`
|
|
68
|
-
- Hosted connector (no install, for URL / Google Photos workflows): `https://mcp.viralspin.ai/mcp`
|
|
69
|
-
|
|
70
|
-
## Local vs hosted
|
|
71
|
-
|
|
72
|
-
- **Hosted (the default):** the cloud pipeline does the heavy lifting — assembly, AI B-roll, rendering — and every edit opens in the web Swap & Refine editor. B-roll from Google Photos / URLs / your synced library. Needs a [viralspin.ai](https://www.viralspin.ai) account (generation is metered as credits); no API keys to paste. The `mcp.viralspin.ai/mcp` hosted connector is the same, zero-install.
|
|
73
|
-
- **Local (advanced):** point `VIRALSPIN_ASSEMBLY_DIR` at a local engine checkout to read your disk — index a folder of clips, select B-roll contextually, and render locally. The npm package ships without the engine, so the local-render tools route you to the hosted path until that env var is set.
|
|
74
|
-
|
|
75
|
-
Either way, the agent reads local files *to upload them* (A-roll, a B-roll folder); only on-disk rendering needs the local engine.
|
|
76
|
-
|
|
77
|
-
## License
|
|
78
|
-
|
|
79
|
-
MIT — see [LICENSE](./LICENSE).
|
|
1
|
+
# ViralSpin MCP — the video editor your AI agent operates
|
|
2
|
+
|
|
3
|
+
[](https://registry.modelcontextprotocol.io/v0/servers?search=viralspin)
|
|
4
|
+
[](https://www.npmjs.com/package/@viralspin/mcp)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Turn a talking-head clip + a folder of B-roll into a polished vertical short — and **let Claude do the edit**. Point it at your footage in plain English; it transcribes, picks B-roll cutaways matched to what's said, adds captions + a hook, and renders a 9:16 short. Then publishes to TikTok, Instagram, or YouTube. No timeline-wrangling required.
|
|
8
|
+
|
|
9
|
+
> **The agent operates a real timeline.** Swap a clip, trim it, reorder scenes, cut a span — all by asking. Edits are instant; you render once when the cut is right.
|
|
10
|
+
|
|
11
|
+
 <!-- TODO: the split-screen "Claude edits, clips stream onto the timeline" clip -->
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Claude Code
|
|
17
|
+
claude mcp add viralspin -- npx -y @viralspin/mcp
|
|
18
|
+
|
|
19
|
+
# or Claude Desktop — add to claude_desktop_config.json:
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"viralspin": { "command": "npx", "args": ["-y", "@viralspin/mcp"] }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then just ask:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Turn my A-roll at C:/clips/talk.mp4 into a 9:16 short, using B-roll
|
|
31
|
+
from C:/my-broll and my Google Photos.
|
|
32
|
+
|
|
33
|
+
Publish that to TikTok and Instagram with the caption: posting through the chaos.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Render a reel on your own computer (`render_locally`)
|
|
37
|
+
|
|
38
|
+
ViralSpin plans the reel and hands over the media; **your Mac or PC does the draw**, nothing is published, 0 credits.
|
|
39
|
+
|
|
40
|
+
1. Install Node 20+ (macOS: `brew install node` - Windows: `winget install OpenJS.NodeJS.LTS`) and keep 8 GB of disk free.
|
|
41
|
+
2. Add the server to Claude Desktop (`claude_desktop_config.json`; on Windows use `"command": "npx.cmd"` if `npx` is not found):
|
|
42
|
+
`"viralspin": { "command": "npx", "args": ["-y", "@viralspin/mcp"] }` - no tokens to paste.
|
|
43
|
+
3. Connect once: in a terminal run `npx -y @viralspin/mcp login` (your browser opens, you click **Authorize**). Skipping this is fine too: the first time you ask Claude to render, the browser opens by itself. Credentials live in `~/.viralspin/oauth.json` (Windows: `%USERPROFILE%\.viralspin`), readable only by you, and renew themselves silently for about 30 days of use. `npx -y @viralspin/mcp whoami` / `logout` show / remove them; nothing ever prints a token.
|
|
44
|
+
4. In the ViralSpin app: open a reel's **style picker**, choose **On my computer**, tap **Send to my computer**.
|
|
45
|
+
5. In Claude Desktop say: *"Render my ViralSpin reel"* - with no arguments it picks your newest waiting reel and says which. The first run downloads a ~0.5 GB kit into `~/.viralspin/kit` and Chrome Headless Shell once. The finished reel is uploaded back and shows up in the app; a copy stays in `~/.viralspin/jobs/<id>/`.
|
|
46
|
+
|
|
47
|
+
Tools: `render_locally` (no arguments, or `job_id`, `render_id`, `request`, `concurrency`, `dry_run`, `complete`, `upload`, `background`) and `render_locally_status` (no arguments lists your waiting / rendering reels).
|
|
48
|
+
If the Chrome download is blocked by antivirus or a proxy the error says so and names the manual step.
|
|
49
|
+
|
|
50
|
+
## What the agent can do
|
|
51
|
+
|
|
52
|
+
| Tool | What it does |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `create_video_assembly` | A-roll + B-roll (local files, a folder, URLs, or Google Photos) → a rendered vertical short. Local or hosted. |
|
|
55
|
+
| `suggest_clips` / `swap_clip` | See the cutaways; swap one for better-fitting B-roll (with a `reason`). |
|
|
56
|
+
| `trim_clip` / `remove_span` / `reorder_scenes` | Granular timeline edits — instant, no re-render. |
|
|
57
|
+
| `render_project` | Bake captions/overlays into the final MP4 (the one render). |
|
|
58
|
+
| `publish_now` / `schedule_post` / `send_to_drafts` | Post to TikTok / Instagram / YouTube. |
|
|
59
|
+
| `generate_caption`, `create_kinetic_text`, carousels, image ads | The rest of the content pipeline. |
|
|
60
|
+
|
|
61
|
+
Edits are **instant and free** (they patch the timeline); `render_project` is the single cloud render. Iterate the cut as much as you like, then finalize once.
|
|
62
|
+
|
|
63
|
+
## Open source
|
|
64
|
+
|
|
65
|
+
The MCP server, the editor, and the agent verbs are **MIT** — use them free. Cloud generation (AI B-roll, rendering, publishing) runs on ViralSpin's infrastructure and is metered as credits; sign in at [viralspin.ai](https://www.viralspin.ai). You never paste an API key for the AI — the heavy lifting is server-side.
|
|
66
|
+
|
|
67
|
+
- Listed in the official **MCP registry**: `ai.viralspin/viralspin`
|
|
68
|
+
- Hosted connector (no install, for URL / Google Photos workflows): `https://mcp.viralspin.ai/mcp`
|
|
69
|
+
|
|
70
|
+
## Local vs hosted
|
|
71
|
+
|
|
72
|
+
- **Hosted (the default):** the cloud pipeline does the heavy lifting — assembly, AI B-roll, rendering — and every edit opens in the web Swap & Refine editor. B-roll from Google Photos / URLs / your synced library. Needs a [viralspin.ai](https://www.viralspin.ai) account (generation is metered as credits); no API keys to paste. The `mcp.viralspin.ai/mcp` hosted connector is the same, zero-install.
|
|
73
|
+
- **Local (advanced):** point `VIRALSPIN_ASSEMBLY_DIR` at a local engine checkout to read your disk — index a folder of clips, select B-roll contextually, and render locally. The npm package ships without the engine, so the local-render tools route you to the hosted path until that env var is set.
|
|
74
|
+
|
|
75
|
+
Either way, the agent reads local files *to upload them* (A-roll, a B-roll folder); only on-disk rendering needs the local engine.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT — see [LICENSE](./LICENSE).
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.runCli = runCli;
|
|
7
7
|
const os_1 = __importDefault(require("os"));
|
|
8
8
|
const client_js_1 = require("./client.js");
|
|
9
|
+
const organize_watch_js_1 = require("./organize-watch.js");
|
|
9
10
|
const tokens_js_1 = require("./local-render/tokens.js");
|
|
10
11
|
const login_js_1 = require("./local-render/login.js");
|
|
11
12
|
const render_js_1 = require("./local-render/render.js");
|
|
@@ -45,6 +46,11 @@ async function runCli(argv, out = (s) => process.stdout.write(s + "\n")) {
|
|
|
45
46
|
await tokens.getAccessToken(); // signs in (opens the browser once) when this computer is not connected yet
|
|
46
47
|
const ac = new AbortController();
|
|
47
48
|
process.once("SIGINT", () => { out("Stopping."); ac.abort(); });
|
|
49
|
+
// "this computer is here" on its own clock: a render can hold the watch loop for many minutes, and the app read that silence as "Not connected"
|
|
50
|
+
const ping = () => tokens.request("POST", `${client_js_1.PY_API_URL}/v1/reel/local-agent/ping`, { version: WATCH_VERSION, host: os_1.default.hostname(), platform: process.platform }).catch(() => { });
|
|
51
|
+
const beat = setInterval(() => void ping(), 30000);
|
|
52
|
+
ac.signal.addEventListener("abort", () => clearInterval(beat));
|
|
53
|
+
void (0, organize_watch_js_1.organizeLoop)({ req: (m, u, b) => tokens.request(m, u, b), log: out }, client_js_1.API_URL, ac.signal);
|
|
48
54
|
await (0, watch_js_1.watchLoop)({
|
|
49
55
|
list: () => (0, render_js_1.listPendingJobs)({ apiBase: client_js_1.PY_API_URL, tokens }),
|
|
50
56
|
render: async (jobId) => { await (0, render_js_1.renderLocally)({ apiBase: client_js_1.PY_API_URL, tokens, job_id: jobId, complete: true, upload: true, log: out }); },
|
|
@@ -53,6 +59,9 @@ async function runCli(argv, out = (s) => process.stdout.write(s + "\n")) {
|
|
|
53
59
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
54
60
|
now: () => Date.now(),
|
|
55
61
|
}, { signal: ac.signal });
|
|
62
|
+
// the thumbnail server, the folder picker and the heartbeat would keep the process alive after Ctrl+C ("Stopping." and then still running,
|
|
63
|
+
// still holding port 47831 with old code), so leave for real
|
|
64
|
+
process.exit(0);
|
|
56
65
|
}
|
|
57
66
|
catch (e) {
|
|
58
67
|
out(`The connector stopped: ${e.message}`);
|
|
@@ -71,7 +80,8 @@ async function runCli(argv, out = (s) => process.stdout.write(s + "\n")) {
|
|
|
71
80
|
out("viralspin-mcp run as an MCP server (Claude Desktop / Claude Code start this)\n" +
|
|
72
81
|
"viralspin-mcp login connect this computer to your ViralSpin account (opens your browser once)\n" +
|
|
73
82
|
"viralspin-mcp whoami show whether this computer is connected (never prints a token)\n" +
|
|
74
|
-
"viralspin-mcp logout disconnect this computer"
|
|
83
|
+
"viralspin-mcp logout disconnect this computer\n" +
|
|
84
|
+
"viralspin-mcp watch keep this computer ready to render reels from the web (--install starts it at sign-in, --uninstall removes that)");
|
|
75
85
|
}
|
|
76
86
|
return true;
|
|
77
87
|
}
|
package/dist/client.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.PY_API_URL = void 0;
|
|
6
|
+
exports.PY_API_URL = exports.API_URL = void 0;
|
|
7
7
|
exports.get = get;
|
|
8
8
|
exports.post = post;
|
|
9
9
|
exports.patch = patch;
|
|
@@ -14,7 +14,7 @@ exports.uploadFiles = uploadFiles;
|
|
|
14
14
|
exports.deleteFiles = deleteFiles;
|
|
15
15
|
const fs_1 = __importDefault(require("fs"));
|
|
16
16
|
const path_1 = __importDefault(require("path"));
|
|
17
|
-
|
|
17
|
+
exports.API_URL = (process.env.VIRALSPIN_API_URL || "https://viralspinbackend.onrender.com").replace(/\/$/, "");
|
|
18
18
|
// Python backend — Higgsfield, review-sessions, OAuth, narrative-render
|
|
19
19
|
exports.PY_API_URL = (process.env.VIRALSPIN_PY_API_URL || "https://viralspin-api.onrender.com").replace(/\/$/, "");
|
|
20
20
|
const API_KEY = process.env.VIRALSPIN_API_KEY || "";
|
|
@@ -31,7 +31,7 @@ function headers(extra = {}) {
|
|
|
31
31
|
// Legacy API key — retained for backward compatibility with personal installs
|
|
32
32
|
return { "X-API-Key": API_KEY, ...extra };
|
|
33
33
|
}
|
|
34
|
-
async function apiFetch(endpoint, init = {}, base = API_URL) {
|
|
34
|
+
async function apiFetch(endpoint, init = {}, base = exports.API_URL) {
|
|
35
35
|
const url = `${base}${endpoint}`;
|
|
36
36
|
const resp = await fetch(url, {
|
|
37
37
|
...init,
|
|
@@ -107,7 +107,7 @@ async function uploadFiles(filePaths) {
|
|
|
107
107
|
const authHeader = OAUTH_TOKEN
|
|
108
108
|
? { "Authorization": `Bearer ${OAUTH_TOKEN}` }
|
|
109
109
|
: { "X-API-Key": API_KEY };
|
|
110
|
-
const resp = await fetch(`${API_URL}/v1/media/upload`, {
|
|
110
|
+
const resp = await fetch(`${exports.API_URL}/v1/media/upload`, {
|
|
111
111
|
method: "POST",
|
|
112
112
|
// Don't set Content-Type manually — fetch will set it with the correct multipart boundary.
|
|
113
113
|
headers: authHeader,
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ const edit_clip_js_1 = require("./tools/edit-clip.js");
|
|
|
19
19
|
const relight_clip_js_1 = require("./tools/relight-clip.js");
|
|
20
20
|
const ideas_js_1 = require("./tools/ideas.js");
|
|
21
21
|
const local_index_js_1 = require("./tools/local-index.js");
|
|
22
|
+
const organize_js_1 = require("./tools/organize.js");
|
|
22
23
|
const profile_js_1 = require("./tools/profile.js");
|
|
23
24
|
const posts_js_1 = require("./tools/posts.js");
|
|
24
25
|
const captions_js_1 = require("./tools/captions.js");
|
|
@@ -27,6 +28,7 @@ const explainer_index_js_1 = require("./tools/explainer-index.js");
|
|
|
27
28
|
const kinetic_text_js_1 = require("./tools/kinetic-text.js");
|
|
28
29
|
const review_js_1 = require("./tools/review.js");
|
|
29
30
|
const cli_js_1 = require("./cli.js");
|
|
31
|
+
const cwd_js_1 = require("./local-render/cwd.js");
|
|
30
32
|
const render_locally_js_1 = require("./tools/render-locally.js");
|
|
31
33
|
const image_ads_js_1 = require("./tools/image-ads.js");
|
|
32
34
|
const tools = [
|
|
@@ -59,6 +61,10 @@ const tools = [
|
|
|
59
61
|
profile_js_1.setCreatorProfileTool,
|
|
60
62
|
profile_js_1.getCreatorProfileTool,
|
|
61
63
|
local_index_js_1.syncLocalIndexTool,
|
|
64
|
+
organize_js_1.organizeScanTool,
|
|
65
|
+
organize_js_1.organizeApplyTool,
|
|
66
|
+
organize_js_1.organizeUndoTool,
|
|
67
|
+
organize_js_1.organizeAddToStoryTool,
|
|
62
68
|
local_index_js_1.searchLocalIndexTool,
|
|
63
69
|
posts_js_1.listPostsTool,
|
|
64
70
|
posts_js_1.cancelPostTool,
|
|
@@ -179,6 +185,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
|
|
|
179
185
|
}
|
|
180
186
|
});
|
|
181
187
|
async function main() {
|
|
188
|
+
(0, cwd_js_1.leaveUnwritableCwd)();
|
|
182
189
|
if (await (0, cli_js_1.runCli)(process.argv.slice(2)))
|
|
183
190
|
return;
|
|
184
191
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
@@ -0,0 +1,29 @@
|
|
|
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.leaveUnwritableCwd = leaveUnwritableCwd;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
/**
|
|
11
|
+
* Started elevated / as a service / from Task Scheduler on Windows the cwd is C:\WINDOWS\system32. Remotion (and
|
|
12
|
+
* anything else that writes "./x") then dies with `EPERM: mkdir '...\system32\.remotion'`. If the cwd is not writable,
|
|
13
|
+
* move to ~/.viralspin; a writable cwd (a normal terminal) is left alone so relative CLI arguments keep working.
|
|
14
|
+
* Returns the cwd afterwards.
|
|
15
|
+
*/
|
|
16
|
+
function leaveUnwritableCwd(home = os_1.default.homedir()) {
|
|
17
|
+
try {
|
|
18
|
+
fs_1.default.accessSync(process.cwd(), fs_1.default.constants.W_OK);
|
|
19
|
+
return process.cwd();
|
|
20
|
+
}
|
|
21
|
+
catch { /* unwritable, or already deleted */ }
|
|
22
|
+
try {
|
|
23
|
+
const dir = path_1.default.join(home, ".viralspin");
|
|
24
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
25
|
+
process.chdir(dir);
|
|
26
|
+
}
|
|
27
|
+
catch { /* keep what we have */ }
|
|
28
|
+
return process.cwd();
|
|
29
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
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.findKitTool = findKitTool;
|
|
7
|
+
exports.firstVideoPts = firstVideoPts;
|
|
8
|
+
exports.fixLeadingNegativePts = fixLeadingNegativePts;
|
|
9
|
+
/**
|
|
10
|
+
* Phone clips that start BEFORE their own zero.
|
|
11
|
+
*
|
|
12
|
+
* An MP4 trimmed with an edit list keeps its pre-roll frames at negative timestamps (the first video packet at -0.88 s, say). Remotion's
|
|
13
|
+
* compositor then reports "No frame found at position 0" the moment the draw reaches that clip, and the whole local render dies at
|
|
14
|
+
* that second. ffmpeg honours the edit list, so ONE re-encode of that clip (usually a few seconds long) starts it at 0 where the
|
|
15
|
+
* creator meant it to. Only clips that need it are touched; the original file's checksum is kept in a `<name>.fixed` marker so the
|
|
16
|
+
* media cache still counts the clip as downloaded.
|
|
17
|
+
*/
|
|
18
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
19
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
20
|
+
const node_child_process_1 = require("node:child_process");
|
|
21
|
+
const VIDEO = /\.(mp4|mov|m4v)$/i;
|
|
22
|
+
/** ffmpeg / ffprobe from the installed kit's compositor package, else from PATH. */
|
|
23
|
+
function findKitTool(kitDir, name) {
|
|
24
|
+
const exe = process.platform === "win32" ? `${name}.exe` : name;
|
|
25
|
+
try {
|
|
26
|
+
const base = node_path_1.default.join(kitDir, "node_modules", "@remotion");
|
|
27
|
+
for (const d of node_fs_1.default.readdirSync(base)) {
|
|
28
|
+
if (!d.startsWith("compositor-"))
|
|
29
|
+
continue;
|
|
30
|
+
const p = node_path_1.default.join(base, d, exe);
|
|
31
|
+
if (node_fs_1.default.existsSync(p))
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { /* no kit yet: fall back to PATH */ }
|
|
36
|
+
return exe;
|
|
37
|
+
}
|
|
38
|
+
/** The first video packet's timestamp in seconds, or null when it cannot be read. */
|
|
39
|
+
function firstVideoPts(file, ffprobe) {
|
|
40
|
+
const r = (0, node_child_process_1.spawnSync)(ffprobe, ["-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time", "-read_intervals", "%+#3", "-of", "csv=p=0", file], { encoding: "utf8", timeout: 30000 });
|
|
41
|
+
if (r.status !== 0)
|
|
42
|
+
return null;
|
|
43
|
+
const vals = r.stdout.split(/\r?\n/).map((s) => Number(s.trim())).filter((n) => Number.isFinite(n));
|
|
44
|
+
return vals.length ? Math.min(...vals) : null;
|
|
45
|
+
}
|
|
46
|
+
/** Re-encode `file` in place when its first frame sits before zero. Returns true when it was rewritten. */
|
|
47
|
+
function fixLeadingNegativePts(file, kitDir, log, origSha) {
|
|
48
|
+
if (!VIDEO.test(file))
|
|
49
|
+
return false;
|
|
50
|
+
const marker = `${file}.fixed`;
|
|
51
|
+
if (node_fs_1.default.existsSync(marker))
|
|
52
|
+
return false;
|
|
53
|
+
const ffprobe = findKitTool(kitDir, "ffprobe");
|
|
54
|
+
const first = firstVideoPts(file, ffprobe);
|
|
55
|
+
if (first === null || first >= -0.001)
|
|
56
|
+
return false;
|
|
57
|
+
const tmp = `${file}.fixing.mp4`;
|
|
58
|
+
const r = (0, node_child_process_1.spawnSync)(findKitTool(kitDir, "ffmpeg"), ["-v", "error", "-y", "-i", file, "-c:v", "libx264", "-crf", "15", "-preset", "fast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", tmp], { encoding: "utf8", timeout: 10 * 60000 });
|
|
59
|
+
if (r.status !== 0 || !node_fs_1.default.existsSync(tmp)) {
|
|
60
|
+
try {
|
|
61
|
+
node_fs_1.default.rmSync(tmp, { force: true });
|
|
62
|
+
}
|
|
63
|
+
catch { /* ignore */ }
|
|
64
|
+
log(`[media] could not straighten ${node_path_1.default.basename(file)} (first frame at ${first.toFixed(2)} s); drawing it as it is`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
node_fs_1.default.renameSync(tmp, file);
|
|
68
|
+
node_fs_1.default.writeFileSync(marker, origSha ?? "fixed");
|
|
69
|
+
log(`[media] straightened ${node_path_1.default.basename(file)} (its first frame was at ${first.toFixed(2)} s)`);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
@@ -14,6 +14,7 @@ exports.planJob = planJob;
|
|
|
14
14
|
exports.downloadMedia = downloadMedia;
|
|
15
15
|
exports.uploadOutput = uploadOutput;
|
|
16
16
|
exports.renderLocally = renderLocally;
|
|
17
|
+
const media_fix_js_1 = require("./media-fix.js");
|
|
17
18
|
const fs_1 = __importDefault(require("fs"));
|
|
18
19
|
const net_1 = __importDefault(require("net"));
|
|
19
20
|
const os_1 = __importDefault(require("os"));
|
|
@@ -105,7 +106,7 @@ async function planJob(o, log) {
|
|
|
105
106
|
throw new Error("The server said the job is ready but sent no manifest.");
|
|
106
107
|
return view.manifest;
|
|
107
108
|
}
|
|
108
|
-
async function downloadMedia(media, dir, fetchImpl, log) {
|
|
109
|
+
async function downloadMedia(media, dir, fetchImpl, log, kitDir) {
|
|
109
110
|
await fs_1.default.promises.mkdir(dir, { recursive: true });
|
|
110
111
|
let downloaded = 0, cached = 0;
|
|
111
112
|
let i = 0;
|
|
@@ -115,7 +116,15 @@ async function downloadMedia(media, dir, fetchImpl, log) {
|
|
|
115
116
|
if (!m)
|
|
116
117
|
return;
|
|
117
118
|
const dest = path_1.default.join(dir, m.name);
|
|
119
|
+
// a clip straightened earlier (media-fix) no longer has the server's checksum: its marker holds the original one
|
|
120
|
+
const fixedMark = `${dest}.fixed`;
|
|
121
|
+
if (fs_1.default.existsSync(dest) && fs_1.default.existsSync(fixedMark) && (!m.sha256 || fs_1.default.readFileSync(fixedMark, "utf8").trim() === m.sha256)) {
|
|
122
|
+
cached++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
118
125
|
if (fs_1.default.existsSync(dest) && m.sha256 && (await (0, kit_js_1.sha256File)(dest)) === m.sha256) {
|
|
126
|
+
if (kitDir)
|
|
127
|
+
(0, media_fix_js_1.fixLeadingNegativePts)(dest, kitDir, log, m.sha256 ?? undefined);
|
|
119
128
|
cached++;
|
|
120
129
|
continue;
|
|
121
130
|
}
|
|
@@ -129,6 +138,8 @@ async function downloadMedia(media, dir, fetchImpl, log) {
|
|
|
129
138
|
catch {
|
|
130
139
|
await (0, kit_js_1.downloadVerified)(m.url, dest, m.sha256, fetchImpl); // one more try: a dropped connection is the usual cause
|
|
131
140
|
}
|
|
141
|
+
if (kitDir)
|
|
142
|
+
(0, media_fix_js_1.fixLeadingNegativePts)(dest, kitDir, log, m.sha256 ?? undefined);
|
|
132
143
|
downloaded++;
|
|
133
144
|
if (downloaded % 10 === 0)
|
|
134
145
|
log(`[media] ${downloaded + cached}/${media.length}`);
|
|
@@ -170,6 +181,7 @@ async function renderLocally(o) {
|
|
|
170
181
|
const secs = (a) => Math.round((Date.now() - a) / 100) / 10;
|
|
171
182
|
const kitDir = (0, kit_js_1.assertSafeKitDir)(o.kitDir ?? path_1.default.join(home, ".viralspin", "kit"), home);
|
|
172
183
|
const workRoot = path_1.default.resolve(o.workDir ?? path_1.default.join(home, ".viralspin"));
|
|
184
|
+
log(`[connector] cwd ${process.cwd()} | work ${workRoot} | kit ${kitDir}`);
|
|
173
185
|
(0, kit_js_1.checkNode)(20);
|
|
174
186
|
const envGb = Number(process.env.VIRALSPIN_MIN_FREE_GB);
|
|
175
187
|
await (0, kit_js_1.checkDisk)(workRoot, o.minFreeBytes ?? (envGb > 0 ? envGb * 2 ** 30 : undefined));
|
|
@@ -193,12 +205,22 @@ async function renderLocally(o) {
|
|
|
193
205
|
// 3 - the media, content-addressed so a second render of the same take downloads nothing
|
|
194
206
|
const td = Date.now();
|
|
195
207
|
const mediaDir = path_1.default.join(workRoot, "cache", "media");
|
|
196
|
-
const dl = await downloadMedia(job.media, mediaDir, fetchImpl, log);
|
|
208
|
+
const dl = await downloadMedia(job.media, mediaDir, fetchImpl, log, kitDir);
|
|
197
209
|
const dlSecs = secs(td);
|
|
198
210
|
log(`[media] ${job.media.length} file(s): ${dl.downloaded} downloaded, ${dl.cached} already cached`);
|
|
199
211
|
// the app shows "rendering on your computer" from here (best effort: a failed stamp never stops the draw)
|
|
200
212
|
if (!o.dryRun)
|
|
201
213
|
await o.tokens.request("POST", `${o.apiBase}/v1/reel/local-jobs/${encodeURIComponent(job.job_id)}/started`, {}).catch(() => undefined);
|
|
214
|
+
// HOW FAR THE DRAW IS, told to ViralSpin as it goes (the kit prints "progress N/M frames"): the app shows the %, the time it has run and what is left
|
|
215
|
+
let lastSent = 0;
|
|
216
|
+
const drawLog = (line) => {
|
|
217
|
+
log(line);
|
|
218
|
+
const m = /progress (\d+)\/(\d+) frames/.exec(line);
|
|
219
|
+
if (!m || o.dryRun || Date.now() - lastSent < 15000)
|
|
220
|
+
return;
|
|
221
|
+
lastSent = Date.now();
|
|
222
|
+
void o.tokens.request("POST", `${o.apiBase}/v1/reel/local-jobs/${encodeURIComponent(job.job_id)}/progress`, { frames: Number(m[1]), total: Number(m[2]) }).catch(() => undefined);
|
|
223
|
+
};
|
|
202
224
|
// 4 - the draw, by the installed kit's own run.mjs
|
|
203
225
|
const outDir = path_1.default.join(workRoot, "jobs", job.job_id);
|
|
204
226
|
await fs_1.default.promises.mkdir(outDir, { recursive: true });
|
|
@@ -215,7 +237,7 @@ async function renderLocally(o) {
|
|
|
215
237
|
try {
|
|
216
238
|
const r = await runKit({
|
|
217
239
|
kitDir, propsPath, mediaDir, audioPath: path_1.default.join(mediaDir, takeName), outPath, port, concurrency,
|
|
218
|
-
dryRun: Boolean(o.dryRun), log, signal: o.signal,
|
|
240
|
+
dryRun: Boolean(o.dryRun), log: drawLog, signal: o.signal,
|
|
219
241
|
});
|
|
220
242
|
const result = {
|
|
221
243
|
job_id: job.job_id, dry_run: Boolean(o.dryRun),
|
|
@@ -42,6 +42,7 @@ function defaultTokenFile() {
|
|
|
42
42
|
return path_1.default.join(os_1.default.homedir(), ".viralspin", "oauth.json");
|
|
43
43
|
}
|
|
44
44
|
/** The server said the refresh token is no good (expired, revoked): a new sign-in is the only way forward. */
|
|
45
|
+
const REQUEST_TIMEOUT_MS = 60000;
|
|
45
46
|
class RefreshRefused extends Error {
|
|
46
47
|
}
|
|
47
48
|
exports.RefreshRefused = RefreshRefused;
|
|
@@ -169,6 +170,9 @@ class TokenManager {
|
|
|
169
170
|
method,
|
|
170
171
|
headers: { Authorization: `Bearer ${token}`, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
171
172
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
173
|
+
// a request that never answers must not stall the watch loop for good: the connector's "I am online" check-ins ran in that loop, and one
|
|
174
|
+
// hung call (2026-10-30) left it silent, so the app read "Not connected" while the render was still running on this computer
|
|
175
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
172
176
|
});
|
|
173
177
|
let resp = await go(await this.getAccessToken());
|
|
174
178
|
if (resp.status === 401 && (this.refreshToken() || this.opts.signIn)) {
|
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.newWatchState = exports.POLL_MS = exports.PING_EVERY_MS = void 0;
|
|
3
|
+
exports.jittered = exports.newWatchState = exports.MAX_TRIES = exports.RETRY_AFTER_MS = exports.IDLE_MAX_MS = exports.POLL_MS = exports.PING_EVERY_MS = void 0;
|
|
4
|
+
exports.mayTry = mayTry;
|
|
4
5
|
exports.watchTick = watchTick;
|
|
5
6
|
exports.nextDelay = nextDelay;
|
|
6
7
|
exports.watchLoop = watchLoop;
|
|
7
8
|
const render_js_1 = require("./render.js");
|
|
8
|
-
|
|
9
|
-
exports.
|
|
9
|
+
/** How often this computer tells ViralSpin it is online. 30 s (was 15 s): each one is a database write, all day; the server allows 90 s. */
|
|
10
|
+
exports.PING_EVERY_MS = 30000;
|
|
11
|
+
exports.POLL_MS = 10000;
|
|
12
|
+
/** While nothing is waiting the connector asks less often (10 s, 15 s, then every 20 s); a job found, or an app that sends one, is noticed within one of these. */
|
|
13
|
+
exports.IDLE_MAX_MS = 20000;
|
|
10
14
|
const MAX_BACKOFF_MS = 60000;
|
|
11
|
-
|
|
15
|
+
/** A job that failed here is tried again after this long, up to MAX_TRIES in all. It used to be dropped for the life of the process: a job that
|
|
16
|
+
* was picked up while ViralSpin was still preparing it (queued behind a cloud draw for over 20 minutes, 2026-10-30) timed out once and was never
|
|
17
|
+
* drawn, though the app went on showing it as waiting for this computer. */
|
|
18
|
+
exports.RETRY_AFTER_MS = 5 * 60000;
|
|
19
|
+
exports.MAX_TRIES = 3;
|
|
20
|
+
const newWatchState = () => ({ lastPing: 0, failed: new Map(), errors: 0, pingWarned: false, idle: 0 });
|
|
12
21
|
exports.newWatchState = newWatchState;
|
|
22
|
+
/** May this job be tried now? Never failed here, or failed long enough ago and not too often. */
|
|
23
|
+
function mayTry(st, jobId, now) {
|
|
24
|
+
const f = st.failed.get(jobId);
|
|
25
|
+
return !f || (f.tries < exports.MAX_TRIES && now - f.at >= exports.RETRY_AFTER_MS);
|
|
26
|
+
}
|
|
13
27
|
async function watchTick(d, st) {
|
|
14
28
|
if (d.now() - st.lastPing >= exports.PING_EVERY_MS) {
|
|
15
29
|
st.lastPing = d.now();
|
|
@@ -34,10 +48,14 @@ async function watchTick(d, st) {
|
|
|
34
48
|
d.log(`[watch] could not ask ViralSpin for jobs (${e.message})`);
|
|
35
49
|
return { did: "error" };
|
|
36
50
|
}
|
|
37
|
-
|
|
38
|
-
// a
|
|
39
|
-
|
|
51
|
+
// ONLY A JOB THAT IS READY. One ViralSpin is still preparing is left until it is: taking it early held it in this computer's 20-minute plan wait,
|
|
52
|
+
// and a plan queued longer than that failed here and was never drawn.
|
|
53
|
+
const { job } = (0, render_js_1.pickJob)(jobs.filter((j) => j.phase === "waiting" && mayTry(st, j.job_id, d.now())));
|
|
54
|
+
if (!job) {
|
|
55
|
+
st.idle++;
|
|
40
56
|
return { did: "idle" };
|
|
57
|
+
}
|
|
58
|
+
st.idle = 0;
|
|
41
59
|
const name = job.title ? `"${job.title}"` : job.job_id.slice(0, 8);
|
|
42
60
|
d.log(`[watch] drawing ${name}${job.duration_sec ? ` (${Math.round(Number(job.duration_sec))} s)` : ""}`);
|
|
43
61
|
try {
|
|
@@ -46,8 +64,9 @@ async function watchTick(d, st) {
|
|
|
46
64
|
return { did: "rendered", jobId: job.job_id };
|
|
47
65
|
}
|
|
48
66
|
catch (e) {
|
|
49
|
-
st.failed.
|
|
50
|
-
|
|
67
|
+
const tries = (st.failed.get(job.job_id)?.tries ?? 0) + 1;
|
|
68
|
+
st.failed.set(job.job_id, { at: d.now(), tries });
|
|
69
|
+
d.log(`[watch] ${name} failed on this computer: ${e.message}${tries < exports.MAX_TRIES ? ` (trying again in ${Math.round(exports.RETRY_AFTER_MS / 60000)} min)` : " (not trying again: send it again from the app)"}`);
|
|
51
70
|
return { did: "failed", jobId: job.job_id };
|
|
52
71
|
}
|
|
53
72
|
}
|
|
@@ -57,13 +76,30 @@ function nextDelay(r, st) {
|
|
|
57
76
|
return 500;
|
|
58
77
|
if (r.did === "error")
|
|
59
78
|
return Math.min(MAX_BACKOFF_MS, exports.POLL_MS * 2 ** Math.min(st.errors, 4));
|
|
60
|
-
return exports.POLL_MS;
|
|
79
|
+
return Math.min(exports.IDLE_MAX_MS, exports.POLL_MS + Math.max(0, st.idle - 1) * 5000);
|
|
61
80
|
}
|
|
81
|
+
/** A little spread (+-15%) so connectors started together do not all ask at the same second. */
|
|
82
|
+
const jittered = (ms, rand = Math.random) => Math.round(ms * (0.85 + 0.3 * rand()));
|
|
83
|
+
exports.jittered = jittered;
|
|
62
84
|
async function watchLoop(d, o = {}) {
|
|
63
85
|
const st = (0, exports.newWatchState)();
|
|
64
86
|
d.log("[watch] ViralSpin connector is running. Send a reel to your computer from the app and it draws here. Ctrl+C to stop.");
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
// CHECK IN WHILE DRAWING TOO. The tick's ping waits for the tick, and a tick that draws takes the whole render (25 min for a 3-minute album):
|
|
88
|
+
// the server read this computer as gone after 45 s (90 s now) and the app said "Not connected" under "Drawing on your computer" (2026-10-31).
|
|
89
|
+
// This timer keeps the check-ins going whatever the loop is doing; the tick's own ping still runs when it is idle.
|
|
90
|
+
const beat = setInterval(() => {
|
|
91
|
+
if (d.now() - st.lastPing < exports.PING_EVERY_MS)
|
|
92
|
+
return;
|
|
93
|
+
st.lastPing = d.now();
|
|
94
|
+
d.ping().then(() => { st.pingWarned = false; }, () => undefined);
|
|
95
|
+
}, exports.PING_EVERY_MS);
|
|
96
|
+
try {
|
|
97
|
+
while (!o.signal?.aborted) {
|
|
98
|
+
const r = await watchTick(d, st);
|
|
99
|
+
await d.sleep((0, exports.jittered)(nextDelay(r, st)));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
clearInterval(beat);
|
|
68
104
|
}
|
|
69
105
|
}
|