hermoso 0.1.294 → 0.1.305
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/bin/hermoso.mjs +10 -3
- package/mcp/client.mjs +16 -1
- package/mcp/hermoso-mcp.mjs +6 -1
- package/mcp/http.mjs +23 -0
- package/mcp/tool-hints.mjs +1 -1
- package/mcp/tools.mjs +83 -38
- package/package.json +1 -1
package/bin/hermoso.mjs
CHANGED
|
@@ -85,7 +85,7 @@ async function main() {
|
|
|
85
85
|
return console.log(`✓ Saved. API: ${apiBase}${cfg.token ? ' · token stored' : ''}`);
|
|
86
86
|
}
|
|
87
87
|
if (group === 'version' || flags.version) {
|
|
88
|
-
console.log(`hermoso-cli ${CLI_VERSION} · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token ? 'authed' : '
|
|
88
|
+
console.log(`hermoso-cli ${CLI_VERSION} · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token || process.env.HERMOSO_TOKEN ? 'authed' : 'not signed in — run: hermoso auth login'}`);
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -120,9 +120,16 @@ async function main() {
|
|
|
120
120
|
// pill. Reading only `balance` printed "Balance: undefined credits" against prod for every signed-in user
|
|
121
121
|
// (measured 2026-08-19). Same expression the hermoso_credits MCP tool uses, so the two cannot disagree.
|
|
122
122
|
const bal = d.accountBalance ?? d.balance;
|
|
123
|
+
// No key, no balance: /api/credits answers an anonymous caller 200 with a null balance, and "Balance: — credits"
|
|
124
|
+
// read as an empty account rather than a missing sign-in (2026-09-25).
|
|
125
|
+
if (bal == null && api.signedOut()) return die(api.SIGN_IN_HINT);
|
|
123
126
|
return out(`Balance: ${bal ?? '—'} credits`, d); }
|
|
124
127
|
case 'brand': {
|
|
125
|
-
|
|
128
|
+
// BARE `hermoso brand` SHOWS THE SAVED BRAND (2026-09-25). The docs have always listed it as "the saved brand
|
|
129
|
+
// profile" and it died with the `brand draft` usage line instead — the first command a new user copies off
|
|
130
|
+
// the CLI page. It is get_brand, run through the same handler the MCP twins use.
|
|
131
|
+
if (!sub) { const reg = await import('../mcp/registry.mjs'); return await runTool(reg, 'get_brand', flags, []); }
|
|
132
|
+
if (sub !== 'draft') return die('usage: hermoso brand the saved brand profile\n hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
|
|
126
133
|
const body = flags.domain ? { domain: flags.domain } : flags.description ? { description: flags.description } : flags.social ? { socialHandle: flags.social, platform: flags.platform || 'instagram' } : null;
|
|
127
134
|
if (!body) return die('give --domain, --description, or --social');
|
|
128
135
|
const d = await api.apiPost('/api/brand/draft', body); const p = d.profile || d;
|
|
@@ -262,7 +269,7 @@ async function main() {
|
|
|
262
269
|
}
|
|
263
270
|
console.log(`hermoso <command>
|
|
264
271
|
auth login [--url <base>] [--token <t>] credits capabilities
|
|
265
|
-
brand draft (--domain|--description|--social …)
|
|
272
|
+
brand · brand draft (--domain|--description|--social …) create --brand --product [--format]
|
|
266
273
|
generate image --prompt [--ref] [--model] [--aspect] generate video|avatar|stitch … [--wait]
|
|
267
274
|
jobs list | jobs get <id> [--wait] competitors <domain>
|
|
268
275
|
ads pull (--company|--domain) research "<request>"
|
package/mcp/client.mjs
CHANGED
|
@@ -101,12 +101,27 @@ function headers(extra = {}) {
|
|
|
101
101
|
return h;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// NOT SIGNED IN, SAID AS HOW TO FIX IT (2026-09-25). A stdio server or CLI started with no key (a new user who skipped
|
|
105
|
+
// `auth login`, a directory's "try this server", an IDE config missing HERMOSO_TOKEN) got the server's bare
|
|
106
|
+
// "Sign in to continue." on every account tool, and `hermoso_credits` printed "Balance: undefined credits" because
|
|
107
|
+
// /api/credits answers an anonymous caller 200 with a null balance. Neither says what to DO. Only when the process
|
|
108
|
+
// itself holds no credential: a hosted request (mcpCtx) always carries its caller's bearer, and a localhost base
|
|
109
|
+
// needs no auth at all. A caller who DOES send a key and still gets a 401 has a bad or revoked key, which the
|
|
110
|
+
// server's own message already says.
|
|
111
|
+
export const SIGN_IN_HINT = 'Not signed in. Run `npx -y hermoso auth login` (it opens a browser), or set HERMOSO_TOKEN to an agent key (hmk_…) created at app.hermoso.ai on the MCP & CLI tab, under Terminal & API keys.';
|
|
112
|
+
export const signedOut = () => !mcpCtx.getStore() && !TOKEN && !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(API_BASE);
|
|
113
|
+
/** The 401 sentence a caller with no credential reads. PURE (the signed-out verdict is an argument) so a check runs it. */
|
|
114
|
+
export function signInMessage(status, body, msg, isSignedOut = signedOut()) {
|
|
115
|
+
if (Number(status) !== 401 || body?.connector || !isSignedOut) return msg;
|
|
116
|
+
return /^sign in to continue\.?$/i.test(String(msg || '').trim()) ? SIGN_IN_HINT : `${msg} ${SIGN_IN_HINT}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
// unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
|
|
105
120
|
async function unwrap(res) {
|
|
106
121
|
let body = null;
|
|
107
122
|
try { body = await res.json(); } catch {}
|
|
108
123
|
if (!res.ok) {
|
|
109
|
-
const msg = (body && (body.error || body.message)) || `HTTP ${res.status}
|
|
124
|
+
const msg = signInMessage(res.status, body, (body && (body.error || body.message)) || `HTTP ${res.status}`);
|
|
110
125
|
// `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
|
|
111
126
|
// ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
|
|
112
127
|
// throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -13,7 +13,12 @@ import { API_BASE, connectedProviders } from './client.mjs';
|
|
|
13
13
|
|
|
14
14
|
// instructions = the full capability map (ad spy · create · raw model playground · account) — one source of truth
|
|
15
15
|
// in tools.mjs, shared with the hosted connector (http.mjs), so every surface tells agents the same breadth.
|
|
16
|
-
|
|
16
|
+
// THE REAL VERSION IN serverInfo (2026-09-25). This said '1.0.0' — a version the package has never had — so a client's
|
|
17
|
+
// server list and a directory that reads serverInfo both reported it, while npm shipped 0.1.x. Read from the package
|
|
18
|
+
// this file ships in (cli/package.json in the npm package; the app's own package.json in the app repo).
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version || '0.0.0'; } catch { return '0.0.0'; } })();
|
|
21
|
+
const server = new McpServer({ name: 'hermoso-mcp', version: PKG_VERSION }, {
|
|
17
22
|
instructions: MCP_INSTRUCTIONS,
|
|
18
23
|
});
|
|
19
24
|
|
package/mcp/http.mjs
CHANGED
|
@@ -283,6 +283,14 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
283
283
|
// a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
|
|
284
284
|
// default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
|
|
285
285
|
registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost: isWidgetHost(clientInfoOf(req.body), req) , hosted: true }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
286
|
+
// WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
|
|
287
|
+
// not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
|
|
288
|
+
// tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
|
|
289
|
+
// that probe from a registry crawler, so the clientInfo it names is logged, one line per anonymous initialize,
|
|
290
|
+
// so the next matcher is chosen from evidence (the Grok one was).
|
|
291
|
+
if (methodsOf(req.body).includes('initialize')) {
|
|
292
|
+
try { const m = (Array.isArray(req.body) ? req.body : [req.body]).find((x) => x && x.method === 'initialize'); const ci = m?.params?.clientInfo || {}; console.error(`[mcp-anon] initialize client=${JSON.stringify(String(ci.name || '').slice(0, 64))} v=${JSON.stringify(String(ci.version || '').slice(0, 24))} proto=${String(m?.params?.protocolVersion || '').slice(0, 16)} ua=${JSON.stringify(String(req.headers['user-agent'] || '').slice(0, 80))} src=${srcOf(req) || '-'}`); } catch {}
|
|
293
|
+
}
|
|
286
294
|
if (typeof onAnonDiscovery === 'function' && methodsOf(req.body).includes('tools/list')) { try { onAnonDiscovery({ client: clientInfoOf(req.body), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req) }); } catch {} }
|
|
287
295
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
288
296
|
res.on('close', () => { try { transport.close(); server.close(); } catch {} });
|
|
@@ -334,6 +342,21 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
334
342
|
// DELETE too. The one exception is an `initialize`, which IS a client starting over: strict in what we send,
|
|
335
343
|
// liberal in what we accept, because the entire point of this fix is that nothing here bricks a client.
|
|
336
344
|
if (sid && !init) return sessionGone(res);
|
|
345
|
+
// ── `server/discover` IS A HOST ASKING WHICH ERA WE SPEAK, AND THE 400 IS THE ANSWER (2026-09-25) ────────────
|
|
346
|
+
// ChatGPT (openai-mcp/1.0.0) and Claude Code (2.1.282) open EVERY connection with the MCP 2026-07-28 probe
|
|
347
|
+
// `server/discover` (headers MCP-Protocol-Version: 2026-07-28 + Mcp-Method, the version in params._meta) and
|
|
348
|
+
// only then fall back to `initialize`. That is the first POST after a token, and it is a 400 in the request log
|
|
349
|
+
// on every connect — which read like the connect failing. It is not: the spec's own compatibility matrix
|
|
350
|
+
// (modelcontextprotocol.io/specification/2026-07-28/basic/versioning, "Dual-era client / Legacy server:
|
|
351
|
+
// Works") says a dual-era host falls back when "the modern request returns a 4xx without a recognized modern
|
|
352
|
+
// error body". So this answer is deliberate and must stay LEGACY-shaped: a -32601 or any other modern code
|
|
353
|
+
// (-32020/-32021/-32022) would tell the host we are a modern server and it would stop falling back — every
|
|
354
|
+
// host would stall at connect. Serving the modern revision for real is a transport rewrite (no protocol-level
|
|
355
|
+
// sessions, per-request _meta, subscriptions/listen) that SDK 1.29 does not implement; this is the answer
|
|
356
|
+
// until then. Pinned by tools/mcp-connect-canary-check.mjs and replayed daily by the connect canary.
|
|
357
|
+
if (!init && req.method === 'POST' && methodsOf(req.body).includes('server/discover')) {
|
|
358
|
+
return res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: this server speaks the initialize-based MCP revisions (2025-11-25 and earlier). Send initialize to open a session.' }, id: null });
|
|
359
|
+
}
|
|
337
360
|
// Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
|
|
338
361
|
// answered here so we never build a server whose only job would be to reject the request.
|
|
339
362
|
if (!init) return needSession(res);
|
package/mcp/tool-hints.mjs
CHANGED
|
@@ -94,7 +94,7 @@ export function videoChoiceText(tool, choice) {
|
|
|
94
94
|
` 1. Make it as an image instead (~${o.image?.credits ?? '?'} credits): ${videoChoiceImageCall(tool)}.`,
|
|
95
95
|
` 2. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and the video goes ahead as asked.`,
|
|
96
96
|
];
|
|
97
|
-
if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits): ${videoChoiceDraftCall(tool, d)}. Premium models once they top up.`);
|
|
97
|
+
if (d) lines.push(` 3. Render the video anyway as a light draft on ${d.label || d.model} (${d.durationSeconds}s, ~${(Number(d.credits) || 0) + (Number(d.planCredits) || 0)} credits${d.audio === false ? '; SILENT: no voice, dialogue or music, so tell the user before they pick it for a spoken ad' : ''}): ${videoChoiceDraftCall(tool, d)}. Premium models once they top up.`);
|
|
98
98
|
else lines.push(` (No light-model draft fits this balance, so there is no "render anyway" option here.)`);
|
|
99
99
|
return lines.join('\n');
|
|
100
100
|
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
|
|
8
|
-
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
|
|
8
|
+
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT } from './client.mjs';
|
|
9
9
|
import { readFile } from 'node:fs/promises';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -117,6 +117,13 @@ const stillMsg = (r, widget = hostRendersWidgets()) => widget
|
|
|
117
117
|
// finished inside the wait, and get_job's when it did not (a prod timeline takes ~3 minutes, past the hosted 45 s wait,
|
|
118
118
|
// so on a hosted MCP the review ONLY ever arrives through get_job). Empty for every other job.
|
|
119
119
|
const timelineReviewText = (rv) => rv ? `\nREVIEW (${rv.verdict || 'unread'}${rv.score != null ? `, ${rv.score}/10` : ''}${rv.linked === false ? ', NOT LINKED: the two clips read as unrelated; say so and offer a follow clip made for the hook (a brief they record, or one generated with the cost quoted first)' : rv.linked ? ', linked' : ''}): ${(rv.issues || []).map((x) => `seam ${x.seam}: ${x.problem} → ${x.fix}`).join(' | ') || 'no issues'}. ${rv.verdict === 'amateur' || rv.verdict === 'ok' ? 'Fix what it names and re-run with the same sources (twice at most) before presenting it.' : 'Look at the seam frames too before presenting it.'}` : '';
|
|
120
|
+
// THE SEAM MATCH + INTRO BUDGET of a timeline or join (lib/seam-match.mjs, post-edit.mjs introBudget): the measured
|
|
121
|
+
// before/after per cut and the surviving window of a clip placed after a hook. Empty for every other result.
|
|
122
|
+
const seamsText = (d) => {
|
|
123
|
+
const rows = Array.isArray(d?.seams) ? d.seams.filter((x) => x && x.before) : [], b = d?.budget, n = (x) => `${x >= 0 ? '+' : ''}${x}`;
|
|
124
|
+
const dl = (x) => x ? `exposure ${n(x.exposurePct)}%, black ${n(x.black)}, WB u${n(x.wbU)} v${n(x.wbV)}, grain ${n(x.grain)}, sharpness x${x.sharpness}` : 'unread';
|
|
125
|
+
return `${rows.length ? `\nSEAMS MATCHED: ${rows.map((x) => `seam ${x.seam} (${x.at}s) before ${dl(x.before)} -> after ${dl(x.after)}; ${x.applied}`).join(' | ')}` : ''}${b ? `\nBUDGET: ${b.total}s total - ${b.intro}s intro = ${b.survivingWindow.seconds}s of ${b.footage}${b.dropped?.length ? `; not shown: ${b.dropped.map((x) => `${x.from}-${x.to}s (${x.why})`).join(', ')}${b.fixes ? `. To keep it: ${b.fixes.join(' / ')}` : ''}` : ''}` : ''}`;
|
|
126
|
+
};
|
|
120
127
|
const okVideo = async (text, r) => {
|
|
121
128
|
if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; const t = text + geoLine(r) + qaLine(r); return { content: [{ type: 'text', text: p ? t + '\n(first frame attached — open the URL for the full video)' : t }, ...(p ? [p] : [])], structuredContent: r ?? {} }; };
|
|
122
129
|
|
|
@@ -440,7 +447,7 @@ const wrap = (fn) => {
|
|
|
440
447
|
}
|
|
441
448
|
catch (e) {
|
|
442
449
|
try { recordToolOutcome(_tool, { ok: false, ms: Date.now() - _t0 }); } catch {}
|
|
443
|
-
if (!e?._viaApi) { try { reportToolError(_tool, e); } catch {} }
|
|
450
|
+
if (!e?._viaApi && !e?._signedOut) { try { reportToolError(_tool, e); } catch {} } // a caller with no key is not a defect, and its report would be anonymous noise
|
|
444
451
|
let msg = `Error: ${e?.message || e}`;
|
|
445
452
|
// THE SAME TWO PIECES OF ADVICE, NAMED (2026-09-17). Everything below stays exactly as it was — the prose is
|
|
446
453
|
// what a model reads and what half the hosts show. `_hints` is the identical advice keyed as {do, why}, so a
|
|
@@ -3614,6 +3621,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3614
3621
|
}, wrap(async () => {
|
|
3615
3622
|
const d = await apiGet('/api/credits');
|
|
3616
3623
|
const bal = d.accountBalance ?? d.balance; // accountBalance = the caller's Hermoso credits (authed); balance = the local-dev usage pill
|
|
3624
|
+
// NO BALANCE IS NOT A NUMBER (2026-09-25): this printed "Balance: undefined credits" to every caller with no key,
|
|
3625
|
+
// because /api/credits answers an anonymous caller 200 with a null balance. Say why there is none instead.
|
|
3626
|
+
if (bal == null) throw Object.assign(new Error(signedOut() ? SIGN_IN_HINT : 'The balance could not be read just now. Try again in a moment; billing_status reads it too.'), { status: signedOut() ? 401 : 503, _signedOut: signedOut() });
|
|
3617
3627
|
// THE CHARGES THEMSELVES (2026-09-12): the same ledger list as Billing ▸ Usage, so "where did my credits go" has an
|
|
3618
3628
|
// answer here too. Best effort: a reply about the balance never fails on the history read.
|
|
3619
3629
|
let recent = ''; try { const u = await apiGet('/api/billing/usage', { limit: 10 }); if (u?.items?.length) recent = `\nRecent charges (newest first):\n${u.items.map((r) => `• ${String(r.at).slice(0, 16).replace('T', ' ')} UTC · ${r.label} · ${r.credits} credits`).join('\n')}`; } catch {}
|
|
@@ -17309,7 +17319,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17309
17319
|
|
|
17310
17320
|
server.registerTool('post_edit', {
|
|
17311
17321
|
title: 'Post-production edit',
|
|
17312
|
-
description: "MECHANICAL post-production on an EXISTING video (
|
|
17322
|
+
description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg in seconds, ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, watermark (brand logo), grain (anti-AI), text (timed words in a native look: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle; start/end), join (this video FOLLOWED BY clips[]: Library URLs, direct files or public post links, as one 1080x1920 video, loudness matched). Presets are shortcuts: text/watermark take any x/y, grain any amount, join any ffmpeg transition, a bridge any sound link. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}], bridge}], and it ALWAYS gets a bridge unless the user asks for a bare cut: {kind:'impact'} cuts the hook just before its payoff (found from the footage; cutAt overrides) and lands our clip on a punch-in and flash, with the payoff sound FROM THE HOOK ITSELF: its own audio carries across the cut, else one generated from its frames (up to 8 credits), else a neutral impact. {kind:'text', text, then?} only when the user asks for words over the cut. No voiceover bridge: have our clip's host say the connecting line. matchCut = where our clip starts. ANY OTHER EDIT, op or bridge (whip, zoom, freeze, wipe, split screen, generated shot) is edit_timeline: free keyframes, any size. NEVER generate_video/render_ad for these.",
|
|
17313
17323
|
inputSchema: {
|
|
17314
17324
|
videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
|
|
17315
17325
|
ops: z.array(z.object({
|
|
@@ -17317,22 +17327,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17317
17327
|
start: z.number().optional().describe('trim/mute/text window start (s)'),
|
|
17318
17328
|
end: z.number().optional().describe('trim/mute/text window end (s)'),
|
|
17319
17329
|
text: z.string().optional().describe('text: the words, verbatim'),
|
|
17320
|
-
position: z.enum(['top', 'center', 'lower', 'bottom']).optional().describe('text: where'),
|
|
17330
|
+
position: z.enum(['top', 'center', 'lower', 'bottom']).optional().describe('text: where, or x/y'),
|
|
17331
|
+
x: z.number().optional().describe('text/watermark centre: 0-1 of frame, or px'),
|
|
17332
|
+
y: z.number().optional(),
|
|
17321
17333
|
style: z.union([z.string(), z.object({}).passthrough()]).optional().describe('text: a look name or a textStyle'),
|
|
17322
17334
|
textStyle: z.union([z.string(), z.object({}).passthrough()]).optional(),
|
|
17323
|
-
clips: z.array(z.object({ url: z.string(), start: z.number().optional(), end: z.number().optional() })).optional().describe('join: the clips after this video'),
|
|
17324
|
-
|
|
17325
|
-
|
|
17335
|
+
clips: z.array(z.object({ url: z.string(), start: z.number().optional(), end: z.number().optional(), match: z.any().optional() })).optional().describe('join: the clips after this video'),
|
|
17336
|
+
match: z.any().optional().describe("join: seam match, 'auto' default | 'off' | {grade,level,grain,blur,strength}"),
|
|
17337
|
+
transition: z.string().optional().describe("join: 'cut' default, 'crossfade', or any ffmpeg xfade name (wipeleft…)"),
|
|
17338
|
+
bridge: z.object({ kind: z.enum(['impact', 'text']), cutAt: z.number().optional().describe('omit: found from the footage'), matchCut: z.number().optional(), sound: z.string().optional().describe("'auto' default, 'own', 'impact', 'whoosh', 'none', or an audio URL (find_sound)"), flash: z.boolean().optional(), shake: z.boolean().optional(), text: z.string().optional(), then: z.string().optional() }).optional().describe('join: connects the hook to the first clip'),
|
|
17326
17339
|
factor: z.number().optional().describe('speed 0.5-2'),
|
|
17327
17340
|
db: z.number().optional().describe('audio_gain -20..+6 dB'),
|
|
17328
|
-
seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s /
|
|
17329
|
-
headline: z.string().optional().describe('append_card: big line (
|
|
17341
|
+
seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s / transition 0.2-1.5s'),
|
|
17342
|
+
headline: z.string().optional().describe('append_card: big line (default: brand name)'),
|
|
17330
17343
|
tagline: z.string().optional().describe('append_card: smaller line under the headline'),
|
|
17331
|
-
sub: z.string().optional().describe('append_card:
|
|
17332
|
-
background: z.string().optional().describe("append_card:
|
|
17333
|
-
card_html: z.string().optional().describe('append_card: your OWN full-frame card
|
|
17334
|
-
corner: z.enum(['tl', 'tr', 'bl', 'br']).optional().describe('watermark corner (default br)'),
|
|
17335
|
-
intensity: z.enum(['default', 'strong']).optional().describe('grain
|
|
17344
|
+
sub: z.string().optional().describe('append_card: pill line (default: website) / text: a second, smaller line'),
|
|
17345
|
+
background: z.string().optional().describe("append_card: hex or a colour name; the user's colour beats the brand palette"),
|
|
17346
|
+
card_html: z.string().optional().describe('append_card: your OWN full-frame card as inline-styled HTML ({{logo}} = the brand logo)'),
|
|
17347
|
+
corner: z.enum(['tl', 'tr', 'bl', 'br']).optional().describe('watermark corner (default br), or x/y'),
|
|
17348
|
+
intensity: z.union([z.enum(['default', 'strong']), z.number()]).optional().describe('grain: or 0-1 (default 0.25)'),
|
|
17336
17349
|
})).describe('the ordered edit plan (max 6 ops)'),
|
|
17337
17350
|
brandName: z.string().optional().describe('override the workspace brand name'),
|
|
17338
17351
|
domain: z.string().optional().describe('override the brand website'),
|
|
@@ -17344,7 +17357,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17344
17357
|
let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // via /api/store/bootstrap — there is no GET /api/store/:key route
|
|
17345
17358
|
const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
|
|
17346
17359
|
const r = await renderJob('postedit', { videoUrl: a.videoUrl, ops: (a.ops || []).slice(0, 6), brandName: a.brandName || b.name || '', domain: a.domain || b.domain || '', logo: b.logo || '', accent: a.accent || pal[0] || '' }, 'MCP post edit');
|
|
17347
|
-
return okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.applied) ? ` (${r.raw.applied.join(', ')})` : ''}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : ''} [job ${r.jobId}]`, r);
|
|
17360
|
+
return okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.applied) ? ` (${r.raw.applied.join(', ')})` : ''}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.filter((x) => !/^seams matched:/.test(x)).join('; ')}` : ''}${seamsText(r?.raw)} [job ${r.jobId}]`, r);
|
|
17348
17361
|
}));
|
|
17349
17362
|
|
|
17350
17363
|
// THE OPEN EDIT PRIMITIVE (2026-09-24). The owner: "I hate being rigid, the whole point of this is being able to do basically
|
|
@@ -17355,6 +17368,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17355
17368
|
// the default LIST on size (ON_DEMAND_TOOLS): find_tools finds it, call_tool and a direct call run it.
|
|
17356
17369
|
// a constant, or keyframes [{t | src, v, ease}] (shape spelled out on `segments`: one description beats thirteen copies)
|
|
17357
17370
|
const KF = z.union([z.number(), z.array(z.any())]);
|
|
17371
|
+
// seam matching (lib/seam-match.mjs): 'auto' | 'off' | {grade, level, grain, blur: booleans, strength 0-1}
|
|
17372
|
+
const SEAM_MATCH = z.union([z.enum(['auto', 'off']), z.object({ grade: z.boolean().optional(), level: z.boolean().optional(), grain: z.boolean().optional(), blur: z.boolean().optional(), strength: z.number().optional() })]);
|
|
17358
17373
|
server.registerTool('edit_timeline', {
|
|
17359
17374
|
title: 'Compose an edit (timeline)',
|
|
17360
17375
|
description: "Compose ANY edit or transition yourself; there is no preset list. Segments on an output timeline (later ones drawn on top; overlapping ones ARE the transition), each animated by keyframes, plus your own HTML overlays and masks, compiled server-side into one render. Whip pan, zoom through, push, spin, speed ramp, slow motion, freeze frame, reverse, flash, J-cut / L-cut, circle or shape wipe, split screen, picture in picture: all composed from the fields below. Local render, the flat post_edit price (~2 credits, overlays included); a `generate` segment (a paid generated transition shot) only when the user explicitly asks for one. "
|
|
@@ -17364,8 +17379,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17364
17379
|
+ "A VIRAL HOOK + THEIR PRODUCT: start from the hook. A clip matched to an unrelated hook never reads as one video, so write the clip AFTER the hook for it: a linking script + shot brief (the first line answers the hook, e.g. 'still waiting for the egg to land... anyway, come check out our restaurant'; what to film so it follows on; 5-15 s; then the pitch). The user records it, or you generate it (render_ad / generate_video, cost quoted first, on their OK); then join here: the hook with out:'payoff' and audio.tail:'payoff', then their clip. Match an existing unrelated clip only if they insist. A {generate:{prompt, seconds 3-8}} segment (a generated transition-only shot, paid, postEditTimeline) is never suggested; build it only when they explicitly ask for one. "
|
|
17365
17380
|
+ "LINK FIRST: an effect alone never connects two unrelated clips; the link comes from what is in the frames. (1) Match cut, the default: video_frames (with its MOTION readout) on the hook's last second and across the other clip; pick the out-point AND the in-point (in: seconds, not always 0) where a motion direction, a screen position or size, a shape, a surface, a gesture or a gaze carries across, then ride the effect on that shared motion. (2) Its host names the hook in the first line. If the two share nothing, say so and offer the follow clip made for the hook. "
|
|
17366
17381
|
+ "PRO, NOT IMOVIE: ease every curve (never linear on a move); keep the picture filling the frame through a move (scale up while it moves: two frames sliding side by side with a seam is the amateur tell); hide the handoff under the fastest, blurriest frames; carry direction into the next shot; cut on motion; end every effect cleanly; 0.2-0.6 s in total; a sound whose peak lands on the handoff (sfx whoosh at handoff minus 0.45 s, or the hook's own payoff sound). Moving segments get a real shutter blur automatically (motionBlur). "
|
|
17367
|
-
+ "
|
|
17368
|
-
+ "RECIPES (c = the cut second, adapt freely): whip pan: A over its last 0.22 s x 0 to -0.22, scale 1 to 1.35, mblur 0 to 220, all ease in; B overlap 0.08, opacity 0 to 1 over 0.08, x 0.22 to 0, scale 1.35 to 1, mblur 220 to 0, all ease out over 0.3 s; whoosh at c-0.45. Zoom through: A over its last 0.35 s scale 1 to 3 ease in anchored on the object, blur 0 to 10; B overlap 0.12, opacity 0 to 1, scale 1.5 to 1 and blur 10 to 0 ease out over 0.4 s. Cut on action: A out ON the motion, B scale 1.08 to 1 ease out over 0.25 s, audio.lead 0.2. Speed ramp: speed [{src:t0,v:1},{src:t0+0.25,v:0.3}] then [{src:t1,v:0.3},{src:t1+0.1,v:2}] into the cut. Circle wipe: B overlap 0.5 + overlays [{mode:'mask', segment:1, start, end, html: a white div whose clip-path circle grows via @keyframes}]. "
|
|
17382
|
+
+ "EVERY SEAM IS MATCHED AUTOMATICALLY, hard cuts too: each cut is measured and the incoming clip graded (exposure, white balance, black level), grained UP (never smoothed) and softened while it moves toward the outgoing one; the reply gives before/after deltas per seam. match (timeline: every cut; segment: the cut into it): 'auto' default, 'off' for a deliberate contrast, or {grade, level, grain, blur: false to skip one, strength 0-1}; a segment's own constant exposure / contrast / saturation replaces the automatic grade. Still yours: subject size and headroom (scale it, never a jump from a third of the frame to two thirds) and sound (a 0.25-0.5 s J/L-cut, never a sonic wall). A clip placed after a hook gets a BUDGET (total - intro = its surviving window, and what was dropped). The plainest thing that links wins: a straight cut on action beats a decorative effect; over 0.5 s is too long in anything under 20 s; never flash more than 3 times a second. "
|
|
17383
|
+
+ "RECIPES (c = the cut second, adapt freely): whip pan: A over its last 0.22 s x 0 to -0.22, scale 1 to 1.35, mblur 0 to 220, all ease in; B overlap 0.08, opacity 0 to 1 over 0.08, x 0.22 to 0, scale 1.35 to 1, mblur 220 to 0, all ease out over 0.3 s; whoosh at c-0.45. Zoom through: A over its last 0.35 s scale 1 to 3 ease in anchored on the object, blur 0 to 10; B overlap 0.12, opacity 0 to 1, scale 1.5 to 1 and blur 10 to 0 ease out over 0.4 s. Cut on action: A out ON the motion, B scale 1.08 to 1 ease out over 0.25 s, audio.lead 0.2. Speed ramp: speed [{src:t0,v:1},{src:t0+0.25,v:0.3}] then [{src:t1,v:0.3},{src:t1+0.1,v:2}] into the cut. Circle wipe: B overlap 0.5 + overlays [{mode:'mask', segment:1, start, end, html: a white div whose clip-path circle grows via @keyframes}]. Card (picture in picture: a proven ad playing in a rounded card over the host watching it, any length): the host segment full frame, then the clip ON TOP with at:0, fit:'contain' (crop to reframe it), scale ~0.6-0.7, y ~0.12, radius ~0.04-0.06; a card is not a cut, so it is never graded toward the host; duck it under the host's first line with audio.gain keys (the host's voice leads the switch) and end it on a hard cut at a sentence break. "
|
|
17369
17384
|
+ "SELF-CRITIQUE: the reply carries a vision REVIEW of each seam (pro / ok / amateur, linked or not, with fixes; about 2 credits, review:false skips it) and the seam frames. When it says ok or amateur, fix what it names and re-run the same sources (twice at most) before presenting; on a re-run give a generated segment {src: its URL, between: true} so it is not paid for twice. Then look at the WHOLE result once with video_frames, not only the seams: the first frame is not black or frozen, no dead air over ~0.3 s at the head, no lone black, flash or repeated frame at a cut, and nothing static for more than ~4-5 s (recut it or add a re-hook). "
|
|
17370
17385
|
+ "FOLLOW-UPS ('cut earlier', 'no splat', 'whip pan instead', 'use the second hook') re-run this with the SAME sources and the one change; the result echoes the resolved timeline (e.g. the found payoff cut) to edit from. Refusals are free and name the field.",
|
|
17371
17386
|
inputSchema: {
|
|
@@ -17386,23 +17401,26 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17386
17401
|
hold: z.array(z.object({ src: z.number(), seconds: z.number() })).optional().describe('freeze frames'),
|
|
17387
17402
|
reverse: z.boolean().optional(),
|
|
17388
17403
|
between: z.boolean().optional().describe('a bridge clip between its neighbours (a re-used generated shot): trimmed and graded to them automatically'),
|
|
17404
|
+
match: SEAM_MATCH.optional().describe('the cut INTO this segment (default: the timeline match)'),
|
|
17389
17405
|
slowmo: z.enum(['blend', 'hold', 'flow']).optional().describe('how slow motion fills frames (flow = motion-interpolated)'),
|
|
17390
17406
|
scale: KF.optional(), x: KF.optional().describe('canvas widths'), y: KF.optional().describe('canvas heights'), rotate: KF.optional().describe('degrees'),
|
|
17391
17407
|
opacity: KF.optional(), blur: KF.optional(), mblur: KF.optional().describe('directional motion blur px'), mblurAngle: z.number().optional(),
|
|
17408
|
+
radius: z.number().optional().describe("rounded corners, a fraction of the picture's shorter side (0-0.5): a picture-in-picture CARD over the segment under it, any length"),
|
|
17392
17409
|
brightness: KF.optional().describe('-1..1, a flash'), exposure: KF.optional().describe('× the light, 1 = as shot (a fine grade)'), contrast: KF.optional(), saturation: KF.optional(),
|
|
17393
17410
|
audio: z.object({ mute: z.boolean().optional(), gain: KF.optional().describe('dB'), lead: z.number().optional(), tail: z.union([z.number(), z.literal('payoff')]).optional(), fadeIn: z.number().optional(), fadeOut: z.number().optional(), level: z.enum(['match', 'keep']).optional() }).optional(),
|
|
17394
17411
|
})).describe("the clips on the output, max 12, later ones on top. Every animated field (scale x y rotate opacity exposure blur mblur brightness contrast saturation, audio.gain) is a constant or keyframes [{t: seconds into this segment | src: a second of the source, v, ease: linear|in|out|inout|hold}]; x and y are in canvas widths / heights, rotate in degrees, mblur in px along mblurAngle"),
|
|
17395
17412
|
overlays: z.array(z.object({ html: z.string().describe('HTML/CSS animated with @keyframes; no script, no network (images as data: URIs)'), start: z.number(), end: z.number(), mode: z.enum(['over', 'mask']).optional().describe("'mask': its opaque pixels are where `segment` shows"), segment: z.number().optional() })).optional(),
|
|
17396
17413
|
sfx: z.array(z.object({ sound: z.enum(['whoosh', 'impact', 'payoff']), at: z.number(), gain: z.number().optional(), segment: z.number().optional().describe("payoff: the segment whose OWN payoff sound (the egg's real splat) lands at `at`") })).optional(),
|
|
17397
|
-
size: z.
|
|
17398
|
-
fps: z.number().optional(),
|
|
17414
|
+
size: z.string().optional().describe("canvas: '9:16' default, '1:1', '4:5', '16:9', any 'W:H' (1:3 to 3:1, on a 1080 short side) or exact 'WxH' pixels"),
|
|
17415
|
+
fps: z.number().optional().describe('the frame rate, a whole number from 24 to 60 (24, 25, 30, 60 are the usual ones). Leave it out: the canvas takes the rate of its own footage (the rate of most of it on screen), so no frames are invented; a faster clip is thinned, a slower one that does not divide it is motion-interpolated'),
|
|
17399
17416
|
motionBlur: z.boolean().optional().describe('default true: anything that moves gets a real shutter blur along its path'),
|
|
17400
17417
|
review: z.boolean().optional().describe('default true: a vision read of each seam (about 2 credits) comes back with the render, with the seam frames'),
|
|
17418
|
+
match: SEAM_MATCH.optional().describe("every cut: 'auto' default"),
|
|
17401
17419
|
},
|
|
17402
17420
|
outputSchema: { ...JOB_OUT },
|
|
17403
17421
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17404
17422
|
}, wrap(async (a) => {
|
|
17405
|
-
const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}) };
|
|
17423
|
+
const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}), ...(a.match != null ? { match: a.match } : {}) };
|
|
17406
17424
|
const r = await renderJob('postedit', { ...(a.videoUrl ? { videoUrl: a.videoUrl } : {}), ops: [op] }, 'MCP timeline');
|
|
17407
17425
|
const tl = r?.raw?.timeline;
|
|
17408
17426
|
const gen = Array.isArray(r?.raw?.generatedShots) && r.raw.generatedShots.length ? `\nGENERATED SHOT: ${r.raw.generatedShots.map((g) => `${g.model} ${g.seconds}s ${abs(g.video)}`).join('; ')} (billed as its own render)` : '';
|
|
@@ -17413,7 +17431,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17413
17431
|
// polling instruction, plus where the self-critique will arrive, so the caller neither re-renders nor presents blind.
|
|
17414
17432
|
if (r?.stillRendering && !hostRendersWidgets()) return ok(`${stillMsg(r)} A timeline edit usually takes about 3 minutes. When get_job(${r.jobId}) reports done, its answer carries the SEAM REVIEW and the seam frames: read them before you present the edit, and fix + re-run (same sources) if it says ok or amateur.`, r);
|
|
17415
17433
|
const rvText = timelineReviewText(rv);
|
|
17416
|
-
const res = await okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : ''}${tl ? `\nRESOLVED: ${tl.segments.map((sg) => `[${sg.i}] ${sg.in != null ? `${sg.in}-${sg.out}s` : `${sg.seconds}s`} @${sg.at}s`).join(', ')} (${tl.seconds}s)` : ''}${gen}${rvText} [job ${r.jobId}]`, r);
|
|
17434
|
+
const res = await okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.filter((x) => !/^seams matched:/.test(x)).join('; ')}` : ''}${tl ? `\nRESOLVED: ${tl.segments.map((sg) => `[${sg.i}] ${sg.in != null ? `${sg.in}-${sg.out}s` : `${sg.seconds}s`} @${sg.at}s`).join(', ')} (${tl.seconds}s)` : ''}${gen}${seamsText(r?.raw)}${rvText} [job ${r.jobId}]`, r);
|
|
17417
17435
|
const sheet = rv?.seamSheets?.[0]?.url ? await imageBlock(abs(rv.seamSheets[0].url)) : null;
|
|
17418
17436
|
if (sheet && Array.isArray(res.content)) res.content.push({ type: 'text', text: `Seam 1 frames (${rv.seamSheets[0].at}s${rv.seamSheets[0].end > rv.seamSheets[0].at ? `-${rv.seamSheets[0].end}s` : ''}, ${rv.seamSheets[0].fps || 15} fps, left to right):` }, sheet);
|
|
17419
17437
|
return res;
|
|
@@ -17422,15 +17440,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17422
17440
|
// THE EYES (2026-09-24): exact frames as one contact sheet, free. Pairs with edit_timeline: look, cut, render, look.
|
|
17423
17441
|
server.registerTool('video_frames', {
|
|
17424
17442
|
title: 'Look at video frames',
|
|
17425
|
-
description: "LOOK at a video's exact frames, free: one contact-sheet image of the frames from start to end at fps (e.g. a seam, 3.0-3.8 s at 10 fps) or at the listed times, tiles in reading order with each tile's second listed. Use it to find a moment before an edit and to check a render's seam after it. Takes a Library / render URL, a direct file or a public post link.",
|
|
17443
|
+
description: "LOOK at a video's exact frames, free: one contact-sheet image of the frames from start to end at fps (e.g. a seam, 3.0-3.8 s at 10 fps) or at the listed times, tiles in reading order with each tile's second listed. Use it to find a moment before an edit and to check a render's seam after it. Takes a Library / render URL, a direct file or a public post link (a TikTok / Reel / Facebook / X post link is opened through one paid lookup, 1 credit; a file is free). SAVE STILLS: save:true returns full-size still images (frame grabs, up to 8) as URLs instead of a contact sheet, picked by times or by clips (numbered at the video's hard cuts, -1 = the last): a before / after from someone's own footage, a cover, a product shot. Compose with those URLs (make_template_ad template 'custom', a post) with no AI.",
|
|
17426
17444
|
inputSchema: {
|
|
17427
17445
|
url: z.string().describe('the video'),
|
|
17428
17446
|
start: z.number().optional(), end: z.number().optional(),
|
|
17429
17447
|
fps: z.number().optional().describe('frames per second in the window (default ~16 frames across it)'),
|
|
17430
|
-
times: z.array(z.number()).optional().describe('exact seconds instead of a window (max 48)'),
|
|
17448
|
+
times: z.array(z.number()).optional().describe('exact seconds instead of a window (max 48; max 8 with save)'),
|
|
17449
|
+
save: z.boolean().optional().describe('true = save the frames at times / clips as full-size stills and return their URLs'),
|
|
17450
|
+
clips: z.array(z.number()).optional().describe('save: one still from the middle of each numbered clip (split at hard cuts; negative counts from the end)'),
|
|
17431
17451
|
},
|
|
17432
|
-
annotations: { readOnlyHint:
|
|
17452
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
17433
17453
|
}, wrap(async (a) => {
|
|
17454
|
+
if (a.save === true || (Array.isArray(a.clips) && a.clips.length)) {
|
|
17455
|
+
const st = await apiGet('/api/video/stills', { url: a.url, ...(Array.isArray(a.times) && a.times.length ? { times: a.times.join(',') } : {}), ...(Array.isArray(a.clips) && a.clips.length ? { clips: a.clips.join(',') } : {}) });
|
|
17456
|
+
const stills = (st.stills || []).map((x) => ({ ...x, url: abs(x.url) }));
|
|
17457
|
+
return { content: [{ type: 'text', text: String(st.summary || '').replace(/(^|\s)(\/generated\/\S+)/g, (m, sp, u) => sp + abs(u)) }], structuredContent: { stills, durationSeconds: st.durationSeconds, clipMap: st.clipMap || null } };
|
|
17458
|
+
}
|
|
17434
17459
|
const d = await apiGet('/api/video/sheet', { url: a.url, ...(a.start != null ? { start: a.start } : {}), ...(a.end != null ? { end: a.end } : {}), ...(a.fps != null ? { fps: a.fps } : {}), ...(Array.isArray(a.times) && a.times.length ? { times: a.times.join(',') } : {}) });
|
|
17435
17460
|
const [head, b64] = String(d.image || '').split(',');
|
|
17436
17461
|
const mv = (d.motion || []).filter(Boolean).map((m) => `${m.t.toFixed(2)}s ${m.region ? `subject at (${m.region.x}, ${m.region.y})${m.heading ? ` moving ${m.heading}` : ''}` : 'no moving subject'}${m.camera !== 'still' ? `, camera ${m.camera}` : ''}`).join('; ');
|
|
@@ -17793,12 +17818,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17793
17818
|
model: j.model || rawJ.model || null, poster: abs(rawJ.poster) || null, creditsUsed: j.creditsUsed ?? null,
|
|
17794
17819
|
deliveredWidth: rawJ.deliveredWidth ?? null, deliveredHeight: rawJ.deliveredHeight ?? null })}`;
|
|
17795
17820
|
if (j.status === 'done' && res?.video && res?.review) { // a finished TIMELINE edit: its seam review + frames, as edit_timeline would have shown them
|
|
17796
|
-
const out = await okVideo(`${wireText}${timelineReviewText(res.review)}`, { ...j, url });
|
|
17821
|
+
const out = await okVideo(`${wireText}${seamsText(res)}${timelineReviewText(res.review)}`, { ...j, url });
|
|
17797
17822
|
const sheet = res.review?.seamSheets?.[0]?.url ? await imageBlock(abs(res.review.seamSheets[0].url)) : null;
|
|
17798
17823
|
if (sheet && Array.isArray(out.content)) out.content.push({ type: 'text', text: `Seam 1 frames (${res.review.seamSheets[0].at}s${res.review.seamSheets[0].end > res.review.seamSheets[0].at ? `-${res.review.seamSheets[0].end}s` : ''}, ${res.review.seamSheets[0].fps || 15} fps, left to right):` }, sheet);
|
|
17799
17824
|
return out;
|
|
17800
17825
|
}
|
|
17801
|
-
|
|
17826
|
+
const seamLine = seamsText(res); // a timeline / join's measured seams + intro budget ('' for any other video)
|
|
17827
|
+
if (j.status === 'done' && res?.video) return okVideo(wireText + seamLine, { ...j, url }); // resumed video → same inline poster as a direct return
|
|
17802
17828
|
if (j.status === 'done' && res?.image) { const img = await imageBlock(url); return { content: [{ type: 'text', text: wireText }, ...(img ? [img] : [])], structuredContent: { ...j, url } }; }
|
|
17803
17829
|
return ok(wireText, { ...j, url });
|
|
17804
17830
|
}));
|
|
@@ -19763,24 +19789,43 @@ function memoryNoteVerdict(text) {
|
|
|
19763
19789
|
// headless caller's only route to a restyled clip was paying for a whole new render.
|
|
19764
19790
|
server.registerTool('edit_video', {
|
|
19765
19791
|
title: 'Edit a video clip',
|
|
19766
|
-
description: "EDIT
|
|
19767
|
-
inputSchema: {
|
|
19768
|
-
video: z.string().describe('the source video URL (
|
|
19769
|
-
instruction: z.string().describe('the
|
|
19770
|
-
keepAudio: z.boolean().optional().describe('default true
|
|
19771
|
-
|
|
19772
|
-
|
|
19792
|
+
description: "EDIT an existing clip from a plain instruction (video-to-video): the motion, timing, framing and cut stay, the named thing changes. 'change only the mug to red', 'make it nighttime', 'restyle it as claymation'. Your words are wrapped so the model keeps everything else identical, changes only what you named and repeats that lock; lighting is kept unless the change needs new light (set lighting to force either); literal:true sends your words as written. One change per call holds best. previewFirstFrame:true edits ONE still first (one image edit; previewAt picks the second) and quotes the clip; nothing else runs until you call again, ideally with previewStill. The reply scores how well the shot held outside the change (free) and flags an edit that touched more than asked. NOT for cuts/trims/end cards (post_edit), a new video (generate_video / render_ad), translation (dub_video) or a saved creator's face (recast_motion). Best on 3-15s clips.",
|
|
19793
|
+
inputSchema: {
|
|
19794
|
+
video: z.string().describe('the source video URL (a render, job result or list_library)'),
|
|
19795
|
+
instruction: z.string().describe('the change, in the user’s own words'),
|
|
19796
|
+
keepAudio: z.boolean().optional().describe('default true: keep the source audio; false = silent'),
|
|
19797
|
+
lighting: z.enum(['auto', 'preserve', 'relight']).optional().describe("default auto: keep the source light unless the change needs new light (night, a lamp, fire). 'preserve' or 'relight' forces it"),
|
|
19798
|
+
reference: z.string().optional().describe('OPTIONAL image URL that anchors the MATERIAL of what changes (a fabric, a finish, a colour swatch, the real product). Only its surface is used, never its framing or light'),
|
|
19799
|
+
literal: z.boolean().optional().describe('true = send the instruction exactly as written, with no preserve/lock wrapper'),
|
|
19800
|
+
previewFirstFrame: z.boolean().optional().describe('true = edit ONE still of the first frame first and quote the clip; the paid clip does not run'),
|
|
19801
|
+
previewStill: z.string().optional().describe('the still a previewFirstFrame call returned; the clip then matches the changed thing to it'),
|
|
19802
|
+
previewAt: z.number().optional().describe('second to preview (default 0); resend with previewStill'),
|
|
19803
|
+
elements: z.array(z.object({ frontal: z.string().describe('the reference image URL'), refs: z.array(z.string()).optional().describe('up to 2 extra angles of the SAME subject') }).passthrough()).optional().describe('OPTIONAL identity/product grounding (≤4): a creator portrait or the real product photo, so the edit restores the REAL thing. Describe each one in the instruction'),
|
|
19804
|
+
interactionId: z.string().optional().describe('OPTIONAL: the interactionId an earlier Gemini Omni render or edit returned; the edit continues that clip on the same Omni model. If it cannot run, the video editor edits it and the reply says so.'),
|
|
19773
19805
|
},
|
|
19774
19806
|
outputSchema: { ...JOB_OUT },
|
|
19775
19807
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
19776
|
-
}, wrap(async ({ video, instruction, keepAudio, elements, interactionId }) => {
|
|
19808
|
+
}, wrap(async ({ video, instruction, keepAudio, lighting, reference, literal, previewFirstFrame, previewStill, previewAt, elements, interactionId }) => {
|
|
19777
19809
|
const prompt = String(instruction || '').trim();
|
|
19778
19810
|
if (!prompt) return { content: [{ type: 'text', text: 'Say what to change — edit_video needs an instruction.' }], isError: true };
|
|
19811
|
+
const _ref = reference ? await toRef(reference) : undefined;
|
|
19812
|
+
if (previewFirstFrame === true) {
|
|
19813
|
+
const d = await apiPost('/api/video/edit/preview', { video, instruction: prompt, ...(lighting ? { lighting } : {}), ...(_ref ? { reference: _ref } : {}), ...(literal === true ? { literal: true } : {}), ...(Number.isFinite(previewAt) ? { previewAt } : {}) });
|
|
19814
|
+
const still = d.still || d.image ? abs(d.still || d.image) : '';
|
|
19815
|
+
const img = still ? await imageBlock(still) : null;
|
|
19816
|
+
const c = d.clip || {};
|
|
19817
|
+
const text = `${d.frameAt > 0 ? `Preview of the frame at ${d.frameAt}s` : 'First-frame preview'} (${d.stillCredits ?? d.creditsUsed ?? '?'} credits): ${still || 'no still came back'}\nThe clip is NOT rendered yet. On ${c.engine || 'the video editor'} it is held at up to ${c.holdCredits ?? '?'} credits and settles at the real price, about ${c.expectedCredits ?? '?'}${c.seconds ? ` for ${c.seconds}s` : ''}.\nTo render it: edit_video with the same video and instruction${still ? ` and previewStill: ${still}${d.frameAt > 0 ? `, previewAt: ${d.frameAt}` : ''}` : ''}.${d.editNote ? `\n${d.editNote}` : ''}`;
|
|
19818
|
+
return { content: [{ type: 'text', text }, ...(img ? [img] : [])], structuredContent: { ...d, ...(still ? { still, image: still } : {}), ...(d.frame ? { frame: abs(d.frame) } : {}) } };
|
|
19819
|
+
}
|
|
19779
19820
|
const els = (Array.isArray(elements) ? elements : []).filter(e => e && e.frontal).slice(0, 4);
|
|
19780
19821
|
const _iid = String(interactionId || '').trim();
|
|
19781
|
-
const
|
|
19782
|
-
const
|
|
19783
|
-
|
|
19822
|
+
const _still = previewStill ? await toRef(previewStill) : undefined;
|
|
19823
|
+
const r = await renderJob('videoedit', { video, prompt, grammar: true, keepAudio: keepAudio !== false, ...(lighting ? { lighting } : {}), ...(_ref ? { reference: _ref } : {}), ...(_still ? { previewStill: _still, ...(previewAt > 0 ? { previewStillAt: previewAt } : {}) } : {}), ...(literal === true ? { literal: true } : {}), ...(els.length ? { elements: els } : {}), ...(_iid ? { interactionId: _iid } : {}) }, `Video edit · ${prompt.slice(0, 40)}`);
|
|
19824
|
+
const p = renderPayload(r) || {};
|
|
19825
|
+
const _nextIid = p.interactionId;
|
|
19826
|
+
const pres = p.preservation;
|
|
19827
|
+
const presLine = pres && pres.measured ? `\nHeld outside the change: ${pres.preserved} SSIM (${pres.verdict}; ${Math.round((pres.changedShare || 0) * 100)}% of the frame changed)${pres.overreach ? ' TOUCHED MORE THAN ASKED' : ''}.` : ''; // an unmeasured read-back says so in editNote; the overreach sentence rides there too
|
|
19828
|
+
return okVideo(`Edited clip: ${r.url}${r.model ? ` (${r.model})` : ''}${presLine}${p.editNote ? `\n${p.editNote}` : ''}${_nextIid ? `\ninteractionId: ${_nextIid} (pass it to edit_video again to keep editing this clip)` : ''}${switchNote(r)}`, r);
|
|
19784
19829
|
}));
|
|
19785
19830
|
|
|
19786
19831
|
// AD MULTIPLIER (2026-09-01): ONE winning ad → N variants (new character / outfit / location / objects) with the edit, the
|
|
@@ -19941,7 +19986,7 @@ function memoryNoteVerdict(text) {
|
|
|
19941
19986
|
// THE FREE FACE CHECK (2026-09-24): a local face detector on our own CPU (lib/face-detect.mjs), no model call, 0 credits.
|
|
19942
19987
|
server.registerTool('face_check', {
|
|
19943
19988
|
title: 'Check a reference for a face (free)',
|
|
19944
|
-
description: "FREE, before any paid render: does a picture or video contain a face, and which video models refuse it (they cannot take a real person's face; a render there fails after it starts) or were measured to take one, plus the model a face-bearing render is routed to. A local detector, no model call, 0 credits. It cannot tell a photo from a drawing, so a drawn face counts. Takes a picture or video URL, a Library item or a public post link
|
|
19989
|
+
description: "FREE, before any paid render: does a picture or video contain a face, and which video models refuse it (they cannot take a real person's face; a render there fails after it starts) or were measured to take one, plus the model a face-bearing render is routed to. A local detector, no model call, 0 credits. It cannot tell a photo from a drawing, so a drawn face counts. Takes a picture or video URL, a Library item or a public post link. A file or Library item is free; a TikTok / Reel / Facebook / X post link is opened through one paid lookup (1 credit), refused when the account has no credits.",
|
|
19945
19990
|
inputSchema: { url: z.string().describe('the picture or video to check') },
|
|
19946
19991
|
outputSchema: { face: z.string().optional(), best: z.number().nullable().optional(), refuse: z.array(z.any()).optional(), accept: z.array(z.any()).optional(), route: z.any().optional(), summary: z.string().optional() },
|
|
19947
19992
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.305",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 863 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|