hermoso 0.1.441 → 0.1.443
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/mcp/tools.mjs +133 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate image & video ad
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**931 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**No markup on models.** Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
|
|
@@ -242,7 +242,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
242
242
|
|
|
243
243
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
244
244
|
|
|
245
|
-
### What the
|
|
245
|
+
### What the 931 tools cover
|
|
246
246
|
|
|
247
247
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
248
248
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/tools.mjs
CHANGED
|
@@ -18897,6 +18897,41 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18897
18897
|
return ok(text, c);
|
|
18898
18898
|
}));
|
|
18899
18899
|
|
|
18900
|
+
// ── CLICK TO AD (2026-10-26) ─────────────────────────────────────────────────────────────────────────────────
|
|
18901
|
+
// A product page link → the product read from its own page (saved to the brand's catalog), its real packshot checked and
|
|
18902
|
+
// locked as the product photo, and a UGC-style video plan. Stops at the plan like clone_video: render_ad is the spend.
|
|
18903
|
+
server.registerTool('click_to_ad', {
|
|
18904
|
+
title: 'Make a video ad from a product link',
|
|
18905
|
+
description: 'Paste a product page link (Shopify, Amazon or any store page) and get a creator-style video ad planned for that product. Hermoso reads the page (name, photos, what it says about the product, price), saves the product to the brand\'s catalog, checks the page\'s real product photo and locks it as the photo the ad is built on, then plans the ad. Nothing renders here: show the user the concept, then call render_ad with the returned creative (dryRun:true first for the exact price). Works with or without a saved brand.',
|
|
18906
|
+
inputSchema: {
|
|
18907
|
+
url: z.string().describe('the product page link, https://…'),
|
|
18908
|
+
style: z.enum(['ugc_selfie', 'ugc_unboxing', 'ugc_demo', 'ugc_tryon', 'ugc_product', 'ugc_shopping', 'ugc_delivery', 'transformation', 'day_in_life', 'grwm', 'ugc_addiction']).optional().describe('ugc_selfie (talking to camera, default), ugc_unboxing, ugc_demo (how-to), ugc_tryon (try-on or haul), ugc_product (product close-ups, no face), ugc_shopping (in-store find), ugc_delivery, transformation (before and after), day_in_life (at home), grwm (get ready with me), ugc_addiction (obsessed with it)'),
|
|
18909
|
+
durationSeconds: z.number().optional().describe('length in seconds, 4-180; default 15'),
|
|
18910
|
+
say: z.string().optional().describe('what the creator should say, or the angle, in the user\'s words'),
|
|
18911
|
+
language: z.string().optional().describe('language for the script and copy, default English'),
|
|
18912
|
+
},
|
|
18913
|
+
outputSchema: {
|
|
18914
|
+
product: z.any().optional().describe('the product as read from its page: {id, title, url, priceLabel, sellingPoints, images, host}'),
|
|
18915
|
+
photo: z.any().optional().describe('the product photo locked for the render {url, from, savedToBrand}, or null when no page image passed the check'),
|
|
18916
|
+
format: z.string().optional(), concept: z.string().optional(), copy: z.array(z.any()).optional(),
|
|
18917
|
+
video_storyboard: z.any().optional(), render_plan: z.any().optional(),
|
|
18918
|
+
},
|
|
18919
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
18920
|
+
}, wrap(async ({ url, style, durationSeconds, say, language }) => {
|
|
18921
|
+
const link = String(url || '').trim();
|
|
18922
|
+
if (!/^https?:\/\//i.test(link)) return ok('click_to_ad needs the product page link, starting with https://. Nothing was read or charged.', {});
|
|
18923
|
+
const r = await apiPost('/api/click-to-ad/product', { url: link, style, durationSeconds, say, language });
|
|
18924
|
+
const d = (r && r.data) || r || {};
|
|
18925
|
+
const s = await apiSSE('/api/create', { stream: true, ...(d.plan || {}) });
|
|
18926
|
+
if (s?.data?.choice && typeof s.data.choice === 'object') throw Object.assign(new Error(String(s.data.choice.message || 'This video does not fit your balance.')), { status: 402, videoChoice: s.data.choice, _viaApi: true });
|
|
18927
|
+
const c = s?.data?.creative || s?.creative || s;
|
|
18928
|
+
const p = d.product || {};
|
|
18929
|
+
const planned = Math.round(+c.render_plan?.duration_seconds || (c.video_storyboard?.scenes || []).reduce((n, x) => n + (+x.seconds || 0), 0) || 0);
|
|
18930
|
+
const photoLine = d.photo ? `Product photo: the page's own photo, checked and locked (${d.photo.url}).` : `⚠ No product photo was locked: ${d.photoNote || 'no page image passed the check'}. Tell the user; render_ad will ask for one (set_product_image).`;
|
|
18931
|
+
const text = `Read "${p.title || 'the product'}"${p.host ? ` from ${p.host}` : ''}${p.priceLabel ? ` (${p.priceLabel})` : ''}, saved to the brand's products.\n${photoLine}\nPlan: "${c.concept || ''}"\nLength: ${planned || '—'}s\nHeadline: ${c.copy?.[0]?.headline || ''}\nNext: show the user this concept, then call render_ad with this creative (dryRun:true first for the exact price).`;
|
|
18932
|
+
return ok(text, { ...c, product: p, photo: d.photo || null });
|
|
18933
|
+
}));
|
|
18934
|
+
|
|
18900
18935
|
// ---------- image (synchronous) ----------
|
|
18901
18936
|
server.group('create');
|
|
18902
18937
|
server.registerTool('generate_image', {
|
|
@@ -19625,6 +19660,104 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19625
19660
|
return okVideo(`Explainer ready${d.sections ? ` — ${d.sections} sections, ${d.frames || d.sections} frames, ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}` : ''}${d.lengthNote ? ` (${d.lengthNote})` : ''}${d.style ? ` in the ${String(d.style).replace(/_/g, ' ')} style${d.styleLocked ? '' : ' (style key unavailable — the look rides on the prompt only)'}` : ''}${fin}: ${r.url} [job ${r.jobId}]${d.musicNote ? `\n${d.musicNote}` : ''}${d.captionNote ? `\n⚠ ${d.captionNote}` : ''}${d.upscaleNote ? `\n⚠ ${d.upscaleNote}` : ''}`, r);
|
|
19626
19661
|
}));
|
|
19627
19662
|
|
|
19663
|
+
// ── CONTENT FACTORY (2026-10-26): a SERIES of faceless shorts from one topic, optionally in a reference video's style.
|
|
19664
|
+
// A content factory, in shape: reference + "make a video about…" + length + episodes. Our renderer is
|
|
19665
|
+
// the faceless explainer job, one per episode; the planning (watch the reference once, write N distinct episodes and a
|
|
19666
|
+
// style card) is POST /api/series/plan. A series multiplies cost, so it is QUOTED FIRST and runs only on confirm:true.
|
|
19667
|
+
const seriesEpisodeInput = (topic, plan, ep, n, a, vo) => {
|
|
19668
|
+
const s = plan.style || {};
|
|
19669
|
+
const how = [s.hookDevice ? `Open the way the reference opens: ${s.hookDevice}` : '', s.pacing ? `Pacing: ${s.pacing}` : '', s.narration ? `Narration: ${s.narration}` : ''].filter(Boolean).join('. ');
|
|
19670
|
+
const captions = typeof a.captions === 'boolean' ? a.captions : !!s.captions;
|
|
19671
|
+
const voice = (a.voice || s.voice) === 'male' ? (vo.male || 'George') : (vo.female || 'Sarah');
|
|
19672
|
+
return {
|
|
19673
|
+
topic: `Episode ${ep.episode} of ${n} in a faceless short-video series about "${topic}". This episode: ${ep.title}. ${ep.brief}${how ? ` ${how}.` : ''} No presenter on camera.`,
|
|
19674
|
+
durationSeconds: plan.durationSeconds, aspectRatio: a.aspectRatio || s.aspectRatio || '9:16',
|
|
19675
|
+
...(a.style ? { style: a.style } : (s.look ? { style: s.look } : {})),
|
|
19676
|
+
...(s.channel ? { channel: s.channel } : {}), voice,
|
|
19677
|
+
...(captions ? { captions: true } : {}), ...(a.format ? { format: a.format } : {}),
|
|
19678
|
+
};
|
|
19679
|
+
};
|
|
19680
|
+
const seriesWallClock = (at, plusHours) => {
|
|
19681
|
+
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::\d{2})?$/.exec(String(at || '').trim());
|
|
19682
|
+
if (!m) { const t = Date.parse(at); return Number.isFinite(t) ? new Date(t + plusHours * 3600e3).toISOString() : null; }
|
|
19683
|
+
const d = new Date(`${m[1]}T${m[2]}:${m[3]}:00Z`); d.setUTCHours(d.getUTCHours() + plusHours);
|
|
19684
|
+
return d.toISOString().slice(0, 16); // a local wall clock stays a wall clock: read in the brand's zone on THAT date
|
|
19685
|
+
};
|
|
19686
|
+
server.registerTool('make_series', {
|
|
19687
|
+
title: 'Make a series of faceless short videos (Content Factory)',
|
|
19688
|
+
description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel / YouTube Short / Facebook / X link or a video file URL whose look, pacing, opening hook and caption style every episode copies; never its words or characters). Find a trending reference with search_tiktok / search_instagram / search_youtube when the user wants one. THREE STEPS, ALWAYS IN THIS ORDER: (1) call with no `confirm` for a FREE quote of the whole series (per episode and total); (2) optionally `planOnly:true` to see the episode titles and the style card first (a small planning charge); (3) `confirm:true` (pass the `plan` from step 2 to reuse it) queues every episode and returns their job ids; poll get_job. Each finished episode can be scheduled with this tool again: pass `jobIds` plus `schedule` {channels, at, everyHours, timezone} and it books one post per finished episode, spaced `everyHours` apart (default 24). Captions follow the reference (on if it burns words on screen) unless `captions` says otherwise.',
|
|
19689
|
+
inputSchema: {
|
|
19690
|
+
topic: z.string().optional().describe('what the series is about, in the user\'s words'),
|
|
19691
|
+
episodes: z.number().optional().describe('how many episodes, 1-10 (default 1)'),
|
|
19692
|
+
episodeTitles: z.array(z.string()).optional().describe('your own episode titles, in order (sets the episode count)'),
|
|
19693
|
+
durationSeconds: z.number().optional().describe('length of EACH episode, 20-120 seconds (default 30)'),
|
|
19694
|
+
reference: z.string().optional().describe('a video link whose style every episode copies'),
|
|
19695
|
+
aspectRatio: z.enum(['9:16', '16:9', '1:1']).optional().describe('default: the reference\'s shape, else 9:16'),
|
|
19696
|
+
style: z.string().optional().describe('override the look in words (e.g. "flat 2D cartoon") instead of the reference\'s'),
|
|
19697
|
+
captions: z.boolean().optional().describe('burn subtitles of every spoken word; default follows the reference'),
|
|
19698
|
+
voice: z.enum(['female', 'male']).optional().describe('narrator; default follows the reference'),
|
|
19699
|
+
format: z.enum(['explainer', 'faceless_channel']).optional().describe("renderer lane: 'explainer' (default; exact length) or 'faceless_channel' (2K, whole 10s blocks)"),
|
|
19700
|
+
planOnly: z.boolean().optional().describe('write the episode plan and style card, render nothing (a small planning charge)'),
|
|
19701
|
+
plan: z.any().optional().describe('the `plan` a planOnly call returned, to run it without planning again'),
|
|
19702
|
+
confirm: z.boolean().optional().describe('REQUIRED true to start rendering (real spend). Without it you get a free quote'),
|
|
19703
|
+
jobIds: z.array(z.string()).optional().describe('SCHEDULE MODE: the episode job ids to schedule (finished ones are booked)'),
|
|
19704
|
+
schedule: z.object({ channels: z.array(z.string()), at: z.string(), everyHours: z.number().optional(), timezone: z.string().optional() }).optional().describe('SCHEDULE MODE: where and when the first episode posts; the rest follow every `everyHours` (default 24)'),
|
|
19705
|
+
postCaptions: z.array(z.string()).optional().describe('SCHEDULE MODE: one post caption per episode, in order (default: the episode title)'),
|
|
19706
|
+
},
|
|
19707
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19708
|
+
}, wrap(async (a) => {
|
|
19709
|
+
// SCHEDULE MODE: book one post per finished episode. A job still rendering is named, never silently skipped.
|
|
19710
|
+
if (Array.isArray(a.jobIds) && a.jobIds.length) {
|
|
19711
|
+
if (!a.schedule || !Array.isArray(a.schedule.channels) || !a.schedule.channels.length || !a.schedule.at) throw new Error('To schedule a series, pass `schedule` with `channels` and `at` (the first post\'s time).');
|
|
19712
|
+
const every = +a.schedule.everyHours > 0 ? +a.schedule.everyHours : 24;
|
|
19713
|
+
const lines = []; const booked = [];
|
|
19714
|
+
for (let i = 0; i < a.jobIds.length; i++) {
|
|
19715
|
+
const id = String(a.jobIds[i]);
|
|
19716
|
+
let job = null; try { job = await getJob(id); } catch (e) { lines.push(`${id}: could not be read (${e.message}), not scheduled.`); continue; }
|
|
19717
|
+
const r = jobResult(job) || {};
|
|
19718
|
+
const url = r.video || r.url;
|
|
19719
|
+
if (job?.status !== 'done' || !url) { lines.push(`${id}: ${job?.status === 'error' ? 'failed, not scheduled' : 'still rendering, not scheduled yet'}.`); continue; }
|
|
19720
|
+
const at = seriesWallClock(a.schedule.at, i * every);
|
|
19721
|
+
const title = String(job.label || '').split(': ').slice(1).join(': ') || `Episode ${i + 1}`;
|
|
19722
|
+
const message = (a.postCaptions && a.postCaptions[i]) || title;
|
|
19723
|
+
const d = await apiPost('/api/schedule', { channels: a.schedule.channels, at, ...(a.schedule.timezone ? { timezone: a.schedule.timezone } : {}), message, videoUrl: url, title });
|
|
19724
|
+
const sid = d?.id || d?.data?.id || d?.job?.id || '';
|
|
19725
|
+
booked.push({ jobId: id, scheduleId: sid, at });
|
|
19726
|
+
lines.push(`Episode ${i + 1} → ${a.schedule.channels.join(', ')} at ${at}${sid ? ` [scheduled ${sid}]` : ''}.`);
|
|
19727
|
+
}
|
|
19728
|
+
return ok(`${booked.length} of ${a.jobIds.length} episodes scheduled (free; posting costs no credits).\n${lines.join('\n')}`, { booked });
|
|
19729
|
+
}
|
|
19730
|
+
const topic = String(a.topic || '').trim();
|
|
19731
|
+
if (topic.length < 3) throw new Error('Say what the series is about (`topic`), e.g. "what if the Moon had a city".');
|
|
19732
|
+
const titles = (a.episodeTitles || []).map((t) => String(t || '').trim()).filter(Boolean);
|
|
19733
|
+
const n = titles.length || Math.round(+a.episodes || 1);
|
|
19734
|
+
if (n < 1 || n > 10) throw new Error('A series is 1 to 10 episodes per run. Nothing was charged.');
|
|
19735
|
+
const secs = Math.round(+a.durationSeconds || 30);
|
|
19736
|
+
if (secs < 20 || secs > 120) throw new Error(`Each episode runs 20 to 120 seconds; ${secs}s is outside that. Ask for a length in that range (or a longer single explainer with make_explainer). Nothing was charged.`);
|
|
19737
|
+
let vo = {}; try { vo = (await apiGet('/api/generate/status'))?.voVoices || {}; } catch {}
|
|
19738
|
+
// FREE QUOTE: the renderer's own dry run for one episode, times the count, plus the planning estimate.
|
|
19739
|
+
const planEst = a.reference ? 14 : 6;
|
|
19740
|
+
const probe = seriesEpisodeInput(topic, { durationSeconds: secs, style: {} }, { episode: 1, title: topic, brief: '' }, n, a, vo);
|
|
19741
|
+
const q = await quoteJob('explainer', probe);
|
|
19742
|
+
const per = q && Number.isFinite(+q.credits) ? +q.credits : null;
|
|
19743
|
+
const quoteLine = per == null ? `the renderer would not quote it${q?.refused ? `: ${q.refused}` : ''}` : `about ${per} credits per ${secs}s episode, ${per * n} for ${n} episode${n === 1 ? '' : 's'}, plus about ${planEst} credits to plan the series${a.reference ? ' and watch the reference' : ''} (total about ${per * n + planEst})`;
|
|
19744
|
+
if (!a.confirm && !a.planOnly) return ok(`QUOTE (nothing planned, rendered or charged): ${quoteLine}. ${PRICE_ESTIMATE_LINE} Call again with planOnly:true to see the episode titles first, or confirm:true to make the series.`, { perEpisodeCredits: per, episodes: n, totalCredits: per == null ? null : per * n + planEst });
|
|
19745
|
+
let plan = a.plan && Array.isArray(a.plan.episodes) && a.plan.episodes.length ? a.plan : null;
|
|
19746
|
+
if (!plan) plan = await apiPost('/api/series/plan', { topic, episodes: n, episodeTitles: titles, durationSeconds: secs, reference: a.reference || '' });
|
|
19747
|
+
const eps = (plan.episodes || []).slice(0, n);
|
|
19748
|
+
const list = eps.map((e) => `${e.episode}. ${e.title}`).join('\n');
|
|
19749
|
+
const s = plan.style || {};
|
|
19750
|
+
const styleLine = s.look ? `Style: ${s.look}${s.captions ? `; captions on (${s.captionLook || 'subtitles'})` : '; no captions'}; ${s.aspectRatio || '9:16'}.` : '';
|
|
19751
|
+
const watched = plan.reference ? ` Reference watched: ${plan.reference.frames || 0} frames${plan.reference.transcript ? ' + transcript' : ''}${plan.reference.note ? ` (${plan.reference.note})` : ''}.` : '';
|
|
19752
|
+
if (!a.confirm) return ok(`SERIES PLAN (nothing rendered yet):\n${list}\n${styleLine}${watched}\nRendering it: ${quoteLine.replace(/, plus about.*$/, '')}. Call again with confirm:true and this plan to make it.`, { plan });
|
|
19753
|
+
const jobs = [];
|
|
19754
|
+
for (const e of eps) {
|
|
19755
|
+
const j = await submitJob('explainer', seriesEpisodeInput(topic, plan, e, n, a, vo), { label: `Series · ${topic.slice(0, 40)} · Ep ${e.episode}/${n}: ${e.title}` });
|
|
19756
|
+
jobs.push({ episode: e.episode, title: e.title, jobId: j?.id || null });
|
|
19757
|
+
}
|
|
19758
|
+
return ok(`Series started: ${jobs.length} episode${jobs.length === 1 ? '' : 's'} rendering (${secs}s each, a few minutes per episode).\n${jobs.map((j) => `${j.episode}. ${j.title} [job ${j.jobId}]`).join('\n')}\n${styleLine}${watched}\nPoll get_job for each id. To schedule them once done, call make_series with jobIds and schedule {channels, at, everyHours}.`, { jobs, plan });
|
|
19759
|
+
}));
|
|
19760
|
+
|
|
19628
19761
|
server.registerTool('product_sizzle', {
|
|
19629
19762
|
title: 'Product sizzle (music-led)',
|
|
19630
19763
|
description: "Render an 18-30s music-led PRODUCT SIZZLE: ONE 15s Seedance 2.0 hero clip of the product, diced into fast cuts and intercut with typeset spec/CTA cards on a brand-coloured grain background, mixed to a music bed. Faceless by design — no people, no voiceover, no spoken lines; the cards carry every word, so nothing is left to a video model's spelling. Pass a real packshot as refImage or the label will not be yours. EXPENSIVE — the hero clip is the only paid leg and it is a full 15s Seedance render: ≈1,040 credits at the DEFAULT 1080p, ≈470 at 720p, ≈220 at 480p, ≈4,130 at 4k (call hermoso_capabilities for the live seedance-2 per-duration numbers; the dicing and the cards are free, and the music bed is already included in the quoted figure). Confirm the spend with the user before calling. For a talking/UGC ad use render_ad or generate_avatar; for a cheap deterministic format use make_template_ad.",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.443",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI marketing agent and AI ad generator, run from your own AI agent.
|
|
5
|
+
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 931 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics are free on every plan. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|