hermoso 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -8,18 +8,17 @@ image & video ads (your real product composited in, copy + CTA included) — all
8
8
  ## Instant: the hosted Claude.ai connector
9
9
 
10
10
  Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
11
- your Hermoso account, done — all 22 tools with your saved brand context, billed to your plan.
11
+ your Hermoso account, done — the full studio toolset with your saved brand context, billed to your plan.
12
12
 
13
13
  ## Quickstart for Claude Code / Cursor / scripts (2 minutes)
14
14
 
15
15
  1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
16
16
  same ones the web Studio uses.
17
17
  2. **Create an agent key**: app.hermoso.ai → **Settings → Agents & API** → Create API key (`hmk_…`).
18
- 3. **Connect** (Claude Code shown; any MCP client works):
18
+ 3. **Connect** — no clone needed, `npx` runs the published `hermoso` package (Claude Code shown; any MCP client works):
19
19
 
20
20
  ```bash
21
- git clone https://github.com/hermoso-ai/hermoso.git && cd hermoso && npm install
22
- claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- node "$(pwd)/mcp/hermoso-mcp.mjs"
21
+ claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
23
22
  ```
24
23
 
25
24
  Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
@@ -28,28 +27,33 @@ learned memory you set up in the web app apply automatically (`get_brand` shows
28
27
 
29
28
  ## 1. MCP server (stdio) — Claude Code / Cursor / Codex
30
29
 
31
- `mcp/hermoso-mcp.mjs` is a stdio MCP server exposing 22 tools.
30
+ `hermoso mcp` runs a stdio MCP server exposing the full studio toolset (40+ tools). The published `hermoso`
31
+ package means no clone — `npx -y hermoso mcp` fetches and runs it:
32
32
 
33
33
  ```bash
34
- npm install
35
- claude mcp add hermoso -- node "$(pwd)/mcp/hermoso-mcp.mjs"
34
+ claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
36
35
  ```
37
36
 
38
37
  Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
39
38
 
40
39
  ```json
41
- { "mcpServers": { "hermoso": { "command": "node", "args": ["<repo>/mcp/hermoso-mcp.mjs"],
40
+ { "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"],
42
41
  "env": { "HERMOSO_API_BASE": "https://app.hermoso.ai", "HERMOSO_TOKEN": "<your token>" } } } }
43
42
  ```
44
43
 
45
44
  Then ask your agent: *“Generate an image ad with Hermoso.”*
46
45
 
47
- **Tools (22):** `hermoso_capabilities`, `hermoso_credits`, `get_brand`, `plan_ad`, `plan_variations`, `generate_image`,
48
- `generate_video`, `generate_avatar`, `stitch_video`, `reframe_video`, `upscale_video`, `dub_video`,
49
- `recast_motion`, `analyze_video`, `score_ad`, `get_job`, `list_jobs`, `find_competitors`,
50
- `pull_competitor_ads`, `research_ads`, `draft_brand`, `fetch_asset`. Call `hermoso_capabilities` first — it
51
- returns valid model ids and per-render credit costs. Render jobs queue server-side and poll to completion,
52
- returning a served URL.
46
+ **Tools (40+):** research/ad-spy (`find_competitors`, `pull_competitor_ads`, `research_ads`, `search_meta_ads`,
47
+ `search_google_ads`, `search_linkedin_ads`, `search_tiktok`, `search_instagram`, `search_youtube`, `search_reddit`,
48
+ `search_threads`, `scrapecreators_fetch`), plan → generate → finish (`plan_ad`, `plan_variations`, `generate_image`,
49
+ `generate_video`, `generate_avatar`, `render_ad`, `make_template_ad`, `stitch_video`, `reframe_video`,
50
+ `upscale_video`, `dub_video`, `change_voice`, `recast_motion`, `remix_static`, `finish_video`, `fix_beat`),
51
+ brand + account (`get_brand`, `list_brands`, `use_brand`, `draft_brand`, `list_product_photos`, `set_product_image`,
52
+ `hermoso_capabilities`, `hermoso_credits`, `buy_credits`), and analysis/jobs (`analyze_video`, `score_ad`,
53
+ `check_ad_policy`, `competitor_teardown`, `mine_angles`, `get_job`, `list_jobs`, `get_skill`, `list_skills`,
54
+ `fetch_asset`). Call `hermoso_capabilities` first — it returns valid model ids and per-render credit costs;
55
+ `tools/list` is the authoritative current set. Render jobs queue server-side and poll to completion, returning a
56
+ served URL.
53
57
 
54
58
  ## 2. CLI — the token-cheap path for terminal agents
55
59
 
package/bin/hermoso.mjs CHANGED
@@ -70,6 +70,12 @@ async function main() {
70
70
  process.env.HERMOSO_API_BASE = process.env.HERMOSO_API_BASE || cfg.apiBase || 'https://app.hermoso.ai';
71
71
  if (cfg.token && !process.env.HERMOSO_TOKEN) process.env.HERMOSO_TOKEN = cfg.token;
72
72
  if (cfg.profile && !process.env.HERMOSO_PROFILE) process.env.HERMOSO_PROFILE = cfg.profile;
73
+
74
+ // `hermoso mcp` → run the stdio MCP server (Claude Code / Cursor / Codex spawn this, e.g. `npx -y hermoso mcp`).
75
+ // It OWNS stdout as the JSON-RPC channel, so hand off immediately and print nothing to stdout here. The
76
+ // config-resolved API base + token (set just above) ride into the server's client via env; all logs go to stderr.
77
+ if (group === 'mcp') { await import('../mcp/hermoso-mcp.mjs'); return; }
78
+
73
79
  const api = await import('../mcp/client.mjs');
74
80
  const out = (label, data) => { if (flags.json) console.log(JSON.stringify(data, null, 2)); else console.log(label); };
75
81
  const absUrl = (u) => (u && u.startsWith('/') ? api.API_BASE + u : u);
@@ -149,7 +155,8 @@ async function main() {
149
155
  generate image --prompt [--ref] [--model] [--aspect] generate video|avatar|stitch … [--wait]
150
156
  jobs list | jobs get <id> [--wait] competitors <domain>
151
157
  ads pull (--company|--domain) research "<request>"
152
- fetch <url> [--out] version
158
+ fetch <url> [--out] mcp (run the stdio MCP server)
159
+ version
153
160
  add --json to any command for machine output.`);
154
161
  }
155
162
  } catch (e) { die(e?.message || String(e)); }
package/mcp/tools.mjs CHANGED
@@ -41,7 +41,7 @@ const wrap = (fn) => async (args, extra) => {
41
41
  catch (e) {
42
42
  let msg = `Error: ${e?.message || e}`;
43
43
  // credit outages need an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
44
- if (/not enough credits/i.test(msg)) msg += `\nTop up or upgrade at https://app.hermoso.ai (Settings → Billing), then retry — nothing was charged. hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
44
+ if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to get a ready-to-pay checkout link (credit packs only; your human pays on Stripe's secure page — nothing was charged here). Or top up / upgrade at https://app.hermoso.ai (Settings → Billing). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
45
45
  return { content: [{ type: 'text', text: msg }], isError: true };
46
46
  }
47
47
  };
@@ -69,10 +69,10 @@ export function registerTools(server) {
69
69
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
70
70
  }, wrap(async () => {
71
71
  const d = await apiGet('/api/generate/status');
72
- const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.best ? ', best' : ''})`).join('; ');
72
+ const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
73
73
  // durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
74
74
  // prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
75
- const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.best ? ', best' : ''})`).join('; ');
75
+ const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.refs ? `, ${m.refs.max} reference image${m.refs.max === 1 ? '' : 's'}${m.refs.required ? ' (required — image-to-video only)' : ''}` : ''}${m.resolutions ? `, resolutions ${m.resolutions.join('/')}` : ''}${m.best ? ', best' : ''})`).join('; ');
76
76
  const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\nIMPORTANT: durations above are SINGLE-PASS — e.g. seedance-2 renders a full multi-beat 15s ad in ONE generation (do NOT assume a generic 8–10s cap, and do NOT stitch for ≤15s spots; stitching is only for longer). durationSeconds must be one of the model's listed values.\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar} canPublish:${d.canPublish}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…`;
