hermoso 0.1.441 → 0.1.444

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 (3) hide show
  1. package/README.md +2 -2
  2. package/mcp/tools.mjs +156 -1
  3. 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
- **929 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **932 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 929 tools cover
245
+ ### What the 932 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
@@ -2850,7 +2850,7 @@ export const WITHHELD_FROM_DIRECTORY = new Set([
2850
2850
  'buy_credits', 'upgrade_plan', 'set_auto_reload',
2851
2851
  'generate_image', 'generate_video', 'generate_avatar', 'generate_voice', 'render_ad', 'plan_ad', 'plan_variations',
2852
2852
  'make_thumbnail', 'make_explainer', 'product_sizzle', 'dub_video', 'change_voice', 'edit_video', 'recast_motion',
2853
- 'upscale_video', 'reframe_video', 'multiply_ad', 'clone_static', 'remix_static', 'stitch_video', 'fix_beat',
2853
+ 'upscale_video', 'reframe_video', 'insert_scene', 'multiply_ad', 'clone_static', 'remix_static', 'stitch_video', 'fix_beat',
2854
2854
  'hook_variants',
2855
2855
  // 2026-09-25, after the directory review named "30+ model ids" and the prompt-only mode: the model catalog, the
2856
2856
  // saved cast (it exists to star a person in a render) and mine_angles (its `next` is a render call) point at the
@@ -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.",
@@ -21978,6 +22111,28 @@ function memoryNoteVerdict(text) {
21978
22111
  return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
21979
22112
  }));
21980
22113
 
22114
+ // INSERT A NEW SCENE (2026-10-26, server workInsertScene + lib/insert-scene.mjs): H3 Max insert-video adds a NEW scene
22115
+ // between two points of a clip and returns the whole video. Every vendor rule is checked before anything is charged.
22116
+ server.registerTool('insert_scene', {
22117
+ title: 'Insert a new scene into a video',
22118
+ description: "INSERT a newly generated scene into a clip you already have: the video plays up to startTime, the new scene (5 to 13 seconds) plays, then the original picks up again at resumeTime. Use for a product close-up, a reaction, a reveal or a missing beat in the middle of an ad without re-rendering the rest. resumeTime = startTime keeps every second of the original; a later resumeTime also cuts the footage in between. Describe the scene in prompt and/or attach up to 4 reference pictures (refImages) or 1 reference clip (refVideos). Clips up to 60 seconds; both times between 1.625 and 60 seconds. Paid render (billed per second of NEW scene); returns the whole video's URL.",
22119
+ inputSchema: {
22120
+ video: z.string().describe('the source video URL (a render, job result or list_library item)'),
22121
+ startTime: z.number().describe('seconds into the clip where the new scene starts (1.625-60)'),
22122
+ resumeTime: z.number().describe('seconds into the ORIGINAL clip where it picks up again after the new scene (2.625-60, at or after startTime)'),
22123
+ prompt: z.string().optional().describe('what happens in the new scene: the action, the subject, the camera'),
22124
+ seconds: z.number().optional().describe('length of the new scene, 5-13 seconds (default 5)'),
22125
+ refImages: z.array(z.string()).optional().describe('up to 4 picture URLs of what should appear (the product, a person, a place)'),
22126
+ refVideos: z.array(z.string()).optional().describe('up to 1 clip URL whose look or motion the scene follows'),
22127
+ resolution: z.enum(['480p', '720p']).optional().describe("default 720p (rendered at 768p); '480p' is cheaper"),
22128
+ },
22129
+ outputSchema: { ...JOB_OUT },
22130
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
22131
+ }, wrap(async ({ video, startTime, resumeTime, prompt, seconds, refImages, refVideos, resolution }) => {
22132
+ const r = await renderJob('insertscene', { video, startTime, resumeTime, ...(prompt ? { prompt } : {}), ...(seconds != null ? { seconds } : {}), ...(refImages && refImages.length ? { refImages } : {}), ...(refVideos && refVideos.length ? { refVideos } : {}), ...(resolution ? { resolution } : {}) }, `Insert a scene at ${startTime}s`);
22133
+ return okVideo(`Video with the new scene inserted at ${startTime}s: ${r.url}`, r);
22134
+ }));
22135
+
21981
22136
  server.registerTool('upscale_video', {
21982
22137
  title: 'Upscale video',
21983
22138
  description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL. A 480p Seedance 2.5 draft: finish_draft (same take, native 1080p). Two engines: the default ('standard') is the safe precision upscaler; engine:'flux' is the FLUX 3 video upscaler (1080p/2K/4K) with an optional mode:'creative' detail-enhancement pass — pick it when the user asks for the FLUX upscaler or wants added detail rather than a faithful enlargement.",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.441",
3
+ "version": "0.1.444",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI marketing agent and AI ad generator, run from your own AI agent. 929 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.",
5
+ "description": "AI marketing agent and AI ad generator, run from your own AI agent. 932 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"