@viralspin/mcp 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/cli.js +77 -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/startup.js +74 -0
- package/dist/local-render/tokens.js +201 -0
- package/dist/local-render/watch.js +69 -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,84 @@
|
|
|
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.searchLocalIndexTool = exports.syncLocalIndexTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* sync_local_index / search_local_index — make the user's LOCAL footage count in
|
|
9
|
+
* cloud idea-planning. sync_local_index analyzes a folder on the GPU (free when
|
|
10
|
+
* local mode is on) and pushes TAGS ONLY to the cloud catalog (files stay local);
|
|
11
|
+
* search_local_index finds existing local clips that fit a script/idea so Claude
|
|
12
|
+
* can recommend reusing them instead of filming new B-roll.
|
|
13
|
+
*/
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
15
|
+
const path_1 = __importDefault(require("path"));
|
|
16
|
+
const engine_js_1 = require("../engine.js");
|
|
17
|
+
const client_js_1 = require("../client.js");
|
|
18
|
+
function runIndex(folder) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "index-folder.mjs"), folder], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
|
|
21
|
+
let out = "";
|
|
22
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
23
|
+
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
24
|
+
child.on("error", reject);
|
|
25
|
+
child.on("close", (code) => {
|
|
26
|
+
const m = out.match(/RESULT:([\s\S]+)/);
|
|
27
|
+
if (code === 0 && m)
|
|
28
|
+
resolve(m[1].trim());
|
|
29
|
+
else
|
|
30
|
+
reject(new Error(out.slice(-400) || `index exited ${code}`));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
exports.syncLocalIndexTool = {
|
|
35
|
+
name: "sync_local_index",
|
|
36
|
+
description: "Analyze a LOCAL folder of footage (free on the user's GPU when local mode is on) and SYNC the tags to the " +
|
|
37
|
+
"cloud so it's searchable when planning ideas / asset-plans — WITHOUT uploading the files (tags + paths only; " +
|
|
38
|
+
"media stays local until actually used). Run this once on the user's B-roll folders so search_local_index and " +
|
|
39
|
+
"idea planning know what they already have.",
|
|
40
|
+
inputSchema: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: { folder: { type: "string", description: "Local folder of footage to index + sync." } },
|
|
43
|
+
required: ["folder"],
|
|
44
|
+
},
|
|
45
|
+
async execute(args) {
|
|
46
|
+
if (!(0, engine_js_1.engineAvailable)("index-folder.mjs")) {
|
|
47
|
+
return "sync_local_index needs the local engine (ffmpeg + the vision model). Unavailable in this install.";
|
|
48
|
+
}
|
|
49
|
+
let r;
|
|
50
|
+
try {
|
|
51
|
+
r = JSON.parse(await runIndex(args.folder));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return `Couldn't index the folder: ${e instanceof Error ? e.message : String(e)}`;
|
|
55
|
+
}
|
|
56
|
+
if (!r.items.length)
|
|
57
|
+
return `No media analyzed in ${args.folder}.`;
|
|
58
|
+
try {
|
|
59
|
+
const res = await (0, client_js_1.post)("/v1/library/local-sync", { items: r.items });
|
|
60
|
+
return (`Indexed + synced ${res.synced} local asset(s) from ${args.folder} → searchable in cloud idea-planning. ` +
|
|
61
|
+
`The files were NOT uploaded (tags only). Use search_local_index to find them by topic when planning a reel.`);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
return `Analyzed ${r.indexed} asset(s) but couldn't sync to the cloud: ${e instanceof Error ? e.message : String(e)}`;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
exports.searchLocalIndexTool = {
|
|
69
|
+
name: "search_local_index",
|
|
70
|
+
description: "Search the user's SYNCED local footage by topic/keyword (from sync_local_index) — use when planning an idea's " +
|
|
71
|
+
"asset plan to find existing local clips that fit a script BEFORE recommending they film or AI-generate new B-roll.",
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: { query: { type: "string", description: "Topic / keyword (e.g. 'hospital', 'sunset rooftop', 'laptop typing')." } },
|
|
75
|
+
required: ["query"],
|
|
76
|
+
},
|
|
77
|
+
async execute(args) {
|
|
78
|
+
const r = await (0, client_js_1.get)(`/v1/library/local-search?q=${encodeURIComponent(args.query)}`);
|
|
79
|
+
if (!r.items.length)
|
|
80
|
+
return `No synced local footage matched "${args.query}". (Run sync_local_index on the user's folders first, or recommend filming / AI B-roll.)`;
|
|
81
|
+
const lines = r.items.map((i) => `- ${i.path}${i.subject ? ` (${i.subject})` : ""}${i.descr ? ` — ${i.descr}` : ""}`);
|
|
82
|
+
return [`Local footage matching "${args.query}":`, ...lines].join("\n");
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
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.setLocalModeTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* set_local_mode — a persistent toggle between LOCAL analysis (free + private,
|
|
9
|
+
* a vision model on the user's GPU via Ollama) and CLOUD (Gemini). The choice is
|
|
10
|
+
* saved to ~/.viralspin/config.json, which the engine reads — so the user flips
|
|
11
|
+
* it once with no env vars to edit. (The VIRALSPIN_VLM env var still works as an
|
|
12
|
+
* explicit override.) Enabling local also verifies Ollama + the model are ready
|
|
13
|
+
* and, if not, returns the one-time setup steps.
|
|
14
|
+
*/
|
|
15
|
+
const fs_1 = __importDefault(require("fs"));
|
|
16
|
+
const os_1 = __importDefault(require("os"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const CONFIG = path_1.default.join(os_1.default.homedir(), ".viralspin", "config.json");
|
|
19
|
+
function readCfg() {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs_1.default.readFileSync(CONFIG, "utf8")) || {};
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function writeCfg(c) {
|
|
28
|
+
try {
|
|
29
|
+
fs_1.default.mkdirSync(path_1.default.dirname(CONFIG), { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
catch { /* ignore */ }
|
|
32
|
+
fs_1.default.writeFileSync(CONFIG, JSON.stringify(c, null, 2));
|
|
33
|
+
}
|
|
34
|
+
async function ollamaStatus(model) {
|
|
35
|
+
try {
|
|
36
|
+
const r = await fetch("http://localhost:11434/api/tags");
|
|
37
|
+
if (!r.ok)
|
|
38
|
+
return { up: false, hasModel: false };
|
|
39
|
+
const d = (await r.json());
|
|
40
|
+
const base = model.split(":")[0];
|
|
41
|
+
const hasModel = (d.models || []).some((m) => m.name === model || m.name.startsWith(base));
|
|
42
|
+
return { up: true, hasModel };
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { up: false, hasModel: false };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.setLocalModeTool = {
|
|
49
|
+
name: "set_local_mode",
|
|
50
|
+
description: "Toggle ViralSpin video analysis between LOCAL (free + private — runs a vision model on the user's GPU via " +
|
|
51
|
+
"Ollama) and CLOUD (Gemini). The choice persists in ~/.viralspin/config.json — no env vars to edit, no restart. " +
|
|
52
|
+
"Enabling local verifies Ollama + the model and, if missing, returns the one-time setup steps.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
enabled: { type: "boolean", description: "true = LOCAL (GPU/Ollama), false = CLOUD (Gemini)." },
|
|
57
|
+
model: { type: "string", description: "Optional Ollama vision model (default qwen2.5vl:7b)." },
|
|
58
|
+
},
|
|
59
|
+
required: ["enabled"],
|
|
60
|
+
},
|
|
61
|
+
async execute(args) {
|
|
62
|
+
const cfg = readCfg();
|
|
63
|
+
if (!args.enabled) {
|
|
64
|
+
cfg.vlm = "cloud";
|
|
65
|
+
writeCfg(cfg);
|
|
66
|
+
return "Switched to CLOUD analysis (Gemini). Local mode is OFF.";
|
|
67
|
+
}
|
|
68
|
+
const model = args.model || cfg.ollama_model || "qwen2.5vl:7b";
|
|
69
|
+
cfg.vlm = "ollama";
|
|
70
|
+
cfg.ollama_model = model;
|
|
71
|
+
writeCfg(cfg);
|
|
72
|
+
const { up, hasModel } = await ollamaStatus(model);
|
|
73
|
+
if (!up) {
|
|
74
|
+
return (`Local mode is ON in settings, but Ollama isn't running. One-time setup:\n` +
|
|
75
|
+
` 1) Install Ollama → https://ollama.com/download (or: winget install Ollama.Ollama / brew install ollama)\n` +
|
|
76
|
+
` 2) ollama pull ${model} (~6GB, one-time)\n` +
|
|
77
|
+
`Once Ollama is running with the model, local analysis works automatically — no restart.`);
|
|
78
|
+
}
|
|
79
|
+
if (!hasModel) {
|
|
80
|
+
return `Local mode is ON and Ollama is running, but the model isn't pulled. Run: ollama pull ${model} (~6GB, one-time). Then it's ready.`;
|
|
81
|
+
}
|
|
82
|
+
return `Local mode is ON ✓ — video analysis now runs on your GPU via Ollama (${model}): free + private. Say "switch to cloud" to use Gemini instead.`;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getCreatorProfileTool = exports.setCreatorProfileTool = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* set_creator_profile / get_creator_profile — the user's niche + social handles +
|
|
6
|
+
* topics, so the weekly idea routine knows their context. Capture it at setup
|
|
7
|
+
* (ask the user, or read their connected accounts via list_connections), and the
|
|
8
|
+
* weekly routine reads it to research what's working in their niche.
|
|
9
|
+
*/
|
|
10
|
+
const client_js_1 = require("../client.js");
|
|
11
|
+
exports.setCreatorProfileTool = {
|
|
12
|
+
name: "set_creator_profile",
|
|
13
|
+
description: "Save the user's creator profile — their NICHE, social HANDLES (instagram/tiktok/youtube), and core TOPICS — " +
|
|
14
|
+
"so the weekly idea routine can research WHAT'S CURRENTLY GOING VIRAL in their niche (trending formats, hooks, " +
|
|
15
|
+
"and 5-7s caption reels — queue_idea format 'reel_7s') and model that week's ideas on proven winners. Capture " +
|
|
16
|
+
"this at setup: ask the user, or infer handles from list_connections. Call get_creator_profile first.",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
niche: { type: "string", description: "The user's niche / what their content is about (e.g. 'AI tech for founders')." },
|
|
21
|
+
handles: {
|
|
22
|
+
type: "object",
|
|
23
|
+
description: "Their social handles: { instagram, tiktok, youtube }.",
|
|
24
|
+
properties: { instagram: { type: "string" }, tiktok: { type: "string" }, youtube: { type: "string" } },
|
|
25
|
+
},
|
|
26
|
+
topics: { type: "array", items: { type: "string" }, description: "Core content pillars / recurring topics." },
|
|
27
|
+
weekly_routine: { type: "boolean", description: "Whether the weekly 5-ideas routine is on for them." },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async execute(args) {
|
|
31
|
+
await (0, client_js_1.post)("/v1/spin/profile", args);
|
|
32
|
+
return `Saved the creator profile${args.niche ? ` (niche: ${args.niche})` : ""}. The weekly idea routine will research this niche + their accounts.`;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
exports.getCreatorProfileTool = {
|
|
36
|
+
name: "get_creator_profile",
|
|
37
|
+
description: "Read the user's saved creator profile (niche, handles, topics, whether the weekly routine is on). Use at the " +
|
|
38
|
+
"start of a brainstorming or weekly-idea session so you target their actual niche/accounts. If it's empty, ask " +
|
|
39
|
+
"the user for their niche + handles (or read list_connections) and set_creator_profile.",
|
|
40
|
+
inputSchema: { type: "object", properties: {} },
|
|
41
|
+
async execute() {
|
|
42
|
+
const r = await (0, client_js_1.get)("/v1/spin/profile");
|
|
43
|
+
if (!r.profile || (!r.profile.niche && !r.profile.handles)) {
|
|
44
|
+
return "No creator profile set yet. Ask the user for their niche + social handles (or read list_connections), then set_creator_profile.";
|
|
45
|
+
}
|
|
46
|
+
const p = r.profile;
|
|
47
|
+
const handles = p.handles ? Object.entries(p.handles).filter(([, v]) => v).map(([k, v]) => `${k}: ${v}`).join(", ") : "";
|
|
48
|
+
return [
|
|
49
|
+
`Creator profile:`,
|
|
50
|
+
`- niche: ${p.niche || "(not set)"}`,
|
|
51
|
+
handles ? `- handles: ${handles}` : `- handles: (none)`,
|
|
52
|
+
p.topics?.length ? `- topics: ${p.topics.join(", ")}` : "",
|
|
53
|
+
`- weekly routine: ${p.weekly_routine ? "on" : "off"}`,
|
|
54
|
+
].filter(Boolean).join("\n");
|
|
55
|
+
},
|
|
56
|
+
};
|
package/dist/tools/publish.js
CHANGED
|
@@ -38,18 +38,35 @@ function expandPaths(inputs) {
|
|
|
38
38
|
}
|
|
39
39
|
return result;
|
|
40
40
|
}
|
|
41
|
+
/** A deck rarely posts the same way everywhere. Say so, or the caller reads
|
|
42
|
+
* "Published to: tiktok" and assumes the slides went out as prepared. */
|
|
43
|
+
function noticeLines(notices) {
|
|
44
|
+
if (!notices?.length)
|
|
45
|
+
return "";
|
|
46
|
+
return `\nNote — this deck does not post the same way everywhere:\n` +
|
|
47
|
+
notices.map((n) => `- ${n.headline}. ${n.detail}`).join("\n");
|
|
48
|
+
}
|
|
41
49
|
const publishSchema = {
|
|
42
50
|
type: "object",
|
|
43
51
|
properties: {
|
|
44
52
|
paths: {
|
|
45
53
|
type: "array",
|
|
46
54
|
items: { type: "string" },
|
|
47
|
-
description: "Local file path(s) or a folder path. Single video → video post. Multiple
|
|
55
|
+
description: "Local file path(s) or a folder path. Single video → video post. Multiple files → carousel. " +
|
|
56
|
+
"A video slide is fine for Instagram, but TikTok has no mixed carousel — it stays a carousel there " +
|
|
57
|
+
"and each video slide publishes as a still. YouTube takes slide 1 only, LinkedIn stills only.",
|
|
48
58
|
},
|
|
49
59
|
platforms: {
|
|
50
60
|
type: "array",
|
|
51
|
-
items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
|
|
52
|
-
description: "Platforms to publish to
|
|
61
|
+
items: { type: "string", enum: ["tiktok", "instagram", "youtube", "linkedin"] },
|
|
62
|
+
description: "Platforms to publish to (uses the first connected account per platform). Omit when using account_ids.",
|
|
63
|
+
},
|
|
64
|
+
account_ids: {
|
|
65
|
+
type: "array",
|
|
66
|
+
items: { type: "string" },
|
|
67
|
+
description: "Specific connection IDs to publish to. Use list_connections to get IDs. " +
|
|
68
|
+
"Required when you have multiple accounts on the same platform and want to target a specific one. " +
|
|
69
|
+
"Takes precedence over 'platforms' when both are provided.",
|
|
53
70
|
},
|
|
54
71
|
caption: { type: "string", description: "Post caption." },
|
|
55
72
|
hashtags: {
|
|
@@ -58,13 +75,18 @@ const publishSchema = {
|
|
|
58
75
|
description: "Hashtags without # prefix.",
|
|
59
76
|
},
|
|
60
77
|
},
|
|
61
|
-
required: ["paths", "
|
|
78
|
+
required: ["paths", "caption"],
|
|
62
79
|
};
|
|
63
80
|
exports.publishNowTool = {
|
|
64
81
|
name: "publish_now",
|
|
65
|
-
description: "Upload local file(s) and publish immediately to TikTok, Instagram, and/or YouTube.
|
|
82
|
+
description: "Upload local file(s) and publish immediately to TikTok, Instagram, and/or YouTube. " +
|
|
83
|
+
"Pass a folder path to publish all images in it as a carousel. " +
|
|
84
|
+
"Use account_ids (from list_connections) to target a specific account when you have multiple accounts on the same platform.",
|
|
66
85
|
inputSchema: publishSchema,
|
|
67
86
|
async execute(args) {
|
|
87
|
+
if (!args.platforms?.length && !args.account_ids?.length) {
|
|
88
|
+
throw new Error("Provide 'platforms' (e.g. [\"instagram\"]) or 'account_ids' from list_connections.");
|
|
89
|
+
}
|
|
68
90
|
const filePaths = expandPaths(args.paths);
|
|
69
91
|
if (!filePaths.length)
|
|
70
92
|
throw new Error("No media files found in the provided paths.");
|
|
@@ -79,7 +101,8 @@ exports.publishNowTool = {
|
|
|
79
101
|
result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
80
102
|
media_urls: urls,
|
|
81
103
|
media_type: mediaType,
|
|
82
|
-
platforms: args.platforms,
|
|
104
|
+
platforms: args.platforms ?? [],
|
|
105
|
+
...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
|
|
83
106
|
caption: args.caption,
|
|
84
107
|
hashtags: args.hashtags ?? [],
|
|
85
108
|
publish_now: true,
|
|
@@ -96,7 +119,7 @@ exports.publishNowTool = {
|
|
|
96
119
|
summary += `Published to: ${published.join(", ")}\n`;
|
|
97
120
|
if (failed_reason)
|
|
98
121
|
summary += `Errors: ${failed_reason}`;
|
|
99
|
-
return summary.trim();
|
|
122
|
+
return (summary.trim() + noticeLines(result.notices)).trim();
|
|
100
123
|
},
|
|
101
124
|
};
|
|
102
125
|
exports.sendToDraftsTool = {
|
|
@@ -130,6 +153,9 @@ exports.sendToDraftsTool = {
|
|
|
130
153
|
required: ["paths", "platforms", "caption"],
|
|
131
154
|
},
|
|
132
155
|
async execute(args) {
|
|
156
|
+
if (!args.platforms?.length && !args.account_ids?.length) {
|
|
157
|
+
throw new Error("Provide 'platforms' (e.g. [\"tiktok\"]) or 'account_ids' from list_connections.");
|
|
158
|
+
}
|
|
133
159
|
const filePaths = expandPaths(args.paths);
|
|
134
160
|
if (!filePaths.length)
|
|
135
161
|
throw new Error("No media files found in the provided paths.");
|
|
@@ -138,7 +164,7 @@ exports.sendToDraftsTool = {
|
|
|
138
164
|
throw new Error(`Carousel max is 10 images. Found ${filePaths.length} files.`);
|
|
139
165
|
}
|
|
140
166
|
// Surface the IG-not-supported gotcha before consuming credits / uploading.
|
|
141
|
-
if (args.platforms
|
|
167
|
+
if (args.platforms?.includes("instagram")) {
|
|
142
168
|
throw new Error("Instagram does not support drafts via API. IG drafts are local to the mobile app only — " +
|
|
143
169
|
"there is no API endpoint to send to drafts. Remove 'instagram' from platforms, or use schedule_post / publish_now instead.");
|
|
144
170
|
}
|
|
@@ -148,7 +174,8 @@ exports.sendToDraftsTool = {
|
|
|
148
174
|
result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
149
175
|
media_urls: urls,
|
|
150
176
|
media_type: mediaType,
|
|
151
|
-
platforms: args.platforms,
|
|
177
|
+
platforms: args.platforms ?? [],
|
|
178
|
+
...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
|
|
152
179
|
caption: args.caption,
|
|
153
180
|
hashtags: args.hashtags ?? [],
|
|
154
181
|
publish_now: true,
|
|
@@ -167,12 +194,13 @@ exports.sendToDraftsTool = {
|
|
|
167
194
|
}
|
|
168
195
|
if (failed_reason)
|
|
169
196
|
summary += `\nErrors: ${failed_reason}`;
|
|
170
|
-
return summary.trim();
|
|
197
|
+
return (summary.trim() + noticeLines(result.notices)).trim();
|
|
171
198
|
},
|
|
172
199
|
};
|
|
173
200
|
exports.schedulePostTool = {
|
|
174
201
|
name: "schedule_post",
|
|
175
|
-
description: "Upload local file(s) and schedule a post for a future time. Pass a folder path to schedule all images as a carousel."
|
|
202
|
+
description: "Upload local file(s) and schedule a post for a future time. Pass a folder path to schedule all images as a carousel. " +
|
|
203
|
+
"TikTok has no mixed carousel, so a video slide publishes there as a still — the return value says so, and the frame is pickable in the app.",
|
|
176
204
|
inputSchema: {
|
|
177
205
|
type: "object",
|
|
178
206
|
properties: {
|
|
@@ -186,9 +214,12 @@ exports.schedulePostTool = {
|
|
|
186
214
|
description: "IANA timezone name (e.g. 'America/New_York'). Defaults to UTC.",
|
|
187
215
|
},
|
|
188
216
|
},
|
|
189
|
-
required: ["paths", "
|
|
217
|
+
required: ["paths", "caption", "scheduled_time"],
|
|
190
218
|
},
|
|
191
219
|
async execute(args) {
|
|
220
|
+
if (!args.platforms?.length && !args.account_ids?.length) {
|
|
221
|
+
throw new Error("Provide 'platforms' (e.g. [\"instagram\"]) or 'account_ids' from list_connections.");
|
|
222
|
+
}
|
|
192
223
|
const filePaths = expandPaths(args.paths);
|
|
193
224
|
if (!filePaths.length)
|
|
194
225
|
throw new Error("No media files found in the provided paths.");
|
|
@@ -199,7 +230,8 @@ exports.schedulePostTool = {
|
|
|
199
230
|
const result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
200
231
|
media_urls: urls,
|
|
201
232
|
media_type: mediaType,
|
|
202
|
-
platforms: args.platforms,
|
|
233
|
+
platforms: args.platforms ?? [],
|
|
234
|
+
...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
|
|
203
235
|
caption: args.caption,
|
|
204
236
|
hashtags: args.hashtags ?? [],
|
|
205
237
|
scheduled_time: args.scheduled_time,
|
|
@@ -209,7 +241,11 @@ exports.schedulePostTool = {
|
|
|
209
241
|
// Note: storage_paths are NOT deleted here — the cron job needs the URLs to stay valid.
|
|
210
242
|
// They expire after 1 hour. For posts scheduled further out, re-upload closer to publish time.
|
|
211
243
|
void storage_paths;
|
|
244
|
+
const targets = args.account_ids?.length
|
|
245
|
+
? `account IDs: ${args.account_ids.join(", ")}`
|
|
246
|
+
: (args.platforms ?? []).join(", ");
|
|
212
247
|
const { post: p } = result;
|
|
213
|
-
return `Scheduled post ${p.id}\
|
|
248
|
+
return `Scheduled post ${p.id}\nTargets: ${targets}\nTime: ${args.scheduled_time}\nStatus: ${p.status}` +
|
|
249
|
+
noticeLines(result.notices);
|
|
214
250
|
},
|
|
215
251
|
};
|
|
@@ -0,0 +1,98 @@
|
|
|
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.relightClipTool = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* relight_clip — relight / re-scene a SHORT clip (3–10s) via Fal.ai (bundled
|
|
9
|
+
* relight.py: nano-banana-2 relights one frame, Kling O3 Pro transfers it onto
|
|
10
|
+
* the clip with audio preserved). PAID, so it's two-phase with a cost gate:
|
|
11
|
+
* phase 1 (no confirm): prep + relight ONE frame (~$0.10) → preview + the video
|
|
12
|
+
* cost estimate + the folder.
|
|
13
|
+
* phase 2 (confirm + folder): run the paid Kling video step.
|
|
14
|
+
* Needs FAL_KEY in the environment. Best on B-roll / short hero shots — a full
|
|
15
|
+
* talking-head monologue can drift on the mouth.
|
|
16
|
+
*/
|
|
17
|
+
const child_process_1 = require("child_process");
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const engine_js_1 = require("../engine.js");
|
|
20
|
+
function pythonBin() { return process.platform === "win32" ? "python" : "python3"; }
|
|
21
|
+
function runRelight(args) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const child = (0, child_process_1.spawn)(pythonBin(), [path_1.default.join(engine_js_1.ENGINE_DIR, "relight.py"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
|
|
24
|
+
let out = "";
|
|
25
|
+
let err = "";
|
|
26
|
+
child.stdout.on("data", (d) => { out += d.toString(); });
|
|
27
|
+
child.stderr.on("data", (d) => { err += d.toString(); process.stderr.write(d); });
|
|
28
|
+
child.on("error", reject);
|
|
29
|
+
child.on("close", (code) => (code === 0 ? resolve(out.trim()) : reject(new Error(err.slice(-300) || out.slice(-300) || `relight exited ${code}`))));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function lastJson(s) {
|
|
33
|
+
const m = s.match(/\{[\s\S]*\}\s*$/);
|
|
34
|
+
return JSON.parse(m ? m[0] : s);
|
|
35
|
+
}
|
|
36
|
+
exports.relightClipTool = {
|
|
37
|
+
name: "relight_clip",
|
|
38
|
+
description: "Relight or re-scene a SHORT clip (3–10s — best on B-roll / short hero shots) via Fal.ai: change the lighting " +
|
|
39
|
+
"or drop the subject into a new environment, audio preserved. PAID (~$0.10 for the reference frame + ~$0.168/sec " +
|
|
40
|
+
"for the video). COST-GATED two-step: call WITHOUT confirm → it preps + relights ONE frame and returns a preview " +
|
|
41
|
+
"+ the video cost + a `folder`; review it, show the user the cost, then call again WITH confirm:true and that " +
|
|
42
|
+
"`folder` to run the paid video step. Write a geometry-locked, LIGHTING-focused scene_prompt (same framing/pose; " +
|
|
43
|
+
"change only lighting + background). Needs FAL_KEY in the environment. For a free non-AI color look, use color_grade.",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
video_path: { type: "string", description: "Local path to a 3–10s clip." },
|
|
48
|
+
scene_prompt: { type: "string", description: "Relight/re-scene prompt — keep the EXACT framing/pose; change only lighting + background (e.g. 'hard key from high front-left, deep shadow on the other side, warm rim; soft golden-hour rooftop bokeh behind him')." },
|
|
49
|
+
reference_image: { type: "string", description: "Optional reference image of the scene/lighting to match." },
|
|
50
|
+
confirm: { type: "boolean", description: "true = run the PAID video step (only after the user approved the preview + cost)." },
|
|
51
|
+
folder: { type: "string", description: "The `folder` returned by the first (no-confirm) call — required with confirm:true." },
|
|
52
|
+
model: { type: "string", enum: ["o3", "o1"], description: "o3 (default, best lip-sync) or o1 (cheaper/faster draft)." },
|
|
53
|
+
},
|
|
54
|
+
required: ["video_path", "scene_prompt"],
|
|
55
|
+
},
|
|
56
|
+
async execute(args) {
|
|
57
|
+
if (!process.env.FAL_KEY) {
|
|
58
|
+
return "relight_clip needs FAL_KEY in the environment. Set it (Windows: setx FAL_KEY \"<key>\") and restart, then try again.";
|
|
59
|
+
}
|
|
60
|
+
// Phase 2 — the PAID video transfer.
|
|
61
|
+
if (args.confirm) {
|
|
62
|
+
if (!args.folder)
|
|
63
|
+
return "To run the paid video step, pass the `folder` returned by the first call.";
|
|
64
|
+
try {
|
|
65
|
+
const res = lastJson(await runRelight(["video", "--folder", args.folder, ...(args.model ? ["--model", args.model] : [])]));
|
|
66
|
+
return `Relit clip rendered (audio preserved): ${res.video_path}\nSwap it into your project, or publish it.`;
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
return `Relight video step failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Phase 1 — prep + relight ONE frame (cheap), then quote the video cost.
|
|
73
|
+
let prep;
|
|
74
|
+
try {
|
|
75
|
+
prep = lastJson(await runRelight(["prep", "--video", args.video_path, "--title", "relight"]));
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
79
|
+
return /3.?10|second|duration/i.test(msg) ? `That clip is outside the 3–10s limit — trim to a ≤10s segment first. (${msg})` : `Couldn't prep the clip: ${msg}`;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await runRelight(["image", "--frame", prep.frame_path, "--folder", prep.folder, "--aspect-ratio", String(prep.aspect_ratio), "--prompt", args.scene_prompt, ...(args.reference_image ? ["--reference", args.reference_image] : [])]);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return `Couldn't relight the reference frame: ${e instanceof Error ? e.message : String(e)}`;
|
|
86
|
+
}
|
|
87
|
+
const sec = Number(prep.duration) || 0;
|
|
88
|
+
const perSec = args.model === "o1" ? 0.168 : 0.168;
|
|
89
|
+
const cost = (0.1 + sec * perSec).toFixed(2);
|
|
90
|
+
return [
|
|
91
|
+
`Relit a reference frame ✓ — preview in: ${prep.folder}`,
|
|
92
|
+
``,
|
|
93
|
+
`The PAID video step transfers that look onto the whole ${sec}s clip (audio kept) ≈ $${cost}.`,
|
|
94
|
+
`Show this cost to the user. To run it, call:`,
|
|
95
|
+
` relight_clip(video_path: "${args.video_path}", scene_prompt: "…", confirm: true, folder: "${prep.folder}")`,
|
|
96
|
+
].join("\n");
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -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
|
+
}
|