hermoso 0.1.3 → 0.1.4
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/mcp/tools.mjs +159 -10
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -124,7 +124,7 @@ export function registerTools(server) {
|
|
|
124
124
|
const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
|
|
125
125
|
const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
|
|
126
126
|
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 —
|
|
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, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
|
|
128
128
|
return ok(text, c);
|
|
129
129
|
}));
|
|
130
130
|
|
|
@@ -149,10 +149,10 @@ export function registerTools(server) {
|
|
|
149
149
|
|
|
150
150
|
// ---------- video / avatar / stitch (job-based, polled to completion) ----------
|
|
151
151
|
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.',
|
|
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`. 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
153
|
inputSchema: {
|
|
154
154
|
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)'),
|
|
155
|
+
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
156
|
durationSeconds: z.number().optional(),
|
|
157
157
|
aspectRatio: z.string().optional(),
|
|
158
158
|
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 +161,33 @@ export function registerTools(server) {
|
|
|
161
161
|
music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
|
|
162
162
|
lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
|
|
163
163
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
164
|
+
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
165
|
},
|
|
165
166
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
166
167
|
}, 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
|
-
|
|
168
|
+
const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
|
|
169
|
+
// LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
|
|
170
|
+
// (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
|
|
171
|
+
// acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
|
|
172
|
+
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 });
|
|
173
|
+
const r = await renderJob(jobType === 'stitch' ? 'stitch' : 'video', input, 'MCP ad render');
|
|
169
174
|
return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]\n${notes || ''}`, r);
|
|
170
175
|
}));
|
|
171
176
|
|
|
172
177
|
|
|
173
178
|
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.",
|
|
179
|
+
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 }); '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
180
|
inputSchema: {
|
|
176
181
|
config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
|
|
177
182
|
},
|
|
178
183
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
179
184
|
}, wrap(async (a) => {
|
|
180
185
|
const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
|
|
186
|
+
if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
|
|
187
|
+
const urls = r.raw.images.map((u) => abs(u));
|
|
188
|
+
const first = await imageBlock(urls[0]).catch(() => null);
|
|
189
|
+
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 };
|
|
190
|
+
}
|
|
181
191
|
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
192
|
return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
|
|
183
193
|
}));
|
|
@@ -222,7 +232,7 @@ export function registerTools(server) {
|
|
|
222
232
|
refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
|
|
223
233
|
durationSeconds: z.number().optional().describe('clip length in seconds'),
|
|
224
234
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
225
|
-
model: z.string().optional(),
|
|
235
|
+
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
236
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
|
|
227
237
|
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
228
238
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
@@ -231,7 +241,9 @@ export function registerTools(server) {
|
|
|
231
241
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
232
242
|
}, wrap(async (a) => {
|
|
233
243
|
const refImage = a.refImage ? await toRef(a.refImage) : undefined;
|
|
234
|
-
|
|
244
|
+
// an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
|
|
245
|
+
// treatment (#310) instead of being treated as a system pick the fallback ladders may silently reroute
|
|
246
|
+
const r = await renderJob('video', { ...a, refImage, modelExplicit: !!a.model }, 'MCP video');
|
|
235
247
|
return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
|
|
236
248
|
}));
|
|
237
249
|
|
|
@@ -278,7 +290,7 @@ export function registerTools(server) {
|
|
|
278
290
|
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
291
|
} catch (e) { console.error('[mcp] single-pass collapse failed, falling back to stitch:', String(e?.message || e).slice(0, 140)); }
|
|
280
292
|
}
|
|
281
|
-
const r = await renderJob('stitch', a, 'MCP stitch');
|
|
293
|
+
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
294
|
return okVideo(`Stitched video ready: ${r.url} [job ${r.jobId}]`, r);
|
|
283
295
|
}));
|
|
284
296
|
|
|
@@ -652,7 +664,7 @@ export function registerTools(server) {
|
|
|
652
664
|
|
|
653
665
|
server.registerTool('reframe_video', {
|
|
654
666
|
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') },
|
|
667
|
+
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
668
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
657
669
|
}, wrap(async ({ video, aspectRatio }) => {
|
|
658
670
|
const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
|
|
@@ -681,6 +693,18 @@ export function registerTools(server) {
|
|
|
681
693
|
return okVideo(`Localized video (${language}): ${r.url}`, r);
|
|
682
694
|
}));
|
|
683
695
|
|
|
696
|
+
server.registerTool('change_voice', {
|
|
697
|
+
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.",
|
|
698
|
+
inputSchema: {
|
|
699
|
+
video: z.string().describe('the source video URL'),
|
|
700
|
+
voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read)"),
|
|
701
|
+
},
|
|
702
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
703
|
+
}, wrap(async ({ video, voice }) => {
|
|
704
|
+
const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
|
|
705
|
+
return okVideo(`Voice-swapped video: ${r.url}`, r);
|
|
706
|
+
}));
|
|
707
|
+
|
|
684
708
|
server.registerTool('recast_motion', {
|
|
685
709
|
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
710
|
inputSchema: {
|
|
@@ -711,4 +735,129 @@ export function registerTools(server) {
|
|
|
711
735
|
const text = vars.map((v, i) => `${i + 1}. ${v.name || v.angle || 'Variant'} — ${v.hook || v.headline || ''}`).join('\n') || 'No variants returned.';
|
|
712
736
|
return ok(text, d);
|
|
713
737
|
}));
|
|
738
|
+
|
|
739
|
+
// ---------- research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless) ----------
|
|
740
|
+
// The web Studio versions of these read the CLIENT's chat/creative state; the MCP variants take explicit inputs and
|
|
741
|
+
// resolve the ACTIVE brand SERVER-SIDE (same source as get_brand). Pass brandId to act on a specific brand — that
|
|
742
|
+
// pins this key's active brand exactly like use_brand (persists) — or omit it to use the currently-active brand.
|
|
743
|
+
const activeBrand = async (brandId) => {
|
|
744
|
+
if (brandId) {
|
|
745
|
+
const list = await apiGet('/api/brands');
|
|
746
|
+
const want = String(brandId).trim().toLowerCase();
|
|
747
|
+
const hit = (list.brands || []).find(b => b.id.toLowerCase() === want || String(b.name || '').toLowerCase() === want);
|
|
748
|
+
if (!hit) throw new Error(`No brand matching "${brandId}" — call list_brands for the available brands.`);
|
|
749
|
+
await apiPost('/api/keys/brand', { profileId: hit.id }); // pin it (use_brand semantics — persists for this key)
|
|
750
|
+
}
|
|
751
|
+
const cur = await apiGet('/api/brand/current').catch(() => null);
|
|
752
|
+
return cur?.hasBrand ? cur.brand : null;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
server.registerTool('competitor_teardown', {
|
|
756
|
+
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).",
|
|
757
|
+
inputSchema: {
|
|
758
|
+
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'),
|
|
759
|
+
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.'),
|
|
760
|
+
language: z.string().optional().describe('output language (default English)'),
|
|
761
|
+
},
|
|
762
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
763
|
+
}, wrap(async ({ competitor, ads, language }) => {
|
|
764
|
+
const name = String(competitor?.name || '').trim();
|
|
765
|
+
if (!name) throw new Error('competitor.name is required.');
|
|
766
|
+
let use = Array.isArray(ads) ? ads : [];
|
|
767
|
+
if (!use.length) { // no ads supplied → pull the competitor's real Meta ads (the pull_competitor_ads path), then tear THOSE down
|
|
768
|
+
const pulled = await apiPost('/api/inspire/fanout', { companyName: name, domain: competitor?.domain || '', platforms: ['facebook'], country: 'US', limit: 30, sort: 'longest_running' });
|
|
769
|
+
use = pulled?.facebook?.ads || [];
|
|
770
|
+
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.`);
|
|
771
|
+
}
|
|
772
|
+
const brand = await activeBrand().catch(() => null); // tailor white space + counter-plays to the saved brand (best-effort)
|
|
773
|
+
const d = await apiPost('/api/research/teardown', { competitor: { name, domain: competitor?.domain || '' }, ads: use, brand, language: language || '' });
|
|
774
|
+
const t = d.teardown || {};
|
|
775
|
+
const hooks = (t.hook_taxonomy || []).map(h => `${h.type}×${h.count}`).join(', ');
|
|
776
|
+
const camps = (t.campaigns || []).map(c => `“${c.theme}” (${c.longest_running_days}d)`).join('; ');
|
|
777
|
+
const ws = (t.white_space || []).map(w => `• ${w.angle}`).join('\n');
|
|
778
|
+
const plays = (t.counter_plays || []).map(p => `• [${p.format}] ${p.title}: ${p.brief}`).join('\n');
|
|
779
|
+
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(' · ') || '—'}`;
|
|
780
|
+
return ok(text, d);
|
|
781
|
+
}));
|
|
782
|
+
|
|
783
|
+
server.registerTool('check_ad_policy', {
|
|
784
|
+
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.",
|
|
785
|
+
inputSchema: {
|
|
786
|
+
copy: z.string().describe('the ad copy / script / on-screen text to check'),
|
|
787
|
+
claims: z.string().optional().describe('the claims / proof points the ad makes'),
|
|
788
|
+
category: z.string().optional().describe('the product category — helps pick the relevant policy pages'),
|
|
789
|
+
imageDescription: z.string().optional().describe('a description of the creative / image when relevant'),
|
|
790
|
+
},
|
|
791
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
792
|
+
}, wrap(async ({ copy, claims, category, imageDescription }) => {
|
|
793
|
+
const d = await apiPost('/api/policy/check', { copy, claims: claims || '', category: category || '', imageDescription: imageDescription || '' });
|
|
794
|
+
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');
|
|
795
|
+
const anchors = (d.anchors || []).map(a => a.url).filter(Boolean).join(', ');
|
|
796
|
+
const text = `Verdict: ${String(d.verdict || '').toUpperCase()} — ${d.summary || ''}\n${findings || '(no issues found)'}\n\nPolicies consulted: ${anchors || '—'}`;
|
|
797
|
+
return ok(text, d);
|
|
798
|
+
}));
|
|
799
|
+
|
|
800
|
+
server.registerTool('remix_static', {
|
|
801
|
+
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.",
|
|
802
|
+
inputSchema: {
|
|
803
|
+
imageUrl: z.string().describe('the URL of the static ad image to remix'),
|
|
804
|
+
brandId: z.string().optional().describe('a brand id/name from list_brands to remix for; omit to use the active brand'),
|
|
805
|
+
},
|
|
806
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
807
|
+
}, wrap(async ({ imageUrl, brandId }) => {
|
|
808
|
+
const brand = await activeBrand(brandId);
|
|
809
|
+
if (!brand) throw new Error('No saved brand to remix for — onboard one with draft_brand, or pass a brandId from list_brands.');
|
|
810
|
+
const spec = await apiPost('/api/remix/spec', { imageUrl }); // ONE vision call → the slot-map spec (flat-billed)
|
|
811
|
+
const d = await apiPost('/api/remix/render', { spec, imageUrl, brand, sourceAdvertiser: spec?.source_brand || '' });
|
|
812
|
+
const url = abs(d.image);
|
|
813
|
+
const img = await imageBlock(url); // show the remixed creative inline, not just a link
|
|
814
|
+
const resid = d.residual && d.residual.clean === false ? `\n⚠ Residual source branding may remain: ${d.residual.note}` : '';
|
|
815
|
+
return { content: [{ type: 'text', text: `Remixed ad ready: ${url}${d.model ? ` (${d.model})` : ''}${resid}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: url } };
|
|
816
|
+
}));
|
|
817
|
+
|
|
818
|
+
server.registerTool('mine_angles', {
|
|
819
|
+
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.",
|
|
820
|
+
inputSchema: {
|
|
821
|
+
brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
|
|
822
|
+
},
|
|
823
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
824
|
+
}, wrap(async ({ brandId }) => {
|
|
825
|
+
const brand = await activeBrand(brandId);
|
|
826
|
+
if (!brand) throw new Error('No saved brand to mine angles for — onboard one with draft_brand, or pass a brandId from list_brands.');
|
|
827
|
+
const d = await apiPost('/api/research/angles', { brand });
|
|
828
|
+
const angles = d.angles || [];
|
|
829
|
+
if (!angles.length) return ok(d.note || 'Not enough public customer language surfaced to mine reliable angles yet.', d);
|
|
830
|
+
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');
|
|
831
|
+
return ok(`Mined ${angles.length} angles from ${d.sourceCount} customer sources:\n${text}`, d);
|
|
832
|
+
}));
|
|
833
|
+
|
|
834
|
+
// ---------- product-photo tools (Studio-chat parity) ----------
|
|
835
|
+
server.registerTool('list_product_photos', {
|
|
836
|
+
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).",
|
|
837
|
+
inputSchema: {
|
|
838
|
+
brandId: z.string().optional().describe('a brand id/name from list_brands whose product library to list; omit to use the active brand'),
|
|
839
|
+
},
|
|
840
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
841
|
+
}, wrap(async ({ brandId }) => {
|
|
842
|
+
const brand = await activeBrand(brandId);
|
|
843
|
+
const d = await apiPost('/api/product/photos', { brand: brand || {} });
|
|
844
|
+
return ok(d.summary || 'The workspace has no saved product photos yet.', d);
|
|
845
|
+
}));
|
|
846
|
+
|
|
847
|
+
server.registerTool('set_product_image', {
|
|
848
|
+
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).",
|
|
849
|
+
inputSchema: {
|
|
850
|
+
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)'),
|
|
851
|
+
source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
|
|
852
|
+
brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
|
|
853
|
+
},
|
|
854
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
855
|
+
}, wrap(async ({ imageUrl, source_note, brandId }) => {
|
|
856
|
+
const brand = await activeBrand(brandId);
|
|
857
|
+
const d = await apiPost('/api/product/set-image', { imageUrl, source_note: source_note || '', brand: brand || {} });
|
|
858
|
+
if (!d.attached) return ok(d.summary || 'That image was not locked as the product.', d); // gate honesty: rejected → nothing attached
|
|
859
|
+
const url = abs(d.url);
|
|
860
|
+
const img = await imageBlock(url); // show the locked product inline
|
|
861
|
+
return { content: [{ type: 'text', text: `${d.summary}\nProduct photo: ${url}` }, ...(img ? [img] : [])], structuredContent: { ...d, url } };
|
|
862
|
+
}));
|
|
714
863
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|