77
77
  return ok(text, d);
78
78
  }));
@@ -86,6 +86,28 @@ export function registerTools(server) {
86
86
  return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}`, d);
87
87
  }));
88
88
 
89
+ // AGENT BILLING HANDOFF: out of credits → mint a ready-to-pay Stripe checkout link for a credit PACK and hand the
90
+ // URL to the human. The human pays on Stripe's hosted page (agents never spend money directly); credits post to
91
+ // this account automatically once payment completes. Packs only — subscriptions are managed by a person in-app.
92
+ server.registerTool('buy_credits', {
93
+ description: "Out of credits? Get a ready-to-pay checkout link for a credit PACK. Call with no argument to list the available packs (id · credits · price); call again with `pack` set to a pack id to get a Stripe checkout URL. Hand that URL to your human — THEY pay on Stripe's secure hosted page (agents never spend money directly), and the credits land on this account the moment payment completes. Packs only; subscriptions are managed by a person in Settings → Billing. Nothing is charged until your human pays.",
94
+ inputSchema: {
95
+ pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
96
+ },
97
+ annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe's page
98
+ }, wrap(async ({ pack }) => {
99
+ const cfg = await apiGet('/api/billing/config');
100
+ const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
101
+ if (!pack) {
102
+ const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · $${p.priceUsd}`).join('\n') || '(no packs configured)';
103
+ return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>" to get a checkout link for your human to pay.`, { packs });
104
+ }
105
+ const match = packs.find(p => p.id === pack);
106
+ if (!match) return ok(`No pack "${pack}". Available: ${packs.map(p => p.id).join(', ') || '(none)'}. Call buy_credits with no argument to see details.`, { packs });
107
+ const d = await apiPost('/api/billing/checkout-link', { packId: pack });
108
+ return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — the credits post to this account automatically once payment completes. Nothing is charged until they pay.`, d);
109
+ }));
110
+
89
111
  server.registerTool('list_brands', {
90
112
  description: "List every brand on this account (id + name) and which one this connection currently acts on. Multi-brand accounts: call this, then use_brand to switch. Read-only, free.",
91
113
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
@@ -124,7 +146,7 @@ export function registerTools(server) {
124
146
  const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
125
147
  const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
126
148
  const c = d.creative || d;
127
- const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a ≤15s storyboard renders as ONE single-pass clip — don’t stitch)' : 'generate_image with the image_concept.prompt'}.`;
149
+ const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a ≤15s storyboard renders as ONE single-pass clip, a longer plan renders as stitched acts automatically never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
128
150
  return ok(text, c);
