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 +18 -14
- package/bin/hermoso.mjs +8 -1
- package/mcp/tools.mjs +185 -14
- package/package.json +2 -1
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 —
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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": "
|
|
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 (
|
|
48
|
-
`
|
|
49
|
-
`
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
|
|
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]
|
|
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 += `\
|
|
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 —
|
|
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
|
-
|
|
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, ≤
|
|
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
|
-
|
|
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
|
+
"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": {
|