@viralspin/mcp 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/startup.js +9 -9
- package/dist/local-render/tokens.js +4 -0
- package/dist/local-render/watch.js +49 -13
- package/dist/organize-watch.js +551 -0
- package/dist/organizer/edit.js +148 -0
- package/dist/organizer/images.js +163 -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 +46 -42
|
@@ -0,0 +1,171 @@
|
|
|
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.bucketKey = exports.organizeAddToStoryTool = exports.organizeUndoTool = exports.organizeApplyTool = exports.organizeScanTool = exports.planFile = exports.STATE_DIR = void 0;
|
|
7
|
+
exports.addPlanToStory = addPlanToStory;
|
|
8
|
+
/**
|
|
9
|
+
* organize_scan / organize_apply / organize_undo — sort a big folder of pictures + clips into buckets.
|
|
10
|
+
*
|
|
11
|
+
* SCAN is read-only and local: it walks the tree, finds exact and near-identical copies, optionally tags each picture with the local
|
|
12
|
+
* vision model, and PROPOSES buckets + moves. Nothing is moved and nothing is uploaded. APPLY is a separate step: without confirm it
|
|
13
|
+
* only previews; with confirm it renames files into <folder>/_Organized/<bucket>/ (never overwrites, never deletes; duplicates are
|
|
14
|
+
* moved to Duplicates, not removed) and writes an undo manifest. UNDO puts everything back.
|
|
15
|
+
*/
|
|
16
|
+
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
const os_1 = __importDefault(require("os"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const client_js_1 = require("../client.js");
|
|
20
|
+
const plan_js_1 = require("../organizer/plan.js");
|
|
21
|
+
const scan_js_1 = require("../organizer/scan.js");
|
|
22
|
+
const images_js_1 = require("../organizer/images.js");
|
|
23
|
+
const STATE = path_1.default.join(os_1.default.homedir(), ".viralspin", "organize");
|
|
24
|
+
exports.STATE_DIR = STATE;
|
|
25
|
+
const planFile = (id) => path_1.default.join(STATE, `${id.replace(/[^\w-]/g, "")}.json`);
|
|
26
|
+
exports.planFile = planFile;
|
|
27
|
+
exports.organizeScanTool = {
|
|
28
|
+
name: "organize_scan",
|
|
29
|
+
description: "Scan a LOCAL folder (recursive) of pictures and clips and PROPOSE how to organize it into buckets — read-only, nothing is moved, nothing is uploaded. " +
|
|
30
|
+
"Finds exact + near-identical duplicates, then groups by `by`: folder (default), date (month), topic (needs vision = local vision tags, free on the GPU in local mode), or type. " +
|
|
31
|
+
"Returns a plan id + the buckets. Show the user the buckets, then call organize_apply (preview first, then confirm) if they want the files moved. " +
|
|
32
|
+
"Use preview_folder first for a quick count.",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
folder: { type: "string", description: "Local folder to scan, e.g. E:\\Money and Design\\062521\\Money and Design" },
|
|
37
|
+
by: { type: "string", enum: ["folder", "date", "topic", "type"], description: "How to group. Default folder. topic implies vision." },
|
|
38
|
+
vision: { type: "boolean", description: "Tag each picture with the local vision model (slow on thousands). Implied by by:'topic'." },
|
|
39
|
+
labels: { type: "array", items: { type: "string" }, description: "Optional labels of the creator's own (e.g. [\"tennis\", \"kids at the beach\"]): each becomes its own bucket when pictures match. Built-in labels are added as the folder needs them." },
|
|
40
|
+
thumbs: { type: "boolean", description: "Also write 480px thumbnails (for the web animation / opt-in My Story upload). Local only." },
|
|
41
|
+
},
|
|
42
|
+
required: ["folder"],
|
|
43
|
+
},
|
|
44
|
+
async execute(args) {
|
|
45
|
+
if (!(await (0, images_js_1.canReadPictures)()))
|
|
46
|
+
return "organize_scan needs to read pictures: install ffmpeg, or reinstall the ViralSpin connector (it brings its own).";
|
|
47
|
+
const by = args.by || "folder";
|
|
48
|
+
fs_1.default.mkdirSync(STATE, { recursive: true });
|
|
49
|
+
const tmpId = Date.now().toString(36);
|
|
50
|
+
let plan;
|
|
51
|
+
try {
|
|
52
|
+
plan = await (0, scan_js_1.organizeScan)(args.folder, { by, labels: args.labels ?? [], vision: !!args.vision || by === "topic", thumbs: args.thumbs ? path_1.default.join(STATE, `${tmpId}-thumbs`) : "" });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return `Couldn't scan ${args.folder}: ${e instanceof Error ? e.message : String(e)}`;
|
|
56
|
+
}
|
|
57
|
+
fs_1.default.writeFileSync((0, exports.planFile)(plan.id), JSON.stringify(plan));
|
|
58
|
+
const t = plan.totals;
|
|
59
|
+
const lines = plan.buckets.slice(0, 25).map((b) => `- ${b.name} (${b.kind}): ${b.items.length}${b.items[0] !== undefined ? `, e.g. ${plan.items[b.items[0]].rel}` : ""}`);
|
|
60
|
+
return [
|
|
61
|
+
`Scanned ${plan.root} — read-only, nothing moved or uploaded. Plan ${plan.id}.`,
|
|
62
|
+
`${t.files} file(s): ${t.images} picture(s), ${t.videos} clip(s), ${(t.bytes / 1e9).toFixed(2)} GB; ${t.dupes} extra copy/copies; ${t.buckets} bucket(s) by ${plan.by}.`,
|
|
63
|
+
...lines,
|
|
64
|
+
plan.buckets.length > 25 ? `…and ${plan.buckets.length - 25} more.` : "",
|
|
65
|
+
`To move the files: organize_apply {plan: "${plan.id}"} previews, then again with confirm:true. Everything is undoable with organize_undo.`,
|
|
66
|
+
].filter(Boolean).join("\n");
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
exports.organizeApplyTool = {
|
|
70
|
+
name: "organize_apply",
|
|
71
|
+
description: "Move the files of an organize_scan plan into <folder>/_Organized/<bucket>/. Without confirm:true it only PREVIEWS (counts + first moves). " +
|
|
72
|
+
"With confirm:true it renames files (never overwrites, never deletes; duplicates go to a Duplicates bucket) and writes an undo manifest. " +
|
|
73
|
+
"Only call with confirm:true after the user has seen the preview and agreed.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: { plan: { type: "string", description: "Plan id from organize_scan." }, confirm: { type: "boolean", description: "true = actually move the files." } },
|
|
77
|
+
required: ["plan"],
|
|
78
|
+
},
|
|
79
|
+
async execute(args) {
|
|
80
|
+
const file = (0, exports.planFile)(args.plan);
|
|
81
|
+
if (!fs_1.default.existsSync(file))
|
|
82
|
+
return `No plan ${args.plan}. Run organize_scan first.`;
|
|
83
|
+
const plan = JSON.parse(fs_1.default.readFileSync(file, "utf8"));
|
|
84
|
+
if (!args.confirm) {
|
|
85
|
+
const sample = plan.moves.slice(0, 12).map((m) => `- ${path_1.default.relative(plan.root, m.from)} → ${path_1.default.relative(plan.root, m.to)}`);
|
|
86
|
+
return [`PREVIEW — nothing has been moved. ${plan.moves.length} file(s) would move into ${path_1.default.join(plan.root, "_Organized")} (${plan.buckets.length} bucket(s)).`, ...sample, plan.moves.length > 12 ? `…and ${plan.moves.length - 12} more.` : "", "Nothing is deleted or overwritten, and organize_undo puts it all back. Call again with confirm:true to go ahead."].filter(Boolean).join("\n");
|
|
87
|
+
}
|
|
88
|
+
const r = (0, plan_js_1.applyMoves)(plan.root, plan.moves, plan.id);
|
|
89
|
+
return `Moved ${r.moved} file(s) into ${path_1.default.join(plan.root, "_Organized")}${r.skipped ? ` (${r.skipped} skipped: gone or unreadable)` : ""}. Undo manifest: ${r.manifest}. Use organize_undo {plan:"${plan.id}"} to put everything back.`;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
exports.organizeUndoTool = {
|
|
93
|
+
name: "organize_undo",
|
|
94
|
+
description: "Put back every file moved by organize_apply for a plan (files changed or re-created since are left alone and counted).",
|
|
95
|
+
inputSchema: { type: "object", properties: { plan: { type: "string", description: "Plan id." } }, required: ["plan"] },
|
|
96
|
+
async execute(args) {
|
|
97
|
+
const file = (0, exports.planFile)(args.plan);
|
|
98
|
+
if (!fs_1.default.existsSync(file))
|
|
99
|
+
return `No plan ${args.plan}.`;
|
|
100
|
+
const plan = JSON.parse(fs_1.default.readFileSync(file, "utf8"));
|
|
101
|
+
const manifest = path_1.default.join(plan.root, "_Organized", `.viralspin-undo-${plan.id}.json`);
|
|
102
|
+
if (!fs_1.default.existsSync(manifest))
|
|
103
|
+
return `Nothing to undo for plan ${plan.id} (no manifest — it was not applied, or was already undone).`;
|
|
104
|
+
const r = (0, plan_js_1.undoMoves)(manifest);
|
|
105
|
+
return `Restored ${r.restored} file(s)${r.left ? `; ${r.left} left where they are (changed since)` : ""}.`;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
/** The opt-in: send the tags + a small thumbnail of each organized file to My Story. The originals stay on this computer. */
|
|
109
|
+
exports.organizeAddToStoryTool = {
|
|
110
|
+
name: "organize_add_to_story",
|
|
111
|
+
description: "OPT-IN. Add an organized folder (an organize_scan plan) to the user's My Story assets, so its buckets show in the ViralSpin web app and feed carousels and 7-second reels. " +
|
|
112
|
+
"Uploads ONLY a 480px thumbnail + tag + bucket per file (duplicates skipped); the original files stay on this computer and nothing is uploaded unless the user asked for this. " +
|
|
113
|
+
"Run it BEFORE organize_apply (thumbnails are read from the original locations). Needs a ViralSpin sign-in.",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: { plan: { type: "string", description: "Plan id from organize_scan." }, remove: { type: "boolean", description: "true = take this folder back out of My Story." } },
|
|
117
|
+
required: ["plan"],
|
|
118
|
+
},
|
|
119
|
+
async execute(args) {
|
|
120
|
+
const file = (0, exports.planFile)(args.plan);
|
|
121
|
+
if (!fs_1.default.existsSync(file))
|
|
122
|
+
return `No plan ${args.plan}. Run organize_scan first.`;
|
|
123
|
+
if (args.remove) {
|
|
124
|
+
const r = await (0, client_js_1.del)(`/v1/library/organized?plan_id=${encodeURIComponent(args.plan)}`);
|
|
125
|
+
return `Removed this folder from My Story (${r.removed} thumbnail(s) deleted). Your files were never touched.`;
|
|
126
|
+
}
|
|
127
|
+
return addPlanToStory(file, { post: client_js_1.post });
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
/** 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
|
+
/** `only`: bucket keys ("image:Family", "video:Outfits") when the creator picked some folders; all of them when absent. */
|
|
132
|
+
const bucketKey = (b) => `${b.media ?? "any"}:${b.name}`;
|
|
133
|
+
exports.bucketKey = bucketKey;
|
|
134
|
+
async function addPlanToStory(file, io, progress, only) {
|
|
135
|
+
let plan = JSON.parse(fs_1.default.readFileSync(file, "utf8"));
|
|
136
|
+
if (!plan.items.some((i) => i.thumb && fs_1.default.existsSync(i.thumb))) {
|
|
137
|
+
await (0, scan_js_1.makeThumbsFor)(file, path_1.default.join(STATE, `${plan.id}-thumbs`));
|
|
138
|
+
plan = JSON.parse(fs_1.default.readFileSync(file, "utf8"));
|
|
139
|
+
}
|
|
140
|
+
const label = path_1.default.basename(plan.root);
|
|
141
|
+
const entries = [];
|
|
142
|
+
const pick = only?.length ? new Set(only) : null;
|
|
143
|
+
for (const b of plan.buckets)
|
|
144
|
+
if (b.kind !== "duplicates" && (!pick || pick.has((0, exports.bucketKey)(b))))
|
|
145
|
+
for (const i of b.items)
|
|
146
|
+
entries.push({ item: plan.items[i], bucket: b });
|
|
147
|
+
let added = 0, thumbs = 0;
|
|
148
|
+
const totalN = entries.length;
|
|
149
|
+
for (let k = 0; k < entries.length; k += 200) {
|
|
150
|
+
const chunk = entries.slice(k, k + 200);
|
|
151
|
+
const r = await io.post("/v1/library/organized", {
|
|
152
|
+
plan_id: plan.id, root_label: label,
|
|
153
|
+
items: chunk.map(({ item, bucket }) => ({ rel: item.rel, kind: item.kind, bucket: bucket.name, bucket_kind: bucket.kind, subject: item.subject, tags: item.tags, month: item.month ?? null, live: !!item.live, has_thumb: !!(item.thumb && fs_1.default.existsSync(item.thumb)) })),
|
|
154
|
+
});
|
|
155
|
+
added += r.added;
|
|
156
|
+
const byRel = new Map(chunk.map((c) => [c.item.rel, c.item]));
|
|
157
|
+
for (const u of r.uploads) {
|
|
158
|
+
const t = byRel.get(u.rel)?.thumb;
|
|
159
|
+
if (!t)
|
|
160
|
+
continue;
|
|
161
|
+
const form = new FormData();
|
|
162
|
+
form.append("cacheControl", "31536000");
|
|
163
|
+
form.append("", new Blob([await fs_1.default.promises.readFile(t)], { type: "image/jpeg" }), "t.jpg");
|
|
164
|
+
const resp = await fetch(u.upload_url, { method: "PUT", body: form, headers: { "x-upsert": "true" } });
|
|
165
|
+
if (resp.ok)
|
|
166
|
+
thumbs++;
|
|
167
|
+
}
|
|
168
|
+
progress?.(Math.min(k + 200, totalN), totalN);
|
|
169
|
+
}
|
|
170
|
+
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.`;
|
|
171
|
+
}
|
package/package.json
CHANGED
|
@@ -1,42 +1,46 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@viralspin/mcp",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"dependencies": {
|
|
31
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
32
|
-
"form-data": "^4.0.0"
|
|
33
|
-
},
|
|
34
|
-
"devDependencies": {
|
|
35
|
-
"@types/form-data": "^2.5.0",
|
|
36
|
-
"@types/node": "^20.0.0",
|
|
37
|
-
"typescript": "^5.0.0"
|
|
38
|
-
},
|
|
39
|
-
"engines": {
|
|
40
|
-
"node": ">=18"
|
|
41
|
-
}
|
|
42
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@viralspin/mcp",
|
|
3
|
+
"version": "0.5.0",
|
|
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
|
+
"dependencies": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
32
|
+
"form-data": "^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/form-data": "^2.5.0",
|
|
36
|
+
"@types/node": "^20.0.0",
|
|
37
|
+
"typescript": "^5.0.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"optionalDependencies": {
|
|
43
|
+
"@huggingface/transformers": "^4.3.2",
|
|
44
|
+
"sharp": "^0.35.5"
|
|
45
|
+
}
|
|
46
|
+
}
|