@viralspin/mcp 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/client.js +8 -0
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +62 -16
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +107 -15
- package/dist/tools/carousel-video.js +7 -0
- package/dist/tools/color-grade.js +64 -0
- package/dist/tools/connections.js +23 -4
- package/dist/tools/edit-clip.js +136 -0
- package/dist/tools/engage.js +13 -4
- package/dist/tools/explainer-index.js +6 -0
- package/dist/tools/find-moments.js +140 -0
- package/dist/tools/folder-preview.js +71 -0
- package/dist/tools/ideas.js +158 -0
- package/dist/tools/kinetic-text.js +6 -2
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +34 -10
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +131 -4
- package/package.json +41 -29
- package/BUNDLE_LOCAL_SPEC.md +0 -133
- package/engine/assemble.mjs +0 -476
- package/engine/gemini.mjs +0 -164
- package/engine/kinetic.mjs +0 -84
- package/engine/prompts.mjs +0 -94
- package/engine/remotion.config.ts +0 -4
- package/engine/src/Assembly.tsx +0 -330
- package/engine/src/KineticText.tsx +0 -253
- package/engine/src/Root.tsx +0 -39
- package/engine/src/index.ts +0 -4
- package/engine/tsconfig.json +0 -15
- package/src/client.ts +0 -119
- package/src/index.ts +0 -151
- package/src/tools/assembly-adjust.ts +0 -110
- package/src/tools/captions.ts +0 -32
- package/src/tools/carousel-video.ts +0 -211
- package/src/tools/connections.ts +0 -27
- package/src/tools/engage.ts +0 -105
- package/src/tools/explainer-index.ts +0 -298
- package/src/tools/image-ads.ts +0 -192
- package/src/tools/kinetic-text.ts +0 -130
- package/src/tools/photo-picker.ts +0 -31
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -236
- package/src/tools/review.ts +0 -126
- package/src/tools/video-assembly.ts +0 -205
- package/tsconfig.json +0 -15
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
|
-
import path from "path";
|
|
3
|
-
|
|
4
|
-
const ENGINE_DIR =
|
|
5
|
-
process.env.VIRALSPIN_ASSEMBLY_DIR || path.resolve(__dirname, "../../engine");
|
|
6
|
-
|
|
7
|
-
const EFFECTS = ["typing", "billboard_flip", "isometric"] as const;
|
|
8
|
-
const STYLES = ["terminal", "cinematic", "minimal"] as const;
|
|
9
|
-
const FORMATS = ["9x16", "16x9", "1x1"] as const;
|
|
10
|
-
|
|
11
|
-
interface KineticTextArgs {
|
|
12
|
-
text: string;
|
|
13
|
-
effect?: typeof EFFECTS[number];
|
|
14
|
-
style?: typeof STYLES[number];
|
|
15
|
-
format?: typeof FORMATS[number];
|
|
16
|
-
font_size?: number;
|
|
17
|
-
camera?: {
|
|
18
|
-
rotateX?: number;
|
|
19
|
-
rotateY?: number;
|
|
20
|
-
rotateZ?: number;
|
|
21
|
-
translateZ?: number;
|
|
22
|
-
};
|
|
23
|
-
duration_sec?: number;
|
|
24
|
-
out?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function runKinetic(args: string[]): Promise<string> {
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
const child = spawn(
|
|
30
|
-
process.platform === "win32" ? "node.exe" : "node",
|
|
31
|
-
[path.join(ENGINE_DIR, "kinetic.mjs"), ...args],
|
|
32
|
-
{ cwd: ENGINE_DIR, env: { ...process.env } }
|
|
33
|
-
);
|
|
34
|
-
let stdout = "";
|
|
35
|
-
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
36
|
-
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
37
|
-
child.on("error", reject);
|
|
38
|
-
child.on("close", (code) => {
|
|
39
|
-
const m = stdout.match(/RESULT:(.+)/);
|
|
40
|
-
if (code === 0 && m) resolve(m[1].trim());
|
|
41
|
-
else reject(new Error(`Kinetic render failed (exit ${code}). ${stdout.slice(-300)}`));
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export const createKineticTextTool = {
|
|
47
|
-
name: "create_kinetic_text",
|
|
48
|
-
description:
|
|
49
|
-
"Render a standalone cinematic typing-animation video — great for explainer intros that visually demo " +
|
|
50
|
-
"what a user types into the prompt. Text is typed out character-by-character with a blinking cursor " +
|
|
51
|
-
"while a 3D camera slowly pans, giving a cinematic 'Star Wars pull-back' feel. " +
|
|
52
|
-
"Three effects: 'typing' (character-by-character with 3D camera), " +
|
|
53
|
-
"'billboard_flip' (words fly from Z-depth and snap into place), " +
|
|
54
|
-
"'isometric' (letters extrude upward on a tilted plane). " +
|
|
55
|
-
"Three styles: 'terminal' (green-on-dark, monospace, scanlines), " +
|
|
56
|
-
"'cinematic' (white-on-black, serif, vignette), " +
|
|
57
|
-
"'minimal' (transparent background, for overlay use). " +
|
|
58
|
-
"Returns the path to a finished MP4 — feed it to publish_now or as A-roll in create_video_assembly.",
|
|
59
|
-
inputSchema: {
|
|
60
|
-
type: "object",
|
|
61
|
-
properties: {
|
|
62
|
-
text: {
|
|
63
|
-
type: "string",
|
|
64
|
-
description: "The text to animate — e.g. the prompt the user would type.",
|
|
65
|
-
},
|
|
66
|
-
effect: {
|
|
67
|
-
type: "string",
|
|
68
|
-
enum: EFFECTS,
|
|
69
|
-
description: "Animation style. Default: typing.",
|
|
70
|
-
},
|
|
71
|
-
style: {
|
|
72
|
-
type: "string",
|
|
73
|
-
enum: STYLES,
|
|
74
|
-
description: "Visual theme. terminal = green monospace; cinematic = white serif; minimal = transparent BG. Default: terminal.",
|
|
75
|
-
},
|
|
76
|
-
format: {
|
|
77
|
-
type: "string",
|
|
78
|
-
enum: FORMATS,
|
|
79
|
-
description: "Output aspect ratio. Default: 9x16 (vertical).",
|
|
80
|
-
},
|
|
81
|
-
font_size: {
|
|
82
|
-
type: "number",
|
|
83
|
-
description: "Font size in px. Auto-sized by format if omitted (72px for 9x16, 96px for 16x9).",
|
|
84
|
-
},
|
|
85
|
-
camera: {
|
|
86
|
-
type: "object",
|
|
87
|
-
description: "Override the 3D camera end position. Each field defaults to the effect's cinematic preset.",
|
|
88
|
-
properties: {
|
|
89
|
-
rotateX: { type: "number", description: "X-axis tilt in degrees (positive = tilt top toward viewer)." },
|
|
90
|
-
rotateY: { type: "number", description: "Y-axis turn in degrees." },
|
|
91
|
-
rotateZ: { type: "number", description: "Z-axis roll in degrees." },
|
|
92
|
-
translateZ: { type: "number", description: "Z-depth offset in px (negative = pull back/zoom out)." },
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
duration_sec: {
|
|
96
|
-
type: "number",
|
|
97
|
-
description: "Override clip length in seconds. Default: auto (≈ text.length × 0.1s + 1.5s hold).",
|
|
98
|
-
},
|
|
99
|
-
out: {
|
|
100
|
-
type: "string",
|
|
101
|
-
description: "Output MP4 path. Defaults to engine/out/kinetic_<timestamp>.mp4.",
|
|
102
|
-
},
|
|
103
|
-
},
|
|
104
|
-
required: ["text"],
|
|
105
|
-
},
|
|
106
|
-
|
|
107
|
-
async execute(args: KineticTextArgs): Promise<string> {
|
|
108
|
-
const cli: string[] = ["--text", args.text];
|
|
109
|
-
if (args.effect) cli.push("--effect", args.effect);
|
|
110
|
-
if (args.style) cli.push("--style", args.style);
|
|
111
|
-
if (args.format) cli.push("--format", args.format);
|
|
112
|
-
if (args.font_size) cli.push("--font-size", String(args.font_size));
|
|
113
|
-
if (args.duration_sec) cli.push("--duration-sec", String(args.duration_sec));
|
|
114
|
-
if (args.out) cli.push("--out", args.out);
|
|
115
|
-
if (args.camera) {
|
|
116
|
-
const c = args.camera;
|
|
117
|
-
if (c.rotateX != null) cli.push("--camera-rx", String(c.rotateX));
|
|
118
|
-
if (c.rotateY != null) cli.push("--camera-ry", String(c.rotateY));
|
|
119
|
-
if (c.rotateZ != null) cli.push("--camera-rz", String(c.rotateZ));
|
|
120
|
-
if (c.translateZ != null) cli.push("--camera-tz", String(c.translateZ));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const outPath = await runKinetic(cli);
|
|
124
|
-
return (
|
|
125
|
-
`Kinetic text render complete.\n` +
|
|
126
|
-
`Output: ${outPath}\n` +
|
|
127
|
-
`Use this as a hook intro by passing it as A-roll to create_video_assembly, or publish directly with publish_now.`
|
|
128
|
-
);
|
|
129
|
-
},
|
|
130
|
-
};
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { post } from "../client.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Opens a Google Photos Picker session for choosing B-roll. Google does NOT allow
|
|
5
|
-
* apps to list or search a user's albums programmatically (Library API readonly was
|
|
6
|
-
* removed 2025-03-31), so the user searches/selects inside Google's own picker UI.
|
|
7
|
-
* This tool just hands back the picker link + sessionId; create_video_assembly then
|
|
8
|
-
* ingests the picks via google_picker_session.
|
|
9
|
-
*/
|
|
10
|
-
export const startPhotoPickerTool = {
|
|
11
|
-
name: "start_photo_picker",
|
|
12
|
-
description:
|
|
13
|
-
"Open a Google Photos picker to choose B-roll for create_video_assembly. Returns a link to open in your browser. " +
|
|
14
|
-
"Google does not allow listing or searching your albums programmatically — so use the picker's OWN search box to find an " +
|
|
15
|
-
"album or topic that matches your video (by person, place, event, or theme), select the photos/clips, and finish. " +
|
|
16
|
-
"Then call create_video_assembly with google_picker_session set to the returned sessionId. " +
|
|
17
|
-
"Requires a Google account connected at app.viralspin.ai/settings.",
|
|
18
|
-
inputSchema: { type: "object", properties: {} },
|
|
19
|
-
|
|
20
|
-
async execute(): Promise<string> {
|
|
21
|
-
const r = await post<{ sessionId: string; pickerUri: string }>("/v1/assembly/picker", {});
|
|
22
|
-
return [
|
|
23
|
-
"📸 Open this link to pick your Google Photos B-roll:",
|
|
24
|
-
r.pickerUri,
|
|
25
|
-
"",
|
|
26
|
-
"Tip: use the picker's search box to find an album/topic that matches your video — by person, place, event, or theme — then select the clips/photos and finish.",
|
|
27
|
-
"",
|
|
28
|
-
`When done, run create_video_assembly with: google_picker_session: "${r.sessionId}" (plus your A-roll).`,
|
|
29
|
-
].join("\n");
|
|
30
|
-
},
|
|
31
|
-
};
|
package/src/tools/posts.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { get, del } from "../client.js";
|
|
2
|
-
|
|
3
|
-
interface ScheduledPost {
|
|
4
|
-
id: string;
|
|
5
|
-
platforms: string[];
|
|
6
|
-
caption: string;
|
|
7
|
-
status: string;
|
|
8
|
-
scheduled_time: string;
|
|
9
|
-
published_at: string | null;
|
|
10
|
-
failed_reason: string | null;
|
|
11
|
-
media_type: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const listPostsTool = {
|
|
15
|
-
name: "list_posts",
|
|
16
|
-
description: "List your scheduled, published, or failed posts.",
|
|
17
|
-
inputSchema: {
|
|
18
|
-
type: "object",
|
|
19
|
-
properties: {
|
|
20
|
-
status: {
|
|
21
|
-
type: "string",
|
|
22
|
-
enum: ["pending", "published", "cancelled", "failed"],
|
|
23
|
-
description: "Filter by status. Omit to list all posts.",
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
required: [],
|
|
27
|
-
},
|
|
28
|
-
|
|
29
|
-
async execute(args: { status?: string }) {
|
|
30
|
-
const qs = args.status ? `?status=${args.status}` : "";
|
|
31
|
-
const posts = await get<ScheduledPost[]>(`/v1/publish/posts${qs}`);
|
|
32
|
-
if (!posts.length) return "No posts found.";
|
|
33
|
-
return posts
|
|
34
|
-
.map((p) => {
|
|
35
|
-
const time = p.published_at ?? p.scheduled_time;
|
|
36
|
-
const label = p.published_at ? "published" : "scheduled";
|
|
37
|
-
return `[${p.id.slice(0, 8)}] ${p.status.toUpperCase()} — ${p.platforms.join("+")} ${p.media_type} | ${label}: ${time}\n "${p.caption?.slice(0, 60)}${(p.caption?.length ?? 0) > 60 ? "…" : ""}"${p.failed_reason ? `\n Error: ${p.failed_reason}` : ""}`;
|
|
38
|
-
})
|
|
39
|
-
.join("\n\n");
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export const cancelPostTool = {
|
|
44
|
-
name: "cancel_post",
|
|
45
|
-
description: "Cancel a pending scheduled post by ID.",
|
|
46
|
-
inputSchema: {
|
|
47
|
-
type: "object",
|
|
48
|
-
properties: {
|
|
49
|
-
post_id: { type: "string", description: "The post ID to cancel." },
|
|
50
|
-
},
|
|
51
|
-
required: ["post_id"],
|
|
52
|
-
},
|
|
53
|
-
|
|
54
|
-
async execute(args: { post_id: string }) {
|
|
55
|
-
await del(`/v1/publish/posts/${args.post_id}`);
|
|
56
|
-
return `Post ${args.post_id} cancelled.`;
|
|
57
|
-
},
|
|
58
|
-
};
|
package/src/tools/publish.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { post, uploadFiles, deleteFiles } from "../client.js";
|
|
4
|
-
|
|
5
|
-
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
|
6
|
-
const VIDEO_EXTS = new Set([".mp4", ".mov", ".webm", ".avi"]);
|
|
7
|
-
|
|
8
|
-
function detectMediaType(filePaths: string[]): "video" | "carousel" {
|
|
9
|
-
if (filePaths.length > 1) return "carousel";
|
|
10
|
-
const ext = path.extname(filePaths[0]).toLowerCase();
|
|
11
|
-
return IMAGE_EXTS.has(ext) ? "carousel" : "video";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Expand a folder path into individual file paths (images/videos only, sorted) */
|
|
15
|
-
function expandPaths(inputs: string[]): string[] {
|
|
16
|
-
const result: string[] = [];
|
|
17
|
-
for (const p of inputs) {
|
|
18
|
-
const stat = fs.statSync(p, { throwIfNoEntry: false });
|
|
19
|
-
if (!stat) throw new Error(`Path not found: ${p}`);
|
|
20
|
-
if (stat.isDirectory()) {
|
|
21
|
-
const files = fs.readdirSync(p)
|
|
22
|
-
.filter((f) => {
|
|
23
|
-
const ext = path.extname(f).toLowerCase();
|
|
24
|
-
return IMAGE_EXTS.has(ext) || VIDEO_EXTS.has(ext);
|
|
25
|
-
})
|
|
26
|
-
.sort()
|
|
27
|
-
.map((f) => path.join(p, f));
|
|
28
|
-
result.push(...files);
|
|
29
|
-
} else {
|
|
30
|
-
result.push(p);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return result;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface PublishResult {
|
|
37
|
-
post: { id: string; status: string; platform_post_ids: Record<string, string>; failed_reason?: string };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const publishSchema = {
|
|
41
|
-
type: "object",
|
|
42
|
-
properties: {
|
|
43
|
-
paths: {
|
|
44
|
-
type: "array",
|
|
45
|
-
items: { type: "string" },
|
|
46
|
-
description: "Local file path(s) or a folder path. Single video → video post. Multiple images or image folder → carousel.",
|
|
47
|
-
},
|
|
48
|
-
platforms: {
|
|
49
|
-
type: "array",
|
|
50
|
-
items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
|
|
51
|
-
description: "Platforms to publish to. Must be connected.",
|
|
52
|
-
},
|
|
53
|
-
caption: { type: "string", description: "Post caption." },
|
|
54
|
-
hashtags: {
|
|
55
|
-
type: "array",
|
|
56
|
-
items: { type: "string" },
|
|
57
|
-
description: "Hashtags without # prefix.",
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
required: ["paths", "platforms", "caption"],
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export const publishNowTool = {
|
|
64
|
-
name: "publish_now",
|
|
65
|
-
description: "Upload local file(s) and publish immediately to TikTok, Instagram, and/or YouTube. Pass a folder path to publish all images in it as a carousel.",
|
|
66
|
-
inputSchema: publishSchema,
|
|
67
|
-
|
|
68
|
-
async execute(args: { paths: string[]; platforms: string[]; caption: string; hashtags?: string[] }) {
|
|
69
|
-
const filePaths = expandPaths(args.paths);
|
|
70
|
-
if (!filePaths.length) throw new Error("No media files found in the provided paths.");
|
|
71
|
-
|
|
72
|
-
const mediaType = detectMediaType(filePaths);
|
|
73
|
-
if (mediaType === "carousel" && filePaths.length > 10) {
|
|
74
|
-
throw new Error(`Instagram supports max 10 images per carousel. Found ${filePaths.length} files.`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Upload files
|
|
78
|
-
const { urls, storage_paths } = await uploadFiles(filePaths);
|
|
79
|
-
|
|
80
|
-
let result: PublishResult;
|
|
81
|
-
try {
|
|
82
|
-
result = await post<PublishResult>("/v1/publish/schedule", {
|
|
83
|
-
media_urls: urls,
|
|
84
|
-
media_type: mediaType,
|
|
85
|
-
platforms: args.platforms,
|
|
86
|
-
caption: args.caption,
|
|
87
|
-
hashtags: args.hashtags ?? [],
|
|
88
|
-
publish_now: true,
|
|
89
|
-
});
|
|
90
|
-
} finally {
|
|
91
|
-
// Always clean up uploaded files
|
|
92
|
-
await deleteFiles(storage_paths).catch(() => {});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const { status, platform_post_ids, failed_reason } = result.post;
|
|
96
|
-
const published = Object.keys(platform_post_ids);
|
|
97
|
-
let summary = `Status: ${status}\n`;
|
|
98
|
-
if (published.length) summary += `Published to: ${published.join(", ")}\n`;
|
|
99
|
-
if (failed_reason) summary += `Errors: ${failed_reason}`;
|
|
100
|
-
return summary.trim();
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
export const sendToDraftsTool = {
|
|
105
|
-
name: "send_to_drafts",
|
|
106
|
-
description:
|
|
107
|
-
"Upload local file(s) and send to the platform's drafts (TikTok inbox / YouTube as private) so the user can finish in the mobile app — useful when they want to add licensed music or polish before publishing. " +
|
|
108
|
-
"Instagram does NOT support drafts via API (IG drafts are local to the mobile app only) — if instagram is in the platforms list, that platform will return an error explaining the limitation. " +
|
|
109
|
-
"Pass a folder path to send all images as a carousel draft.",
|
|
110
|
-
inputSchema: {
|
|
111
|
-
type: "object",
|
|
112
|
-
properties: {
|
|
113
|
-
paths: {
|
|
114
|
-
type: "array",
|
|
115
|
-
items: { type: "string" },
|
|
116
|
-
description: "Local file path(s) or a folder path. Single video → video draft. Multiple images or image folder → carousel draft.",
|
|
117
|
-
},
|
|
118
|
-
platforms: {
|
|
119
|
-
type: "array",
|
|
120
|
-
items: { type: "string", enum: ["tiktok", "youtube"] },
|
|
121
|
-
description: "Platforms to send drafts to. Must be connected. Instagram is not supported (API limitation).",
|
|
122
|
-
},
|
|
123
|
-
caption: {
|
|
124
|
-
type: "string",
|
|
125
|
-
description: "Caption text. On TikTok video drafts, the caption is NOT used (user enters it in-app); on TikTok photo drafts and YouTube drafts it is preserved.",
|
|
126
|
-
},
|
|
127
|
-
hashtags: {
|
|
128
|
-
type: "array",
|
|
129
|
-
items: { type: "string" },
|
|
130
|
-
description: "Hashtags without # prefix.",
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
required: ["paths", "platforms", "caption"],
|
|
134
|
-
},
|
|
135
|
-
|
|
136
|
-
async execute(args: { paths: string[]; platforms: string[]; caption: string; hashtags?: string[] }) {
|
|
137
|
-
const filePaths = expandPaths(args.paths);
|
|
138
|
-
if (!filePaths.length) throw new Error("No media files found in the provided paths.");
|
|
139
|
-
|
|
140
|
-
const mediaType = detectMediaType(filePaths);
|
|
141
|
-
if (mediaType === "carousel" && filePaths.length > 10) {
|
|
142
|
-
throw new Error(`Carousel max is 10 images. Found ${filePaths.length} files.`);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Surface the IG-not-supported gotcha before consuming credits / uploading.
|
|
146
|
-
if (args.platforms.includes("instagram")) {
|
|
147
|
-
throw new Error(
|
|
148
|
-
"Instagram does not support drafts via API. IG drafts are local to the mobile app only — " +
|
|
149
|
-
"there is no API endpoint to send to drafts. Remove 'instagram' from platforms, or use schedule_post / publish_now instead."
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const { urls, storage_paths } = await uploadFiles(filePaths);
|
|
154
|
-
|
|
155
|
-
let result: PublishResult;
|
|
156
|
-
try {
|
|
157
|
-
result = await post<PublishResult>("/v1/publish/schedule", {
|
|
158
|
-
media_urls: urls,
|
|
159
|
-
media_type: mediaType,
|
|
160
|
-
platforms: args.platforms,
|
|
161
|
-
caption: args.caption,
|
|
162
|
-
hashtags: args.hashtags ?? [],
|
|
163
|
-
publish_now: true,
|
|
164
|
-
mode: "draft",
|
|
165
|
-
});
|
|
166
|
-
} finally {
|
|
167
|
-
await deleteFiles(storage_paths).catch(() => {});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const { status, platform_post_ids, failed_reason } = result.post;
|
|
171
|
-
const sentTo = Object.keys(platform_post_ids);
|
|
172
|
-
let summary = `Status: ${status}\n`;
|
|
173
|
-
if (sentTo.length) {
|
|
174
|
-
summary += `Sent to drafts on: ${sentTo.join(", ")}\n`;
|
|
175
|
-
summary += `Open the platform's mobile app to finish — add music, adjust caption, then publish.`;
|
|
176
|
-
}
|
|
177
|
-
if (failed_reason) summary += `\nErrors: ${failed_reason}`;
|
|
178
|
-
return summary.trim();
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
export const schedulePostTool = {
|
|
183
|
-
name: "schedule_post",
|
|
184
|
-
description: "Upload local file(s) and schedule a post for a future time. Pass a folder path to schedule all images as a carousel.",
|
|
185
|
-
inputSchema: {
|
|
186
|
-
type: "object",
|
|
187
|
-
properties: {
|
|
188
|
-
...publishSchema.properties,
|
|
189
|
-
scheduled_time: {
|
|
190
|
-
type: "string",
|
|
191
|
-
description: "ISO 8601 datetime string for when to publish (e.g. '2026-05-10T09:00:00'). Uses your local timezone if no timezone offset provided.",
|
|
192
|
-
},
|
|
193
|
-
timezone: {
|
|
194
|
-
type: "string",
|
|
195
|
-
description: "IANA timezone name (e.g. 'America/New_York'). Defaults to UTC.",
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
required: ["paths", "platforms", "caption", "scheduled_time"],
|
|
199
|
-
},
|
|
200
|
-
|
|
201
|
-
async execute(args: {
|
|
202
|
-
paths: string[];
|
|
203
|
-
platforms: string[];
|
|
204
|
-
caption: string;
|
|
205
|
-
hashtags?: string[];
|
|
206
|
-
scheduled_time: string;
|
|
207
|
-
timezone?: string;
|
|
208
|
-
}) {
|
|
209
|
-
const filePaths = expandPaths(args.paths);
|
|
210
|
-
if (!filePaths.length) throw new Error("No media files found in the provided paths.");
|
|
211
|
-
|
|
212
|
-
const mediaType = detectMediaType(filePaths);
|
|
213
|
-
|
|
214
|
-
// Upload files (storage URLs remain valid for 1 hour; cron will publish before then if scheduled soon,
|
|
215
|
-
// otherwise the cron route will fail — advise scheduling within the hour for now)
|
|
216
|
-
const { urls, storage_paths } = await uploadFiles(filePaths);
|
|
217
|
-
|
|
218
|
-
const result = await post<PublishResult>("/v1/publish/schedule", {
|
|
219
|
-
media_urls: urls,
|
|
220
|
-
media_type: mediaType,
|
|
221
|
-
platforms: args.platforms,
|
|
222
|
-
caption: args.caption,
|
|
223
|
-
hashtags: args.hashtags ?? [],
|
|
224
|
-
scheduled_time: args.scheduled_time,
|
|
225
|
-
timezone: args.timezone,
|
|
226
|
-
publish_now: false,
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// Note: storage_paths are NOT deleted here — the cron job needs the URLs to stay valid.
|
|
230
|
-
// They expire after 1 hour. For posts scheduled further out, re-upload closer to publish time.
|
|
231
|
-
void storage_paths;
|
|
232
|
-
|
|
233
|
-
const { post: p } = result;
|
|
234
|
-
return `Scheduled post ${p.id}\nPlatforms: ${args.platforms.join(", ")}\nTime: ${args.scheduled_time}\nStatus: ${p.status}`;
|
|
235
|
-
},
|
|
236
|
-
};
|
package/src/tools/review.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
|
-
import { get, post } from "../client.js";
|
|
3
|
-
|
|
4
|
-
const APP_BASE = process.env.VIRALSPIN_APP_URL || "https://app.viralspin.ai";
|
|
5
|
-
|
|
6
|
-
function makeToken(): string {
|
|
7
|
-
return crypto.randomBytes(9).toString("base64url").slice(0, 12);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
interface CreateReviewArgs {
|
|
11
|
-
output_path: string;
|
|
12
|
-
metadata?: {
|
|
13
|
-
caption?: string;
|
|
14
|
-
hook_text?: string;
|
|
15
|
-
style?: string;
|
|
16
|
-
format?: "9x16" | "1x1" | "16x9";
|
|
17
|
-
platform_targets?: string[];
|
|
18
|
-
};
|
|
19
|
-
pin?: string;
|
|
20
|
-
expires_hours?: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface ReviewStatusResponse {
|
|
24
|
-
status: "pending" | "approved" | "rejected";
|
|
25
|
-
feedback: string | null;
|
|
26
|
-
approved_at: string | null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const createReviewLinkTool = {
|
|
30
|
-
name: "create_review_link",
|
|
31
|
-
description:
|
|
32
|
-
"After assembling a video, generate a private shareable review link for a client. " +
|
|
33
|
-
"The client opens the link in their browser — no account required — sees the video in a " +
|
|
34
|
-
"premium 'Private Review Room', and clicks Approve or Request Changes. " +
|
|
35
|
-
"Returns the review URL to paste into an email or DM.",
|
|
36
|
-
inputSchema: {
|
|
37
|
-
type: "object" as const,
|
|
38
|
-
required: ["output_path"],
|
|
39
|
-
properties: {
|
|
40
|
-
output_path: {
|
|
41
|
-
type: "string",
|
|
42
|
-
description:
|
|
43
|
-
"The local file path or public URL of the rendered MP4 — e.g. the path returned by create_video_assembly.",
|
|
44
|
-
},
|
|
45
|
-
metadata: {
|
|
46
|
-
type: "object",
|
|
47
|
-
description: "Optional context shown in the review room: caption, hook_text, style, format, platform_targets.",
|
|
48
|
-
properties: {
|
|
49
|
-
caption: { type: "string" },
|
|
50
|
-
hook_text: { type: "string" },
|
|
51
|
-
style: { type: "string" },
|
|
52
|
-
format: { type: "string", enum: ["9x16", "1x1", "16x9"] },
|
|
53
|
-
platform_targets: { type: "array", items: { type: "string" } },
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
pin: {
|
|
57
|
-
type: "string",
|
|
58
|
-
description: "Optional 4-digit PIN the client must enter before viewing the review.",
|
|
59
|
-
},
|
|
60
|
-
expires_hours: {
|
|
61
|
-
type: "number",
|
|
62
|
-
description: "How many hours until the link expires (default: 168 = 7 days).",
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
async execute(args: CreateReviewArgs): Promise<string> {
|
|
67
|
-
const token = makeToken();
|
|
68
|
-
const expiresHours = args.expires_hours ?? 168;
|
|
69
|
-
|
|
70
|
-
await post("/v1/review-sessions", {
|
|
71
|
-
token,
|
|
72
|
-
output_path: args.output_path,
|
|
73
|
-
metadata: args.metadata ?? {},
|
|
74
|
-
pin: args.pin ?? null,
|
|
75
|
-
expires_hours: expiresHours,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const reviewUrl = `${APP_BASE}/review/${token}`;
|
|
79
|
-
return [
|
|
80
|
-
`✅ Review link created:`,
|
|
81
|
-
``,
|
|
82
|
-
` ${reviewUrl}`,
|
|
83
|
-
``,
|
|
84
|
-
`Send this link to your client. They'll see the video in a private premiere room`,
|
|
85
|
-
`and can Approve or Request Changes — no account required.`,
|
|
86
|
-
args.pin ? `PIN required: ${args.pin}` : ``,
|
|
87
|
-
`Link expires in ${expiresHours >= 24 ? `${Math.round(expiresHours / 24)} days` : `${expiresHours}h`}.`,
|
|
88
|
-
``,
|
|
89
|
-
`To check if they've approved: check_review_status with token "${token}"`,
|
|
90
|
-
]
|
|
91
|
-
.filter((l) => l !== undefined)
|
|
92
|
-
.join("\n");
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
export const checkReviewStatusTool = {
|
|
97
|
-
name: "check_review_status",
|
|
98
|
-
description:
|
|
99
|
-
"Check whether a client has approved or requested changes on a review link. " +
|
|
100
|
-
"Pass the token from the URL (e.g. 'abc123xyz456' from /review/abc123xyz456).",
|
|
101
|
-
inputSchema: {
|
|
102
|
-
type: "object" as const,
|
|
103
|
-
required: ["token"],
|
|
104
|
-
properties: {
|
|
105
|
-
token: {
|
|
106
|
-
type: "string",
|
|
107
|
-
description: "The 12-character review token from the review URL.",
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
async execute({ token }: { token: string }): Promise<string> {
|
|
112
|
-
const data = await get<ReviewStatusResponse>(`/v1/review-sessions/${token}/status`);
|
|
113
|
-
|
|
114
|
-
const statusLine =
|
|
115
|
-
data.status === "approved"
|
|
116
|
-
? `✅ Approved${data.approved_at ? ` on ${new Date(data.approved_at).toLocaleString()}` : ""}`
|
|
117
|
-
: data.status === "rejected"
|
|
118
|
-
? `📝 Changes requested`
|
|
119
|
-
: `⏳ Pending — client hasn't reviewed yet`;
|
|
120
|
-
|
|
121
|
-
const lines = [statusLine];
|
|
122
|
-
if (data.feedback) lines.push(``, `Client feedback: "${data.feedback}"`);
|
|
123
|
-
if (data.status === "approved") lines.push(``, `Ready to publish with publish_now or schedule_post.`);
|
|
124
|
-
return lines.join("\n");
|
|
125
|
-
},
|
|
126
|
-
};
|