hermoso 0.1.2 → 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 +204 -9
- 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,14 +161,70 @@ 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
|
|
|
177
|
+
|
|
178
|
+
server.registerTool('make_template_ad', {
|
|
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.",
|
|
180
|
+
inputSchema: {
|
|
181
|
+
config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
|
|
182
|
+
},
|
|
183
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
184
|
+
}, wrap(async (a) => {
|
|
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
|
+
}
|
|
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 }; }
|
|
192
|
+
return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
server.registerTool('finish_video', {
|
|
196
|
+
description: "Post-process an EXISTING rendered video (its served mp4 URL) with the proven direct-response 'reviewer' finish and/or a film-grain pass — no AI model, ~30s, a couple of credits. pills=true composites a header pill (e.g. '10/10 would buy again'), a brand-accent sub-pill, and 3-4 green-check proof pills cascading in on the beat (YOU author the copy: header ≤40 chars, sub ≤34, each point ≤44 — concrete real benefits, never fabricated stats). grain=true applies a subtle camera-grain finish that makes photoreal AI renders look phone-shot ('less AI') — works alone or with pills. Returns a NEW video; the original is untouched.",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
videoUrl: z.string().describe('the served URL of the video to finish (from a previous render/job)'),
|
|
199
|
+
header: z.string().optional().describe('header pill copy, ≤40 chars (required when pills is on)'),
|
|
200
|
+
sub: z.string().optional().describe('accent sub-pill copy, ≤34 chars (usually the product/brand)'),
|
|
201
|
+
points: z.array(z.string()).optional().describe('3-4 proof points, ≤44 chars each'),
|
|
202
|
+
accent: z.string().optional().describe('brand accent hex for the sub-pill'),
|
|
203
|
+
pills: z.boolean().optional().describe('default true — set false for a grain-only pass'),
|
|
204
|
+
grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
|
|
205
|
+
},
|
|
206
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
207
|
+
}, wrap(async (a) => {
|
|
208
|
+
const r = await renderJob('videofinish', { videoUrl: a.videoUrl, header: a.header, sub: a.sub, points: a.points, accent: a.accent, pills: a.pills !== false, grain: !!a.grain }, 'MCP video finish');
|
|
209
|
+
return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
server.registerTool('fix_beat', {
|
|
213
|
+
description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this.",
|
|
214
|
+
inputSchema: {
|
|
215
|
+
videoUrl: z.string().describe('the served URL of the master video to fix'),
|
|
216
|
+
startSeconds: z.number().describe('window start in seconds'),
|
|
217
|
+
endSeconds: z.number().describe('window end in seconds (window 1.5-8s)'),
|
|
218
|
+
prompt: z.string().describe('what the replacement footage should show — describe the shot, matching the master\'s style'),
|
|
219
|
+
refImage: z.string().optional().describe('optional product/style anchor image URL'),
|
|
220
|
+
speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
|
|
221
|
+
},
|
|
222
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
223
|
+
}, wrap(async (a) => {
|
|
224
|
+
const r = await renderJob('fixbeat', { videoUrl: a.videoUrl, startSeconds: a.startSeconds, endSeconds: a.endSeconds, prompt: a.prompt, refImage: a.refImage, speechWindows: a.speechWindows }, 'MCP fix beat');
|
|
225
|
+
return okVideo(`Fixed beat spliced in: ${r.url} [job ${r.jobId}]`, r);
|
|
226
|
+
}));
|
|
227
|
+
|
|
172
228
|
server.registerTool('generate_video', {
|
|
173
229
|
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. Spends credits (Starter plan is video-blocked server-side).',
|
|
174
230
|
inputSchema: {
|
|
@@ -176,7 +232,7 @@ export function registerTools(server) {
|
|
|
176
232
|
refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
|
|
177
233
|
durationSeconds: z.number().optional().describe('clip length in seconds'),
|
|
178
234
|
aspectRatio: z.string().optional().describe("default '9:16'"),
|
|
179
|
-
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'),
|
|
180
236
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
|
|
181
237
|
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
182
238
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
@@ -185,7 +241,9 @@ export function registerTools(server) {
|
|
|
185
241
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
186
242
|
}, wrap(async (a) => {
|
|
187
243
|
const refImage = a.refImage ? await toRef(a.refImage) : undefined;
|
|
188
|
-
|
|
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');
|
|
189
247
|
return okVideo(`Video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
|
|
190
248
|
}));
|
|
191
249
|
|
|
@@ -232,7 +290,7 @@ export function registerTools(server) {
|
|
|
232
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);
|
|
233
291
|
} catch (e) { console.error('[mcp] single-pass collapse failed, falling back to stitch:', String(e?.message || e).slice(0, 140)); }
|
|
234
292
|
}
|
|
235
|
-
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
|
|
236
294
|
return okVideo(`Stitched video ready: ${r.url} [job ${r.jobId}]`, r);
|
|
237
295
|
}));
|
|
238
296
|
|
|
@@ -606,7 +664,7 @@ export function registerTools(server) {
|
|
|
606
664
|
|
|
607
665
|
server.registerTool('reframe_video', {
|
|
608
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.",
|
|
609
|
-
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') },
|
|
610
668
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
611
669
|
}, wrap(async ({ video, aspectRatio }) => {
|
|
612
670
|
const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
|
|
@@ -635,6 +693,18 @@ export function registerTools(server) {
|
|
|
635
693
|
return okVideo(`Localized video (${language}): ${r.url}`, r);
|
|
636
694
|
}));
|
|
637
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
|
+
|
|
638
708
|
server.registerTool('recast_motion', {
|
|
639
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.",
|
|
640
710
|
inputSchema: {
|
|
@@ -665,4 +735,129 @@ export function registerTools(server) {
|
|
|
665
735
|
const text = vars.map((v, i) => `${i + 1}. ${v.name || v.angle || 'Variant'} — ${v.hook || v.headline || ''}`).join('\n') || 'No variants returned.';
|
|
666
736
|
return ok(text, d);
|
|
667
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
|
+
}));
|
|
668
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": {
|