@viralspin/mcp 0.2.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 +12 -3
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +77 -17
- 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 +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 +50 -14
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/render-locally.js +158 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +131 -4
- package/package.json +42 -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
|
@@ -0,0 +1,201 @@
|
|
|
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.TokenManager = exports.RefreshRefused = void 0;
|
|
7
|
+
exports.jwtExpiry = jwtExpiry;
|
|
8
|
+
exports.saveTokens = saveTokens;
|
|
9
|
+
exports.defaultTokenFile = defaultTokenFile;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const os_1 = __importDefault(require("os"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
function jwtExpiry(token) {
|
|
14
|
+
try {
|
|
15
|
+
const part = token.split(".")[1];
|
|
16
|
+
if (!part)
|
|
17
|
+
return null;
|
|
18
|
+
const json = JSON.parse(Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
19
|
+
return typeof json.exp === "number" ? json.exp : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Write the token file owner-only (dir 0700, file 0600), atomically. chmod even if the file existed with looser bits. */
|
|
26
|
+
function saveTokens(file, data) {
|
|
27
|
+
const dir = path_1.default.dirname(file);
|
|
28
|
+
fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
try {
|
|
30
|
+
fs_1.default.chmodSync(dir, 0o700);
|
|
31
|
+
}
|
|
32
|
+
catch { /* not ours to chmod (a shared home on Windows) */ }
|
|
33
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
34
|
+
fs_1.default.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
35
|
+
try {
|
|
36
|
+
fs_1.default.chmodSync(tmp, 0o600);
|
|
37
|
+
}
|
|
38
|
+
catch { /* Windows */ }
|
|
39
|
+
fs_1.default.renameSync(tmp, file);
|
|
40
|
+
}
|
|
41
|
+
function defaultTokenFile() {
|
|
42
|
+
return path_1.default.join(os_1.default.homedir(), ".viralspin", "oauth.json");
|
|
43
|
+
}
|
|
44
|
+
/** The server said the refresh token is no good (expired, revoked): a new sign-in is the only way forward. */
|
|
45
|
+
class RefreshRefused extends Error {
|
|
46
|
+
}
|
|
47
|
+
exports.RefreshRefused = RefreshRefused;
|
|
48
|
+
class TokenManager {
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
this.opts = opts;
|
|
51
|
+
this.mem = null;
|
|
52
|
+
this.refreshCount = 0;
|
|
53
|
+
this.signing = null;
|
|
54
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
55
|
+
this.file = opts.file ?? defaultTokenFile();
|
|
56
|
+
this.env = opts.env ?? process.env;
|
|
57
|
+
this.now = opts.now ?? Date.now;
|
|
58
|
+
this.skew = opts.skewSec ?? 120;
|
|
59
|
+
}
|
|
60
|
+
readFile() {
|
|
61
|
+
try {
|
|
62
|
+
const j = JSON.parse(fs_1.default.readFileSync(this.file, "utf8"));
|
|
63
|
+
return j && typeof j.access_token === "string" ? j : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
fromEnv() {
|
|
70
|
+
const a = this.env.VIRALSPIN_OAUTH_TOKEN;
|
|
71
|
+
if (!a && !this.env.VIRALSPIN_OAUTH_REFRESH_TOKEN)
|
|
72
|
+
return null;
|
|
73
|
+
return {
|
|
74
|
+
access_token: a || "",
|
|
75
|
+
refresh_token: this.env.VIRALSPIN_OAUTH_REFRESH_TOKEN || undefined,
|
|
76
|
+
client_id: this.env.VIRALSPIN_OAUTH_CLIENT_ID || undefined,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
expiry(p) {
|
|
80
|
+
if (!p.access_token)
|
|
81
|
+
return 0;
|
|
82
|
+
return jwtExpiry(p.access_token) ?? Number.POSITIVE_INFINITY; // not a JWT with exp: trust until the server says 401
|
|
83
|
+
}
|
|
84
|
+
/** The candidate with the latest expiry (re-read from the file every call). */
|
|
85
|
+
best() {
|
|
86
|
+
const all = [this.mem, this.readFile(), this.fromEnv()].filter(Boolean);
|
|
87
|
+
if (!all.length)
|
|
88
|
+
return null;
|
|
89
|
+
return all.sort((a, b) => this.expiry(b) - this.expiry(a))[0];
|
|
90
|
+
}
|
|
91
|
+
refreshToken() {
|
|
92
|
+
for (const p of [this.mem, this.readFile(), this.fromEnv()]) {
|
|
93
|
+
if (p?.refresh_token)
|
|
94
|
+
return { rt: p.refresh_token, client_id: p.client_id };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
/** Run the browser sign-in, one at a time, and forget anything stale held in memory. */
|
|
99
|
+
async signInNow() {
|
|
100
|
+
if (!this.opts.signIn)
|
|
101
|
+
return false;
|
|
102
|
+
this.signing ?? (this.signing = this.opts.signIn().finally(() => { this.signing = null; }));
|
|
103
|
+
await this.signing;
|
|
104
|
+
this.mem = null;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
async getAccessToken() {
|
|
108
|
+
let cur = this.best();
|
|
109
|
+
if (!cur && (await this.signInNow()))
|
|
110
|
+
cur = this.best();
|
|
111
|
+
if (!cur) {
|
|
112
|
+
throw new Error("Not signed in to ViralSpin on this computer. Run `npx -y @viralspin/mcp login` once (it opens your browser), " +
|
|
113
|
+
"or set VIRALSPIN_OAUTH_TOKEN (+ VIRALSPIN_OAUTH_REFRESH_TOKEN) in the MCP server's env.");
|
|
114
|
+
}
|
|
115
|
+
const nowSec = this.now() / 1000;
|
|
116
|
+
if (cur.access_token && this.expiry(cur) - nowSec > this.skew)
|
|
117
|
+
return cur.access_token;
|
|
118
|
+
if (this.refreshToken()) {
|
|
119
|
+
try {
|
|
120
|
+
return await this.refresh();
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
// the 30-day refresh token ran out (or was revoked): sign in again in the browser, if we can
|
|
124
|
+
if (!(e instanceof RefreshRefused) || !(await this.signInNow()))
|
|
125
|
+
throw e;
|
|
126
|
+
const fresh = this.best();
|
|
127
|
+
if (fresh?.access_token)
|
|
128
|
+
return fresh.access_token;
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (cur.access_token)
|
|
133
|
+
return cur.access_token; // nothing to refresh with: let the server judge it
|
|
134
|
+
throw new Error("The ViralSpin sign-in has expired and there is no refresh token. Sign in again.");
|
|
135
|
+
}
|
|
136
|
+
async refresh() {
|
|
137
|
+
const r = this.refreshToken();
|
|
138
|
+
if (!r)
|
|
139
|
+
throw new Error("The ViralSpin sign-in expired and there is no refresh token (VIRALSPIN_OAUTH_REFRESH_TOKEN). Sign in again.");
|
|
140
|
+
const form = new URLSearchParams({ grant_type: "refresh_token", refresh_token: r.rt });
|
|
141
|
+
if (r.client_id)
|
|
142
|
+
form.set("client_id", r.client_id);
|
|
143
|
+
const resp = await this.fetchImpl(`${this.opts.apiBase}/v1/oauth/token`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
146
|
+
body: form.toString(),
|
|
147
|
+
});
|
|
148
|
+
if (!resp.ok) {
|
|
149
|
+
const text = await resp.text().catch(() => "");
|
|
150
|
+
const msg = `Could not renew the ViralSpin sign-in (${resp.status}). The refresh token lasts 30 days; sign in again. ${text.slice(0, 160)}`;
|
|
151
|
+
throw resp.status === 400 || resp.status === 401 ? new RefreshRefused(msg) : new Error(msg);
|
|
152
|
+
}
|
|
153
|
+
const j = (await resp.json());
|
|
154
|
+
if (!j.access_token)
|
|
155
|
+
throw new Error("The token endpoint answered without an access_token.");
|
|
156
|
+
this.mem = { access_token: j.access_token, refresh_token: j.refresh_token || r.rt, client_id: r.client_id };
|
|
157
|
+
this.refreshCount++;
|
|
158
|
+
try {
|
|
159
|
+
saveTokens(this.file, { ...this.mem });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
/* the file is a convenience; memory is enough for this process */
|
|
163
|
+
}
|
|
164
|
+
return j.access_token;
|
|
165
|
+
}
|
|
166
|
+
/** Authenticated JSON request; one retry after a refresh when the server says 401. */
|
|
167
|
+
async request(method, url, body) {
|
|
168
|
+
const go = async (token) => this.fetchImpl(url, {
|
|
169
|
+
method,
|
|
170
|
+
headers: { Authorization: `Bearer ${token}`, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
171
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
172
|
+
});
|
|
173
|
+
let resp = await go(await this.getAccessToken());
|
|
174
|
+
if (resp.status === 401 && (this.refreshToken() || this.opts.signIn)) {
|
|
175
|
+
let fresh;
|
|
176
|
+
try {
|
|
177
|
+
fresh = this.refreshToken() ? await this.refresh() : (await this.signInNow(), await this.getAccessToken());
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (!(e instanceof RefreshRefused) || !(await this.signInNow()))
|
|
181
|
+
throw e;
|
|
182
|
+
fresh = await this.getAccessToken();
|
|
183
|
+
}
|
|
184
|
+
resp = await go(fresh);
|
|
185
|
+
}
|
|
186
|
+
if (!resp.ok) {
|
|
187
|
+
const text = await resp.text().catch(() => "");
|
|
188
|
+
let detail = text;
|
|
189
|
+
try {
|
|
190
|
+
const d = JSON.parse(text).detail;
|
|
191
|
+
detail = typeof d === "string" ? d : JSON.stringify(d ?? text);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* keep text */
|
|
195
|
+
}
|
|
196
|
+
throw new Error(`API error ${resp.status} on ${method} ${url.replace(this.opts.apiBase, "")}: ${detail.slice(0, 400)}`);
|
|
197
|
+
}
|
|
198
|
+
return (await resp.json());
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
exports.TokenManager = TokenManager;
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Strips unpaired UTF-16 surrogates from strings so MCP responses can be
|
|
3
|
+
// safely JSON-serialized and written to the stdio transport. Lone surrogates
|
|
4
|
+
// (e.g. an emoji's surrogate pair cut in half by a fixed-length .slice) cause
|
|
5
|
+
// the transport's UTF-8 encoder to throw "no low surrogate in string".
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.sanitizeSurrogates = sanitizeSurrogates;
|
|
8
|
+
exports.sanitizeObject = sanitizeObject;
|
|
9
|
+
const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
10
|
+
function sanitizeSurrogates(str) {
|
|
11
|
+
return str.replace(UNPAIRED_SURROGATE, "");
|
|
12
|
+
}
|
|
13
|
+
function sanitizeObject(obj) {
|
|
14
|
+
if (typeof obj === "string")
|
|
15
|
+
return sanitizeSurrogates(obj);
|
|
16
|
+
if (Array.isArray(obj))
|
|
17
|
+
return obj.map(sanitizeObject);
|
|
18
|
+
if (obj && typeof obj === "object") {
|
|
19
|
+
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, sanitizeObject(v)]));
|
|
20
|
+
}
|
|
21
|
+
return obj;
|
|
22
|
+
}
|
|
@@ -6,13 +6,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.swapCaptionStyleTool = exports.swapClipTool = exports.suggestClipsTool = void 0;
|
|
7
7
|
const child_process_1 = require("child_process");
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
9
|
+
const hosted_js_1 = require("../hosted.js");
|
|
10
|
+
const engine_js_1 = require("../engine.js");
|
|
11
|
+
// Adjustment tools operate on the most recent create_video_assembly project. By
|
|
12
|
+
// default that's the LOCAL engine project (the engine keeps the timeline +
|
|
13
|
+
// analyzed candidate pool + staged clips on disk), so suggesting is instant and
|
|
14
|
+
// swapping just re-renders locally. When a hosted project is targeted instead —
|
|
15
|
+
// a `job_id` arg, or the VIRALSPIN_HOSTED_JOB_ID env set by a hosted
|
|
16
|
+
// create_video_assembly — the same verbs drive the web Swap & Refine project via
|
|
17
|
+
// the deployed narrative endpoints, so the edit shows up in the browser editor.
|
|
18
|
+
// Shown when a local action is requested but no engine is bundled (the public
|
|
19
|
+
// npm package) and no hosted project is targeted.
|
|
20
|
+
const NO_LOCAL_ENGINE = "This local action needs the ViralSpin engine, which isn't bundled in this install.\n" +
|
|
21
|
+
"Target a hosted Swap & Refine project instead — pass `job_id` (or set VIRALSPIN_HOSTED_JOB_ID) — " +
|
|
22
|
+
"or set VIRALSPIN_ASSEMBLY_DIR to a local engine checkout.";
|
|
13
23
|
function runEngine(args) {
|
|
14
24
|
return new Promise((resolve, reject) => {
|
|
15
|
-
const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(ENGINE_DIR, "assemble.mjs"), ...args], { cwd: ENGINE_DIR, env: { ...process.env } });
|
|
25
|
+
const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "assemble.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
|
|
16
26
|
let stdout = "";
|
|
17
27
|
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
18
28
|
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
@@ -22,17 +32,56 @@ function runEngine(args) {
|
|
|
22
32
|
}
|
|
23
33
|
exports.suggestClipsTool = {
|
|
24
34
|
name: "suggest_clips",
|
|
25
|
-
description: "
|
|
26
|
-
"
|
|
27
|
-
"
|
|
35
|
+
description: "See a project's cutaways and find better B-roll for one of them. " +
|
|
36
|
+
"LOCAL project (default): pass `cut` (0-based) to get ranked alternatives for that cutaway, each with a thumbnail. " +
|
|
37
|
+
"HOSTED Swap & Refine project (pass `job_id`, or set VIRALSPIN_HOSTED_JOB_ID): " +
|
|
38
|
+
"call with no `query` to list the timeline's cutaways (segment id + the beat each covers + current clip); " +
|
|
39
|
+
"call with `query` (e.g. \"newborn in hospital\") to search the user's B-roll library — each result includes the " +
|
|
40
|
+
"library tags it matched on, which is the 'why this clip fits' to show the user. " +
|
|
41
|
+
"Then call swap_clip to apply a choice.",
|
|
28
42
|
inputSchema: {
|
|
29
43
|
type: "object",
|
|
30
44
|
properties: {
|
|
31
|
-
cut: { type: "number", description: "
|
|
45
|
+
cut: { type: "number", description: "LOCAL only: index of the cutaway to replace (0-based; from the cut list create_video_assembly returns)." },
|
|
46
|
+
job_id: { type: "string", description: "HOSTED: the Swap & Refine project (narrative job) to inspect. Omit to use VIRALSPIN_HOSTED_JOB_ID, or the local project." },
|
|
47
|
+
query: { type: "string", description: "HOSTED: theme to search the B-roll library for (the moment's subject). Omit to list the timeline's cutaways instead." },
|
|
32
48
|
},
|
|
33
|
-
required: ["cut"],
|
|
34
49
|
},
|
|
35
50
|
async execute(args) {
|
|
51
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
52
|
+
if (jobId) {
|
|
53
|
+
// Hosted project: either list the timeline's cutaways, or search the
|
|
54
|
+
// library for alternatives (with the matched tags = the "why").
|
|
55
|
+
if (args.query) {
|
|
56
|
+
const r = await (0, hosted_js_1.searchLibrary)(args.query);
|
|
57
|
+
if (!r.items.length)
|
|
58
|
+
return `No library B-roll matched "${args.query}". Try a broader term.`;
|
|
59
|
+
const lines = r.items.map((it, i) => `${i + 1}. ${it.url}\n ${it.kind}${it.album ? ` · from "${it.album}"` : ""} — matched on: "${args.query}"`);
|
|
60
|
+
return [
|
|
61
|
+
`B-roll matching "${args.query}" (best fit first):`,
|
|
62
|
+
...lines,
|
|
63
|
+
``,
|
|
64
|
+
`Apply one with: swap_clip(job_id: "${jobId}", segment_id: "<from the cutaway list>", asset_url: "<url above>").`,
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
const job = await (0, hosted_js_1.getJob)(jobId);
|
|
68
|
+
const segs = (0, hosted_js_1.listSegments)(job.timeline);
|
|
69
|
+
if (!segs.length) {
|
|
70
|
+
return `Project ${jobId} has no listable cutaways yet (status: ${job.status}). Open it: ${(0, hosted_js_1.editorUrl)(jobId)}`;
|
|
71
|
+
}
|
|
72
|
+
const lines = segs.map((s, i) => `${i + 1}. segment ${s.id}${s.beat ? ` — "${s.beat}"` : ""}${s.asset_url ? `\n current: ${s.asset_url}` : ""}`);
|
|
73
|
+
return [
|
|
74
|
+
`Cutaways in ${(0, hosted_js_1.editorUrl)(jobId)} :`,
|
|
75
|
+
...lines,
|
|
76
|
+
``,
|
|
77
|
+
`Find alternatives for one: suggest_clips(job_id: "${jobId}", query: "<what that moment is about>").`,
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
if (!(0, engine_js_1.engineAvailable)())
|
|
81
|
+
return NO_LOCAL_ENGINE;
|
|
82
|
+
if (args.cut === undefined) {
|
|
83
|
+
return "For a local project, pass `cut` (the 0-based cutaway index). For a hosted Swap & Refine project, pass `job_id`.";
|
|
84
|
+
}
|
|
36
85
|
const out = await runEngine(["--suggest", String(args.cut)]);
|
|
37
86
|
const m = out.match(/SUGGEST:(.+)/);
|
|
38
87
|
if (!m)
|
|
@@ -50,17 +99,49 @@ exports.suggestClipsTool = {
|
|
|
50
99
|
};
|
|
51
100
|
exports.swapClipTool = {
|
|
52
101
|
name: "swap_clip",
|
|
53
|
-
description: "Replace one cutaway
|
|
54
|
-
"
|
|
102
|
+
description: "Replace one cutaway with a chosen B-roll asset (from suggest_clips), then re-render. " +
|
|
103
|
+
"LOCAL project (default): pass `cut` (0-based) + `asset` (filename) → re-renders locally, returns the MP4 path. " +
|
|
104
|
+
"HOSTED Swap & Refine project (pass `job_id`, or set VIRALSPIN_HOSTED_JOB_ID): pass `segment_id` + `asset_url` → " +
|
|
105
|
+
"patches the same timeline the browser editor shows and kicks off a cloud re-render; the change appears live in " +
|
|
106
|
+
"Swap & Refine. Returns immediately — the new preview lands on the project shortly.",
|
|
55
107
|
inputSchema: {
|
|
56
108
|
type: "object",
|
|
57
109
|
properties: {
|
|
58
|
-
cut: { type: "number", description: "
|
|
59
|
-
asset: { type: "string", description: "
|
|
110
|
+
cut: { type: "number", description: "LOCAL: index of the cutaway to replace (0-based)." },
|
|
111
|
+
asset: { type: "string", description: "LOCAL: asset filename from suggest_clips (e.g. \"v03.mp4\" or \"07.jpg\")." },
|
|
112
|
+
job_id: { type: "string", description: "HOSTED: the Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID." },
|
|
113
|
+
segment_id: { type: "string", description: "HOSTED: the cutaway's segment id (from suggest_clips)." },
|
|
114
|
+
asset_url: { type: "string", description: "HOSTED: public URL of the replacement B-roll (from suggest_clips)." },
|
|
115
|
+
asset_kind: { type: "string", enum: ["broll_video", "broll_photo"], description: "HOSTED: optional — kind of the replacement asset." },
|
|
116
|
+
reason: { type: "string", description: "HOSTED: optional one-line rationale for WHY this clip fits here (e.g. \"the line says 'the day she arrived'\"). Stored as provenance + shown on the timeline." },
|
|
117
|
+
render: { type: "boolean", description: "HOSTED: re-render now (costs a render/credits). Default false = instant timeline-only edit; iterate freely, then call render_project once to bake & publish." },
|
|
60
118
|
},
|
|
61
|
-
required: ["cut", "asset"],
|
|
62
119
|
},
|
|
63
120
|
async execute(args) {
|
|
121
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
122
|
+
if (jobId) {
|
|
123
|
+
if (!args.segment_id || !args.asset_url) {
|
|
124
|
+
return "For a hosted project, pass `segment_id` and `asset_url` (both from suggest_clips).";
|
|
125
|
+
}
|
|
126
|
+
let kind = args.asset_kind;
|
|
127
|
+
if (!kind)
|
|
128
|
+
kind = /\.(mp4|mov|webm|m4v)(\?|$)/i.test(args.asset_url) ? "broll_video" : "broll_photo";
|
|
129
|
+
await (0, hosted_js_1.swapSegment)(jobId, args.segment_id, args.asset_url, kind, args.reason);
|
|
130
|
+
if (args.render) {
|
|
131
|
+
await (0, hosted_js_1.rerender)(jobId);
|
|
132
|
+
return (`Swapped segment ${args.segment_id} → ${args.asset_url}${args.reason ? ` (${args.reason})` : ""} and started a cloud re-render.\n` +
|
|
133
|
+
`Updating live in the editor: ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
|
|
134
|
+
`Publish it with publish_now or schedule_post once it finishes.`);
|
|
135
|
+
}
|
|
136
|
+
return (`Swapped segment ${args.segment_id} → ${args.asset_url}${args.reason ? ` (${args.reason})` : ""} (instant, no render).\n` +
|
|
137
|
+
`Keep editing freely — it's all on the timeline. Open: ${(0, hosted_js_1.editorUrl)(jobId)}\n` +
|
|
138
|
+
`When the cut is right, call render_project(job_id: "${jobId}") to bake captions/overlays and get the final MP4.`);
|
|
139
|
+
}
|
|
140
|
+
if (!(0, engine_js_1.engineAvailable)())
|
|
141
|
+
return NO_LOCAL_ENGINE;
|
|
142
|
+
if (args.cut === undefined || !args.asset) {
|
|
143
|
+
return "For a local project, pass `cut` and `asset`. For a hosted Swap & Refine project, pass `job_id`, `segment_id`, and `asset_url`.";
|
|
144
|
+
}
|
|
64
145
|
const out = await runEngine(["--swap", String(args.cut), args.asset]);
|
|
65
146
|
const m = out.match(/RESULT:(.+)/);
|
|
66
147
|
if (!m)
|
|
@@ -87,10 +168,21 @@ exports.swapCaptionStyleTool = {
|
|
|
87
168
|
"minimalist = lowercase thin font fade-in (calm, educational). " +
|
|
88
169
|
"whiteboard = text revealed left-to-right (explainers, tutorials).",
|
|
89
170
|
},
|
|
171
|
+
job_id: { type: "string", description: "HOSTED: the Swap & Refine project. Omit to use VIRALSPIN_HOSTED_JOB_ID or the local project." },
|
|
90
172
|
},
|
|
91
173
|
required: ["caption_style"],
|
|
92
174
|
},
|
|
93
175
|
async execute(args) {
|
|
176
|
+
// Hosted projects set caption style via the editor's render options; there's
|
|
177
|
+
// no standalone caption-swap endpoint to drive from here yet. Be explicit
|
|
178
|
+
// rather than hit a guessed route.
|
|
179
|
+
const jobId = (0, hosted_js_1.hostedJobId)(args.job_id);
|
|
180
|
+
if (jobId) {
|
|
181
|
+
return (`Caption style isn't swappable from here for hosted Swap & Refine projects yet — ` +
|
|
182
|
+
`set it in the editor: ${(0, hosted_js_1.editorUrl)(jobId)} (this is the next slice to wire up).`);
|
|
183
|
+
}
|
|
184
|
+
if (!(0, engine_js_1.engineAvailable)())
|
|
185
|
+
return NO_LOCAL_ENGINE;
|
|
94
186
|
const out = await runEngine(["--caption-style", args.caption_style, "--swap-caption-style"]);
|
|
95
187
|
const m = out.match(/RESULT:(.+)/);
|
|
96
188
|
if (!m)
|
|
@@ -71,6 +71,12 @@ exports.publishCarouselAsVideoTool = {
|
|
|
71
71
|
items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
|
|
72
72
|
description: "Platforms to publish to. Must be connected. For mode='draft', Instagram is not allowed (no IG drafts API).",
|
|
73
73
|
},
|
|
74
|
+
account_ids: {
|
|
75
|
+
type: "array",
|
|
76
|
+
items: { type: "string" },
|
|
77
|
+
description: "Specific connection IDs (from list_connections) to target. REQUIRED when you have multiple accounts on a platform — " +
|
|
78
|
+
"without it the post fans out to EVERY connected account on the platform (e.g. a draft landing on the wrong handle).",
|
|
79
|
+
},
|
|
74
80
|
caption: { type: "string", description: "Post caption." },
|
|
75
81
|
hashtags: {
|
|
76
82
|
type: "array",
|
|
@@ -158,6 +164,7 @@ exports.publishCarouselAsVideoTool = {
|
|
|
158
164
|
media_urls: upload.urls,
|
|
159
165
|
media_type: "video",
|
|
160
166
|
platforms: args.platforms,
|
|
167
|
+
...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
|
|
161
168
|
caption: args.caption,
|
|
162
169
|
hashtags: args.hashtags ?? [],
|
|
163
170
|
publish_now: true,
|
|
@@ -0,0 +1,64 @@
|
|
|
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.colorGradeTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* color_grade — free, instant cinematic LUT looks (ffmpeg, no AI, no cost). The
|
|
9
|
+
* cheap counterpart to relight_clip: it regrades color/contrast but can't change
|
|
10
|
+
* the background or add 3D relighting. Great for the A-roll or any clip.
|
|
11
|
+
*/
|
|
12
|
+
const child_process_1 = require("child_process");
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const engine_js_1 = require("../engine.js");
|
|
15
|
+
const LOOKS = ["teal-orange", "noir", "warm-golden", "cool-cinematic", "vintage", "vibrant", "moody-blue"];
|
|
16
|
+
function runGrade(args) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "grade.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
|
|
19
|
+
let out = "";
|
|
20
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
21
|
+
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
22
|
+
child.on("error", reject);
|
|
23
|
+
child.on("close", (code) => {
|
|
24
|
+
const m = out.match(/RESULT:([\s\S]+)/);
|
|
25
|
+
if (code === 0 && m)
|
|
26
|
+
resolve(m[1].trim());
|
|
27
|
+
else
|
|
28
|
+
reject(new Error(out.slice(-300) || `grade exited ${code}`));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
exports.colorGradeTool = {
|
|
33
|
+
name: "color_grade",
|
|
34
|
+
description: "Apply a FREE, instant cinematic color grade / LUT look to a LOCAL video (no AI, no cost) — teal-orange, " +
|
|
35
|
+
"noir, warm-golden, cool-cinematic, vintage, vibrant, or moody-blue. Great for the A-roll or any clip; it's " +
|
|
36
|
+
"a toggle-able look (keep the original or the graded file). For true relighting / background change, that's " +
|
|
37
|
+
"the paid relight_clip instead. Omit `look` to list the available looks.",
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
video_path: { type: "string", description: "Local path to the video to grade." },
|
|
42
|
+
look: { type: "string", enum: LOOKS, description: "The look to apply. Omit to list all looks." },
|
|
43
|
+
},
|
|
44
|
+
required: ["video_path"],
|
|
45
|
+
},
|
|
46
|
+
async execute(args) {
|
|
47
|
+
if (!(0, engine_js_1.engineAvailable)("grade.mjs")) {
|
|
48
|
+
return "color_grade needs the local engine (ffmpeg). It's unavailable in this install.";
|
|
49
|
+
}
|
|
50
|
+
if (!args.look) {
|
|
51
|
+
const r = JSON.parse(await runGrade(["--list"]));
|
|
52
|
+
const lines = r.looks.map((l) => ` • ${l.key} — ${l.label}`);
|
|
53
|
+
return ["Free cinematic looks (no cost):", ...lines, "", `Apply one: color_grade(video_path: "${args.video_path}", look: "teal-orange").`].join("\n");
|
|
54
|
+
}
|
|
55
|
+
let r;
|
|
56
|
+
try {
|
|
57
|
+
r = JSON.parse(await runGrade([args.video_path, args.look]));
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return `Couldn't grade the video: ${e instanceof Error ? e.message : String(e)}`;
|
|
61
|
+
}
|
|
62
|
+
return `Applied the "${r.look}" grade → ${r.out}\nFree + instant (ffmpeg). Swap it in, or try another look.`;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -4,15 +4,34 @@ exports.listConnectionsTool = void 0;
|
|
|
4
4
|
const client_js_1 = require("../client.js");
|
|
5
5
|
exports.listConnectionsTool = {
|
|
6
6
|
name: "list_connections",
|
|
7
|
-
description: "List your connected social media accounts (TikTok, Instagram, YouTube)."
|
|
7
|
+
description: "List your connected social media accounts (TikTok, Instagram, YouTube). " +
|
|
8
|
+
"Returns each account's connection ID — pass the ID to publish_now / schedule_post / send_to_drafts " +
|
|
9
|
+
"via the account_ids param when you have multiple accounts on the same platform.",
|
|
8
10
|
inputSchema: { type: "object", properties: {}, required: [] },
|
|
9
11
|
async execute(_args) {
|
|
12
|
+
var _a;
|
|
10
13
|
const connections = await (0, client_js_1.get)("/v1/publish/connections");
|
|
11
14
|
if (!connections.length) {
|
|
12
15
|
return "No platforms connected yet. Visit https://www.viralspin.ai/spin/settings to connect your accounts.";
|
|
13
16
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
// Group by platform
|
|
18
|
+
const byPlatform = {};
|
|
19
|
+
for (const c of connections) {
|
|
20
|
+
(byPlatform[_a = c.platform] ?? (byPlatform[_a] = [])).push(c);
|
|
21
|
+
}
|
|
22
|
+
const lines = [];
|
|
23
|
+
for (const [platform, accounts] of Object.entries(byPlatform)) {
|
|
24
|
+
lines.push(`${platform.toUpperCase()}:`);
|
|
25
|
+
for (const c of accounts) {
|
|
26
|
+
const label = c.account_label ? ` [${c.account_label}]` : "";
|
|
27
|
+
const handle = c.username ? ` (@${c.username.replace(/^@/, "")})` : "";
|
|
28
|
+
const followers = c.follower_count > 0 ? ` — ${c.follower_count.toLocaleString()} followers` : "";
|
|
29
|
+
lines.push(` • ${c.display_name}${handle}${label}${followers}`);
|
|
30
|
+
lines.push(` id: ${c.id}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push("Tip: when publishing to a specific account, pass its id in the account_ids param.");
|
|
35
|
+
return lines.join("\n");
|
|
17
36
|
},
|
|
18
37
|
};
|
|
@@ -0,0 +1,136 @@
|
|
|
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.editRawClipTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* edit_raw_clip — "edit this" for a RAW local talking-head clip. Transcribes it, cuts the dead
|
|
9
|
+
* space + filler words, burns captions, and drops RICH on-beat motion-graphics (an LLM plans
|
|
10
|
+
* varied stat / logo tile / step / quote cards from the transcript) → a finished 9:16 short.
|
|
11
|
+
*
|
|
12
|
+
* Runs on the user's machine via the bundled intake engine (mcp/engine/intake.py). Only the
|
|
13
|
+
* transcript (not the footage) is sent to the cloud graphics planner, so the video stays local.
|
|
14
|
+
* Falls back to on-device deterministic graphics if the planner isn't reachable.
|
|
15
|
+
*/
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const os_1 = __importDefault(require("os"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const engine_js_1 = require("../engine.js");
|
|
21
|
+
const client_js_1 = require("../client.js");
|
|
22
|
+
/** Spawn the local Python intake engine, trying the usual interpreter names. */
|
|
23
|
+
function runPy(args) {
|
|
24
|
+
const bins = process.platform === "win32" ? ["python", "py", "python3"] : ["python3", "python"];
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const tryBin = (i) => {
|
|
27
|
+
if (i >= bins.length) {
|
|
28
|
+
reject(new Error("Python 3 not found on PATH — install it to use edit_raw_clip."));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const child = (0, child_process_1.spawn)(bins[i], [path_1.default.join(engine_js_1.ENGINE_DIR, "intake.py"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
|
|
32
|
+
let out = "", err = "";
|
|
33
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
34
|
+
child.stderr.on("data", (d) => { err += d.toString(); });
|
|
35
|
+
child.on("error", (e) => { if (e.code === "ENOENT")
|
|
36
|
+
tryBin(i + 1);
|
|
37
|
+
else
|
|
38
|
+
reject(e); });
|
|
39
|
+
child.on("close", (code) => {
|
|
40
|
+
if (code === 0)
|
|
41
|
+
resolve(out);
|
|
42
|
+
else
|
|
43
|
+
reject(new Error((err || out).slice(-500) || `intake exited ${code}`));
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
tryBin(0);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
exports.editRawClipTool = {
|
|
50
|
+
name: "edit_raw_clip",
|
|
51
|
+
description: "EDIT a raw local talking-head video into a finished 9:16 short — the natural tool for \"edit this: " +
|
|
52
|
+
"<path>\", \"dress this clip\", or \"clean up my talking-head recording\". It transcribes the clip, cuts " +
|
|
53
|
+
"the dead space and filler words (um/uh/…), burns captions, and drops RICH on-beat motion-graphics — " +
|
|
54
|
+
"varied stat cards, brand/logo tiles, step/pipeline cards, and quote cards, chosen and filled by an LLM " +
|
|
55
|
+
"from what's actually said (like a polished explainer, not just captions). Runs on the user's machine; " +
|
|
56
|
+
"the footage stays local (only the transcript is used to plan graphics). Give it the video_path; returns " +
|
|
57
|
+
"the finished file path (and a preview URL). For a pure color/LUT look instead, that's color_grade.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
video_path: { type: "string", description: "Local path to the raw talking-head video to edit (e.g. G:/clips/talk.mov)." },
|
|
62
|
+
captions: { type: "boolean", description: "Burn captions onto the reel (default true)." },
|
|
63
|
+
graphics: { type: "boolean", description: "Add on-beat motion-graphics (default true)." },
|
|
64
|
+
sensitivity: { type: "number", description: "Dead-space cut threshold in seconds (default 0.5; lower = tighter, more cuts)." },
|
|
65
|
+
out_path: { type: "string", description: "Where to write the finished reel (default: next to the input as <name>_edited.mp4)." },
|
|
66
|
+
},
|
|
67
|
+
required: ["video_path"],
|
|
68
|
+
},
|
|
69
|
+
async execute(args) {
|
|
70
|
+
if (!(0, engine_js_1.engineAvailable)("intake.py")) {
|
|
71
|
+
return "edit_raw_clip needs the local editing engine (Python + ffmpeg). It's unavailable in this install.";
|
|
72
|
+
}
|
|
73
|
+
const vp = (args.video_path || "").trim().replace(/^["']|["']$/g, "");
|
|
74
|
+
if (!vp || !fs_1.default.existsSync(vp)) {
|
|
75
|
+
return `Couldn't find that video: ${vp || "(no path given)"}. Pass a local path, e.g. edit_raw_clip(video_path: "G:/clips/talk.mov").`;
|
|
76
|
+
}
|
|
77
|
+
const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "vs-edit-"));
|
|
78
|
+
const wordsPath = path_1.default.join(tmp, "words.json");
|
|
79
|
+
const outPath = args.out_path || vp.replace(/\.[^./\\]+$/, "") + "_edited.mp4";
|
|
80
|
+
const wantGraphics = args.graphics !== false;
|
|
81
|
+
try {
|
|
82
|
+
// 1) transcribe → word timestamps (reused by the cut + captions + planner, so no double work)
|
|
83
|
+
await runPy(["transcribe", "--clip", vp, "--out", wordsPath]);
|
|
84
|
+
const words = JSON.parse(fs_1.default.readFileSync(wordsPath, "utf8"));
|
|
85
|
+
// 2) plan RICH graphics from the transcript (LLM, best-effort). Only the transcript leaves the
|
|
86
|
+
// machine. If it fails, the engine falls back to on-device deterministic graphics.
|
|
87
|
+
const planArgs = [];
|
|
88
|
+
let plannerNote = "on-device graphics";
|
|
89
|
+
if (wantGraphics && words.length) {
|
|
90
|
+
try {
|
|
91
|
+
const dur = words[words.length - 1]?.end || 0;
|
|
92
|
+
const { graphics } = await (0, client_js_1.pyPost)("/v1/spin/plan-graphics", { words, duration: dur });
|
|
93
|
+
if (Array.isArray(graphics) && graphics.length) {
|
|
94
|
+
const pp = path_1.default.join(tmp, "plan.json");
|
|
95
|
+
fs_1.default.writeFileSync(pp, JSON.stringify(graphics));
|
|
96
|
+
planArgs.push("--graphics", pp);
|
|
97
|
+
plannerNote = `${graphics.length} LLM-planned graphics`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { /* planner unreachable → on-device fallback */ }
|
|
101
|
+
}
|
|
102
|
+
// 3) dress: cut dead space + filler → captions → graphics (reusing the transcript)
|
|
103
|
+
const dressArgs = ["dress", "--clip", vp, "--out", outPath, "--words", wordsPath, "--max-gap", String(args.sensitivity ?? 0.5), ...planArgs];
|
|
104
|
+
if (args.captions === false)
|
|
105
|
+
dressArgs.push("--no-captions");
|
|
106
|
+
if (!wantGraphics)
|
|
107
|
+
dressArgs.push("--no-graphics");
|
|
108
|
+
const dressOut = await runPy(dressArgs);
|
|
109
|
+
const rm = dressOut.match(/REPORT:(\{.*\})/);
|
|
110
|
+
const report = rm ? JSON.parse(rm[1]) : {};
|
|
111
|
+
// 4) best-effort upload for a shareable preview URL (local file is the real deliverable)
|
|
112
|
+
let url = "";
|
|
113
|
+
try {
|
|
114
|
+
url = (await (0, client_js_1.uploadFiles)([outPath])).urls[0] || "";
|
|
115
|
+
}
|
|
116
|
+
catch { /* local-only is fine */ }
|
|
117
|
+
const bits = [
|
|
118
|
+
`Edited **${path_1.default.basename(vp)}** → \`${outPath}\``,
|
|
119
|
+
`• ${report.original_sec ?? "?"}s → ${report.cut_sec ?? "?"}s (${report.pct_removed ?? 0}% tighter), ${report.n_cuts ?? 0} cuts`,
|
|
120
|
+
`• ${report.n_graphics ?? 0} on-beat graphics (${plannerNote})${args.captions === false ? "" : " + captions"}`,
|
|
121
|
+
];
|
|
122
|
+
if (url)
|
|
123
|
+
bits.push(`• Preview: ${url}`);
|
|
124
|
+
return bits.join("\n");
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
return `Couldn't edit the clip: ${e instanceof Error ? e.message : String(e)}`;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
try {
|
|
131
|
+
fs_1.default.rmSync(tmp, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
catch { /* ignore */ }
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|