@viralspin/mcp 0.5.0 → 0.5.3
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/local-render/startup.js +9 -9
- package/dist/organize-watch.js +221 -73
- package/dist/organizer/images.js +68 -0
- package/dist/tools/organize.js +21 -11
- package/package.json +51 -46
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).
|
|
@@ -27,15 +27,15 @@ function startupCommand(e) {
|
|
|
27
27
|
}
|
|
28
28
|
function plistFor(e) {
|
|
29
29
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
30
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
32
|
-
<plist version="1.0"><dict>
|
|
33
|
-
<key>Label</key><string>ai.viralspin.connector</string>
|
|
34
|
-
<key>ProgramArguments</key><array><string>${esc(e.node)}</string><string>${esc(e.script)}</string><string>watch</string></array>
|
|
35
|
-
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
|
|
36
|
-
<key>StandardOutPath</key><string>${esc(path_1.default.join(e.home, ".viralspin", "watch.log"))}</string>
|
|
37
|
-
<key>StandardErrorPath</key><string>${esc(path_1.default.join(e.home, ".viralspin", "watch.log"))}</string>
|
|
38
|
-
</dict></plist>
|
|
30
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
32
|
+
<plist version="1.0"><dict>
|
|
33
|
+
<key>Label</key><string>ai.viralspin.connector</string>
|
|
34
|
+
<key>ProgramArguments</key><array><string>${esc(e.node)}</string><string>${esc(e.script)}</string><string>watch</string></array>
|
|
35
|
+
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
|
|
36
|
+
<key>StandardOutPath</key><string>${esc(path_1.default.join(e.home, ".viralspin", "watch.log"))}</string>
|
|
37
|
+
<key>StandardErrorPath</key><string>${esc(path_1.default.join(e.home, ".viralspin", "watch.log"))}</string>
|
|
38
|
+
</dict></plist>
|
|
39
39
|
`;
|
|
40
40
|
}
|
|
41
41
|
const plistPath = (e) => path_1.default.join(e.home, "Library", "LaunchAgents", "ai.viralspin.connector.plist");
|
package/dist/organize-watch.js
CHANGED
|
@@ -37,61 +37,61 @@ const ORIGIN_OK = /^(https:\/\/([\w-]+\.)?viralspin\.ai|http:\/\/localhost:\d+|h
|
|
|
37
37
|
* folders only and a "Sort this folder" button, via IFileOpenDialog + FOS_PICKFOLDERS), forced to the front: a background process can not normally take the foreground, which left the
|
|
38
38
|
* old dialog hidden behind the browser and looking like it took forever.
|
|
39
39
|
*/
|
|
40
|
-
const WIN_PICKER = String.raw `
|
|
41
|
-
[Console]::OutputEncoding=[Text.Encoding]::UTF8
|
|
42
|
-
Add-Type -AssemblyName System.Windows.Forms
|
|
43
|
-
Add-Type -TypeDefinition @'
|
|
44
|
-
using System;
|
|
45
|
-
using System.Runtime.InteropServices;
|
|
46
|
-
[ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class VsFileOpenDialogRCW {}
|
|
47
|
-
[ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
48
|
-
interface IVsShellItem {
|
|
49
|
-
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
50
|
-
void GetParent(out IVsShellItem p);
|
|
51
|
-
void GetDisplayName(uint sigdn, [MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
52
|
-
void GetAttributes(uint m, out uint a);
|
|
53
|
-
void Compare(IVsShellItem i, uint h, out int o);
|
|
54
|
-
}
|
|
55
|
-
[ComImport, Guid("d57c7288-d4ad-4768-be02-9d969532d960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
56
|
-
interface IVsFileOpenDialog {
|
|
57
|
-
[PreserveSig] int Show(IntPtr parent);
|
|
58
|
-
void SetFileTypes(uint c, IntPtr f); void SetFileTypeIndex(uint i); void GetFileTypeIndex(out uint i);
|
|
59
|
-
void Advise(IntPtr e, out uint c); void Unadvise(uint c);
|
|
60
|
-
void SetOptions(uint fos); void GetOptions(out uint fos);
|
|
61
|
-
void SetDefaultFolder(IVsShellItem i); void SetFolder(IVsShellItem i); void GetFolder(out IVsShellItem i); void GetCurrentSelection(out IVsShellItem i);
|
|
62
|
-
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string n); void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string n);
|
|
63
|
-
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string t); void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
64
|
-
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
65
|
-
void GetResult(out IVsShellItem i);
|
|
66
|
-
void AddPlace(IVsShellItem i, int fdap); void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string e);
|
|
67
|
-
void Close(int hr); void SetClientGuid(ref Guid g); void ClearClientData(); void SetFilter(IntPtr f);
|
|
68
|
-
void GetResults(out IntPtr e); void GetSelectedItems(out IntPtr e);
|
|
69
|
-
}
|
|
70
|
-
public static class VsPick {
|
|
71
|
-
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
72
|
-
[DllImport("user32.dll")] public static extern void keybd_event(byte k, byte s, int f, int e);
|
|
73
|
-
public static string Pick(IntPtr owner, string title) {
|
|
74
|
-
IVsFileOpenDialog d = (IVsFileOpenDialog)new VsFileOpenDialogRCW();
|
|
75
|
-
uint o; d.GetOptions(out o);
|
|
76
|
-
d.SetOptions(o | 0x20 | 0x40);
|
|
77
|
-
d.SetTitle(title); d.SetOkButtonLabel("Sort this folder");
|
|
78
|
-
if (d.Show(owner) != 0) return "";
|
|
79
|
-
IVsShellItem r; d.GetResult(out r);
|
|
80
|
-
string p; r.GetDisplayName(0x80058000, out p);
|
|
81
|
-
return p;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
'@
|
|
85
|
-
[Console]::Out.WriteLine("ready"); [Console]::Out.Flush()
|
|
86
|
-
while ($true) {
|
|
87
|
-
$line = [Console]::In.ReadLine(); if ($line -eq $null) { break }
|
|
88
|
-
$o = New-Object System.Windows.Forms.Form
|
|
89
|
-
$o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.StartPosition = 'Manual'
|
|
90
|
-
$o.Location = New-Object System.Drawing.Point(-3000, -3000); $o.Size = New-Object System.Drawing.Size(1, 1)
|
|
91
|
-
$o.Show(); [VsPick]::keybd_event(0x12, 0, 0, 0); [VsPick]::keybd_event(0x12, 0, 2, 0); [void][VsPick]::SetForegroundWindow($o.Handle); $o.Activate()
|
|
92
|
-
$r = ''; try { $r = [VsPick]::Pick($o.Handle, 'Choose a folder of pictures and clips to sort') } catch { $r = '' }
|
|
93
|
-
$o.Close(); $o.Dispose(); [Console]::Out.WriteLine('PICKED:' + $r); [Console]::Out.Flush()
|
|
94
|
-
}
|
|
40
|
+
const WIN_PICKER = String.raw `
|
|
41
|
+
[Console]::OutputEncoding=[Text.Encoding]::UTF8
|
|
42
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
43
|
+
Add-Type -TypeDefinition @'
|
|
44
|
+
using System;
|
|
45
|
+
using System.Runtime.InteropServices;
|
|
46
|
+
[ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class VsFileOpenDialogRCW {}
|
|
47
|
+
[ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
48
|
+
interface IVsShellItem {
|
|
49
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
50
|
+
void GetParent(out IVsShellItem p);
|
|
51
|
+
void GetDisplayName(uint sigdn, [MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
52
|
+
void GetAttributes(uint m, out uint a);
|
|
53
|
+
void Compare(IVsShellItem i, uint h, out int o);
|
|
54
|
+
}
|
|
55
|
+
[ComImport, Guid("d57c7288-d4ad-4768-be02-9d969532d960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
56
|
+
interface IVsFileOpenDialog {
|
|
57
|
+
[PreserveSig] int Show(IntPtr parent);
|
|
58
|
+
void SetFileTypes(uint c, IntPtr f); void SetFileTypeIndex(uint i); void GetFileTypeIndex(out uint i);
|
|
59
|
+
void Advise(IntPtr e, out uint c); void Unadvise(uint c);
|
|
60
|
+
void SetOptions(uint fos); void GetOptions(out uint fos);
|
|
61
|
+
void SetDefaultFolder(IVsShellItem i); void SetFolder(IVsShellItem i); void GetFolder(out IVsShellItem i); void GetCurrentSelection(out IVsShellItem i);
|
|
62
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string n); void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string n);
|
|
63
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string t); void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
64
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
65
|
+
void GetResult(out IVsShellItem i);
|
|
66
|
+
void AddPlace(IVsShellItem i, int fdap); void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string e);
|
|
67
|
+
void Close(int hr); void SetClientGuid(ref Guid g); void ClearClientData(); void SetFilter(IntPtr f);
|
|
68
|
+
void GetResults(out IntPtr e); void GetSelectedItems(out IntPtr e);
|
|
69
|
+
}
|
|
70
|
+
public static class VsPick {
|
|
71
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
72
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte k, byte s, int f, int e);
|
|
73
|
+
public static string Pick(IntPtr owner, string title) {
|
|
74
|
+
IVsFileOpenDialog d = (IVsFileOpenDialog)new VsFileOpenDialogRCW();
|
|
75
|
+
uint o; d.GetOptions(out o);
|
|
76
|
+
d.SetOptions(o | 0x20 | 0x40);
|
|
77
|
+
d.SetTitle(title); d.SetOkButtonLabel("Sort this folder");
|
|
78
|
+
if (d.Show(owner) != 0) return "";
|
|
79
|
+
IVsShellItem r; d.GetResult(out r);
|
|
80
|
+
string p; r.GetDisplayName(0x80058000, out p);
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
'@
|
|
85
|
+
[Console]::Out.WriteLine("ready"); [Console]::Out.Flush()
|
|
86
|
+
while ($true) {
|
|
87
|
+
$line = [Console]::In.ReadLine(); if ($line -eq $null) { break }
|
|
88
|
+
$o = New-Object System.Windows.Forms.Form
|
|
89
|
+
$o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.StartPosition = 'Manual'
|
|
90
|
+
$o.Location = New-Object System.Drawing.Point(-3000, -3000); $o.Size = New-Object System.Drawing.Size(1, 1)
|
|
91
|
+
$o.Show(); [VsPick]::keybd_event(0x12, 0, 0, 0); [VsPick]::keybd_event(0x12, 0, 2, 0); [void][VsPick]::SetForegroundWindow($o.Handle); $o.Activate()
|
|
92
|
+
$r = ''; try { $r = [VsPick]::Pick($o.Handle, 'Choose a folder of pictures and clips to sort') } catch { $r = '' }
|
|
93
|
+
$o.Close(); $o.Dispose(); [Console]::Out.WriteLine('PICKED:' + $r); [Console]::Out.Flush()
|
|
94
|
+
}
|
|
95
95
|
`;
|
|
96
96
|
let winPicker = null;
|
|
97
97
|
function winPickerProc() {
|
|
@@ -187,10 +187,12 @@ function clipFile(planId, i) {
|
|
|
187
187
|
function startThumbServer(log) {
|
|
188
188
|
const server = http_1.default.createServer((req, res) => {
|
|
189
189
|
const origin = String(req.headers.origin || "");
|
|
190
|
+
// Vary on EVERY answer, granted or not: a plain <img> (no Origin) cached without it was handed by the browser to the 3D space's CORS
|
|
191
|
+
// request for the same preview, which then failed, and every card in it stayed a colour block
|
|
192
|
+
res.setHeader("Vary", "Origin");
|
|
190
193
|
if (ORIGIN_OK.test(origin)) {
|
|
191
194
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
192
195
|
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
193
|
-
res.setHeader("Vary", "Origin");
|
|
194
196
|
}
|
|
195
197
|
if (req.method === "OPTIONS") {
|
|
196
198
|
res.statusCode = 204;
|
|
@@ -236,6 +238,78 @@ function startThumbServer(log) {
|
|
|
236
238
|
return res.end(JSON.stringify({ ok: true, items: 1 }));
|
|
237
239
|
}
|
|
238
240
|
// a sorted folder's pictures, for browsing and using them on this computer without uploading anything: one row per picture with a preview
|
|
241
|
+
// open the sorted folder (/reveal/<plan>) or one file of it, selected (/reveal/<plan>/<i>), in Explorer or Finder. Sorting moves nothing,
|
|
242
|
+
// so a sort's folders are not on disk: this shows each file where it really is. Only paths in the plan, and only for a ViralSpin page (POST
|
|
243
|
+
// with an allowed Origin), so no other site can open windows on this computer
|
|
244
|
+
const rv = /^\/reveal\/([\w-]{4,40})(?:\/(\d{1,6}))?$/.exec(route);
|
|
245
|
+
if (rv) {
|
|
246
|
+
if (req.method !== "POST" || !ORIGIN_OK.test(origin)) {
|
|
247
|
+
res.statusCode = 403;
|
|
248
|
+
return res.end();
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const plan = readPlan(rv[1]);
|
|
252
|
+
const it = rv[2] !== undefined ? plan.items?.[Number(rv[2])] : null;
|
|
253
|
+
const file = it ? path_1.default.join(plan.root, it.rel) : null;
|
|
254
|
+
const sel = file && fs_1.default.existsSync(file) ? file : null, dir = sel ? null : file && fs_1.default.existsSync(path_1.default.dirname(file)) ? path_1.default.dirname(file) : plan.root;
|
|
255
|
+
if (!sel && !fs_1.default.existsSync(dir)) {
|
|
256
|
+
res.statusCode = 404;
|
|
257
|
+
return res.end();
|
|
258
|
+
}
|
|
259
|
+
// explorer wants /select,"path" exactly: Node's own quoting of the whole argument breaks it on a path with spaces
|
|
260
|
+
const [cmd, args] = process.platform === "win32" ? ["explorer.exe", sel ? [`/select,"${sel}"`] : [`"${dir}"`]]
|
|
261
|
+
: process.platform === "darwin" ? ["open", sel ? ["-R", sel] : [dir]] : ["xdg-open", [sel ? path_1.default.dirname(sel) : dir]];
|
|
262
|
+
(0, child_process_1.spawn)(cmd, args, { detached: true, stdio: "ignore", windowsVerbatimArguments: process.platform === "win32" }).on("error", () => undefined).unref();
|
|
263
|
+
res.setHeader("Content-Type", "application/json");
|
|
264
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
res.statusCode = 404;
|
|
268
|
+
return res.end();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// a file served with Range, so a <video> starts at once and seeks without reading the whole file
|
|
272
|
+
const ranged = (file) => {
|
|
273
|
+
const size = fs_1.default.statSync(file).size;
|
|
274
|
+
const m = /bytes=(\d*)-(\d*)/.exec(String(req.headers.range || ""));
|
|
275
|
+
res.setHeader("Content-Type", "video/mp4");
|
|
276
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
277
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
278
|
+
if (m && size > 0) {
|
|
279
|
+
const start = m[1] ? Math.min(Number(m[1]), size - 1) : Math.max(0, size - Number(m[2] || 0)), end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
|
|
280
|
+
res.statusCode = 206;
|
|
281
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
|
|
282
|
+
res.setHeader("Content-Length", String(end - start + 1));
|
|
283
|
+
fs_1.default.createReadStream(file, { start, end }).pipe(res);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
res.setHeader("Content-Length", String(size));
|
|
287
|
+
fs_1.default.createReadStream(file).pipe(res);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
// a video (or a Live Photo's clip) as it is on disk, for playing it in full at once: nothing converted. A browser that can't decode it (HEVC
|
|
291
|
+
// without hardware support) says so, and the page falls back to /clip
|
|
292
|
+
const vm = /^\/video\/([\w-]{4,40})\/(\d{1,6})$/.exec(route);
|
|
293
|
+
if (vm) {
|
|
294
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
295
|
+
res.statusCode = 403;
|
|
296
|
+
return res.end();
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const plan = readPlan(vm[1]);
|
|
300
|
+
const it = plan.items?.[Number(vm[2])];
|
|
301
|
+
const src = it?.live ? path_1.default.join(plan.root, it.live) : it?.kind === "video" ? path_1.default.join(plan.root, it.rel) : null;
|
|
302
|
+
if (!src || !fs_1.default.existsSync(src)) {
|
|
303
|
+
res.statusCode = 404;
|
|
304
|
+
return res.end();
|
|
305
|
+
}
|
|
306
|
+
return ranged(src);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
res.statusCode = 404;
|
|
310
|
+
return res.end();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
239
313
|
// a Live Photo's clip, or the first seconds of a video, converted once to H.264 (iPhone clips are HEVC, which most browsers on Windows
|
|
240
314
|
// can't play) and kept next to the previews; served with Range so the reel picker can show and seek it
|
|
241
315
|
const cm = /^\/clip\/([\w-]{4,40})\/(\d{1,6})\.mp4$/.exec(route);
|
|
@@ -249,22 +323,7 @@ function startThumbServer(log) {
|
|
|
249
323
|
res.statusCode = 404;
|
|
250
324
|
return res.end();
|
|
251
325
|
}
|
|
252
|
-
|
|
253
|
-
const m = /bytes=(\d*)-(\d*)/.exec(String(req.headers.range || ""));
|
|
254
|
-
res.setHeader("Content-Type", "video/mp4");
|
|
255
|
-
res.setHeader("Accept-Ranges", "bytes");
|
|
256
|
-
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
257
|
-
if (m) {
|
|
258
|
-
const start = m[1] ? Number(m[1]) : 0, end = m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
|
|
259
|
-
res.statusCode = 206;
|
|
260
|
-
res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
|
|
261
|
-
res.setHeader("Content-Length", String(end - start + 1));
|
|
262
|
-
fs_1.default.createReadStream(file, { start, end }).pipe(res);
|
|
263
|
-
}
|
|
264
|
-
else {
|
|
265
|
-
res.setHeader("Content-Length", String(size));
|
|
266
|
-
fs_1.default.createReadStream(file).pipe(res);
|
|
267
|
-
}
|
|
326
|
+
ranged(file);
|
|
268
327
|
}).catch(() => { res.statusCode = 500; res.end(); });
|
|
269
328
|
return;
|
|
270
329
|
}
|
|
@@ -295,7 +354,7 @@ function startThumbServer(log) {
|
|
|
295
354
|
where.set(i, b);
|
|
296
355
|
const rows = [...where.entries()].filter(([i]) => plan.items[i]?.thumb).map(([i, b]) => {
|
|
297
356
|
const it = plan.items[i];
|
|
298
|
-
return { i, bucket: b.name, media: b.media ?? null, key: (0, organize_js_1.bucketKey)(b), tags: it.tags ?? [], month: it.month ?? null, live: !!it.live, kind: it.kind, file: path_1.default.basename(it.rel), clip: !!it.live || it.kind === "video" };
|
|
357
|
+
return { i, bucket: b.name, media: b.media ?? null, key: (0, organize_js_1.bucketKey)(b), tags: it.tags ?? [], month: it.month ?? null, live: !!it.live, kind: it.kind, file: path_1.default.basename(it.rel), dir: (path_1.default.dirname(it.rel) === "." ? "" : path_1.default.dirname(it.rel)).split(path_1.default.sep).join("/"), clip: !!it.live || it.kind === "video" };
|
|
299
358
|
});
|
|
300
359
|
res.setHeader("Content-Type", "application/json");
|
|
301
360
|
return res.end(JSON.stringify({ plan: plan.id, root: path_1.default.basename(plan.root), items: rows }));
|
|
@@ -313,6 +372,95 @@ function startThumbServer(log) {
|
|
|
313
372
|
void pickFolder().then((p) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ path: p })); });
|
|
314
373
|
return;
|
|
315
374
|
}
|
|
375
|
+
// the picture itself at screen size (up to PREVIEW_W px wide), made once from the original and kept next to the previews; for a clip, a frame
|
|
376
|
+
// about a second in (what the viewer shows before it plays). 404 for a file that is gone, and the page falls back to the small preview
|
|
377
|
+
const pv = /^\/photo\/([\w-]{4,40})\/(\d{1,6})\.jpg$/.exec(route);
|
|
378
|
+
if (pv) {
|
|
379
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
380
|
+
res.statusCode = 403;
|
|
381
|
+
return res.end();
|
|
382
|
+
}
|
|
383
|
+
void (async () => {
|
|
384
|
+
try {
|
|
385
|
+
const plan = readPlan(pv[1]);
|
|
386
|
+
const it = plan.items?.[Number(pv[2])];
|
|
387
|
+
const src = it && (it.kind === "image" || it.kind === "video") ? path_1.default.join(plan.root, it.rel) : null;
|
|
388
|
+
if (!src || !fs_1.default.existsSync(src)) {
|
|
389
|
+
res.statusCode = 404;
|
|
390
|
+
return res.end();
|
|
391
|
+
}
|
|
392
|
+
const dir = path_1.default.join(organize_js_1.STATE_DIR, `${pv[1]}-photos2400`), out = path_1.default.join(dir, `${pv[2]}.jpg`); // a new folder: the old one holds 1600 px copies
|
|
393
|
+
if (!(fs_1.default.existsSync(out) && fs_1.default.statSync(out).size > 0)) {
|
|
394
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
395
|
+
if (!(await (0, images_js_1.makePreview)(src, out, it.kind))) {
|
|
396
|
+
res.statusCode = 404;
|
|
397
|
+
return res.end();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
res.setHeader("Content-Type", "image/jpeg");
|
|
401
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
402
|
+
fs_1.default.createReadStream(out).pipe(res);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
res.statusCode = 404;
|
|
406
|
+
res.end();
|
|
407
|
+
}
|
|
408
|
+
})();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// one file's details for the viewer: name, folder, size, dates, pixel size, a clip's length (GET /meta/<plan>/<i>)
|
|
412
|
+
const mm = /^\/meta\/([\w-]{4,40})\/(\d{1,6})$/.exec(route);
|
|
413
|
+
if (mm) {
|
|
414
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
415
|
+
res.statusCode = 403;
|
|
416
|
+
return res.end();
|
|
417
|
+
}
|
|
418
|
+
void (async () => {
|
|
419
|
+
try {
|
|
420
|
+
const plan = readPlan(mm[1]);
|
|
421
|
+
const it = plan.items?.[Number(mm[2])];
|
|
422
|
+
const src = it ? path_1.default.join(plan.root, it.rel) : null;
|
|
423
|
+
if (!it || !src || !fs_1.default.existsSync(src)) {
|
|
424
|
+
res.statusCode = 404;
|
|
425
|
+
return res.end();
|
|
426
|
+
}
|
|
427
|
+
const st = fs_1.default.statSync(src), px = await (0, images_js_1.fileMeta)(src, it.kind);
|
|
428
|
+
res.setHeader("Content-Type", "application/json");
|
|
429
|
+
res.end(JSON.stringify({ file: path_1.default.basename(it.rel), dir: path_1.default.dirname(it.rel) === "." ? "" : path_1.default.dirname(it.rel).split(path_1.default.sep).join("/"), root: plan.root, kind: it.kind, live: !!it.live, size: st.size, modified: st.mtime.toISOString(), month: it.month ?? null, tags: it.tags ?? [], ...px }));
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
res.statusCode = 404;
|
|
433
|
+
res.end();
|
|
434
|
+
}
|
|
435
|
+
})();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// the original file itself, for zooming in to its real pixels: only formats every browser can show (a HEIC or RAW gets 404 and the viewer
|
|
439
|
+
// stays on /photo); served from this computer to this computer, nothing is uploaded
|
|
440
|
+
const om = /^\/original\/([\w-]{4,40})\/(\d{1,6})$/.exec(route);
|
|
441
|
+
if (om) {
|
|
442
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
443
|
+
res.statusCode = 403;
|
|
444
|
+
return res.end();
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const plan = readPlan(om[1]);
|
|
448
|
+
const it = plan.items?.[Number(om[2])];
|
|
449
|
+
const src = it && it.kind === "image" ? path_1.default.join(plan.root, it.rel) : null;
|
|
450
|
+
const type = src ? { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif" }[path_1.default.extname(src).toLowerCase()] : undefined;
|
|
451
|
+
if (!src || !type || !fs_1.default.existsSync(src)) {
|
|
452
|
+
res.statusCode = 404;
|
|
453
|
+
return res.end();
|
|
454
|
+
}
|
|
455
|
+
res.setHeader("Content-Type", type);
|
|
456
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
457
|
+
return void fs_1.default.createReadStream(src).pipe(res);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
res.statusCode = 404;
|
|
461
|
+
return res.end();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
316
464
|
const m = /^\/thumb\/([\w-]{4,40})\/(\d{1,6})\.jpg$/.exec((req.url || "").split("?")[0]);
|
|
317
465
|
if (!m) {
|
|
318
466
|
res.statusCode = 404;
|
package/dist/organizer/images.js
CHANGED
|
@@ -36,12 +36,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.PREVIEW_W = void 0;
|
|
39
40
|
exports.ffmpegPath = ffmpegPath;
|
|
40
41
|
exports.hasFfmpeg = hasFfmpeg;
|
|
41
42
|
exports.canReadPictures = canReadPictures;
|
|
42
43
|
exports.dhash = dhash;
|
|
43
44
|
exports.makeThumb = makeThumb;
|
|
45
|
+
exports.makePreview = makePreview;
|
|
44
46
|
exports.rgb224 = rgb224;
|
|
47
|
+
exports.fileMeta = fileMeta;
|
|
45
48
|
exports.takenMonth = takenMonth;
|
|
46
49
|
/**
|
|
47
50
|
* Pixels for the organizer: a 64-bit picture hash and a small thumbnail. `sharp` (libvips, prebuilt, shrink-on-load, far faster than a process per picture)
|
|
@@ -130,6 +133,29 @@ async function makeThumb(file, kind, dst) {
|
|
|
130
133
|
ff.on("close", (c) => res(c === 0 && fs_1.default.existsSync(dst)));
|
|
131
134
|
});
|
|
132
135
|
}
|
|
136
|
+
/** A large (up to PREVIEW_W px wide) jpeg of a picture, for full-screen viewing and zooming in: the 480 px thumbnail is soft on a big screen. False when it could not be made. */
|
|
137
|
+
exports.PREVIEW_W = 2400;
|
|
138
|
+
async function makePreview(file, dst, kind = "image") {
|
|
139
|
+
// a clip's still: a frame about a second in (the first is often black or mid-fade); a clip shorter than that falls back to its first frame
|
|
140
|
+
if (kind === "video")
|
|
141
|
+
return (await ffFrame(file, dst, ["-ss", "1"])) || ffFrame(file, dst, []);
|
|
142
|
+
const s = await sharp();
|
|
143
|
+
if (s) {
|
|
144
|
+
try {
|
|
145
|
+
await s(file, { failOn: "none" }).rotate().resize({ width: exports.PREVIEW_W, withoutEnlargement: true }).jpeg({ quality: 86 }).toFile(dst);
|
|
146
|
+
return fs_1.default.existsSync(dst);
|
|
147
|
+
}
|
|
148
|
+
catch { /* fall through */ }
|
|
149
|
+
}
|
|
150
|
+
return ffFrame(file, dst, []);
|
|
151
|
+
}
|
|
152
|
+
function ffFrame(file, dst, seek) {
|
|
153
|
+
return new Promise((res) => {
|
|
154
|
+
const ff = (0, child_process_1.spawn)(ffmpegPath(), ["-v", "error", "-y", ...seek, "-i", file, "-vframes", "1", "-vf", `scale='min(${exports.PREVIEW_W},iw)':-2`, "-q:v", "2", dst]);
|
|
155
|
+
ff.on("error", () => res(false));
|
|
156
|
+
ff.on("close", (c) => res(c === 0 && fs_1.default.existsSync(dst) && fs_1.default.statSync(dst).size > 0));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
133
159
|
/** A 224x224 RGB picture (cover-cropped) for the topic model: sharp for a picture, a frame at about 1 s for a clip. Null when it can not be read. */
|
|
134
160
|
async function rgb224(file, kind) {
|
|
135
161
|
const s = kind === "image" ? await sharp() : null;
|
|
@@ -147,6 +173,48 @@ async function rgb224(file, kind) {
|
|
|
147
173
|
ff.on("close", () => { const b = Buffer.concat(bufs); res(b.length >= 224 * 224 * 3 ? b.subarray(0, 224 * 224 * 3) : null); });
|
|
148
174
|
});
|
|
149
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* What the viewer shows about one file: its pixel size (as displayed, so a rotated phone photo reads portrait), when it was taken (EXIF
|
|
178
|
+
* DateTimeOriginal, to the second) and, for a clip, how long it runs. Every field is best effort: sharp for pictures, ffmpeg's banner for clips
|
|
179
|
+
* and for anything sharp can't open (HEIC without libheif); null when neither knows.
|
|
180
|
+
*/
|
|
181
|
+
async function fileMeta(file, kind) {
|
|
182
|
+
const out = { width: null, height: null, taken: null, duration: null };
|
|
183
|
+
const s = kind === "image" ? await sharp() : null;
|
|
184
|
+
if (s) {
|
|
185
|
+
try {
|
|
186
|
+
const m = await s(file, { failOn: "none" }).metadata();
|
|
187
|
+
const turned = (m.orientation ?? 1) >= 5;
|
|
188
|
+
out.width = (turned ? m.height : m.width) ?? null;
|
|
189
|
+
out.height = (turned ? m.width : m.height) ?? null;
|
|
190
|
+
const d = m.exif ? /(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(m.exif.toString("latin1")) : null;
|
|
191
|
+
if (d && +d[1] > 1990)
|
|
192
|
+
out.taken = `${d[1]}-${d[2]}-${d[3]}T${d[4]}:${d[5]}:${d[6]}`;
|
|
193
|
+
}
|
|
194
|
+
catch { /* ffmpeg below */ }
|
|
195
|
+
}
|
|
196
|
+
if (out.width && (kind === "image"))
|
|
197
|
+
return out;
|
|
198
|
+
await new Promise((res) => {
|
|
199
|
+
(0, child_process_1.execFile)(ffmpegPath(), ["-hide_banner", "-i", file], { timeout: 15000 }, (_e, _o, err) => {
|
|
200
|
+
const t = String(err || "");
|
|
201
|
+
const v = /Stream #[^\n]*Video:[^\n]*?(\d{2,5})x(\d{2,5})/.exec(t), rot = /rotate\s*:\s*-?(90|270)|rotation of -?(90|270)/.exec(t);
|
|
202
|
+
if (v && !out.width) {
|
|
203
|
+
const w = +v[1], h = +v[2];
|
|
204
|
+
out.width = rot ? h : w;
|
|
205
|
+
out.height = rot ? w : h;
|
|
206
|
+
}
|
|
207
|
+
const du = /Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/.exec(t);
|
|
208
|
+
if (du)
|
|
209
|
+
out.duration = +du[1] * 3600 + +du[2] * 60 + +du[3];
|
|
210
|
+
const c = /creation_time\s*:\s*(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/.exec(t);
|
|
211
|
+
if (c && !out.taken)
|
|
212
|
+
out.taken = `${c[1]}T${c[2]}`;
|
|
213
|
+
res();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
150
218
|
/** "YYYY-MM" a picture was taken, from its EXIF DateTimeOriginal (sharp reads only the header), or null. */
|
|
151
219
|
async function takenMonth(file) {
|
|
152
220
|
const s = await sharp();
|
package/dist/tools/organize.js
CHANGED
|
@@ -127,6 +127,7 @@ exports.organizeAddToStoryTool = {
|
|
|
127
127
|
return addPlanToStory(file, { post: client_js_1.post });
|
|
128
128
|
},
|
|
129
129
|
};
|
|
130
|
+
const UPLOADS_AT_ONCE = 16;
|
|
130
131
|
/** Upload a plan's tags + 480px thumbnails to My Story (the opt-in). Used by the tool and by the connector when the web app asks. */
|
|
131
132
|
/** `only`: bucket keys ("image:Family", "video:Outfits") when the creator picked some folders; all of them when absent. */
|
|
132
133
|
const bucketKey = (b) => `${b.media ?? "any"}:${b.name}`;
|
|
@@ -154,17 +155,26 @@ async function addPlanToStory(file, io, progress, only) {
|
|
|
154
155
|
});
|
|
155
156
|
added += r.added;
|
|
156
157
|
const byRel = new Map(chunk.map((c) => [c.item.rel, c.item]));
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
158
|
+
// UPLOADS_AT_ONCE thumbnails in flight: one at a time left the line idle (12,700 pictures were "about 38 minutes")
|
|
159
|
+
let next = 0;
|
|
160
|
+
const worker = async () => {
|
|
161
|
+
while (next < r.uploads.length) {
|
|
162
|
+
const u = r.uploads[next++];
|
|
163
|
+
const t = byRel.get(u.rel)?.thumb;
|
|
164
|
+
if (!t)
|
|
165
|
+
continue;
|
|
166
|
+
try {
|
|
167
|
+
const form = new FormData();
|
|
168
|
+
form.append("cacheControl", "31536000");
|
|
169
|
+
form.append("", new Blob([await fs_1.default.promises.readFile(t)], { type: "image/jpeg" }), "t.jpg");
|
|
170
|
+
const resp = await fetch(u.upload_url, { method: "PUT", body: form, headers: { "x-upsert": "true" } });
|
|
171
|
+
if (resp.ok)
|
|
172
|
+
thumbs++;
|
|
173
|
+
}
|
|
174
|
+
catch { /* one thumbnail missing never stops the rest */ }
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
await Promise.all(Array.from({ length: Math.min(UPLOADS_AT_ONCE, r.uploads.length) }, worker));
|
|
168
178
|
progress?.(Math.min(k + 200, totalN), totalN);
|
|
169
179
|
}
|
|
170
180
|
return `Added ${added} file(s) from "${label}" to My Story in ${plan.buckets.filter((b) => b.kind !== "duplicates").length} bucket(s) (${thumbs} thumbnail(s) uploaded; originals stay on this computer). They show in My Story ▸ Organized and feed carousels and 7s reels. organize_add_to_story {plan:"${plan.id}", remove:true} takes them back out.`;
|
package/package.json
CHANGED
|
@@ -1,46 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@viralspin/mcp",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"bin": {
|
|
7
|
-
"viralspin-mcp": "dist/index.js"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist",
|
|
11
|
-
"README.md",
|
|
12
|
-
"LICENSE"
|
|
13
|
-
],
|
|
14
|
-
"scripts": {
|
|
15
|
-
"build": "tsc",
|
|
16
|
-
"dev": "ts-node src/index.ts",
|
|
17
|
-
"start": "node dist/index.js",
|
|
18
|
-
"prepublishOnly": "npm run build",
|
|
19
|
-
"test": "tsc && node --test test/"
|
|
20
|
-
},
|
|
21
|
-
"keywords": [
|
|
22
|
-
"mcp",
|
|
23
|
-
"viralspin",
|
|
24
|
-
"tiktok",
|
|
25
|
-
"instagram",
|
|
26
|
-
"youtube",
|
|
27
|
-
"social-media"
|
|
28
|
-
],
|
|
29
|
-
"license": "MIT",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"@
|
|
37
|
-
"
|
|
38
|
-
},
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@viralspin/mcp",
|
|
3
|
+
"version": "0.5.3",
|
|
4
|
+
"description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"viralspin-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "ts-node src/index.ts",
|
|
17
|
+
"start": "node dist/index.js",
|
|
18
|
+
"prepublishOnly": "npm run build",
|
|
19
|
+
"test": "tsc && node --test test/"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"viralspin",
|
|
24
|
+
"tiktok",
|
|
25
|
+
"instagram",
|
|
26
|
+
"youtube",
|
|
27
|
+
"social-media"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/AI-Product-Allen-Yu/viralspin_new.git",
|
|
33
|
+
"directory": "mcp"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
37
|
+
"form-data": "^4.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/form-data": "^2.5.0",
|
|
41
|
+
"@types/node": "^20.0.0",
|
|
42
|
+
"typescript": "^5.0.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"optionalDependencies": {
|
|
48
|
+
"@huggingface/transformers": "^4.3.2",
|
|
49
|
+
"sharp": "^0.35.5"
|
|
50
|
+
}
|
|
51
|
+
}
|