129
151
  }));
130
152
 
@@ -149,10 +171,10 @@ export function registerTools(server) {
149
171
 
150
172
  // ---------- video / avatar / stitch (job-based, polled to completion) ----------
151
173
  server.registerTool('render_ad', {
152
- description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
174
+ description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a ≤15s storyboard renders as ONE single-pass clip; a longer plan automatically renders as STITCHED ACTS (fewest balanced ≤15s clips) — never time-compressed into one clip. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
153
175
  inputSchema: {
154
176
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
155
- model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick)'),
177
+ model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
156
178
  durationSeconds: z.number().optional(),
157
179
  aspectRatio: z.string().optional(),
158
180
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
@@ -161,23 +183,33 @@ export function registerTools(server) {
161
183
  music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
162
184
  lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
163
185
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
186
+ dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) WITHOUT submitting a render — free, nothing charged'),
164
187
  },
165
188
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
166
189
  }, wrap(async (a) => {
167
- const { input, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
168
- const r = await renderJob('video', input, 'MCP ad render');
190
+ const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
191
+ // LAW 8: render_ad honors render_plan.structure/duration a >single-clip creative assembles as stitched ACTS
192
+ // (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
193
+ // acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
194
+ if (a.dryRun) return ok(`DRY RUN — routing decision (no job submitted, nothing charged): jobType=${jobType || 'video'}, model=${input.model}, durationSeconds=${input.durationSeconds}${Array.isArray(input.scenes) ? `, acts=[${input.scenes.map(s => Math.round(s.seconds * 10) / 10).join(', ')}]s` : ' (single pass)'}${input.modelExplicit ? ', modelExplicit (ask-don’t-swap)' : ''}.\n${notes || ''}`, { dryRun: true, jobType: jobType || 'video', input });
195
+ const r = await renderJob(jobType === 'stitch' ? 'stitch' : 'video', input, 'MCP ad render');
169
196
  return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]\n${notes || ''}`, r);
170
197
  }));
171
198
 
172
199
 
173
200
  server.registerTool('make_template_ad', {
174
- description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook, claims: string[] (3-5, ≤4 words each), productImages: string[], palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
201
+ description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
175
202
  inputSchema: {
176
203
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
177
204
  },
178
205
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
179
206
  }, wrap(async (a) => {
180
207
  const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
208
+ if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
209
+ const urls = r.raw.images.map((u) => abs(u));
210
+ const first = await imageBlock(urls[0]).catch(() => null);
211
+ return { content: [{ type: 'text', text: `Carousel ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ?? undefined };
212
+ }
181
213
  if (r?.raw?.image || /\.png($|\?)/.test(r?.url || '')) { const img = r?.url ? await imageBlock(r.url) : null; return { content: [{ type: 'text', text: `Template ad ready: ${r.url} [job ${r.jobId}]` }, ...(img ? [img] : [])], structuredContent: r ?? undefined }; }
182
214
  return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
183
215
  }));
