hermoso 0.1.30 → 0.1.33
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/mcp/tools.mjs +57 -5
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -62,7 +62,7 @@ const channelOutcomeLine = (res) => {
|
|
|
62
62
|
};
|
|
63
63
|
const stillMsg = (r) => `Still rendering — job ${r.jobId}. This is NORMAL: video renders take 1–3 minutes and each get_job call waits up to ~45s, so it can take several calls. Keep calling get_job with this id until status is done or error — do NOT ask the user whether to keep waiting, and do NOT re-fire the render on another model (that double-charges). Only surface a problem after ~6 minutes of polling.`;
|
|
64
64
|
const okVideo = async (text, r) => {
|
|
65
|
-
if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; return { content: [{ type: 'text', text: p ?
|
|
65
|
+
if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; const t = text + qaLine(r); return { content: [{ type: 'text', text: p ? t + '\n(first frame attached — open the URL for the full video)' : t }, ...(p ? [p] : [])], structuredContent: r ?? {} }; };
|
|
66
66
|
|
|
67
67
|
// ── CAPABILITY MAP — the FULL agent surface, four categories. Appended to hermoso_capabilities so an agent that
|
|
68
68
|
// probes once learns everything Hermoso does (not just the models): ad spy, create, raw playground, account. Keep
|
|
@@ -172,6 +172,14 @@ async function renderJob(type, input, label) {
|
|
|
172
172
|
// bill always follows the model that RAN. An agent that asked for one model and reads a one-line "ready" reply would
|
|
173
173
|
// otherwise never learn it got another, so the note rides the reply text every render tool prints.
|
|
174
174
|
const switchNote = (r) => { const n = r?.raw?.modelNote || r?.modelNote; return n ? `\n⚠ ${n}` : ''; };
|
|
175
|
+
// THE VISION-QA READ-BACK (2026-08-03). The server WATCHES every generatively-rendered clip before it reports it
|
|
176
|
+
// done — free ffmpeg cadence/freeze probes plus ONE vision call over frames sampled at the hook, either side of
|
|
177
|
+
// each cut, and the tail — and puts what it saw on the result as `qaNote` (see vision-qa.mjs). An agent reading a
|
|
178
|
+
// one-line "ready" reply would otherwise never learn that the register flipped at an act seam or that the model
|
|
179
|
+
// painted its own end card, so the note rides the reply of EVERY video tool via okVideo. It REPORTS ONLY: nothing
|
|
180
|
+
// was re-rendered and nothing extra was charged, and a check it could not settle says "could not tell" rather
|
|
181
|
+
// than passing by default.
|
|
182
|
+
const qaLine = (r) => { const n = r?.raw?.qaNote; return n ? `\n${n}` : ''; };
|
|
175
183
|
|
|
176
184
|
// Shared outputSchema fields for the job-based render tools (the renderJob result that becomes structuredContent).
|
|
177
185
|
// Every field is optional so validation can never fail on a sparse or still-rendering result.
|
|
@@ -1074,7 +1082,7 @@ export function registerTools(server) {
|
|
|
1074
1082
|
// no need for one edge case just for Facebook."
|
|
1075
1083
|
server.registerTool('schedule_post', {
|
|
1076
1084
|
title: 'Schedule a post for later',
|
|
1077
|
-
description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, google_business). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word, which is what a Pin headline used to be. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.',
|
|
1085
|
+
description: 'Queue a post to go out at a future time, to one or more connected channels at once (facebook, instagram, threads, tiktok, youtube, linkedin, x, pinterest, google_business). A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order — never schedule just its first slide. This is how you run a content calendar: schedule now, and Hermoso publishes at the time you set — you do not need to be around. Either name the exact time in `at`, or pass `useQueue:true` to take the brand’s next free POSTING SLOT (its saved posting times, skipping any already occupied) — that is what “just queue it” means and it saves the user picking a minute. Pass a Hermoso render URL as imageUrl/videoUrl (or an upload_file URL for external media). PINTEREST AND YOUTUBE ALSO SHOW A TITLE: pass `title` (max 100 chars) — omit it and Hermoso derives one from the caption\u2019s first sentence rather than truncating the caption mid-word, which is what a Pin headline used to be. A YOUTUBE ITEM’S CAPTION IS ITS TITLE, NOT ITS DESCRIPTION: pass `description` (≤5000 chars) for the box under the video — the links, the CTA and everything YouTube search reads — plus `tags` (up to 30). Omit them and the upload lands with an empty description, which is not recoverable by the time anyone notices. Use `captions` to give each channel its own wording; anything not listed falls back to `message`. PER-CHANNEL SETTINGS, all carried straight through to the real publisher: TIKTOK takes the paid-partnership disclosure (`brandedContent`) and the own-brand one (`yourBrand`) — set them whenever the post is commercial, they are compliance declarations — plus `disableComment` and, on a video, `disableDuet` / `disableStitch` / `coverTimestampMs`. GOOGLE BUSINESS takes `topicType` (STANDARD / EVENT / OFFER / ALERT) with `event` and `offer`, and a real `actionType` button instead of the hard-coded Learn more. X takes a whole `thread`, a `poll`, `replySettings` and `madeWithAi`. Channels are attempted INDEPENDENTLY, so one failing channel never blocks the others. SOME CHANNELS MUST BE TOLD WHICH ACCOUNT, and Hermoso never guesses one: a Pinterest pin needs `boardId` (list_pinterest_boards) or it is refused outright; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages) and without it the post goes to the connected person’s own profile; a brand with more than one connected Facebook Page needs `pageId` (list_meta_pages) and an account managing more than one Google Business listing needs `locationId` (list_business_locations) — resolve those FIRST and let the user pick, because with several to choose from and no id the post is refused when it fires, hours later. A scheduled post GOES LIVE PUBLICLY by default on every channel — that is what scheduling means, and nothing is ever quietly downgraded to a draft or an unlisted upload. If the user genuinely wants something staged instead, set `visibility` (or `visibilityByChannel` for just one channel): ‘public’ (default, live) · ‘unlisted’ (YouTube only — link-only) · ‘private’ (YouTube private, or TikTok posted SELF_ONLY) · ‘draft’ (TikTok drafts, or an unpublished Facebook Page post for a human to publish). Ask for a weaker visibility only if the user asked for one. If a channel cannot do the visibility requested, the call is REFUSED right now with the reason, rather than posting something weaker later. Most channels publish publicly and nothing else: only YouTube has unlisted/private, only TikTok has private/draft, and only Facebook has draft.',
|
|
1078
1086
|
inputSchema: {
|
|
1079
1087
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business'])).describe('one or more channels to post to at that time'),
|
|
1080
1088
|
at: z.string().optional().describe('when to post — ISO timestamp (2026-08-01T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out. Give this OR useQueue, never both.'),
|
|
@@ -1096,6 +1104,26 @@ export function registerTools(server) {
|
|
|
1096
1104
|
tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
|
|
1097
1105
|
altText: z.string().optional().describe('ACCESSIBILITY — the screen-reader description of the attached image. X (max 1000 characters), Pinterest (max 500) and LinkedIn COMPANY PAGE posts each attach it to the picture; the schedule is REFUSED if it is longer than the tightest of the channels on it, rather than truncated on the way out. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. A LinkedIn post to a PERSONAL profile carries none \u2014 LinkedIn\u2019s member posting API has no alt-text field at all.'),
|
|
1098
1106
|
link: z.string().optional().describe('a link to attach (Facebook)'),
|
|
1107
|
+
// ── PER-CHANNEL PUBLISH SETTINGS (server-side SCHED_META_FIELDS, 2026-08-03). Every one of these has been on
|
|
1108
|
+
// the immediate publish helper since the day it was written — tiktokPost's disclosures and interaction
|
|
1109
|
+
// toggles, gbpCreatePost's topic type / event / offer / CTA, xPost's thread, poll and reply settings — and the
|
|
1110
|
+
// QUEUE could carry none of them, so a post composed with a paid-partnership declaration, an Offer or a poll
|
|
1111
|
+
// became a plain post the moment it was scheduled instead of sent. Bounds and enums are the vendors' own, read
|
|
1112
|
+
// 2026-08-03: TikTok's video/photo Post Info tables, Google's v4 LocalPost reference, docs.x.com create-post.
|
|
1113
|
+
brandedContent: z.boolean().optional().describe('TIKTOK — the PAID-PARTNERSHIP disclosure (TikTok’s brand_content_toggle): true when this post promotes a THIRD-PARTY business. It is a compliance declaration, not a preference — set it whenever the post is sponsored. TikTok only accepts branded content on a public or friends-only post, so it cannot ride a private/SELF_ONLY or draft TikTok item and the schedule is refused with the reason.'),
|
|
1114
|
+
yourBrand: z.boolean().optional().describe('TIKTOK — the OWN-BRAND disclosure (brand_organic_toggle): true when the post promotes the creator’s own business. TikTok asks for at least one of this and brandedContent once a post is commercial.'),
|
|
1115
|
+
disableComment: z.boolean().optional().describe('TIKTOK — turn comments off on this post.'),
|
|
1116
|
+
disableDuet: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Duets. TikTok’s photo-post API has no Duets, so this is refused on a photo/slideshow item rather than silently dropped.'),
|
|
1117
|
+
disableStitch: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Stitches. Same photo-post rule as disableDuet.'),
|
|
1118
|
+
coverTimestampMs: z.number().optional().describe('TIKTOK VIDEO ONLY — which frame TikTok uses as the cover, in milliseconds from the start. Omit and TikTok uses the first frame.'),
|
|
1119
|
+
topicType: z.enum(['STANDARD', 'EVENT', 'OFFER', 'ALERT']).optional().describe('GOOGLE BUSINESS — the KIND of Post. STANDARD is the default; EVENT and OFFER both REQUIRE `event` (title + start date), and OFFER also takes `offer`.'),
|
|
1120
|
+
actionType: z.enum(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']).optional().describe('GOOGLE BUSINESS — the call-to-action button. Every button except CALL needs `link` (CALL dials the number on the listing and takes none). Google IGNORES the button link on an OFFER post — put the destination in offer.redeemOnlineUrl. Omit and a post carrying a link gets LEARN_MORE.'),
|
|
1121
|
+
event: z.object({ title: z.string().optional(), startDate: z.string().optional(), startTime: z.string().optional(), endDate: z.string().optional(), endTime: z.string().optional() }).optional().describe('GOOGLE BUSINESS — required for an EVENT or OFFER post: {title, startDate:"YYYY-MM-DD", endDate, startTime:"HH:MM", endTime}. `title` is the EVENT’s headline, a different thing from the post `title` (which is the Pinterest/YouTube one). Google documents its TimeInterval as needing all four date/time parts to be valid, so send the times whenever you know them.'),
|
|
1122
|
+
offer: z.object({ couponCode: z.string().optional(), redeemOnlineUrl: z.string().optional(), termsConditions: z.string().optional() }).optional().describe('GOOGLE BUSINESS — OFFER posts only: {couponCode, redeemOnlineUrl, termsConditions}. redeemOnlineUrl is where an offer actually sends people, since the button link is ignored on an Offer.'),
|
|
1123
|
+
thread: z.array(z.string()).optional().describe('X — publish a THREAD, one entry per post, each replying to the one before (at most 25, each ≤280 characters). It REPLACES the X caption: with a thread set, `message`/`captions.x` is not sent to X at all. A thread cannot carry a poll.'),
|
|
1124
|
+
poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — attach a poll: {options:["…","…"], durationMinutes}. 2–4 options of at most 25 characters each; voting runs 5–10080 minutes (7 days), default 1440. X makes a poll MUTUALLY EXCLUSIVE with media, so an item carrying an image or video is refused — schedule the poll as its own X-only post.'),
|
|
1125
|
+
replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply. Omit for everyone, which is the right default for a brand post.'),
|
|
1126
|
+
madeWithAi: z.boolean().optional().describe('X — X’s AI-media label on this post. Opt-in: X treats it as the poster’s own claim about their media, so it is never set on the user’s behalf.'),
|
|
1099
1127
|
// ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
|
|
1100
1128
|
// to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
|
|
1101
1129
|
boardId: z.string().optional().describe('PINTEREST — REQUIRED whenever pinterest is a channel: the board the Pin goes on, from list_pinterest_boards. The user picks it; a Pin on the wrong board is a public mistake. Scheduling pinterest without one is refused immediately.'),
|
|
@@ -1152,6 +1180,22 @@ export function registerTools(server) {
|
|
|
1152
1180
|
tags: z.array(z.string()).optional().describe('YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.'),
|
|
1153
1181
|
altText: z.string().optional().describe('ACCESSIBILITY — replace the image\u2019s screen-reader description (used by X, Pinterest and LinkedIn company Pages); "" clears it.'),
|
|
1154
1182
|
link: z.string().optional(),
|
|
1183
|
+
// The per-channel publish settings (SCHED_META_FIELDS), on the same omitted-means-keep rule as everything else
|
|
1184
|
+
// here: send only what changes. A whole-record field (`event`, `offer`, `poll`, `thread`) REPLACES the record.
|
|
1185
|
+
brandedContent: z.boolean().optional().describe('TIKTOK — the paid-partnership disclosure; false turns it off.'),
|
|
1186
|
+
yourBrand: z.boolean().optional().describe('TIKTOK — the own-brand disclosure; false turns it off.'),
|
|
1187
|
+
disableComment: z.boolean().optional().describe('TIKTOK — comments off on this post.'),
|
|
1188
|
+
disableDuet: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Duets.'),
|
|
1189
|
+
disableStitch: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Stitches.'),
|
|
1190
|
+
coverTimestampMs: z.number().optional().describe('TIKTOK VIDEO ONLY — cover frame in milliseconds.'),
|
|
1191
|
+
topicType: z.enum(['STANDARD', 'EVENT', 'OFFER', 'ALERT']).optional().describe('GOOGLE BUSINESS — the kind of Post; EVENT and OFFER both require `event`.'),
|
|
1192
|
+
actionType: z.enum(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']).optional().describe('GOOGLE BUSINESS — the call-to-action button; "" clears it.'),
|
|
1193
|
+
event: z.object({ title: z.string().optional(), startDate: z.string().optional(), startTime: z.string().optional(), endDate: z.string().optional(), endTime: z.string().optional() }).optional().describe('GOOGLE BUSINESS — replaces the whole event record {title, startDate, startTime, endDate, endTime}.'),
|
|
1194
|
+
offer: z.object({ couponCode: z.string().optional(), redeemOnlineUrl: z.string().optional(), termsConditions: z.string().optional() }).optional().describe('GOOGLE BUSINESS — replaces the whole offer record {couponCode, redeemOnlineUrl, termsConditions}.'),
|
|
1195
|
+
thread: z.array(z.string()).optional().describe('X — replaces the WHOLE thread; an explicit [] drops back to a single post using the caption.'),
|
|
1196
|
+
poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — replaces the poll; an empty options list removes it.'),
|
|
1197
|
+
replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply; "" goes back to everyone.'),
|
|
1198
|
+
madeWithAi: z.boolean().optional().describe('X — the AI-media label; false turns it off.'),
|
|
1155
1199
|
boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
|
|
1156
1200
|
linkedinOrganizationId: z.string().optional().describe('LINKEDIN — target a different company Page, or "" to post as the connected person instead'),
|
|
1157
1201
|
pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)'),
|
|
@@ -4393,7 +4437,7 @@ export function registerTools(server) {
|
|
|
4393
4437
|
|
|
4394
4438
|
server.registerTool('make_explainer', {
|
|
4395
4439
|
title: 'Make an explainer video',
|
|
4396
|
-
description: "Turn a TOPIC into a finished narrated, captioned explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, adds gentle Ken-Burns motion, then composites the on-screen text + end card with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame,
|
|
4440
|
+
description: "Turn a TOPIC into a finished narrated, captioned explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, adds gentle Ken-Burns motion, then composites the on-screen text + end card with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.",
|
|
4397
4441
|
inputSchema: {
|
|
4398
4442
|
topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
|
|
4399
4443
|
durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
|
|
@@ -4404,15 +4448,23 @@ export function registerTools(server) {
|
|
|
4404
4448
|
voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
|
|
4405
4449
|
captions: z.boolean().optional().describe('burn on-screen text (default true)'),
|
|
4406
4450
|
subtitles: z.boolean().optional().describe('burn CAPS SUBTITLES timed to the narration instead of one held key point per section (default false). Free — no extra render, no extra credits.'),
|
|
4451
|
+
music: z.string().optional().describe("music bed under the narration, ducked under the voice. Omit and the KIDS and FAIRYTALE channels get their recommended bed from the curated library FREE (and simply ship dry when no track is on file); the other channels are dry. 'off' forces silence. NAME A MOOD — upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic — and a bespoke instrumental is COMPOSED for this film, which costs a small flat fee on top. hermoso_capabilities reports the exact figure as explainerMusicCredits; quote it before you pick a mood."),
|
|
4452
|
+
upscale: z.number().optional().describe("optional FINAL upscale — 2 doubles each side, 4 quadruples. Captions and the end card are burned BEFORE it so they upscale with the frame. It is priced BY LENGTH and it is the expensive part — several times the cost of rendering the film itself. hermoso_capabilities reports the exact figures per length as explainerUpscaleCredits. Never turn it on unasked: quote the number and let the user choose."),
|
|
4407
4453
|
endCard: z.boolean().optional().describe('append the branded end card (default true)'),
|
|
4408
4454
|
brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
|
|
4409
4455
|
},
|
|
4410
4456
|
outputSchema: { ...JOB_OUT },
|
|
4411
4457
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
4412
4458
|
}, wrap(async (a) => {
|
|
4413
|
-
const r = await renderJob('explainer', { topic: a.topic, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, style: a.style, channel: a.channel, frameDensity: a.frameDensity, subtitles: a.subtitles, voice: a.voice, captions: a.captions, endCard: a.endCard, brandName: a.brandName }, 'MCP explainer');
|
|
4459
|
+
const r = await renderJob('explainer', { topic: a.topic, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, style: a.style, channel: a.channel, frameDensity: a.frameDensity, subtitles: a.subtitles, voice: a.voice, captions: a.captions, music: a.music, upscale: a.upscale, endCard: a.endCard, brandName: a.brandName }, 'MCP explainer');
|
|
4414
4460
|
const d = r?.raw || {};
|
|
4415
|
-
|
|
4461
|
+
// MUSIC + UPSCALE are REPORTED off the result, never assumed from the ask: a composed bed that failed to mux
|
|
4462
|
+
// ships the film dry and is not billed, and a failed upscale delivers at the render resolution. `musicNote`
|
|
4463
|
+
// fires only when a channel that reads best WITH a bed shipped without one, and it carries the price.
|
|
4464
|
+
const fin = `${d.music ? ` · ${d.music.source === 'composed' ? 'composed' : 'library'} ${d.music.mood} music bed` : ''}${d.upscaled ? ` · upscaled ${d.upscaled}×` : ''}`;
|
|
4465
|
+
// The length is whatever ffprobe measured on the DELIVERED file, and `null` means it could not be measured — print
|
|
4466
|
+
// the `—` placeholder rather than inventing a number from the ask (the read-back law: only a measurement ships).
|
|
4467
|
+
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 && d.style !== 'cinematic' ? ` 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.upscaleNote ? `\n⚠ ${d.upscaleNote}` : ''}`, r);
|
|
4416
4468
|
}));
|
|
4417
4469
|
|
|
4418
4470
|
server.registerTool('product_sizzle', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI — spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic — then publish to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest and build & manage the ad campaigns behind them on Meta, Google Ads, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads. MCP server (262 tools), CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
|
|
6
6
|
"type": "module",
|