@viralspin/mcp 0.1.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 +55 -9
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +129 -1
- 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 +104 -9
- package/dist/tools/relight-clip.js +98 -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 +14 -2
- 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,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
|
+
};
|