@viralspin/mcp 0.1.0 → 0.3.1
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 +79 -0
- package/dist/cli.js +40 -0
- package/dist/client.js +56 -9
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +144 -2
- package/dist/local-render/kit.js +217 -0
- package/dist/local-render/login.js +138 -0
- package/dist/local-render/render.js +252 -0
- package/dist/local-render/tokens.js +201 -0
- package/dist/sanitize.js +22 -0
- package/dist/tools/assembly-adjust.js +194 -0
- package/dist/tools/carousel-video.js +197 -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 +97 -0
- package/dist/tools/explainer-index.js +307 -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/image-ads.js +156 -0
- package/dist/tools/kinetic-text.js +122 -0
- package/dist/tools/local-index.js +84 -0
- package/dist/tools/local-mode.js +84 -0
- package/dist/tools/photo-picker.js +31 -0
- package/dist/tools/profile.js +56 -0
- package/dist/tools/publish.js +119 -12
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/render-locally.js +158 -0
- package/dist/tools/review.js +103 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +310 -0
- package/package.json +16 -3
- package/src/client.ts +0 -80
- package/src/index.ts +0 -60
- package/src/tools/captions.ts +0 -32
- package/src/tools/connections.ts +0 -27
- package/src/tools/posts.ts +0 -58
- package/src/tools/publish.ts +0 -158
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderLocallyStatusTool = exports.renderLocallyTool = void 0;
|
|
4
|
+
exports.tm = tm;
|
|
5
|
+
const client_js_1 = require("../client.js");
|
|
6
|
+
const tokens_js_1 = require("../local-render/tokens.js");
|
|
7
|
+
const render_js_1 = require("../local-render/render.js");
|
|
8
|
+
const login_js_1 = require("../local-render/login.js");
|
|
9
|
+
const jobs = new Map();
|
|
10
|
+
let tokens = null;
|
|
11
|
+
function tm() {
|
|
12
|
+
if (!tokens) {
|
|
13
|
+
// Nothing to sign in with (first time, or the 30-day refresh ran out): open the browser and let the person approve. No pasted tokens.
|
|
14
|
+
const interactive = process.env.VIRALSPIN_NO_BROWSER_LOGIN !== "1";
|
|
15
|
+
tokens = new tokens_js_1.TokenManager({
|
|
16
|
+
apiBase: client_js_1.PY_API_URL,
|
|
17
|
+
signIn: interactive ? async () => { await (0, login_js_1.login)({ apiBase: client_js_1.PY_API_URL, log: (l) => process.stderr.write(`[render_locally] ${l}\n`) }); } : undefined,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return tokens;
|
|
21
|
+
}
|
|
22
|
+
/** "Which reel?" with no arguments: the newest one the app sent to this person's computer and nobody has taken yet. */
|
|
23
|
+
async function discover(log) {
|
|
24
|
+
const jobs = await (0, render_js_1.listPendingJobs)({ apiBase: client_js_1.PY_API_URL, tokens: tm() });
|
|
25
|
+
const { job, others, busy } = (0, render_js_1.pickJob)(jobs);
|
|
26
|
+
if (!job) {
|
|
27
|
+
if (busy.length) {
|
|
28
|
+
return { job: null, message: `Your reel "${busy[0].title || busy[0].job_id.slice(0, 8)}" is already being drawn on a computer (sent ${(0, render_js_1.ago)(busy[0].created_at)}). ` +
|
|
29
|
+
`If that is this computer, call render_locally_status; to draw it again here, pass job_id "${busy[0].job_id}".` };
|
|
30
|
+
}
|
|
31
|
+
return { job: null, message: "There is no reel waiting for your computer. In the ViralSpin app, open a reel's style picker, choose \"On my computer\" and tap \"Send to my computer\" - then say \"Render my ViralSpin reel\" again." };
|
|
32
|
+
}
|
|
33
|
+
const name = job.title ? `"${job.title}"` : `job ${job.job_id.slice(0, 8)}`;
|
|
34
|
+
log(`[pick] your newest waiting reel: ${name}${job.duration_sec ? `, ${Math.round(Number(job.duration_sec))} s` : ""}, sent ${(0, render_js_1.ago)(job.created_at)}` +
|
|
35
|
+
(job.phase === "planning" ? " (ViralSpin is still preparing it - waiting for that)" : "") +
|
|
36
|
+
(others.length ? `; ${others.length} older one${others.length > 1 ? "s" : ""} also waiting - ask again afterwards to render the next` : ""));
|
|
37
|
+
return { job };
|
|
38
|
+
}
|
|
39
|
+
function tail(lines, n = 14) {
|
|
40
|
+
return lines.slice(-n).join("\n");
|
|
41
|
+
}
|
|
42
|
+
function report(r, lines) {
|
|
43
|
+
const head = r.dry_run
|
|
44
|
+
? `Dry run OK - everything except the draw works on this computer (job ${r.job_id}).`
|
|
45
|
+
: `Rendered on this computer: ${r.output_path}`;
|
|
46
|
+
return [
|
|
47
|
+
head,
|
|
48
|
+
r.timing_path ? `timing: ${r.timing_path}` : "",
|
|
49
|
+
r.timing ? `draw ${r.timing.drawSeconds}s + mux ${r.timing.muxSeconds}s, ${r.timing.frames} frames, concurrency ${r.timing.concurrency}, ${r.timing.cores} threads, ${r.timing.ramGB} GB RAM, file ${r.timing.fileBytes} bytes` : "",
|
|
50
|
+
`prep: toolkit ${r.seconds.kit}s, plan ${r.seconds.plan}s, media ${r.seconds.download}s (${r.media.downloaded} downloaded, ${r.media.cached} cached); total ${r.seconds.total}s`,
|
|
51
|
+
r.completed ? (r.output_url ? "Done. The finished reel is in your ViralSpin app now (0 credits)." : "Reported back to ViralSpin (0 credits).") : "",
|
|
52
|
+
"",
|
|
53
|
+
"last log lines:",
|
|
54
|
+
tail(lines),
|
|
55
|
+
"",
|
|
56
|
+
JSON.stringify(r),
|
|
57
|
+
].filter((x) => x !== "").join("\n");
|
|
58
|
+
}
|
|
59
|
+
exports.renderLocallyTool = {
|
|
60
|
+
name: "render_locally",
|
|
61
|
+
description: "Render a ViralSpin REEL on THIS computer (Mac or Windows) instead of the cloud: ViralSpin plans the reel and hands over the media, " +
|
|
62
|
+
"this machine does the Remotion draw and the audio mix, and the finished mp4 stays on disk (nothing is published or uploaded; 0 credits). " +
|
|
63
|
+
"First use downloads a ~0.5 GB render kit into ~/.viralspin/kit (needs Node 20+ and 8 GB free disk); later runs reuse it. " +
|
|
64
|
+
"WITH NO ARGUMENTS it renders the newest reel the person sent to their computer from the ViralSpin app (it says which one it picked); " +
|
|
65
|
+
"the first time it opens the browser once so they can approve ViralSpin (no tokens to paste). The finished reel is uploaded back so it appears in the app. " +
|
|
66
|
+
"Or give a job_id, a render_id (a reel the app already planned) or a reel request {plan, aroll_url}. dry_run:true does everything except the draw, to check the setup. " +
|
|
67
|
+
"A real run takes minutes; pass background:true to return at once and poll with render_locally_status.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
job_id: { type: "string", description: "A job the app sent to this computer. Leave out to take the newest waiting one." },
|
|
72
|
+
upload: { type: "boolean", description: "Upload the finished mp4 so it appears in the app. Default true for app jobs." },
|
|
73
|
+
render_id: { type: "string", description: "A reel ViralSpin already planned (its render_id)." },
|
|
74
|
+
request: {
|
|
75
|
+
type: "object",
|
|
76
|
+
description: "OR the same body as POST /v1/reel/renders: { plan, aroll_url, style?, style_source? }.",
|
|
77
|
+
properties: { plan: { type: "object" }, aroll_url: { type: "string" }, style: { type: "string" }, style_source: { type: "object" } },
|
|
78
|
+
required: ["plan", "aroll_url"],
|
|
79
|
+
},
|
|
80
|
+
concurrency: { type: "number", description: `Frames drawn in parallel. Default min(4, cores/2) = ${(0, render_js_1.defaultConcurrency)()} here; 6+ was measured slower.` },
|
|
81
|
+
dry_run: { type: "boolean", description: "Install/update the kit, prepare the reel, download media, start the draw stack - then stop before drawing." },
|
|
82
|
+
complete: { type: "boolean", description: "After the draw, tell ViralSpin it finished (status + timing). Nothing is uploaded. Default false." },
|
|
83
|
+
background: { type: "boolean", description: "Return immediately with a job id; check progress with render_locally_status." },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
async execute(args = {}, onProgress) {
|
|
87
|
+
const lines = [];
|
|
88
|
+
const log = (l) => {
|
|
89
|
+
lines.push(l);
|
|
90
|
+
process.stderr.write(`[render_locally] ${l}\n`);
|
|
91
|
+
onProgress?.(l);
|
|
92
|
+
};
|
|
93
|
+
// An app job (picked or named by job_id) is reported back and uploaded by default: the app is waiting for it.
|
|
94
|
+
let job_id = args.job_id;
|
|
95
|
+
const explicit = Boolean(args.render_id || args.request);
|
|
96
|
+
if (!job_id && !explicit) {
|
|
97
|
+
const found = await discover(log);
|
|
98
|
+
if (!found.job)
|
|
99
|
+
return found.message;
|
|
100
|
+
job_id = found.job.job_id;
|
|
101
|
+
}
|
|
102
|
+
const appJob = Boolean(job_id);
|
|
103
|
+
const run = () => (0, render_js_1.renderLocally)({
|
|
104
|
+
apiBase: client_js_1.PY_API_URL, tokens: tm(), render_id: args.render_id, request: args.request, job_id,
|
|
105
|
+
concurrency: args.concurrency, dryRun: args.dry_run, complete: args.complete ?? (appJob && !args.dry_run),
|
|
106
|
+
upload: args.upload, log,
|
|
107
|
+
});
|
|
108
|
+
if (args.background) {
|
|
109
|
+
const id = `bg-${Date.now().toString(36)}`;
|
|
110
|
+
const job = { state: "running", lines, started: Date.now() };
|
|
111
|
+
jobs.set(id, job);
|
|
112
|
+
run().then((r) => { job.state = "done"; job.result = r; }).catch((e) => { job.state = "failed"; job.error = String(e?.message || e); });
|
|
113
|
+
return `Started in the background: ${id}. Call render_locally_status with job_id "${id}" to follow it.`;
|
|
114
|
+
}
|
|
115
|
+
const r = await run();
|
|
116
|
+
return report(r, lines);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
exports.renderLocallyStatusTool = {
|
|
120
|
+
name: "render_locally_status",
|
|
121
|
+
description: "Progress of a render_locally job started with background:true. With no job_id: your reels waiting for / being drawn on a computer, from ViralSpin.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: { job_id: { type: "string" } },
|
|
125
|
+
},
|
|
126
|
+
async execute(args = {}) {
|
|
127
|
+
if (args.job_id) {
|
|
128
|
+
const j = jobs.get(args.job_id);
|
|
129
|
+
if (j) {
|
|
130
|
+
const secs = Math.round((Date.now() - j.started) / 1000);
|
|
131
|
+
if (j.state === "running")
|
|
132
|
+
return `running (${secs}s)\n${tail(j.lines)}`;
|
|
133
|
+
if (j.state === "failed")
|
|
134
|
+
return `failed after ${secs}s: ${j.error}\n${tail(j.lines)}`;
|
|
135
|
+
return report(j.result, j.lines);
|
|
136
|
+
}
|
|
137
|
+
// not a background handle of this process: it may be an app job id
|
|
138
|
+
const v = await tm().request("GET", `${client_js_1.PY_API_URL}/v1/reel/local-jobs/${encodeURIComponent(args.job_id)}`).catch(() => null);
|
|
139
|
+
if (!v)
|
|
140
|
+
return `No such local render job: ${args.job_id}`;
|
|
141
|
+
return describeJob(v);
|
|
142
|
+
}
|
|
143
|
+
const running = [...jobs.entries()].filter(([, j]) => j.state === "running");
|
|
144
|
+
const lines = running.map(([id, j]) => `${id}: running here (${Math.round((Date.now() - j.started) / 1000)}s)\n${tail(j.lines, 4)}`);
|
|
145
|
+
const pending = await (0, render_js_1.listPendingJobs)({ apiBase: client_js_1.PY_API_URL, tokens: tm() });
|
|
146
|
+
for (const p of pending)
|
|
147
|
+
lines.push(describeJob(p));
|
|
148
|
+
return lines.length ? lines.join("\n\n") : "Nothing is waiting or rendering. Send a reel from the ViralSpin app (style picker > On my computer > Send to my computer).";
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
function describeJob(v) {
|
|
152
|
+
const name = v.title ? `"${v.title}"` : v.job_id.slice(0, 8);
|
|
153
|
+
const says = {
|
|
154
|
+
planning: "ViralSpin is preparing it", waiting: "ready, waiting for your computer", rendering: "being drawn on a computer",
|
|
155
|
+
done: "finished - it is in the app", failed: `failed: ${v.error_message || "no reason given"}`, cancelled: "cancelled",
|
|
156
|
+
};
|
|
157
|
+
return `${name} (${v.job_id}): ${says[v.phase ?? ""] ?? v.status}, sent ${(0, render_js_1.ago)(v.created_at)}`;
|
|
158
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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.checkReviewStatusTool = exports.createReviewLinkTool = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const client_js_1 = require("../client.js");
|
|
9
|
+
const APP_BASE = process.env.VIRALSPIN_APP_URL || "https://app.viralspin.ai";
|
|
10
|
+
function makeToken() {
|
|
11
|
+
return crypto_1.default.randomBytes(9).toString("base64url").slice(0, 12);
|
|
12
|
+
}
|
|
13
|
+
exports.createReviewLinkTool = {
|
|
14
|
+
name: "create_review_link",
|
|
15
|
+
description: "After assembling a video, generate a private shareable review link for a client. " +
|
|
16
|
+
"The client opens the link in their browser — no account required — sees the video in a " +
|
|
17
|
+
"premium 'Private Review Room', and clicks Approve or Request Changes. " +
|
|
18
|
+
"Returns the review URL to paste into an email or DM.",
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
required: ["output_path"],
|
|
22
|
+
properties: {
|
|
23
|
+
output_path: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "The local file path or public URL of the rendered MP4 — e.g. the path returned by create_video_assembly.",
|
|
26
|
+
},
|
|
27
|
+
metadata: {
|
|
28
|
+
type: "object",
|
|
29
|
+
description: "Optional context shown in the review room: caption, hook_text, style, format, platform_targets.",
|
|
30
|
+
properties: {
|
|
31
|
+
caption: { type: "string" },
|
|
32
|
+
hook_text: { type: "string" },
|
|
33
|
+
style: { type: "string" },
|
|
34
|
+
format: { type: "string", enum: ["9x16", "1x1", "16x9"] },
|
|
35
|
+
platform_targets: { type: "array", items: { type: "string" } },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
pin: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Optional 4-digit PIN the client must enter before viewing the review.",
|
|
41
|
+
},
|
|
42
|
+
expires_hours: {
|
|
43
|
+
type: "number",
|
|
44
|
+
description: "How many hours until the link expires (default: 168 = 7 days).",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
async execute(args) {
|
|
49
|
+
const token = makeToken();
|
|
50
|
+
const expiresHours = args.expires_hours ?? 168;
|
|
51
|
+
await (0, client_js_1.post)("/v1/review-sessions", {
|
|
52
|
+
token,
|
|
53
|
+
output_path: args.output_path,
|
|
54
|
+
metadata: args.metadata ?? {},
|
|
55
|
+
pin: args.pin ?? null,
|
|
56
|
+
expires_hours: expiresHours,
|
|
57
|
+
});
|
|
58
|
+
const reviewUrl = `${APP_BASE}/review/${token}`;
|
|
59
|
+
return [
|
|
60
|
+
`✅ Review link created:`,
|
|
61
|
+
``,
|
|
62
|
+
` ${reviewUrl}`,
|
|
63
|
+
``,
|
|
64
|
+
`Send this link to your client. They'll see the video in a private premiere room`,
|
|
65
|
+
`and can Approve or Request Changes — no account required.`,
|
|
66
|
+
args.pin ? `PIN required: ${args.pin}` : ``,
|
|
67
|
+
`Link expires in ${expiresHours >= 24 ? `${Math.round(expiresHours / 24)} days` : `${expiresHours}h`}.`,
|
|
68
|
+
``,
|
|
69
|
+
`To check if they've approved: check_review_status with token "${token}"`,
|
|
70
|
+
]
|
|
71
|
+
.filter((l) => l !== undefined)
|
|
72
|
+
.join("\n");
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
exports.checkReviewStatusTool = {
|
|
76
|
+
name: "check_review_status",
|
|
77
|
+
description: "Check whether a client has approved or requested changes on a review link. " +
|
|
78
|
+
"Pass the token from the URL (e.g. 'abc123xyz456' from /review/abc123xyz456).",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: "object",
|
|
81
|
+
required: ["token"],
|
|
82
|
+
properties: {
|
|
83
|
+
token: {
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "The 12-character review token from the review URL.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
async execute({ token }) {
|
|
90
|
+
const data = await (0, client_js_1.get)(`/v1/review-sessions/${token}/status`);
|
|
91
|
+
const statusLine = data.status === "approved"
|
|
92
|
+
? `✅ Approved${data.approved_at ? ` on ${new Date(data.approved_at).toLocaleString()}` : ""}`
|
|
93
|
+
: data.status === "rejected"
|
|
94
|
+
? `📝 Changes requested`
|
|
95
|
+
: `⏳ Pending — client hasn't reviewed yet`;
|
|
96
|
+
const lines = [statusLine];
|
|
97
|
+
if (data.feedback)
|
|
98
|
+
lines.push(``, `Client feedback: "${data.feedback}"`);
|
|
99
|
+
if (data.status === "approved")
|
|
100
|
+
lines.push(``, `Ready to publish with publish_now or schedule_post.`);
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.keepWindowsTool = exports.removeSpansTool = exports.getTranscriptTool = exports.trimSilencesTool = exports.renderProjectTool = exports.reorderScenesTool = exports.removeSpanTool = exports.trimClipTool = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Granular timeline edit verbs for a HOSTED Swap & Refine project (a narrative
|
|
6
|
+
* job). These widen Claude's editing vocabulary beyond swap — trim a cutaway,
|
|
7
|
+
* cut a span out of the cut, and reorder the A-roll's backbone scenes — each
|
|
8
|
+
* mapped to an already-deployed narrative endpoint, so the edit lands in the
|
|
9
|
+
* same project the web editor shows (and streams in live via realtime).
|
|
10
|
+
*
|
|
11
|
+
* All are hosted-only: they take a `job_id` (or VIRALSPIN_HOSTED_JOB_ID). There
|
|
12
|
+
* is no local-engine equivalent for these ops.
|
|
13
|
+
*/
|
|
14
|
+
const hosted_js_1 = require("../hosted.js");
|
|
15
|
+
function fmt(sec) {
|
|
16
|
+
const m = Math.floor(sec / 60);
|
|
17
|
+
const s = Math.round(sec % 60);
|
|
18
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
19
|
+
}
|
|
20
|
+
exports.trimClipTool = {
|
|
21
|
+
name: "trim_clip",
|
|
22
|
+
description: "Shorten (or restore) how long ONE B-roll cutaway stays on screen in a hosted Swap & Refine project, " +
|
|
23
|
+
"freeing that time back to the A-roll. Audio-safe — total video length is preserved. " +
|
|
24
|
+
"Get the segment id from suggest_clips. Instant timeline edit by default (no render); call render_project when done.",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
29
|
+
segment_id: { type: "string", description: "The cutaway to trim (from suggest_clips)." },
|
|
30
|
+
duration_sec: { type: "number", description: "New on-screen duration in seconds (e.g. 1.5)." },
|
|
31
|
+
render: { type: "boolean", description: "Re-render now (costs credits). Default false = instant timeline-only edit." },
|
|
32
|
+
},
|
|
33
|
+
required: ["segment_id", "duration_sec"],
|
|
34
|
+
},
|
|
35
|
+
async execute(args) {
|
|
36
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
37
|
+
if (!jobId)
|
|
38
|
+
return "trim_clip is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
|
|
39
|
+
await (0, hosted_js_1.setClipDuration)(jobId, args.segment_id, args.duration_sec);
|
|
40
|
+
if (args.render) {
|
|
41
|
+
await (0, hosted_js_1.rerender)(jobId);
|
|
42
|
+
return `Trimmed segment ${args.segment_id} to ${args.duration_sec}s and started a re-render. ${(0, hosted_js_1.editorUrl)(jobId)}`;
|
|
43
|
+
}
|
|
44
|
+
return (`Trimmed segment ${args.segment_id} to ${args.duration_sec}s (instant, no render). ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
|
|
45
|
+
`Call render_project(job_id: "${jobId}") when the cut is final.`);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
exports.removeSpanTool = {
|
|
49
|
+
name: "remove_span",
|
|
50
|
+
description: "Cut a span of time [from_sec, to_sec] out of a hosted Swap & Refine video — e.g. a duplicated opening " +
|
|
51
|
+
"sentence or a dead pause. The backbone A-roll is trimmed and the rest shifts up; reuses cached B-roll. " +
|
|
52
|
+
"Re-renders; streams into the editor.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
57
|
+
from_sec: { type: "number", description: "Start of the span to remove (seconds)." },
|
|
58
|
+
to_sec: { type: "number", description: "End of the span to remove (seconds)." },
|
|
59
|
+
},
|
|
60
|
+
required: ["from_sec", "to_sec"],
|
|
61
|
+
},
|
|
62
|
+
async execute(args) {
|
|
63
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
64
|
+
if (!jobId)
|
|
65
|
+
return "remove_span is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
|
|
66
|
+
if (!(args.to_sec > args.from_sec))
|
|
67
|
+
return "to_sec must be greater than from_sec.";
|
|
68
|
+
await (0, hosted_js_1.removeSpan)(jobId, args.from_sec, args.to_sec, true);
|
|
69
|
+
return (`Removing ${fmt(args.from_sec)}–${fmt(args.to_sec)} and re-rendering.\n` +
|
|
70
|
+
`Updating live: ${(0, hosted_js_1.editorUrl)(jobId)}`);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
exports.reorderScenesTool = {
|
|
74
|
+
name: "reorder_scenes",
|
|
75
|
+
description: "See or reorder the A-roll's backbone scenes in a hosted Swap & Refine project. " +
|
|
76
|
+
"Call with no `order` to LIST the scenes (id + label + time) — the reorderable building blocks. " +
|
|
77
|
+
"Call with `order` (the scene ids in the new running order; omit ids to drop them) to apply it and re-render.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: {
|
|
81
|
+
job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
82
|
+
order: {
|
|
83
|
+
type: "array",
|
|
84
|
+
items: { type: "string" },
|
|
85
|
+
description: "New running order as scene ids. Any scene id omitted here is removed from the cut. Omit `order` entirely to just list the scenes.",
|
|
86
|
+
},
|
|
87
|
+
render: { type: "boolean", description: "Re-render now (costs credits). Default false = instant timeline-only edit." },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
async execute(args) {
|
|
91
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
92
|
+
if (!jobId)
|
|
93
|
+
return "reorder_scenes is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
|
|
94
|
+
const { scenes } = await (0, hosted_js_1.detectScenes)(jobId);
|
|
95
|
+
if (!scenes.length)
|
|
96
|
+
return `No backbone scenes detected for project ${jobId} yet. Open it: ${(0, hosted_js_1.editorUrl)(jobId)}`;
|
|
97
|
+
if (!args.order || !args.order.length) {
|
|
98
|
+
const lines = [...scenes]
|
|
99
|
+
.sort((a, b) => a.order - b.order)
|
|
100
|
+
.map((s) => `- ${s.id}${s.label ? ` — "${s.label}"` : ""}${s.src_start != null ? ` (${fmt(s.src_start)}${s.src_end != null ? `–${fmt(s.src_end)}` : ""})` : ""}`);
|
|
101
|
+
return [
|
|
102
|
+
`Backbone scenes in ${(0, hosted_js_1.editorUrl)(jobId)} :`,
|
|
103
|
+
...lines,
|
|
104
|
+
``,
|
|
105
|
+
`Reorder with: reorder_scenes(job_id: "${jobId}", order: ["${scenes[0].id}", …]). Omit a scene id to drop it.`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
const known = new Set(scenes.map((s) => s.id));
|
|
109
|
+
const wanted = args.order.filter((id) => known.has(id));
|
|
110
|
+
if (!wanted.length)
|
|
111
|
+
return "None of the given ids matched this project's scenes — call reorder_scenes with no `order` to list valid ids.";
|
|
112
|
+
const payload = scenes.map((s) => {
|
|
113
|
+
const idx = wanted.indexOf(s.id);
|
|
114
|
+
return { id: s.id, order: idx === -1 ? 9999 : idx, removed: idx === -1 };
|
|
115
|
+
});
|
|
116
|
+
await (0, hosted_js_1.saveSceneOrder)(jobId, payload);
|
|
117
|
+
const dropped = scenes.length - wanted.length;
|
|
118
|
+
if (args.render) {
|
|
119
|
+
await (0, hosted_js_1.rerender)(jobId);
|
|
120
|
+
return `Reordered to ${wanted.length} scene(s)${dropped ? ` (dropped ${dropped})` : ""} and started a re-render. ${(0, hosted_js_1.editorUrl)(jobId)}`;
|
|
121
|
+
}
|
|
122
|
+
return (`Reordered to ${wanted.length} scene(s)${dropped ? ` (dropped ${dropped})` : ""} (instant, no render). ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
|
|
123
|
+
`Call render_project(job_id: "${jobId}") when the cut is final.`);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
exports.renderProjectTool = {
|
|
127
|
+
name: "render_project",
|
|
128
|
+
description: "Bake the current hosted Swap & Refine timeline into a final MP4 — the ONE cloud render after a batch of " +
|
|
129
|
+
"instant timeline edits (swap_clip / trim_clip / reorder_scenes). Captions, hook, overlays and motion are " +
|
|
130
|
+
"applied here. Costs a render/credits. Returns immediately; the finished preview lands on the project, then " +
|
|
131
|
+
"publish with publish_now or schedule_post.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
async execute(args) {
|
|
139
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
140
|
+
if (!jobId)
|
|
141
|
+
return "render_project is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
|
|
142
|
+
await (0, hosted_js_1.rerender)(jobId);
|
|
143
|
+
return (`Rendering the final cut for project ${jobId}.\n` +
|
|
144
|
+
`Watch it in the editor: ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
|
|
145
|
+
`When it finishes, publish with publish_now or schedule_post.`);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
exports.trimSilencesTool = {
|
|
149
|
+
name: "trim_silences",
|
|
150
|
+
description: "Edit out the dead air / empty pauses in a hosted Swap & Refine project. Reads the transcript word " +
|
|
151
|
+
"timings, finds the silent gaps between spoken words (plus a long lead-in / trailing pause), and removes " +
|
|
152
|
+
"them to tighten the cut. It re-cuts the A-roll once per gap, so it can take a couple of minutes on a " +
|
|
153
|
+
"long clip — it does the BIGGEST gaps first (capped by max_cuts). Needs a transcribed talking-head A-roll.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
158
|
+
min_gap_sec: { type: "number", description: "Smallest pause to treat as dead air (default 0.7s). Lower = tighter." },
|
|
159
|
+
max_cuts: { type: "number", description: "Safety cap on how many gaps to remove this pass (default 12, biggest first)." },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
async execute(args) {
|
|
163
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
164
|
+
if (!jobId)
|
|
165
|
+
return "trim_silences is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
|
|
166
|
+
const minGap = args.min_gap_sec ?? 0.7;
|
|
167
|
+
const maxCuts = args.max_cuts ?? 12;
|
|
168
|
+
const PAD = 0.1;
|
|
169
|
+
const job = await (0, hosted_js_1.getJob)(jobId);
|
|
170
|
+
const tl = job.timeline || {};
|
|
171
|
+
const words = Array.isArray(tl.words) ? tl.words : [];
|
|
172
|
+
if (words.length < 2) {
|
|
173
|
+
return "No transcript word-timings on this project, so I can't detect silences (needs a transcribed talking-head A-roll).";
|
|
174
|
+
}
|
|
175
|
+
const total = Number(tl.total_duration_sec) || Number(words[words.length - 1]?.end) || 0;
|
|
176
|
+
const gaps = [];
|
|
177
|
+
// long lead-in pause
|
|
178
|
+
const firstStart = Number(words[0]?.start) || 0;
|
|
179
|
+
if (firstStart >= minGap + PAD)
|
|
180
|
+
gaps.push({ from: PAD, to: +(firstStart - PAD).toFixed(2), len: firstStart });
|
|
181
|
+
// gaps between words
|
|
182
|
+
for (let i = 0; i < words.length - 1; i++) {
|
|
183
|
+
const a = Number(words[i]?.end), b = Number(words[i + 1]?.start);
|
|
184
|
+
if (!Number.isFinite(a) || !Number.isFinite(b))
|
|
185
|
+
continue;
|
|
186
|
+
const len = b - a;
|
|
187
|
+
if (len >= minGap)
|
|
188
|
+
gaps.push({ from: +(a + PAD).toFixed(2), to: +(b - PAD).toFixed(2), len });
|
|
189
|
+
}
|
|
190
|
+
// trailing pause
|
|
191
|
+
const lastEnd = Number(words[words.length - 1]?.end) || 0;
|
|
192
|
+
if (total - lastEnd >= minGap + PAD)
|
|
193
|
+
gaps.push({ from: +(lastEnd + PAD).toFixed(2), to: +total.toFixed(2), len: total - lastEnd });
|
|
194
|
+
const valid = gaps.filter((g) => g.to - g.from >= 0.2);
|
|
195
|
+
if (!valid.length)
|
|
196
|
+
return `No silences ≥ ${minGap}s found — the cut is already tight.`;
|
|
197
|
+
// biggest gaps first (within the cap), then remove latest→earliest so each
|
|
198
|
+
// removal doesn't invalidate the earlier gaps' timestamps.
|
|
199
|
+
const chosen = [...valid].sort((a, b) => b.len - a.len).slice(0, maxCuts).sort((a, b) => b.from - a.from);
|
|
200
|
+
let removed = 0;
|
|
201
|
+
let ok = 0;
|
|
202
|
+
for (const g of chosen) {
|
|
203
|
+
try {
|
|
204
|
+
await (0, hosted_js_1.removeSpan)(jobId, g.from, g.to, false);
|
|
205
|
+
removed += g.to - g.from;
|
|
206
|
+
ok++;
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
process.stderr.write(`[trim_silences] span ${g.from}-${g.to} failed: ${e instanceof Error ? e.message : e}\n`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!ok)
|
|
213
|
+
return "Couldn't trim the silences — the backend rejected the spans. The project may not allow span removal yet.";
|
|
214
|
+
await (0, hosted_js_1.rerender)(jobId);
|
|
215
|
+
const more = valid.length > chosen.length ? ` (${valid.length - chosen.length} smaller pause(s) left — run again or lower min_gap_sec)` : "";
|
|
216
|
+
return (`Trimmed ${ok} silent gap(s) — about ${removed.toFixed(1)}s of dead air${more} — and started a re-render.\n` +
|
|
217
|
+
`Watch it tighten in the editor: ${(0, hosted_js_1.editorUrl)(jobId)}`);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
exports.getTranscriptTool = {
|
|
221
|
+
name: "get_transcript",
|
|
222
|
+
description: "Get the timestamped transcript of a hosted Swap & Refine project so YOU (the agent) can spot what to cut " +
|
|
223
|
+
"— bad takes, restarts ('let me start over'), repeated sentences, filler — or which moments are worth " +
|
|
224
|
+
"highlighting in a long talking/narrated video. Returns spoken segments with start/end times. Then act: " +
|
|
225
|
+
"remove_spans to cut bad parts, or keep_windows to keep only the highlight moments.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
type: "object",
|
|
228
|
+
properties: {
|
|
229
|
+
job_id: { type: "string", description: "The Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
async execute(args) {
|
|
233
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
234
|
+
if (!jobId)
|
|
235
|
+
return "get_transcript is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
|
|
236
|
+
const job = await (0, hosted_js_1.getJob)(jobId);
|
|
237
|
+
const tl = job.timeline || {};
|
|
238
|
+
let segs = [];
|
|
239
|
+
if (Array.isArray(tl.transcript) && tl.transcript.length) {
|
|
240
|
+
segs = tl.transcript.map((s) => ({ a: Number(s.start_sec ?? s.start ?? 0), b: Number(s.end_sec ?? s.end ?? 0), text: String(s.text ?? "").trim() }));
|
|
241
|
+
}
|
|
242
|
+
else if (Array.isArray(tl.words) && tl.words.length) {
|
|
243
|
+
// group words into phrases on pauses > 0.5s
|
|
244
|
+
let cur = null;
|
|
245
|
+
for (const w of tl.words) {
|
|
246
|
+
const a = Number(w.start), b = Number(w.end), t = String(w.word ?? w.text ?? "");
|
|
247
|
+
if (!Number.isFinite(a))
|
|
248
|
+
continue;
|
|
249
|
+
if (cur && a - cur.b > 0.5) {
|
|
250
|
+
segs.push(cur);
|
|
251
|
+
cur = null;
|
|
252
|
+
}
|
|
253
|
+
if (!cur)
|
|
254
|
+
cur = { a, b, text: t };
|
|
255
|
+
else {
|
|
256
|
+
cur.b = b;
|
|
257
|
+
cur.text += (t.startsWith("'") ? "" : " ") + t;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (cur)
|
|
261
|
+
segs.push(cur);
|
|
262
|
+
}
|
|
263
|
+
if (!segs.length)
|
|
264
|
+
return "No transcript on this project (needs a transcribed talking-head/narrated A-roll).";
|
|
265
|
+
const lines = segs.map((s) => `[${s.a.toFixed(1)}–${s.b.toFixed(1)}] ${s.text}`);
|
|
266
|
+
return [
|
|
267
|
+
`Transcript for ${jobId} (spot restarts / repeats / filler to cut, or highlight moments to keep):`,
|
|
268
|
+
...lines,
|
|
269
|
+
``,
|
|
270
|
+
`Cut bad spans: remove_spans(job_id: "${jobId}", spans: [{from_sec, to_sec}, …]).`,
|
|
271
|
+
`Keep only highlights: keep_windows(job_id: "${jobId}", windows: [{from_sec, to_sec}, …]).`,
|
|
272
|
+
].join("\n");
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
/** Remove several spans (latest→earliest so earlier coords stay valid), one render. */
|
|
276
|
+
async function removeMany(jobId, spans) {
|
|
277
|
+
const ordered = [...spans].filter((s) => s.to - s.from >= 0.15).sort((a, b) => b.from - a.from);
|
|
278
|
+
let ok = 0, removed = 0;
|
|
279
|
+
for (const s of ordered) {
|
|
280
|
+
try {
|
|
281
|
+
await (0, hosted_js_1.removeSpan)(jobId, +s.from.toFixed(2), +s.to.toFixed(2), false);
|
|
282
|
+
removed += s.to - s.from;
|
|
283
|
+
ok++;
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
process.stderr.write(`[removeMany] ${s.from}-${s.to} failed: ${e instanceof Error ? e.message : e}\n`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { ok, removed };
|
|
290
|
+
}
|
|
291
|
+
exports.removeSpansTool = {
|
|
292
|
+
name: "remove_spans",
|
|
293
|
+
description: "Cut SEVERAL time spans out of a hosted project in one pass (e.g. bad takes / restarts you found via " +
|
|
294
|
+
"get_transcript), then re-render once. Spans are seconds of the current cut. Re-cuts the A-roll per span, " +
|
|
295
|
+
"so several takes a minute or two.",
|
|
296
|
+
inputSchema: {
|
|
297
|
+
type: "object",
|
|
298
|
+
properties: {
|
|
299
|
+
job_id: { type: "string", description: "Project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
300
|
+
spans: {
|
|
301
|
+
type: "array",
|
|
302
|
+
items: { type: "object", properties: { from_sec: { type: "number" }, to_sec: { type: "number" } }, required: ["from_sec", "to_sec"] },
|
|
303
|
+
description: "Spans to cut, each { from_sec, to_sec }.",
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
required: ["spans"],
|
|
307
|
+
},
|
|
308
|
+
async execute(args) {
|
|
309
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
310
|
+
if (!jobId)
|
|
311
|
+
return "remove_spans is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
|
|
312
|
+
const spans = (args.spans || []).filter((s) => s && s.to_sec > s.from_sec).map((s) => ({ from: +s.from_sec, to: +s.to_sec }));
|
|
313
|
+
if (!spans.length)
|
|
314
|
+
return "No valid spans (each needs to_sec > from_sec).";
|
|
315
|
+
const { ok, removed } = await removeMany(jobId, spans);
|
|
316
|
+
if (!ok)
|
|
317
|
+
return "Couldn't remove the spans — the backend rejected them.";
|
|
318
|
+
await (0, hosted_js_1.rerender)(jobId);
|
|
319
|
+
return `Removed ${ok} span(s) — about ${removed.toFixed(1)}s — and started a re-render.\n${(0, hosted_js_1.editorUrl)(jobId)}`;
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
exports.keepWindowsTool = {
|
|
323
|
+
name: "keep_windows",
|
|
324
|
+
description: "Turn a long video into a highlight reel: KEEP only the given time windows and cut everything else, then " +
|
|
325
|
+
"re-render. Great for 'find the worth-highlighting moments in this 10-min recording' — get_transcript, " +
|
|
326
|
+
"pick the punchy windows, then keep_windows. Windows are seconds of the current cut.",
|
|
327
|
+
inputSchema: {
|
|
328
|
+
type: "object",
|
|
329
|
+
properties: {
|
|
330
|
+
job_id: { type: "string", description: "Project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
331
|
+
windows: {
|
|
332
|
+
type: "array",
|
|
333
|
+
items: { type: "object", properties: { from_sec: { type: "number" }, to_sec: { type: "number" } }, required: ["from_sec", "to_sec"] },
|
|
334
|
+
description: "The moments to KEEP, each { from_sec, to_sec }. Everything outside them is cut.",
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
required: ["windows"],
|
|
338
|
+
},
|
|
339
|
+
async execute(args) {
|
|
340
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
341
|
+
if (!jobId)
|
|
342
|
+
return "keep_windows is for hosted projects — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID).";
|
|
343
|
+
const wins = (args.windows || []).filter((w) => w && w.to_sec > w.from_sec).map((w) => ({ from: +w.from_sec, to: +w.to_sec })).sort((a, b) => a.from - b.from);
|
|
344
|
+
if (!wins.length)
|
|
345
|
+
return "No valid windows to keep (each needs to_sec > from_sec).";
|
|
346
|
+
const job = await (0, hosted_js_1.getJob)(jobId);
|
|
347
|
+
const total = Number(job.timeline?.total_duration_sec) || wins[wins.length - 1].to;
|
|
348
|
+
// complement = everything outside the kept windows
|
|
349
|
+
const cuts = [];
|
|
350
|
+
if (wins[0].from > 0.15)
|
|
351
|
+
cuts.push({ from: 0, to: wins[0].from });
|
|
352
|
+
for (let i = 0; i < wins.length - 1; i++) {
|
|
353
|
+
if (wins[i + 1].from - wins[i].to > 0.15)
|
|
354
|
+
cuts.push({ from: wins[i].to, to: wins[i + 1].from });
|
|
355
|
+
}
|
|
356
|
+
if (total - wins[wins.length - 1].to > 0.15)
|
|
357
|
+
cuts.push({ from: wins[wins.length - 1].to, to: total });
|
|
358
|
+
if (!cuts.length)
|
|
359
|
+
return "The windows already cover the whole video — nothing to cut.";
|
|
360
|
+
const { ok, removed } = await removeMany(jobId, cuts);
|
|
361
|
+
if (!ok)
|
|
362
|
+
return "Couldn't trim to the windows — the backend rejected the spans.";
|
|
363
|
+
await (0, hosted_js_1.rerender)(jobId);
|
|
364
|
+
const kept = wins.reduce((s, w) => s + (w.to - w.from), 0);
|
|
365
|
+
return `Kept ${wins.length} highlight window(s) (~${kept.toFixed(1)}s), cut ~${removed.toFixed(1)}s, and started a re-render.\n${(0, hosted_js_1.editorUrl)(jobId)}`;
|
|
366
|
+
},
|
|
367
|
+
};
|