hermoso 0.1.490 → 0.1.491
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 +59 -9
- 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
|
+
**1108 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 1108 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
|
@@ -22397,7 +22397,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22397
22397
|
};
|
|
22398
22398
|
server.registerTool('make_series', {
|
|
22399
22399
|
title: 'Make a series of faceless short videos (Content Factory)',
|
|
22400
|
-
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 and opening hook every episode copies, with plain subtitles only if the reference burns its own; 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.',
|
|
22400
|
+
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 and opening hook every episode copies, with plain subtitles only if the reference burns its own; 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. To make MORE of a series later, pass its returned plan as `moreOf`: same style, no episode repeated, the reference not watched again. 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.',
|
|
22401
22401
|
inputSchema: {
|
|
22402
22402
|
topic: z.string().optional().describe('what the series is about, in the user\'s words'),
|
|
22403
22403
|
episodes: z.number().optional().describe('how many episodes, 1-10 (default 1)'),
|
|
@@ -22411,6 +22411,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22411
22411
|
format: z.enum(['explainer', 'faceless_channel']).optional().describe("renderer lane: 'explainer' (default; exact length) or 'faceless_channel' (2K, whole 10s blocks)"),
|
|
22412
22412
|
planOnly: z.boolean().optional().describe('write the episode plan and style card, render nothing (a small planning charge)'),
|
|
22413
22413
|
plan: z.any().optional().describe('the `plan` a planOnly call returned, to run it without planning again'),
|
|
22414
|
+
moreOf: z.any().optional().describe('MORE EPISODES: the `plan` an earlier call returned. Keeps its style and length, writes new episodes only'),
|
|
22414
22415
|
confirm: z.boolean().optional().describe('REQUIRED true to start rendering (real spend). Without it you get a free quote'),
|
|
22415
22416
|
jobIds: z.array(z.string()).optional().describe('SCHEDULE MODE: the episode job ids to schedule (finished ones are booked)'),
|
|
22416
22417
|
schedule: z.object({ channels: z.array(z.string()).describe('channels to post each episode to, e.g. tiktok, instagram, youtube (as schedule_post takes them)'), at: z.string().describe('time of the first post: YYYY-MM-DDTHH:MM wall-clock, or an ISO date-time'), everyHours: z.number().optional().describe('hours between episodes; default 24'), timezone: z.string().optional().describe('timezone name for at, as schedule_post takes it') }).optional().describe('SCHEDULE MODE: where and when the first episode posts; the rest follow every `everyHours` (default 24)'),
|
|
@@ -22444,30 +22445,79 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22444
22445
|
const titles = (a.episodeTitles || []).map((t) => String(t || '').trim()).filter(Boolean);
|
|
22445
22446
|
const n = titles.length || Math.round(+a.episodes || 1);
|
|
22446
22447
|
if (n < 1 || n > 10) throw new Error('A series is 1 to 10 episodes per run. Nothing was charged.');
|
|
22447
|
-
const
|
|
22448
|
+
const prior = a.moreOf && typeof a.moreOf === 'object' ? a.moreOf : null; // more of an earlier series: its plan comes back
|
|
22449
|
+
const secs = Math.round(+a.durationSeconds || +prior?.durationSeconds || 30);
|
|
22448
22450
|
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.`);
|
|
22449
22451
|
let vo = {}; try { vo = (await apiGet('/api/generate/status'))?.voVoices || {}; } catch {}
|
|
22450
22452
|
// FREE QUOTE: the renderer's own dry run for one episode, times the count, plus the planning estimate.
|
|
22451
|
-
const planEst = a.reference ? 14 : 6;
|
|
22452
|
-
const probe = seriesEpisodeInput(topic, { durationSeconds: secs, style: {} }, { episode: 1, title: topic, brief: '' }, n, a, vo);
|
|
22453
|
+
const planEst = a.reference && !prior ? 14 : 6;
|
|
22454
|
+
const probe = seriesEpisodeInput(topic, { durationSeconds: secs, style: prior?.style || {}, reference: prior?.reference || null }, { episode: 1, title: topic, brief: '' }, n, a, vo);
|
|
22453
22455
|
const q = await quoteJob('explainer', probe);
|
|
22454
22456
|
const per = q && Number.isFinite(+q.credits) ? +q.credits : null;
|
|
22455
|
-
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})`;
|
|
22457
|
+
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 && !prior ? ' and watch the reference' : ''} (total about ${per * n + planEst})`;
|
|
22456
22458
|
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 });
|
|
22457
22459
|
let plan = a.plan && Array.isArray(a.plan.episodes) && a.plan.episodes.length ? a.plan : null;
|
|
22458
|
-
if (!plan) plan = await apiPost('/api/series/plan', { topic, episodes: n, episodeTitles: titles, durationSeconds: secs, reference: a.reference || '' });
|
|
22460
|
+
if (!plan) plan = await apiPost('/api/series/plan', { topic, episodes: n, episodeTitles: titles, durationSeconds: secs, reference: a.reference || '', ...(prior ? { moreOf: prior } : {}) });
|
|
22459
22461
|
const eps = (plan.episodes || []).slice(0, n);
|
|
22460
22462
|
const list = eps.map((e) => `${e.episode}. ${e.title}`).join('\n');
|
|
22461
22463
|
const s = plan.style || {};
|
|
22462
22464
|
const styleLine = s.look ? `Style: ${s.look}${s.captions ? `; captions on (${s.captionLook || 'subtitles'})` : '; no captions'}; ${s.aspectRatio || '9:16'}.` : '';
|
|
22463
22465
|
const watched = plan.reference ? ` Reference watched: ${plan.reference.frames || 0} frames${plan.reference.transcript ? ' + transcript' : ''}${plan.reference.note ? ` (${plan.reference.note})` : ''}.` : '';
|
|
22464
|
-
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 });
|
|
22466
|
+
if (!a.confirm) return ok(`SERIES PLAN (nothing rendered yet${plan.planCredits ? `; planning charged ${plan.planCredits} credits` : ''}):\n${list}\n${styleLine}${watched}\nRendering it: ${quoteLine.replace(/, plus about.*$/, '')}. Call again with confirm:true and this plan to make it.`, { plan });
|
|
22465
22467
|
const jobs = [];
|
|
22466
22468
|
for (const e of eps) {
|
|
22467
|
-
const
|
|
22469
|
+
const total = plan.totalEpisodes || n; // a continued series keeps counting: episode 5 of 6, not 2 of 2
|
|
22470
|
+
const j = await submitJob('explainer', seriesEpisodeInput(topic, plan, e, total, a, vo), { label: `Series · ${topic.slice(0, 40)} · Ep ${e.episode}/${total}: ${e.title}` });
|
|
22468
22471
|
jobs.push({ episode: e.episode, title: e.title, jobId: j?.id || null });
|
|
22469
22472
|
}
|
|
22470
|
-
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 });
|
|
22473
|
+
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}. For more episodes later, call it with moreOf set to this plan.`, { jobs, plan });
|
|
22474
|
+
}));
|
|
22475
|
+
|
|
22476
|
+
// THE EFFECTS, FOR AGENTS (2026-11-01; held since the 2026-10-09 parity batch): the same presets as the /tools effect pages, the
|
|
22477
|
+
// same two jobs (still, then clip), each with its own free quote and its own confirm. The server writes the prompt from the id.
|
|
22478
|
+
const effectQuote = (q, what) => { const c = q && (q.priceCredits || q.credits); return c != null && Number.isFinite(+c) ? `QUOTE (nothing made, nothing charged): ${what} is about ${c} credits. ${PRICE_ESTIMATE_LINE} Add confirm:true to make it.` : `${what} would not start${q && q.refused ? `: ${q.refused}` : ', so it has no price'}. Nothing was charged.`; };
|
|
22479
|
+
const effectLine = (x) => `${x.id}: ${x.short} [takes ${x.inputs.map((f) => `${f.id}${f.optional ? ' (optional)' : ''}`).join(', ') || 'no picture'}${x.options.length ? `; ${x.options.map((o) => `${o.id}=${o.values.join('|')}${o.orDescribe ? '|custom' : ''}`).join('; ')}` : ''}${x.text ? `; text up to ${x.text.max}` : ''}; ${x.moves ? 'can move' : 'picture only'}]`;
|
|
22480
|
+
server.registerTool('make_effect', {
|
|
22481
|
+
title: 'Run a one-click effect on a photo',
|
|
22482
|
+
description: "ONE-CLICK EFFECTS, the same ones as the /tools pages: a photo goes in, a styled picture comes out, and most can then become a 5-second clip. Plush toy, 3D figure, mascot, comic book, pixel game, billboard ad, giant product, bullet time, packshot, outfit swap, headshot, relight and many more. Name no `effect` for the free list. `effect` alone: what it takes. `effect` + `images`: a free quote; add confirm:true to make the picture. Then `effect` + `still` (that picture's URL): a free quote for the clip; confirm:true makes it. `describe` adds anything of your own. For a look not on the list use generate_image. A real person's photo follows the same likeness rules as every render.",
|
|
22483
|
+
inputSchema: {
|
|
22484
|
+
effect: z.string().optional().describe('effect id from the list'),
|
|
22485
|
+
images: z.array(z.string()).optional().describe('its pictures, in the order listed: URL, local path or Library id'),
|
|
22486
|
+
options: z.record(z.string(), z.string()).optional().describe('its listed options, e.g. {material:"knit"}; "custom" means use `describe`'),
|
|
22487
|
+
text: z.string().optional().describe('a line printed exactly, on an effect that takes one'),
|
|
22488
|
+
describe: z.string().optional().describe('your own words: added to the look, or the custom choice'),
|
|
22489
|
+
aspectRatio: z.string().optional().describe('one of its listed shapes'),
|
|
22490
|
+
still: z.string().optional().describe('CLIP STEP: the finished picture to animate'),
|
|
22491
|
+
speech: z.string().optional().describe('CLIP STEP: a line said in the clip, on an effect that speaks'),
|
|
22492
|
+
confirm: z.boolean().optional().describe('true = run it (real spend); without it, a free quote'),
|
|
22493
|
+
},
|
|
22494
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22495
|
+
}, wrap(async (a) => {
|
|
22496
|
+
if (!a.effect) {
|
|
22497
|
+
const list = (await apiGet('/api/effects'))?.effects || [];
|
|
22498
|
+
return ok(`${list.length} effects (free to list; each run is quoted first). Call with effect and images:\n${list.map(effectLine).join('\n')}`, { count: list.length });
|
|
22499
|
+
}
|
|
22500
|
+
const row = (await apiGet('/api/effects', { id: a.effect }))?.effect;
|
|
22501
|
+
if (!row) throw new Error(`There is no effect called "${String(a.effect).slice(0, 60)}". Call make_effect with no effect for the list. Nothing was charged.`);
|
|
22502
|
+
const describe = String(a.describe || '').trim();
|
|
22503
|
+
if (a.still) {
|
|
22504
|
+
if (!row.moves) throw new Error(`${row.title} makes a picture only. Nothing was charged.`);
|
|
22505
|
+
const refImage = await toRef(a.still);
|
|
22506
|
+
if (!refImage) throw new Error('The `still` could not be read: pass the picture\'s URL or a Library id. Nothing was charged.');
|
|
22507
|
+
const input = { effect: row.id, refImage, ...(a.aspectRatio ? { aspectRatio: a.aspectRatio } : {}), ...(describe ? { effectDescribe: describe } : {}), ...(a.speech ? { effectSpeech: String(a.speech) } : {}) };
|
|
22508
|
+
if (!a.confirm) return ok(effectQuote(await quoteJob('video', input), `a 5-second ${row.title} clip`));
|
|
22509
|
+
const r = await renderJob('video', input, `Effect clip · ${row.title}`);
|
|
22510
|
+
if (r?.stillRendering) return ok(`${row.title} clip still rendering [job ${r.jobId}]. Call get_job with that id.`, r);
|
|
22511
|
+
return okVideo(`${row.title} clip ready: ${r.url} [job ${r.jobId}]`, r);
|
|
22512
|
+
}
|
|
22513
|
+
if (!a.images?.length && row.inputs.some((f) => !f.optional)) return ok(`${effectLine(row)}\nShapes: ${row.aspects.join(', ')} (default ${row.aspect}). Pass images to get a quote.`, { effect: row });
|
|
22514
|
+
const refs = (await Promise.all((a.images || []).map(toRef))).filter(Boolean);
|
|
22515
|
+
if (refs.length !== (a.images || []).length) throw new Error('One of the pictures could not be read: pass a URL, a local path or a Library id. Nothing was charged.');
|
|
22516
|
+
const input = { effect: row.id, refImages: refs, aspectRatio: a.aspectRatio || row.aspect, ...(a.options && Object.keys(a.options).length ? { effectOptions: a.options } : {}), ...(a.text ? { effectText: String(a.text) } : {}), ...(describe ? { effectDescribe: describe } : {}) };
|
|
22517
|
+
if (!a.confirm) return ok(effectQuote(await quoteJob('image', input), `this ${row.title} picture`));
|
|
22518
|
+
const r = await renderJob('image', input, `Effect · ${row.title}`);
|
|
22519
|
+
if (r?.stillRendering) return ok(`${row.title} still rendering [job ${r.jobId}]. Call get_job with that id.`, r);
|
|
22520
|
+
return ok(`${row.title} ready: ${r.url} [job ${r.jobId}]${row.moves ? `\nTo make it move: make_effect({effect:'${row.id}', still:'${r.url}'}) for its price, then confirm:true.` : ''}${row.grid ? `\nOne ${row.grid} sheet: each panel is a shot.` : ''}`, r);
|
|
22471
22521
|
}));
|
|
22472
22522
|
|
|
22473
22523
|
server.registerTool('product_sizzle', {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.491",
|
|
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. 1108 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"
|