@viralspin/mcp 0.1.0 → 0.2.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/BUNDLE_LOCAL_SPEC.md +133 -0
- package/dist/client.js +47 -9
- package/dist/index.js +83 -1
- package/dist/tools/assembly-adjust.js +102 -0
- package/dist/tools/carousel-video.js +190 -0
- package/dist/tools/engage.js +88 -0
- package/dist/tools/explainer-index.js +301 -0
- package/dist/tools/image-ads.js +156 -0
- package/dist/tools/kinetic-text.js +118 -0
- package/dist/tools/photo-picker.js +31 -0
- package/dist/tools/publish.js +72 -1
- package/dist/tools/review.js +103 -0
- package/dist/tools/video-assembly.js +183 -0
- package/engine/assemble.mjs +476 -0
- package/engine/gemini.mjs +164 -0
- package/engine/kinetic.mjs +84 -0
- package/engine/prompts.mjs +94 -0
- package/engine/remotion.config.ts +4 -0
- package/engine/src/Assembly.tsx +330 -0
- package/engine/src/KineticText.tsx +253 -0
- package/engine/src/Root.tsx +39 -0
- package/engine/src/index.ts +4 -0
- package/engine/tsconfig.json +15 -0
- package/package.json +29 -29
- package/src/client.ts +119 -80
- package/src/index.ts +151 -60
- package/src/tools/assembly-adjust.ts +110 -0
- package/src/tools/captions.ts +32 -32
- package/src/tools/carousel-video.ts +211 -0
- package/src/tools/connections.ts +27 -27
- package/src/tools/engage.ts +105 -0
- package/src/tools/explainer-index.ts +298 -0
- package/src/tools/image-ads.ts +192 -0
- package/src/tools/kinetic-text.ts +130 -0
- package/src/tools/photo-picker.ts +31 -0
- package/src/tools/posts.ts +58 -58
- package/src/tools/publish.ts +236 -158
- package/src/tools/review.ts +126 -0
- package/src/tools/video-assembly.ts +205 -0
- package/tsconfig.json +15 -15
package/src/tools/publish.ts
CHANGED
|
@@ -1,158 +1,236 @@
|
|
|
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
|
|
105
|
-
name: "
|
|
106
|
-
description:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
};
|