@viralspin/mcp 0.2.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 +8 -0
- package/dist/engine.js +29 -0
- package/dist/hosted.js +159 -0
- package/dist/index.js +62 -16
- 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 +34 -10
- package/dist/tools/relight-clip.js +98 -0
- package/dist/tools/timeline-edit.js +367 -0
- package/dist/tools/video-assembly.js +131 -4
- package/package.json +41 -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,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.listIdeasTool = exports.queueIdeaTool = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* queue_idea / list_ideas — the Prompt/Idea History bridge. Claude brainstorms
|
|
6
|
+
* with the user, then queues each idea (hook + script + an asset plan) here; it
|
|
7
|
+
* persists in Supabase (via /v1/spin/ideas) and shows up in the user's web
|
|
8
|
+
* /spin/history, cross-session and cross-browser. The asset_plan tells the user
|
|
9
|
+
* what's needed to execute (existing clips, AI B-roll, web images, kinetic text,
|
|
10
|
+
* or footage to film).
|
|
11
|
+
*/
|
|
12
|
+
const client_js_1 = require("../client.js");
|
|
13
|
+
exports.queueIdeaTool = {
|
|
14
|
+
name: "queue_idea",
|
|
15
|
+
description: "Queue a content idea into the user's persistent Prompt/Idea History (shows in their web /spin/history, " +
|
|
16
|
+
"cross-browser). Use after brainstorming or after analyzing a saved/pasted post the user wants to replicate. " +
|
|
17
|
+
"Give it a `hook`, a ready-to-film `script` (they read it off the teleprompter at /create), the `topic`, and " +
|
|
18
|
+
"an `asset_plan` — per shot, say what to use: existing clip / AI-generated B-roll / web image (logo, person) / " +
|
|
19
|
+
"kinetic text overlay / footage to film. Set `source_url` if it came from a saved post. " +
|
|
20
|
+
"For a punchy 5-7s relatable joke where the VALUE IS THE OVERLAY CAPTION (not narration) — e.g. a video of " +
|
|
21
|
+
"someone on a plane captioned 'Flight attendant: OMG is there anyone onboard who can align cross-functional " +
|
|
22
|
+
"stakeholders while navigating ambiguity?' — use format:'reel_7s' and put the line in `caption`. " +
|
|
23
|
+
"GROUND every reel_7s in what is CURRENTLY GOING VIRAL in the creator's niche: call get_creator_profile " +
|
|
24
|
+
"for their niche + handles + topics, research the caption-reel formats + in-group jokes trending in that " +
|
|
25
|
+
"niche right now (their and adjacent creators' recent hits), and adapt a PROVEN pattern to their audience — " +
|
|
26
|
+
"don't invent a random joke. When generating a WEEK's batch of ideas, include AT LEAST ONE reel_7s modeled " +
|
|
27
|
+
"on that week's viral trend in the niche (set `source_url` to the post it's modeled on when there is one). " +
|
|
28
|
+
"reel_7s ASSET PLAN — if the caption has a SETUP → PAYOFF shape (before/after, question → reaction, 'four " +
|
|
29
|
+
"hours later:'), SPLIT the asset_plan into ONE BEAT PER SEGMENT so the shoot app can guide the creator " +
|
|
30
|
+
"part-by-part; a single-line caption = a single beat. Give each segment its own overlay line QUOTED in " +
|
|
31
|
+
"`note` (e.g. note: 'Overlay: \"Four hours later:\". Same framing as segment 1 - the contrast IS the joke.'), " +
|
|
32
|
+
"its own `search_terms`, and kind:'existing' — so the app can AI-pick that segment from the creator's camera " +
|
|
33
|
+
"roll (a 7s clip OR a photo) and they can still film/overwrite it themselves. " +
|
|
34
|
+
"HOOK PATTERN — a strong attention-grab is to OPEN ON A SURPRISED REACTION, then demonstrate the payoff " +
|
|
35
|
+
"('wait, what?!' → here's the thing). For ideas that PROMOTE VIRALSPIN (which are encouraged), lead the " +
|
|
36
|
+
"asset_plan with a reaction beat: first shot kind:'film' note the creator's surprised/delighted reaction " +
|
|
37
|
+
"(e.g. 'reacts stunned to how fast the reel was made'), THEN the demonstration beats. The mobile shoot flow " +
|
|
38
|
+
"(Take → React → Show → Build) mirrors this, so a react-first plan maps cleanly to how it's executed. " +
|
|
39
|
+
"SHOT PHRASING — write each `shot` as a SHORT, PLAIN, second-person creator ACTION they can just DO, not a " +
|
|
40
|
+
"cinematic third-person screenplay direction. GOOD: 'Show/gesture a perfect prompt', 'Sift through a bunch of AI " +
|
|
41
|
+
"output and pick a few good ones', 'React impressed at the polished result'. BAD (too abstract/wordy): 'Creator " +
|
|
42
|
+
"looking thoughtful, gesturing beyond the camera to a conceptual future', 'Hands sifting through a grid of " +
|
|
43
|
+
"AI-generated text/images on a tablet, selecting a few'. If a beat is really an EMOTIONAL/impressed MOMENT (e.g. " +
|
|
44
|
+
"'confidently presenting a refined high-quality result'), make it a REACTION the creator performs to camera — an " +
|
|
45
|
+
"impressed facial reaction — NOT a staged 'show' of holding up a screen. " +
|
|
46
|
+
"TAKE vs SHOW — the creator ON CAMERA (talking, gesturing, reacting) belongs to the Take + React steps, so those " +
|
|
47
|
+
"are kind:'film'. The 'SHOW IT' beats are footage of the SUBJECT — a screen recording, a demo, product/B-roll — " +
|
|
48
|
+
"NOT the creator; make them kind:'existing' (a clip the user records/provides) or kind:'ai_broll', and describe " +
|
|
49
|
+
"WHAT'S ON SCREEN, not the person. NEVER write a show/demo beat as 'creator looking thoughtful' or 'creator " +
|
|
50
|
+
"pointing at a screen' — that's a talking-head (Take). E.g. a demo beat = existing 'Screen-record the bad-vs-good " +
|
|
51
|
+
"prompt output', not film 'Creator points to the screen'. " +
|
|
52
|
+
"NO REDUNDANT TAKE BEATS — the creator reads the WHOLE script off the teleprompter in step 1, so NEVER add a " +
|
|
53
|
+
"kind:'film' beat that is just 'talk/gesture to camera as you tee up the idea': that IS the Take, and it orphans " +
|
|
54
|
+
"in the shoot UI. A kind:'film' beat must be a PERFORMED MOMENT — a face or a GESTURE — because those become " +
|
|
55
|
+
"real tasks in the React step (step 2). Reach for a gesture when a phrase invites one: 'the real secret…' → film " +
|
|
56
|
+
"'Shhh - index finger to your lips as you tease the secret'. " +
|
|
57
|
+
"EVERY LINE SHOULD HAVE SOMETHING ON SCREEN — the shoot app shows the script beside an 'on screen' column, so " +
|
|
58
|
+
"cover the script with beats, and lean on the NON-FILM kinds so the creator isn't asked to shoot everything: " +
|
|
59
|
+
"kind:'web_image' = a logo/graphic/image PULLED FROM THE WEB, floated in a card beside the creator's head (use it " +
|
|
60
|
+
"when a concept has an obvious visual — 'AI success' → a trending-up chart / trophy); kind:'kinetic' = text " +
|
|
61
|
+
"overlays AND animated motion graphics. Describe a motion graphic in PLAIN TERMS of what the viewer sees ('a " +
|
|
62
|
+
"terminal window that types the prompt out') — never name an implementation (don't say 'SVG', 'Lottie' or " +
|
|
63
|
+
"'Remotion'): these are drawn natively on the phone, so an implementation name is both wrong and meaningless to " +
|
|
64
|
+
"the creator reading the plan. A terminal/typing animation is the one bespoke motion graphic that renders today " +
|
|
65
|
+
"(say 'terminal' or 'typing' in the shot and it's picked up); any other kinetic beat renders as its text overlay, " +
|
|
66
|
+
"so put the words you want burned on screen IN QUOTES. " +
|
|
67
|
+
"For any web_image/kinetic overlay DON'T pin an exact position — say it floats above the creator's head " +
|
|
68
|
+
"or wherever there's space, and note that placement is resolved at render so it won't collide with the captions.",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
hook: { type: "string", description: "The scroll-stopping opening line / concept." },
|
|
73
|
+
script: { type: "string", description: "The full narration script to film (teleprompter-ready)." },
|
|
74
|
+
topic: { type: "string", description: "The core topic / pillar this idea belongs to." },
|
|
75
|
+
tags: { type: "array", items: { type: "string" }, description: "Optional tags." },
|
|
76
|
+
asset_plan: {
|
|
77
|
+
type: "array",
|
|
78
|
+
description: "Per-shot plan: [{shot, kind:'existing'|'ai_broll'|'web_image'|'kinetic'|'film', note, narration, search_terms, asset_url?}]. " +
|
|
79
|
+
"ALWAYS set `search_terms` on any SHOW beat (existing / ai_broll) AND on every `web_image` beat — on a " +
|
|
80
|
+
"web_image it's the WEB SEARCH we run to find the graphic, and a result is only kept if its title mentions " +
|
|
81
|
+
"one of these terms, so an abstract concept with no concrete nouns finds nothing and the card falls back to " +
|
|
82
|
+
"plain text (e.g. 'AI success' → search_terms 'trophy, winner podium, chart going up, success'). " +
|
|
83
|
+
"Set `asset_url` instead when a SPECIFIC image is wanted; then no search runs. search_terms are " +
|
|
84
|
+
"CONCRETE VISUAL NOUNS, comma-separated, " +
|
|
85
|
+
"that could literally appear in a photo — the shoot app matches these against on-device Vision labels of " +
|
|
86
|
+
"the creator's camera roll. `shot` is written for a HUMAN to read and is often abstract, which matches " +
|
|
87
|
+
"nothing; `search_terms` is what the matcher actually searches. E.g. shot 'Sift through a bunch of AI " +
|
|
88
|
+
"output and pick a few good ones' → search_terms 'tablet, laptop screen, computer monitor, grid of images, " +
|
|
89
|
+
"hands typing'. Use plain everyday objects/places/people (laptop, screen, desk, phone, coffee, office, " +
|
|
90
|
+
"whiteboard) — NOT abstractions ('AI output', 'workflow', 'the future'). " +
|
|
91
|
+
"ALWAYS set `narration` to the EXACT word(s) from `script` the creator is SPEAKING while this beat is on " +
|
|
92
|
+
"screen — the beats run in order and together cover the whole script, so each beat's narration is its slice of " +
|
|
93
|
+
"the script verbatim. A slice does NOT have to be a whole sentence: when an overlay is triggered by SPECIFIC " +
|
|
94
|
+
"WORDS, CUT THE SLICE AT THOSE WORDS so the beat lands on the row it belongs to. E.g. for 'The real secret to " +
|
|
95
|
+
"AI success isn't learning to prompt.' with a shush gesture on 'the real secret' and a web graphic on 'AI " +
|
|
96
|
+
"success', emit TWO beats — narration 'The real secret' then 'to AI success isn't learning to prompt.' — " +
|
|
97
|
+
"NOT one sentence-beat plus a graphic pushed onto the NEXT sentence, which mis-times the graphic. This is what the shoot UI shows in quotes under each shot, so the creator knows what " +
|
|
98
|
+
"they're saying during it. (A kinetic text-overlay beat still has narration playing under it.)",
|
|
99
|
+
items: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: {
|
|
102
|
+
shot: { type: "string" },
|
|
103
|
+
kind: { type: "string", enum: ["existing", "ai_broll", "web_image", "kinetic", "film"] },
|
|
104
|
+
note: { type: "string" },
|
|
105
|
+
narration: { type: "string", description: "The verbatim script line(s) spoken during this beat." },
|
|
106
|
+
search_terms: { type: "string", description: "Concrete visual nouns (comma-separated) to match the creator's camera roll, e.g. 'tablet, laptop screen, grid of images'." },
|
|
107
|
+
asset_url: { type: "string" },
|
|
108
|
+
},
|
|
109
|
+
required: ["shot", "kind"],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
source_url: { type: "string", description: "The saved/pasted post URL this idea is modeled on, if any." },
|
|
113
|
+
format: { type: "string", enum: ["video", "carousel", "image", "reel_7s"], description: "How it's executed: 'video' (film off the teleprompter) | 'carousel' (image carousel) | 'image' | 'reel_7s' (a 5-7s caption reel — see below). Default video." },
|
|
114
|
+
caption: { type: "string", description: "For format 'reel_7s' ONLY: the single punchy OVERLAY CAPTION that IS the joke/value (e.g. \"Flight attendant: OMG is there anyone onboard who can align cross-functional stakeholders while navigating ambiguity?\"). The user films 5-7s of themselves reacting / doing a light action (or any B-roll — it barely matters); the caption carries it. Put the caption here AND in `hook`." },
|
|
115
|
+
week: { type: "string", description: "ISO week tag for weekly batches, e.g. '2026-W27'. Group a week's 5 ideas under the same value." },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
async execute(args) {
|
|
119
|
+
// For a 7s caption reel the overlay caption IS the content — use it as the hook/script if not given.
|
|
120
|
+
const hook = args.hook ?? args.caption;
|
|
121
|
+
const script = args.script ?? (args.format === "reel_7s" ? args.caption : undefined);
|
|
122
|
+
if (!hook && !script)
|
|
123
|
+
return "Give the idea at least a `hook` or a `script` (or a `caption` for reel_7s).";
|
|
124
|
+
const r = await (0, client_js_1.post)("/v1/spin/ideas", {
|
|
125
|
+
hook,
|
|
126
|
+
script,
|
|
127
|
+
topic: args.topic,
|
|
128
|
+
tags: args.tags,
|
|
129
|
+
asset_plan: args.asset_plan,
|
|
130
|
+
source_url: args.source_url,
|
|
131
|
+
format: args.format,
|
|
132
|
+
caption: args.caption,
|
|
133
|
+
week: args.week,
|
|
134
|
+
});
|
|
135
|
+
const planNote = args.asset_plan?.length ? ` with a ${args.asset_plan.length}-shot asset plan` : "";
|
|
136
|
+
return (`Queued idea ${r.id}${planNote} → it's in the user's Prompt/Idea History (/spin/history), saved across sessions + browsers.\n` +
|
|
137
|
+
`They can open it, film the script on the teleprompter at /create, and the plan says what B-roll to use vs film.`);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
exports.listIdeasTool = {
|
|
141
|
+
name: "list_ideas",
|
|
142
|
+
description: "List the user's queued content ideas (their Prompt/Idea History). Optionally filter by status " +
|
|
143
|
+
"(queued / scripted / filmed / done / dismissed). Use to resume planning or pick the next idea to execute.",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: {
|
|
147
|
+
status: { type: "string", enum: ["queued", "scripted", "filmed", "done", "dismissed"], description: "Optional status filter." },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
async execute(args) {
|
|
151
|
+
const q = args.status ? `?status=${encodeURIComponent(args.status)}` : "";
|
|
152
|
+
const r = await (0, client_js_1.get)(`/v1/spin/ideas${q}`);
|
|
153
|
+
if (!r.ideas.length)
|
|
154
|
+
return args.status ? `No ideas with status "${args.status}".` : "No ideas queued yet — brainstorm some and queue_idea them.";
|
|
155
|
+
const lines = r.ideas.map((i) => `- [${i.status}] ${i.hook || "(no hook)"}${i.topic ? ` · ${i.topic}` : ""}${i.source_url ? ` · from ${i.source_url}` : ""} (id ${i.id})`);
|
|
156
|
+
return [`${r.ideas.length} idea(s) in the Prompt/Idea History:`, ...lines].join("\n");
|
|
157
|
+
},
|
|
158
|
+
};
|
|
@@ -6,13 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.createKineticTextTool = void 0;
|
|
7
7
|
const child_process_1 = require("child_process");
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
-
const
|
|
9
|
+
const engine_js_1 = require("../engine.js");
|
|
10
10
|
const EFFECTS = ["typing", "billboard_flip", "isometric"];
|
|
11
11
|
const STYLES = ["terminal", "cinematic", "minimal"];
|
|
12
12
|
const FORMATS = ["9x16", "16x9", "1x1"];
|
|
13
13
|
function runKinetic(args) {
|
|
14
14
|
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, "kinetic.mjs"), ...args], { cwd: ENGINE_DIR, env: { ...process.env } });
|
|
15
|
+
const child = (0, child_process_1.spawn)(process.platform === "win32" ? "node.exe" : "node", [path_1.default.join(engine_js_1.ENGINE_DIR, "kinetic.mjs"), ...args], { cwd: engine_js_1.ENGINE_DIR, env: { ...process.env } });
|
|
16
16
|
let stdout = "";
|
|
17
17
|
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
18
18
|
child.stderr.on("data", (d) => process.stderr.write(d));
|
|
@@ -86,6 +86,10 @@ exports.createKineticTextTool = {
|
|
|
86
86
|
required: ["text"],
|
|
87
87
|
},
|
|
88
88
|
async execute(args) {
|
|
89
|
+
if (!(0, engine_js_1.engineAvailable)("kinetic.mjs")) {
|
|
90
|
+
return ("create_kinetic_text needs the ViralSpin engine, which isn't bundled in this install.\n" +
|
|
91
|
+
"Set VIRALSPIN_ASSEMBLY_DIR to a local engine checkout to enable it.");
|
|
92
|
+
}
|
|
89
93
|
const cli = ["--text", args.text];
|
|
90
94
|
if (args.effect)
|
|
91
95
|
cli.push("--effect", args.effect);
|
|
@@ -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
|
@@ -48,8 +48,15 @@ const publishSchema = {
|
|
|
48
48
|
},
|
|
49
49
|
platforms: {
|
|
50
50
|
type: "array",
|
|
51
|
-
items: { type: "string", enum: ["tiktok", "instagram", "youtube"] },
|
|
52
|
-
description: "Platforms to publish to
|
|
51
|
+
items: { type: "string", enum: ["tiktok", "instagram", "youtube", "linkedin"] },
|
|
52
|
+
description: "Platforms to publish to (uses the first connected account per platform). Omit when using account_ids. LinkedIn posts the caption as the article body + the images as a native multi-image carousel; LinkedIn has no draft mode.",
|
|
53
|
+
},
|
|
54
|
+
account_ids: {
|
|
55
|
+
type: "array",
|
|
56
|
+
items: { type: "string" },
|
|
57
|
+
description: "Specific connection IDs to publish to. Use list_connections to get IDs. " +
|
|
58
|
+
"Required when you have multiple accounts on the same platform and want to target a specific one. " +
|
|
59
|
+
"Takes precedence over 'platforms' when both are provided.",
|
|
53
60
|
},
|
|
54
61
|
caption: { type: "string", description: "Post caption." },
|
|
55
62
|
hashtags: {
|
|
@@ -58,13 +65,18 @@ const publishSchema = {
|
|
|
58
65
|
description: "Hashtags without # prefix.",
|
|
59
66
|
},
|
|
60
67
|
},
|
|
61
|
-
required: ["paths", "
|
|
68
|
+
required: ["paths", "caption"],
|
|
62
69
|
};
|
|
63
70
|
exports.publishNowTool = {
|
|
64
71
|
name: "publish_now",
|
|
65
|
-
description: "Upload local file(s) and publish immediately to TikTok, Instagram, and/or YouTube.
|
|
72
|
+
description: "Upload local file(s) and publish immediately to TikTok, Instagram, and/or YouTube. " +
|
|
73
|
+
"Pass a folder path to publish all images in it as a carousel. " +
|
|
74
|
+
"Use account_ids (from list_connections) to target a specific account when you have multiple accounts on the same platform.",
|
|
66
75
|
inputSchema: publishSchema,
|
|
67
76
|
async execute(args) {
|
|
77
|
+
if (!args.platforms?.length && !args.account_ids?.length) {
|
|
78
|
+
throw new Error("Provide 'platforms' (e.g. [\"instagram\"]) or 'account_ids' from list_connections.");
|
|
79
|
+
}
|
|
68
80
|
const filePaths = expandPaths(args.paths);
|
|
69
81
|
if (!filePaths.length)
|
|
70
82
|
throw new Error("No media files found in the provided paths.");
|
|
@@ -79,7 +91,8 @@ exports.publishNowTool = {
|
|
|
79
91
|
result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
80
92
|
media_urls: urls,
|
|
81
93
|
media_type: mediaType,
|
|
82
|
-
platforms: args.platforms,
|
|
94
|
+
platforms: args.platforms ?? [],
|
|
95
|
+
...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
|
|
83
96
|
caption: args.caption,
|
|
84
97
|
hashtags: args.hashtags ?? [],
|
|
85
98
|
publish_now: true,
|
|
@@ -130,6 +143,9 @@ exports.sendToDraftsTool = {
|
|
|
130
143
|
required: ["paths", "platforms", "caption"],
|
|
131
144
|
},
|
|
132
145
|
async execute(args) {
|
|
146
|
+
if (!args.platforms?.length && !args.account_ids?.length) {
|
|
147
|
+
throw new Error("Provide 'platforms' (e.g. [\"tiktok\"]) or 'account_ids' from list_connections.");
|
|
148
|
+
}
|
|
133
149
|
const filePaths = expandPaths(args.paths);
|
|
134
150
|
if (!filePaths.length)
|
|
135
151
|
throw new Error("No media files found in the provided paths.");
|
|
@@ -138,7 +154,7 @@ exports.sendToDraftsTool = {
|
|
|
138
154
|
throw new Error(`Carousel max is 10 images. Found ${filePaths.length} files.`);
|
|
139
155
|
}
|
|
140
156
|
// Surface the IG-not-supported gotcha before consuming credits / uploading.
|
|
141
|
-
if (args.platforms
|
|
157
|
+
if (args.platforms?.includes("instagram")) {
|
|
142
158
|
throw new Error("Instagram does not support drafts via API. IG drafts are local to the mobile app only — " +
|
|
143
159
|
"there is no API endpoint to send to drafts. Remove 'instagram' from platforms, or use schedule_post / publish_now instead.");
|
|
144
160
|
}
|
|
@@ -148,7 +164,8 @@ exports.sendToDraftsTool = {
|
|
|
148
164
|
result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
149
165
|
media_urls: urls,
|
|
150
166
|
media_type: mediaType,
|
|
151
|
-
platforms: args.platforms,
|
|
167
|
+
platforms: args.platforms ?? [],
|
|
168
|
+
...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
|
|
152
169
|
caption: args.caption,
|
|
153
170
|
hashtags: args.hashtags ?? [],
|
|
154
171
|
publish_now: true,
|
|
@@ -186,9 +203,12 @@ exports.schedulePostTool = {
|
|
|
186
203
|
description: "IANA timezone name (e.g. 'America/New_York'). Defaults to UTC.",
|
|
187
204
|
},
|
|
188
205
|
},
|
|
189
|
-
required: ["paths", "
|
|
206
|
+
required: ["paths", "caption", "scheduled_time"],
|
|
190
207
|
},
|
|
191
208
|
async execute(args) {
|
|
209
|
+
if (!args.platforms?.length && !args.account_ids?.length) {
|
|
210
|
+
throw new Error("Provide 'platforms' (e.g. [\"instagram\"]) or 'account_ids' from list_connections.");
|
|
211
|
+
}
|
|
192
212
|
const filePaths = expandPaths(args.paths);
|
|
193
213
|
if (!filePaths.length)
|
|
194
214
|
throw new Error("No media files found in the provided paths.");
|
|
@@ -199,7 +219,8 @@ exports.schedulePostTool = {
|
|
|
199
219
|
const result = await (0, client_js_1.post)("/v1/publish/schedule", {
|
|
200
220
|
media_urls: urls,
|
|
201
221
|
media_type: mediaType,
|
|
202
|
-
platforms: args.platforms,
|
|
222
|
+
platforms: args.platforms ?? [],
|
|
223
|
+
...(args.account_ids?.length ? { connection_ids: args.account_ids } : {}),
|
|
203
224
|
caption: args.caption,
|
|
204
225
|
hashtags: args.hashtags ?? [],
|
|
205
226
|
scheduled_time: args.scheduled_time,
|
|
@@ -209,7 +230,10 @@ exports.schedulePostTool = {
|
|
|
209
230
|
// Note: storage_paths are NOT deleted here — the cron job needs the URLs to stay valid.
|
|
210
231
|
// They expire after 1 hour. For posts scheduled further out, re-upload closer to publish time.
|
|
211
232
|
void storage_paths;
|
|
233
|
+
const targets = args.account_ids?.length
|
|
234
|
+
? `account IDs: ${args.account_ids.join(", ")}`
|
|
235
|
+
: (args.platforms ?? []).join(", ");
|
|
212
236
|
const { post: p } = result;
|
|
213
|
-
return `Scheduled post ${p.id}\
|
|
237
|
+
return `Scheduled post ${p.id}\nTargets: ${targets}\nTime: ${args.scheduled_time}\nStatus: ${p.status}`;
|
|
214
238
|
},
|
|
215
239
|
};
|
|
@@ -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
|
+
};
|