@@ -222,7 +254,7 @@ export function registerTools(server) {
222
254
  refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
223
255
  durationSeconds: z.number().optional().describe('clip length in seconds'),
224
256
  aspectRatio: z.string().optional().describe("default '9:16'"),
225
- model: z.string().optional(),
257
+ model: z.string().optional().describe('video model id from hermoso_capabilities. Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback); omit it to let the router pick'),
226
258
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
227
259
  ttsScript: z.string().optional().describe('voiceover script to speak'),
228
260
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
@@ -231,7 +263,9 @@ export function registerTools(server) {
231
263
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
232
264
  }, wrap(async (a) => {
233
265
  const refImage = a.refImage ? await toRef(a.refImage) : undefined;
234
- const r = await renderJob('video', { ...a, refImage }, 'MCP video');
266
+ // an agent that NAMES a model made a deliberate pick modelExplicit gives it the server-side ask-don't-swap
267
+ // treatment (#310) instead of being treated as a system pick the fallback ladders may silently reroute
268
+ const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model }, 'MCP video');
235
269
  return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
236
270
  }));
237
271
 
@@ -278,7 +312,7 @@ export function registerTools(server) {
278
312
  return okVideo(`Rendered as ONE single-pass ${input.durationSeconds}s clip instead of stitching (this length fits a single generation — cleaner cuts, exact script, far fewer credits): ${r.url} [job ${r.jobId}]`, r);
279
313
  } catch (e) { console.error('[mcp] single-pass collapse failed, falling back to stitch:', String(e?.message || e).slice(0, 140)); }
280
314
  }
281
- const r = await renderJob('stitch', a, 'MCP stitch');
315
+ const r = await renderJob('stitch', { ...a, modelExplicit: !!a.model }, 'MCP stitch'); // a named model is a deliberate pick — the server belt never coerces it
282
316
  return okVideo(`Stitched video ready: ${r.url} [job ${r.jobId}]`, r);
283
317
  }));
284
318
 
@@ -583,7 +617,7 @@ export function registerTools(server) {
583
617
  }));
584
618
 
