hermoso 0.1.490 → 0.1.493
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 +82 -11
- 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
|
@@ -21983,7 +21983,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21983
21983
|
};
|
|
21984
21984
|
server.registerTool('make_motion_film', {
|
|
21985
21985
|
title: 'Make a motion graphics video',
|
|
21986
|
-
description: "MOTION GRAPHICS and ANIMATED VIDEO in one call
|
|
21986
|
+
description: "MOTION GRAPHICS and ANIMATED VIDEO in one call: kinetic typography (animated text), a logo animation or intro, a product launch film, an animated social cut. MOVING PICTURES are the default for launch, product and social: in each scene with a picture, one thing physically happens (a seed splits open, an eye opens, the view travels through a forest), filmed by an image-to-video model, with the type, transitions and the music's beat put on top; a few minutes, priced per shot, and with no pictures given each scene paints its own. moving:false, and always for type and logo, makes it in code with no video model: type, pictures and drawn graphics filmed by a moving virtual camera, a 1080p MP4 in about a minute, billed for its compute only. Pick it for 'animated video', 'motion graphics', 'kinetic text', 'text animation', 'logo animation', 'logo intro', 'animated explainer' asks. recipe: 'type' (every line its own animated card, no picture needed), 'logo' (the logo assembles out of dots and settles, the name and tagline land under it), 'launch' (30s 16:9), 'product' (15s), 'social' (15s 9:16). Or `spec`, the whole film as JSON (help:true returns the language). STEPS: without confirm it is a FREE quote that gives both prices (length, the music's tempo; the spec in structuredContent): say both; preview:true is a 1-credit contact sheet of the still version; confirm:true renders. You may write `moves` yourself, or the film's own planner writes them for a few credits. Only the user's or the brand's words appear: never invent a claim, number or review. projectUrl = a past film's url returns its saved spec to edit and render again.",
|
|
21987
21987
|
inputSchema: {
|
|
21988
21988
|
recipe: z.enum(['type', 'logo', 'launch', 'product', 'social']).optional().describe('type, logo, launch, product or social; help:true lists the recipes'),
|
|
21989
21989
|
hook: z.string().optional().describe("opening line; '*word*' = italic accent"),
|
|
@@ -21998,6 +21998,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
21998
21998
|
music: z.string().optional().describe("'off' = silent"),
|
|
21999
21999
|
theme: z.enum(['light', 'dark']).optional().describe("logo: dark for a light logo"),
|
|
22000
22000
|
energy: z.number().optional().describe('how much the film keeps moving, 0 to 1; 0 films exactly what is written'),
|
|
22001
|
+
moving: z.boolean().optional().describe('true: one thing physically happens in each picture, filmed by a video model. false: stills moved by a virtual camera, far cheaper. Default true for launch, product, social'),
|
|
22002
|
+
moves: z.array(z.object({ scene: z.string().describe('scene id'), action: z.string().describe('the one thing that physically happens, in plain words'), still: z.string().optional().describe('a picture to animate, when not the one in the scene'), stillPrompt: z.string().optional().describe('a picture to paint first, for a scene with none'), layer: z.string().optional().describe("the picture layer's id, when the scene has several"), video: z.string().optional().describe('a shot already filmed: kept, and not paid for again'), matchNext: z.boolean().optional().describe("the shot ends on the next scene's picture") })).optional().describe('per scene, what happens in its picture; omit and the planner writes them'),
|
|
22003
|
+
brief: z.string().optional().describe('what the film is about, for the planner'),
|
|
22004
|
+
model: z.string().optional().describe('image-to-video model id from hermoso_capabilities; default is the best value for short shots'),
|
|
22005
|
+
resolution: z.string().optional().describe('the moving shots: 720p or 1080p (default) where the model has them'),
|
|
22001
22006
|
spec: z.object({}).passthrough().optional().describe('the whole film as JSON, from a quote or a saved film; help:true shows the spec language'),
|
|
22002
22007
|
projectUrl: z.string().optional().describe("a past film's url; returns its saved spec to edit and render again"),
|
|
22003
22008
|
help: z.boolean().optional().describe('true returns the spec language and recipes; renders nothing'),
|
|
@@ -22023,15 +22028,31 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22023
22028
|
const name = String(a.name || b.name || '').slice(0, 40), brandLogo = logo || b.logo || '';
|
|
22024
22029
|
let spec = a.spec && typeof a.spec === 'object' ? a.spec : null, music = typeof a.music === 'string' && a.music.trim() ? a.music.trim().slice(0, 300) : '';
|
|
22025
22030
|
let credits = null, previewCredits = null, bpm = null;
|
|
22031
|
+
// the moving version is the default wherever a film has pictures to move; a spec already holding filmed shots stays as it is unless asked, so changing a word does not re-buy them
|
|
22032
|
+
const hasClips = Array.isArray(spec?.scenes) && spec.scenes.some((s) => Array.isArray(s?.layers) && s.layers.some((l) => l && l.type === 'clip'));
|
|
22033
|
+
const wantMoving = a.moving ?? (a.preview ? false : Array.isArray(a.moves) && a.moves.length ? true : spec ? !hasClips : a.recipe === 'launch' || a.recipe === 'product' || a.recipe === 'social');
|
|
22026
22034
|
if (!spec) {
|
|
22027
22035
|
if (!a.recipe) throw Object.assign(new Error("Pass a recipe ('type', 'logo', 'launch', 'product' or 'social') with words, or a whole spec."), { status: 400, _userInput: true });
|
|
22028
|
-
const d = await apiPost('/api/motion/build', { recipe: a.recipe, size: a.size || null, seconds: a.seconds ?? null, mood: a.mood || null, theme: a.theme || null, ...(a.energy != null ? { energy: a.energy } : {}), brand: { name, palette: pal, logo: brandLogo ? 'yes' : '' }, words: { hook: a.hook || '', lines: a.lines || [], tagline: a.tagline || '' }, images });
|
|
22036
|
+
const d = await apiPost('/api/motion/build', { ...(wantMoving ? { moving: true } : {}), recipe: a.recipe, size: a.size || null, seconds: a.seconds ?? null, mood: a.mood || null, theme: a.theme || null, ...(a.energy != null ? { energy: a.energy } : {}), brand: { name, palette: pal, logo: brandLogo ? 'yes' : '' }, words: { hook: a.hook || '', lines: a.lines || [], tagline: a.tagline || '' }, images });
|
|
22029
22037
|
spec = d.spec; if (!music) music = d.music || ''; credits = d.credits; previewCredits = d.previewCredits; bpm = d.bpm;
|
|
22030
22038
|
}
|
|
22031
22039
|
const film = { config: { template: 'motion', spec, ...(music ? { music } : {}) }, brand: { name, domain: b.domain || '', logo: brandLogo, palette: pal } };
|
|
22032
22040
|
if (credits == null) { const q = await quoteJob('templatead', film); credits = q?.credits ?? null; if (q?.refused) throw Object.assign(new Error(`The film was refused before anything rendered (free): ${q.refused}`), { status: 400, _userInput: true }); }
|
|
22033
22041
|
const secs = Array.isArray(spec?.scenes) ? +spec.scenes.reduce((x, s) => x + (+s.seconds || 0), 0).toFixed(2) : null;
|
|
22034
22042
|
const what = `${secs != null ? secs + 's, ' : ''}${spec?.size || '16:9'}, ${Array.isArray(spec?.scenes) ? spec.scenes.length : '?'} scenes${music && music !== 'off' ? `, music "${music}"${bpm ? ` at ${bpm} BPM` : ''}` : ''}`;
|
|
22043
|
+
if (wantMoving) {
|
|
22044
|
+
const mv = { ...(a.model ? { model: a.model } : {}), ...(a.resolution ? { resolution: a.resolution } : {}) };
|
|
22045
|
+
const q = await apiPost('/api/motion/moving-quote', { spec, music, ...(Array.isArray(a.moves) && a.moves.length ? { moves: a.moves } : {}), ...mv });
|
|
22046
|
+
if (q?.moving) {
|
|
22047
|
+
if (!a.confirm) return ok(`QUOTE (nothing rendered or charged): with moving pictures this film is about ${q.credits} credits (${what}; ${q.moving} moving shot${q.moving === 1 ? '' : 's'} on ${q.model} at ${q.resolution}). With still pictures moved by a virtual camera it is ${q.stillFilmCredits} credits (moving:false). ${PRICE_ESTIMATE_LINE} Tell the user both prices, then call again with the same arguments and confirm:true (pass structuredContent.spec as spec to keep this exact film).`, { credits: q.credits, stillFilmCredits: q.stillFilmCredits, moving: q.moving, shots: q.scenes, lines: q.lines, model: q.model, resolution: q.resolution, seconds: secs, music, bpm, spec });
|
|
22048
|
+
let moves = Array.isArray(a.moves) && a.moves.length ? a.moves : null, planned = 0;
|
|
22049
|
+
if (!moves) { const p = await apiPost('/api/motion/moves', { spec, brief: a.brief || [a.hook, ...(a.lines || []), a.tagline].filter(Boolean).join(' / '), brand: { name } }); moves = p?.moves; planned = +p?.planCreditsUsed || 0; if (!Array.isArray(moves) || !moves.length) throw new Error('The moves for this film could not be written. Nothing else was charged. Try again, or pass moves yourself.'); }
|
|
22050
|
+
const r = await renderJob('motionmoving', { ...film, moves, ...mv }, 'MCP moving motion film');
|
|
22051
|
+
const raw = r?.raw || {};
|
|
22052
|
+
const notes = Array.isArray(raw.notes) && raw.notes.length ? `\nNOTE: ${raw.notes.join('; ')}` : '';
|
|
22053
|
+
return okVideo(`Motion film ready (${what}, ${raw.movingShots ?? moves.length} moving shot${(raw.movingShots ?? moves.length) === 1 ? '' : 's'}): ${r.url} [job ${r.jobId}]${planned ? ` Writing the moves was ${planned} credits.` : ''}${notes}\nIts spec is saved with the shots in it: make_motion_film({projectUrl: this url}) opens it, and changing the words costs only the render.`, r);
|
|
22054
|
+
}
|
|
22055
|
+
}
|
|
22035
22056
|
if (a.preview) {
|
|
22036
22057
|
const r = await renderJob('templatead', { ...film, config: { ...film.config, preview: true } }, 'MCP motion contact sheet');
|
|
22037
22058
|
if (r?.stillRendering) return ok(stillMsg(r), r);
|
|
@@ -22397,7 +22418,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22397
22418
|
};
|
|
22398
22419
|
server.registerTool('make_series', {
|
|
22399
22420
|
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.',
|
|
22421
|
+
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
22422
|
inputSchema: {
|
|
22402
22423
|
topic: z.string().optional().describe('what the series is about, in the user\'s words'),
|
|
22403
22424
|
episodes: z.number().optional().describe('how many episodes, 1-10 (default 1)'),
|
|
@@ -22411,6 +22432,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22411
22432
|
format: z.enum(['explainer', 'faceless_channel']).optional().describe("renderer lane: 'explainer' (default; exact length) or 'faceless_channel' (2K, whole 10s blocks)"),
|
|
22412
22433
|
planOnly: z.boolean().optional().describe('write the episode plan and style card, render nothing (a small planning charge)'),
|
|
22413
22434
|
plan: z.any().optional().describe('the `plan` a planOnly call returned, to run it without planning again'),
|
|
22435
|
+
moreOf: z.any().optional().describe('MORE EPISODES: the `plan` an earlier call returned. Keeps its style and length, writes new episodes only'),
|
|
22414
22436
|
confirm: z.boolean().optional().describe('REQUIRED true to start rendering (real spend). Without it you get a free quote'),
|
|
22415
22437
|
jobIds: z.array(z.string()).optional().describe('SCHEDULE MODE: the episode job ids to schedule (finished ones are booked)'),
|
|
22416
22438
|
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 +22466,79 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22444
22466
|
const titles = (a.episodeTitles || []).map((t) => String(t || '').trim()).filter(Boolean);
|
|
22445
22467
|
const n = titles.length || Math.round(+a.episodes || 1);
|
|
22446
22468
|
if (n < 1 || n > 10) throw new Error('A series is 1 to 10 episodes per run. Nothing was charged.');
|
|
22447
|
-
const
|
|
22469
|
+
const prior = a.moreOf && typeof a.moreOf === 'object' ? a.moreOf : null; // more of an earlier series: its plan comes back
|
|
22470
|
+
const secs = Math.round(+a.durationSeconds || +prior?.durationSeconds || 30);
|
|
22448
22471
|
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
22472
|
let vo = {}; try { vo = (await apiGet('/api/generate/status'))?.voVoices || {}; } catch {}
|
|
22450
22473
|
// 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);
|
|
22474
|
+
const planEst = a.reference && !prior ? 14 : 6;
|
|
22475
|
+
const probe = seriesEpisodeInput(topic, { durationSeconds: secs, style: prior?.style || {}, reference: prior?.reference || null }, { episode: 1, title: topic, brief: '' }, n, a, vo);
|
|
22453
22476
|
const q = await quoteJob('explainer', probe);
|
|
22454
22477
|
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})`;
|
|
22478
|
+
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
22479
|
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
22480
|
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 || '' });
|
|
22481
|
+
if (!plan) plan = await apiPost('/api/series/plan', { topic, episodes: n, episodeTitles: titles, durationSeconds: secs, reference: a.reference || '', ...(prior ? { moreOf: prior } : {}) });
|
|
22459
22482
|
const eps = (plan.episodes || []).slice(0, n);
|
|
22460
22483
|
const list = eps.map((e) => `${e.episode}. ${e.title}`).join('\n');
|
|
22461
22484
|
const s = plan.style || {};
|
|
22462
22485
|
const styleLine = s.look ? `Style: ${s.look}${s.captions ? `; captions on (${s.captionLook || 'subtitles'})` : '; no captions'}; ${s.aspectRatio || '9:16'}.` : '';
|
|
22463
22486
|
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 });
|
|
22487
|
+
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
22488
|
const jobs = [];
|
|
22466
22489
|
for (const e of eps) {
|
|
22467
|
-
const
|
|
22490
|
+
const total = plan.totalEpisodes || n; // a continued series keeps counting: episode 5 of 6, not 2 of 2
|
|
22491
|
+
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
22492
|
jobs.push({ episode: e.episode, title: e.title, jobId: j?.id || null });
|
|
22469
22493
|
}
|
|
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 });
|
|
22494
|
+
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 });
|
|
22495
|
+
}));
|
|
22496
|
+
|
|
22497
|
+
// THE EFFECTS, FOR AGENTS (2026-11-01; held since the 2026-10-09 parity batch): the same presets as the /tools effect pages, the
|
|
22498
|
+
// same two jobs (still, then clip), each with its own free quote and its own confirm. The server writes the prompt from the id.
|
|
22499
|
+
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.`; };
|
|
22500
|
+
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'}]`;
|
|
22501
|
+
server.registerTool('make_effect', {
|
|
22502
|
+
title: 'Run a one-click effect on a photo',
|
|
22503
|
+
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.",
|
|
22504
|
+
inputSchema: {
|
|
22505
|
+
effect: z.string().optional().describe('effect id from the list'),
|
|
22506
|
+
images: z.array(z.string()).optional().describe('its pictures, in the order listed: URL, local path or Library id'),
|
|
22507
|
+
options: z.record(z.string(), z.string()).optional().describe('its listed options, e.g. {material:"knit"}; "custom" means use `describe`'),
|
|
22508
|
+
text: z.string().optional().describe('a line printed exactly, on an effect that takes one'),
|
|
22509
|
+
describe: z.string().optional().describe('your own words: added to the look, or the custom choice'),
|
|
22510
|
+
aspectRatio: z.string().optional().describe('one of its listed shapes'),
|
|
22511
|
+
still: z.string().optional().describe('CLIP STEP: the finished picture to animate'),
|
|
22512
|
+
speech: z.string().optional().describe('CLIP STEP: a line said in the clip, on an effect that speaks'),
|
|
22513
|
+
confirm: z.boolean().optional().describe('true = run it (real spend); without it, a free quote'),
|
|
22514
|
+
},
|
|
22515
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
22516
|
+
}, wrap(async (a) => {
|
|
22517
|
+
if (!a.effect) {
|
|
22518
|
+
const list = (await apiGet('/api/effects'))?.effects || [];
|
|
22519
|
+
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 });
|
|
22520
|
+
}
|
|
22521
|
+
const row = (await apiGet('/api/effects', { id: a.effect }))?.effect;
|
|
22522
|
+
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.`);
|
|
22523
|
+
const describe = String(a.describe || '').trim();
|
|
22524
|
+
if (a.still) {
|
|
22525
|
+
if (!row.moves) throw new Error(`${row.title} makes a picture only. Nothing was charged.`);
|
|
22526
|
+
const refImage = await toRef(a.still);
|
|
22527
|
+
if (!refImage) throw new Error('The `still` could not be read: pass the picture\'s URL or a Library id. Nothing was charged.');
|
|
22528
|
+
const input = { effect: row.id, refImage, ...(a.aspectRatio ? { aspectRatio: a.aspectRatio } : {}), ...(describe ? { effectDescribe: describe } : {}), ...(a.speech ? { effectSpeech: String(a.speech) } : {}) };
|
|
22529
|
+
if (!a.confirm) return ok(effectQuote(await quoteJob('video', input), `a 5-second ${row.title} clip`));
|
|
22530
|
+
const r = await renderJob('video', input, `Effect clip · ${row.title}`);
|
|
22531
|
+
if (r?.stillRendering) return ok(`${row.title} clip still rendering [job ${r.jobId}]. Call get_job with that id.`, r);
|
|
22532
|
+
return okVideo(`${row.title} clip ready: ${r.url} [job ${r.jobId}]`, r);
|
|
22533
|
+
}
|
|
22534
|
+
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 });
|
|
22535
|
+
const refs = (await Promise.all((a.images || []).map(toRef))).filter(Boolean);
|
|
22536
|
+
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.');
|
|
22537
|
+
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 } : {}) };
|
|
22538
|
+
if (!a.confirm) return ok(effectQuote(await quoteJob('image', input), `this ${row.title} picture`));
|
|
22539
|
+
const r = await renderJob('image', input, `Effect · ${row.title}`);
|
|
22540
|
+
if (r?.stillRendering) return ok(`${row.title} still rendering [job ${r.jobId}]. Call get_job with that id.`, r);
|
|
22541
|
+
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
22542
|
}));
|
|
22472
22543
|
|
|
22473
22544
|
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.493",
|
|
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"
|