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 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' : 'no token — run: hermoso auth login --token <key>'}`);
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
- if (sub !== 'draft') return die('usage: hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
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 …) create --brand --product [--format]
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.
@@ -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
- const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
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);
@@ -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 (its URL): an ordered plan of whitelisted primitives run by ffmpeg (+ Chrome) in seconds for ~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 (corner logo), grain (anti-AI), text (timed words over the clip in a native look, no branding: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle, position, start/end), join (this video FOLLOWED BY clips[], each a Library URL, a direct file or a public TikTok / Reel / Facebook / X / YouTube post link, as one 1080x1920 video with matched loudness; transition 'cut' or 'crossfade'). Up to 6 ops, in order. '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 taken FROM THE HOOK ITSELF: its own audio carries across the cut, else a sound generated from its frames (then up to 8 credits), else a neutral impact (sound 'auto' default | 'own' never a model | 'impact' | 'whoosh' | 'none'). {kind:'text', text, then?} only when the user asks for words over the cut. No voiceover bridge: best, make our clip's host say the connecting line. matchCut = where our clip starts. Other edits, transitions: edit_timeline. NEVER generate_video/render_ad for these.",
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
- transition: z.enum(['cut', 'crossfade']).optional().describe('join'),
17325
- bridge: z.object({ kind: z.enum(['impact', 'text']), cutAt: z.number().optional().describe('omit: found from the footage'), matchCut: z.number().optional(), sound: z.enum(['auto', 'own', 'impact', 'whoosh', 'none']).optional(), 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'),
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 / crossfade 0.2-1.5s'),
17329
- headline: z.string().optional().describe('append_card: big line (defaults to the brand name)'),
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: the pill line (defaults to the website) / text: a smaller second line'),
17332
- background: z.string().optional().describe("append_card: card background — hex or a color name ('red', 'navy'…); the user's stated color always wins over the brand palette"),
17333
- card_html: z.string().optional().describe('append_card: your OWN full-frame card design as inline-styled HTML ({{logo}} inserts the real brand logo) — use when the standard layout cannot honor the request'),
17334
- corner: z.enum(['tl', 'tr', 'bl', 'br']).optional().describe('watermark corner (default br)'),
17335
- intensity: z.enum(['default', 'strong']).optional().describe('grain look'),
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
- + "MATCH EVERY SEAM, hard cuts too, cheapest first: exposure (skin and midtones within ~5%), white balance (skin first; a green cast reads worse than a warm one), black level, then grain UP, never down (post_edit grain on the result so the clean clip meets the grainy one; never smooth the grainy one), motion blur (cut where both move least, or mblur the crisp side only while it moves), subject size and headroom (scale it, never a jump from a third of the frame to two thirds), sound (a 0.25-0.5 s J/L-cut, never a sonic wall). Grade with the segment's exposure / contrast / saturation. 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. "
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.enum(['9:16', '1:1', '4:5', '16:9']).optional(),
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: true, destructiveHint: false, openWorldHint: true },
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
- if (j.status === 'done' && res?.video) return okVideo(wireText, { ...j, url }); // resumed video → same inline poster as a direct return
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/transform an existing video clip with a natural-language instruction (video-to-video) — KEEPS the original motion, timing and edit, changes the subject/setting/style. Use for 'change the background to a city', 'make it nighttime', 'restyle it as claymation', 'swap the product'. Best on 3–10s clips. NOT for mechanical cuts, trims, end cards or watermarks (use post_edit — seconds, ~2 credits, no AI model), NOT for making a new video (generate_video / render_ad), NOT for translating the spoken track (dub_video) and NOT for putting a saved creator's face on the motion (recast_motion). Paid render; returns the served URL of the edited clip.",
19767
- inputSchema: {
19768
- video: z.string().describe('the source video URL (from a previous render, a job result, or list_library)'),
19769
- instruction: z.string().describe('the exact transformation to apply, in the user’s own words'),
19770
- keepAudio: z.boolean().optional().describe('default true — keep the source clip’s audio track. Set false to return the edit silent'),
19771
- 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 instead of re-inventing it. Describe each one in the instruction. Leave out for a plain restyle'),
19772
- interactionId: z.string().optional().describe('OPTIONAL: the interactionId an earlier Gemini Omni render or edit returned. The edit then continues that clip on the SAME Omni model from its own stored context (identity-true, no re-upload, usually cheaper). If that edit cannot run, the clip is edited by the video editor instead and the reply says so.'),
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 r = await renderJob('videoedit', { video, prompt, keepAudio: keepAudio !== false, ...(els.length ? { elements: els } : {}), ...(_iid ? { interactionId: _iid } : {}) }, `Video edit · ${prompt.slice(0, 40)}`);
19782
- const _nextIid = renderPayload(r)?.interactionId;
19783
- return okVideo(`Edited clip: ${r.url}${r.model ? ` (${r.model})` : ''}${_nextIid ? `\ninteractionId: ${_nextIid} (pass it to edit_video again to keep editing this clip)` : ''}${switchNote(r)}`, r);
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 (a post link uses its one lookup).",
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.294",
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",