585
619
  server.registerTool('draft_brand', {
586
- description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 ScrapeCreators credits.',
620
+ description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 ScrapeCreators credits. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
587
621
  inputSchema: {
588
622
  domain: z.string().optional().describe('a website to scrape'),
589
623
  description: z.string().optional().describe('a free-text brand description (no website)'),
@@ -652,7 +686,7 @@ export function registerTools(server) {
652
686
 
653
687
  server.registerTool('reframe_video', {
654
688
  description: "Reframe a video to a different aspect ratio (e.g. 16:9 master → 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.",
655
- inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9']).describe('the target aspect ratio') },
689
+ inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9', '4:3', '3:4', '21:9', '9:21']).describe('the target aspect ratio') },
656
690
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
657
691
  }, wrap(async ({ video, aspectRatio }) => {
658
692
  const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
@@ -681,6 +715,18 @@ export function registerTools(server) {
681
715
  return okVideo(`Localized video (${language}): ${r.url}`, r);
682
716
  }));
683
717
 
718
+ server.registerTool('change_voice', {
719
+ description: "Swap the narration of a finished video into a different voice — keeps the performance, lip-sync, and background sound. Use when the user likes the video but wants a different narrator voice; use dub_video only for language translation. Paid; returns the served URL.",
720
+ inputSchema: {
721
+ video: z.string().describe('the source video URL'),
722
+ voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read)"),
723
+ },
724
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
725
+ }, wrap(async ({ video, voice }) => {
726
+ const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
727
+ return okVideo(`Voice-swapped video: ${r.url}`, r);
728
+ }));
729
+
684
730
  server.registerTool('recast_motion', {
685
731
  description: "Motion transfer: re-perform a reference video's motion with a different person/character (supply their image). The reference clip drives the movement; the image supplies the identity. Paid render.",
686
732
  inputSchema: {
@@ -711,4 +757,129 @@ export function registerTools(server) {
711
757
  const text = vars.map((v, i) => `${i + 1}. ${v.name || v.angle || 'Variant'} — ${v.hook || v.headline || ''}`).join('\n') || 'No variants returned.';
712
758
  return ok(text, d);
713
759
  }));
760
+
761
+ // ---------- research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless) ----------
762
+ // The web Studio versions of these read the CLIENT's chat/creative state; the MCP variants take explicit inputs and
763
+ // resolve the ACTIVE brand SERVER-SIDE (same source as get_brand). Pass brandId to act on a specific brand — that
764
+ // pins this key's active brand exactly like use_brand (persists) — or omit it to use the currently-active brand.
765
+ const activeBrand = async (brandId) => {
766
+ if (brandId) {
767
+ const list = await apiGet('/api/brands');
768
+ const want = String(brandId).trim().toLowerCase();
769
+ const hit = (list.brands || []).find(b => b.id.toLowerCase() === want || String(b.name || '').toLowerCase() === want);
770
+ if (!hit) throw new Error(`No brand matching "${brandId}" — call list_brands for the available brands.`);
771
+ await apiPost('/api/keys/brand', { profileId: hit.id }); // pin it (use_brand semantics — persists for this key)
772
+ }
773
+ const cur = await apiGet('/api/brand/current').catch(() => null);
774
+ return cur?.hasBrand ? cur.brand : null;
775
+ };
776
+
777
+ server.registerTool('competitor_teardown', {
778
+ description: "Tear a competitor's ad strategy down into an actionable playbook: their opening-hook MIX, longest-running campaign THEMES, the WHITE SPACE nobody in their set runs, 2-3 render-ready COUNTER-PLAYS, and the territories they own that you should avoid. Pass `competitor` {name, domain?}. CONTRACT: supply `ads` (raw ad objects from a prior pull_competitor_ads / search_meta_ads call) to tear exactly those down, OR omit `ads` and this pulls the competitor's real Meta ads first (spends ~1-2 ScrapeCreators credits, longest-running = proven winners). Auto-tailors the white space + counter-plays to YOUR saved brand. Spends LLM tokens (0 SC credits when you pass ads).",
779
+ inputSchema: {
780
+ competitor: z.object({ name: z.string().describe('the competitor brand name'), domain: z.string().optional().describe('their domain — sharpens the auto-pull page match') }).describe('the competitor to tear down'),
781
+ ads: z.array(z.object({}).passthrough()).optional().describe('ad objects to tear down (from pull_competitor_ads / search_meta_ads). Omit to auto-pull their Meta ads first.'),
782
+ language: z.string().optional().describe('output language (default English)'),
783
+ },
784
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
785
+ }, wrap(async ({ competitor, ads, language }) => {
786
+ const name = String(competitor?.name || '').trim();
787
+ if (!name) throw new Error('competitor.name is required.');
788
+ let use = Array.isArray(ads) ? ads : [];
789
+ if (!use.length) { // no ads supplied → pull the competitor's real Meta ads (the pull_competitor_ads path), then tear THOSE down
790
+ const pulled = await apiPost('/api/inspire/fanout', { companyName: name, domain: competitor?.domain || '', platforms: ['facebook'], country: 'US', limit: 30, sort: 'longest_running' });
791
+ use = pulled?.facebook?.ads || [];
792
+ if (!use.length) throw new Error(`No Meta ads found to tear down for "${name}". Pull them another way (search_meta_ads with a keyword) and pass the results as ads.`);
793
+ }
794
+ const brand = await activeBrand().catch(() => null); // tailor white space + counter-plays to the saved brand (best-effort)
795
+ const d = await apiPost('/api/research/teardown', { competitor: { name, domain: competitor?.domain || '' }, ads: use, brand, language: language || '' });
796
+ const t = d.teardown || {};
797
+ const hooks = (t.hook_taxonomy || []).map(h => `${h.type}×${h.count}`).join(', ');
798
+ const camps = (t.campaigns || []).map(c => `“${c.theme}” (${c.longest_running_days}d)`).join('; ');
799
+ const ws = (t.white_space || []).map(w => `• ${w.angle}`).join('\n');
800
+ const plays = (t.counter_plays || []).map(p => `• [${p.format}] ${p.title}: ${p.brief}`).join('\n');
801
+ const text = `Teardown of ${name} (${d.adCount} ads):\nHook mix: ${hooks || '—'}\nCampaign themes: ${camps || '—'}\nWhite space:\n${ws || '—'}\nCounter-plays:\n${plays || '—'}\nThey own (avoid): ${(t.not_saying || []).join(' · ') || '—'}`;
802
+ return ok(text, d);
803
+ }));
804
+
805
+ server.registerTool('check_ad_policy', {
806
+ description: "Pre-flight ad copy against Meta's REAL, live Advertising Standards before you run it — a flat 1-credit check. Pulls Meta's actual policy pages and returns a verdict (pass / fix / block) where every flagged issue QUOTES Meta's own policy text verbatim plus a compliant rewrite that keeps the sell. It's a check, not an edit — it never changes the creative. Especially worth running for regulated-adjacent categories (health/supplements, weight-loss or beauty results claims, finance/crypto/insurance, alcohol, dating, gambling) or ANY strong/absolute/guaranteed claim.",
807
+ inputSchema: {
808
+ copy: z.string().describe('the ad copy / script / on-screen text to check'),
809
+ claims: z.string().optional().describe('the claims / proof points the ad makes'),
810
+ category: z.string().optional().describe('the product category — helps pick the relevant policy pages'),
811
+ imageDescription: z.string().optional().describe('a description of the creative / image when relevant'),
812
+ },
813
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
814
+ }, wrap(async ({ copy, claims, category, imageDescription }) => {
815
+ const d = await apiPost('/api/policy/check', { copy, claims: claims || '', category: category || '', imageDescription: imageDescription || '' });
816
+ const findings = (d.findings || []).map((f, i) => `${i + 1}. [${f.severity || 'issue'}] ${f.where_in_ad ? `"${f.where_in_ad}" — ` : ''}${f.issue || ''}\n Meta: “${f.policy_quote || ''}”${f.fix_suggestion ? `\n Fix: ${f.fix_suggestion}` : ''}`).join('\n');
817
+ const anchors = (d.anchors || []).map(a => a.url).filter(Boolean).join(', ');
818
+ const text = `Verdict: ${String(d.verdict || '').toUpperCase()} — ${d.summary || ''}\n${findings || '(no issues found)'}\n\nPolicies consulted: ${anchors || '—'}`;
819
+ return ok(text, d);
820
+ }));
821
+
822
+ server.registerTool('remix_static', {
823
+ description: "One-click STATIC-AD REMIX: rebuild a competitor/reference STATIC (image) ad as an on-brand version — SAME layout, composition and energy, but YOUR product, brand colours, logo and voice, with every trace of the source brand removed. Pass `imageUrl` = the static ad image to remix. Uses your saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). IMAGES ONLY — for video ads use render_ad. Bills as one image generation.",
824
+ inputSchema: {
825
+ imageUrl: z.string().describe('the URL of the static ad image to remix'),
826
+ brandId: z.string().optional().describe('a brand id/name from list_brands to remix for; omit to use the active brand'),
827
+ },
828
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
829
+ }, wrap(async ({ imageUrl, brandId }) => {
830
+ const brand = await activeBrand(brandId);
831
+ if (!brand) throw new Error('No saved brand to remix for — onboard one with draft_brand, or pass a brandId from list_brands.');
832
+ const spec = await apiPost('/api/remix/spec', { imageUrl }); // ONE vision call → the slot-map spec (flat-billed)
833
+ const d = await apiPost('/api/remix/render', { spec, imageUrl, brand, sourceAdvertiser: spec?.source_brand || '' });
834
+ const url = abs(d.image);
835
+ const img = await imageBlock(url); // show the remixed creative inline, not just a link
836
+ const resid = d.residual && d.residual.clean === false ? `\n⚠ Residual source branding may remain: ${d.residual.note}` : '';
837
+ return { content: [{ type: 'text', text: `Remixed ad ready: ${url}${d.model ? ` (${d.model})` : ''}${resid}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: url } };
838
+ }));
839
+
840
+ server.registerTool('mine_angles', {
841
+ description: "Mine ad ANGLES from real customer language: gathers the customer's own words (Reddit, TikTok, the brand's review page + review-site results) and returns a RANKED angle bank — each angle tagged (pain / outcome / identity / fear / competitive-displacement / social-proof / contrast), 2-5 VERBATIM proof quotes, a 0-100 score with breakdown, and a ready-to-run hook in the customer's own voice. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). To tear down a COMPETITOR use competitor_teardown instead. Spends a few ScrapeCreators credits + LLM tokens.",
842
+ inputSchema: {
843
+ brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
844
+ },
845
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
846
+ }, wrap(async ({ brandId }) => {
847
+ const brand = await activeBrand(brandId);
848
+ if (!brand) throw new Error('No saved brand to mine angles for — onboard one with draft_brand, or pass a brandId from list_brands.');
849
+ const d = await apiPost('/api/research/angles', { brand });
850
+ const angles = d.angles || [];
851
+ if (!angles.length) return ok(d.note || 'Not enough public customer language surfaced to mine reliable angles yet.', d);
852
+ const text = angles.map((a, i) => `${i + 1}. [${a.category}] ${a.angle} (score ${a.score})\n Hook: ${a.hook_draft || ''}\n Proof: ${(a.proof_quotes || []).map(q => `“${q}”`).join(' · ')}`).join('\n');
853
+ return ok(`Mined ${angles.length} angles from ${d.sourceCount} customer sources:\n${text}`, d);
854
+ }));
855
+
856
+ // ---------- product-photo tools (Studio-chat parity) ----------
857
+ server.registerTool('list_product_photos', {
858
+ description: "List the product photos ALREADY saved in your workspace — the brand's product library plus any app-store screens (also surfaces photos locked in your OTHER creations, since a set product lands in the shared library). FREE — returns each photo's url + label. Call it before set_product_image to see the existing photos you can reuse. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand).",
859
+ inputSchema: {
860
+ brandId: z.string().optional().describe('a brand id/name from list_brands whose product library to list; omit to use the active brand'),
861
+ },
862
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
863
+ }, wrap(async ({ brandId }) => {
864
+ const brand = await activeBrand(brandId);
865
+ const d = await apiPost('/api/product/photos', { brand: brand || {} });
866
+ return ok(d.summary || 'The workspace has no saved product photos yet.', d);
867
+ }));
868
+
869
+ server.registerTool('set_product_image', {
870
+ description: "Lock an image as the ad's real PRODUCT photo so every render grounds on the true packaging. Pass `imageUrl` = a product shot's URL — an image from a prior research result (an organic Instagram/TikTok post, a scraped page image), a workspace / list_product_photos url, or any public product photo. The server downloads it and runs a product+safety check: a lifestyle/scene shot with no clear product, or an off-category / unsafe image, is REJECTED and NOTHING is locked (the summary says why). On PASS it persists the photo to a DURABLE url and returns it — pass that url as a reference to generate_image / render_ad. Bills one vision check. Reads YOUR saved brand for the category match (pass brandId to target a specific brand — switches this key's active brand like use_brand).",
871
+ inputSchema: {
872
+ imageUrl: z.string().describe('the image URL to lock as the product (from a research result, a workspace / list_product_photos url, or any public product photo)'),
873
+ source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
874
+ brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
875
+ },
876
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
877
+ }, wrap(async ({ imageUrl, source_note, brandId }) => {
878
+ const brand = await activeBrand(brandId);
879
+ const d = await apiPost('/api/product/set-image', { imageUrl, source_note: source_note || '', brand: brand || {} });
880
+ if (!d.attached) return ok(d.summary || 'That image was not locked as the product.', d); // gate honesty: rejected → nothing attached
881
+ const url = abs(d.url);
882
+ const img = await imageBlock(url); // show the locked product inline
883
+ return { content: [{ type: 'text', text: `${d.summary}\nProduct photo: ${url}` }, ...(img ? [img] : [])], structuredContent: { ...d, url } };
884
+ }));
714
885
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
+ "mcpName": "io.github.hermoso-ai/hermoso",
4
5
  "description": "Drive Hermoso — the AI ad studio — from any AI agent: MCP server, CLI, and Claude skills for researching winning ads and generating finished image & video ads.",
5
6
  "type": "module",
6
7
  "bin": {