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.
Files changed (2) hide show
  1. package/mcp/tools.mjs +204 -9
  2. 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 — don’t stitch)' : 'generate_image with the image_concept.prompt'}.`;
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
- const r = await renderJob('video', input, 'MCP ad render');
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
- const r = await renderJob('video', { ...a, refImage }, 'MCP video');
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.2",
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": {