hermoso 0.1.30 → 0.1.34

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 (2) hide show
  1. package/mcp/tools.mjs +74 -9
  2. 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 ? text + '\n(first frame attached — open the URL for the full video)' : text }, ...(p ? [p] : [])], structuredContent: r ?? {} }; };
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
@@ -167,11 +167,27 @@ async function renderJob(type, input, label) {
167
167
  }
168
168
  }
169
169
 
170
+ // TWO SHAPES REACH THESE READERS, AND ONLY ONE OF THEM HAS `.raw` (fixed 2026-08-03). `renderJob` returns
171
+ // `{jobId, url, model, raw:<result>}`. `get_job` returns the JOB — `{...job, url}` — whose payload sits at
172
+ // `job.result` (or `job.result.data`), so a reader that only knew `.raw` returned nothing for it. That is not an
173
+ // edge case: the hosted transport answers any render outliving one request with `stillRendering`, which makes
174
+ // resume-via-get_job THE ORDINARY delivery path for a long render, and it was exactly the path that dropped the
175
+ // verdict from the text a human reads (it survived only in `structuredContent`, which nothing prints). One
176
+ // resolver, so a note added to either reader inherits both shapes.
177
+ const renderPayload = (r) => (r?.raw ?? jobResult(r) ?? null);
170
178
  // A MODEL SUBSTITUTION IS NEVER SILENT (2026-08-01). The server sets `modelNote` on any render whose delivered model
171
179
  // is not the model the caller named — an entry-belt coercion, or an in-flight content-filter/quota fallback — and the
172
180
  // bill always follows the model that RAN. An agent that asked for one model and reads a one-line "ready" reply would
173
181
  // otherwise never learn it got another, so the note rides the reply text every render tool prints.
174
- const switchNote = (r) => { const n = r?.raw?.modelNote || r?.modelNote; return n ? `\n⚠ ${n}` : ''; };
182
+ const switchNote = (r) => { const n = renderPayload(r)?.modelNote || r?.modelNote; return n ? `\n⚠ ${n}` : ''; };
183
+ // THE VISION-QA READ-BACK (2026-08-03). The server WATCHES every generatively-rendered clip before it reports it
184
+ // done — free ffmpeg cadence/freeze probes plus ONE vision call over frames sampled at the hook, either side of
185
+ // each cut, and the tail — and puts what it saw on the result as `qaNote` (see vision-qa.mjs). An agent reading a
186
+ // one-line "ready" reply would otherwise never learn that the register flipped at an act seam or that the model
187
+ // painted its own end card, so the note rides the reply of EVERY video tool via okVideo. It REPORTS ONLY: nothing
188
+ // was re-rendered and nothing extra was charged, and a check it could not settle says "could not tell" rather
189
+ // than passing by default.
190
+ const qaLine = (r) => { const n = renderPayload(r)?.qaNote; return n ? `\n${n}` : ''; };
175
191
 
176
192
  // Shared outputSchema fields for the job-based render tools (the renderJob result that becomes structuredContent).
177
193
  // Every field is optional so validation can never fail on a sparse or still-rendering result.
@@ -1074,7 +1090,7 @@ export function registerTools(server) {
1074
1090
  // no need for one edge case just for Facebook."
1075
1091
  server.registerTool('schedule_post', {
1076
1092
  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.',
1093
+ 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
1094
  inputSchema: {
1079
1095
  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
1096
  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 +1112,26 @@ export function registerTools(server) {
1096
1112
  tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
1097
1113
  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
1114
  link: z.string().optional().describe('a link to attach (Facebook)'),
1115
+ // ── PER-CHANNEL PUBLISH SETTINGS (server-side SCHED_META_FIELDS, 2026-08-03). Every one of these has been on
1116
+ // the immediate publish helper since the day it was written — tiktokPost's disclosures and interaction
1117
+ // toggles, gbpCreatePost's topic type / event / offer / CTA, xPost's thread, poll and reply settings — and the
1118
+ // QUEUE could carry none of them, so a post composed with a paid-partnership declaration, an Offer or a poll
1119
+ // became a plain post the moment it was scheduled instead of sent. Bounds and enums are the vendors' own, read
1120
+ // 2026-08-03: TikTok's video/photo Post Info tables, Google's v4 LocalPost reference, docs.x.com create-post.
1121
+ 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.'),
1122
+ 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.'),
1123
+ disableComment: z.boolean().optional().describe('TIKTOK — turn comments off on this post.'),
1124
+ 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.'),
1125
+ disableStitch: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Stitches. Same photo-post rule as disableDuet.'),
1126
+ 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.'),
1127
+ 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`.'),
1128
+ 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.'),
1129
+ 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.'),
1130
+ 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.'),
1131
+ 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.'),
1132
+ 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.'),
1133
+ 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.'),
1134
+ 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
1135
  // ── WHICH ACCOUNT (server-side SCHED_ID_FIELDS). Every one of these is an answer the publish helper REFUSES
1100
1136
  // to guess, so a schedule that cannot carry it can only fail at fire time with nobody watching.
1101
1137
  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 +1188,22 @@ export function registerTools(server) {
1152
1188
  tags: z.array(z.string()).optional().describe('YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.'),
1153
1189
  altText: z.string().optional().describe('ACCESSIBILITY — replace the image\u2019s screen-reader description (used by X, Pinterest and LinkedIn company Pages); "" clears it.'),
1154
1190
  link: z.string().optional(),
1191
+ // The per-channel publish settings (SCHED_META_FIELDS), on the same omitted-means-keep rule as everything else
1192
+ // here: send only what changes. A whole-record field (`event`, `offer`, `poll`, `thread`) REPLACES the record.
1193
+ brandedContent: z.boolean().optional().describe('TIKTOK — the paid-partnership disclosure; false turns it off.'),
1194
+ yourBrand: z.boolean().optional().describe('TIKTOK — the own-brand disclosure; false turns it off.'),
1195
+ disableComment: z.boolean().optional().describe('TIKTOK — comments off on this post.'),
1196
+ disableDuet: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Duets.'),
1197
+ disableStitch: z.boolean().optional().describe('TIKTOK VIDEO ONLY — block Stitches.'),
1198
+ coverTimestampMs: z.number().optional().describe('TIKTOK VIDEO ONLY — cover frame in milliseconds.'),
1199
+ topicType: z.enum(['STANDARD', 'EVENT', 'OFFER', 'ALERT']).optional().describe('GOOGLE BUSINESS — the kind of Post; EVENT and OFFER both require `event`.'),
1200
+ actionType: z.enum(['BOOK', 'ORDER', 'SHOP', 'LEARN_MORE', 'SIGN_UP', 'CALL']).optional().describe('GOOGLE BUSINESS — the call-to-action button; "" clears it.'),
1201
+ 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}.'),
1202
+ 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}.'),
1203
+ thread: z.array(z.string()).optional().describe('X — replaces the WHOLE thread; an explicit [] drops back to a single post using the caption.'),
1204
+ poll: z.object({ options: z.array(z.string()), durationMinutes: z.number().optional() }).optional().describe('X — replaces the poll; an empty options list removes it.'),
1205
+ replySettings: z.enum(['following', 'mentionedUsers', 'subscribers', 'verified']).optional().describe('X — who may reply; "" goes back to everyone.'),
1206
+ madeWithAi: z.boolean().optional().describe('X — the AI-media label; false turns it off.'),
1155
1207
  boardId: z.string().optional().describe('PINTEREST — move the Pin to a different board (list_pinterest_boards)'),
1156
1208
  linkedinOrganizationId: z.string().optional().describe('LINKEDIN — target a different company Page, or "" to post as the connected person instead'),
1157
1209
  pageId: z.string().optional().describe('FACEBOOK / INSTAGRAM / THREADS — publish from a different connected Page (list_meta_pages)'),
@@ -4393,7 +4445,7 @@ export function registerTools(server) {
4393
4445
 
4394
4446
  server.registerTool('make_explainer', {
4395
4447
  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, in order. 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.",
4448
+ 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
4449
  inputSchema: {
4398
4450
  topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
4399
4451
  durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
@@ -4404,15 +4456,23 @@ export function registerTools(server) {
4404
4456
  voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
4405
4457
  captions: z.boolean().optional().describe('burn on-screen text (default true)'),
4406
4458
  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.'),
4459
+ 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."),
4460
+ 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
4461
  endCard: z.boolean().optional().describe('append the branded end card (default true)'),
4408
4462
  brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
4409
4463
  },
4410
4464
  outputSchema: { ...JOB_OUT },
4411
4465
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
4412
4466
  }, 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');
4467
+ 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
4468
  const d = r?.raw || {};
4415
- return okVideo(`Explainer ready${d.sections ? ` ${d.sections} sections, ${d.frames || d.sections} frames, ${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)'}` : ''}: ${r.url} [job ${r.jobId}]`, r);
4469
+ // MUSIC + UPSCALE are REPORTED off the result, never assumed from the ask: a composed bed that failed to mux
4470
+ // ships the film dry and is not billed, and a failed upscale delivers at the render resolution. `musicNote`
4471
+ // fires only when a channel that reads best WITH a bed shipped without one, and it carries the price.
4472
+ const fin = `${d.music ? ` · ${d.music.source === 'composed' ? 'composed' : 'library'} ${d.music.mood} music bed` : ''}${d.upscaled ? ` · upscaled ${d.upscaled}×` : ''}`;
4473
+ // The length is whatever ffprobe measured on the DELIVERED file, and `null` means it could not be measured — print
4474
+ // the `—` placeholder rather than inventing a number from the ask (the read-back law: only a measurement ships).
4475
+ 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
4476
  }));
4417
4477
 
4418
4478
  server.registerTool('product_sizzle', {
@@ -4444,7 +4504,7 @@ export function registerTools(server) {
4444
4504
 
4445
4505
  server.registerTool('generate_video', {
4446
4506
  title: 'Generate video',
4447
- description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).',
4507
+ description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).',
4448
4508
  inputSchema: {
4449
4509
  prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
4450
4510
  refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
@@ -4455,7 +4515,8 @@ export function registerTools(server) {
4455
4515
  resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
4456
4516
  ttsScript: z.string().optional().describe('voiceover script to speak'),
4457
4517
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
4458
- musicMood: z.string().optional().describe('licensed music-bed mood (e.g. upbeat / cinematic) — omit for no music bed'),
4518
+ musicMood: z.string().optional().describe('WHICH mood the music bed is composed in (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic). It does NOT decide WHETHER there is one: a clip that comes back with no audio track every model hermoso_capabilities lists as "silent", plus any audio model that returned mute — gets a bed composed and CHARGED automatically, at the flat per-track fee hermoso_capabilities reports as explainerMusicCredits, and omitting this field only means the mood defaults to "warm". Pass audio:false for a genuinely silent clip with no bed and no bed charge.'),
4519
+ audio: z.boolean().optional().describe('default true. false = render SILENT: no native model audio, no music bed, and no bed charge held or billed. This is the ONLY way to decline the automatic bed (see musicMood) — leave it alone for anything that should have sound, and do not combine it with ttsScript.'),
4459
4520
  },
4460
4521
  outputSchema: { ...JOB_OUT,
4461
4522
  refused: z.string().optional().describe("set when NOTHING was rendered and nothing charged — currently 'duration_exceeds_single_clip'"),
@@ -4554,7 +4615,11 @@ export function registerTools(server) {
4554
4615
  const j = await getJob(id);
4555
4616
  const res = jobResult(j);
4556
4617
  const url = abs(res?.video || res?.image || res?.url);
4557
- const text = `Job ${id}: ${j.status}${j.progress ? ` (${Math.round(j.progress * 100)}%)` : ''}${url ? ` → ${url}` : ''}${channelOutcomeLine(res)}${j.error ? ` ${j.error}` : ''}`;
4618
+ // A RESUMED RENDER READS THE SAME SENTENCE AS AN INLINE ONE. `stillRendering` makes this tool the ordinary way a
4619
+ // long render is received, so every disclosure the direct reply carries has to be here too — the model
4620
+ // substitution (`switchNote`) explicitly, the vision-QA verdict via `okVideo`'s own `qaLine`. Both resolve the
4621
+ // payload through `renderPayload`, which is why handing them the JOB works at all.
4622
+ const text = `Job ${id}: ${j.status}${j.progress ? ` (${Math.round(j.progress * 100)}%)` : ''}${url ? ` → ${url}` : ''}${channelOutcomeLine(res)}${j.error ? ` — ${j.error}` : ''}${j.status === 'done' ? switchNote(j) : ''}`;
4558
4623
  if (j.status === 'done' && res?.video) return okVideo(text, { ...j, url }); // resumed video → same inline poster as a direct return
4559
4624
  if (j.status === 'done' && res?.image) { const img = await imageBlock(url); return { content: [{ type: 'text', text }, ...(img ? [img] : [])], structuredContent: { ...j, url } }; }
4560
4625
  return ok(text, { ...j, url });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.30",
3
+ "version": "0.1.34",
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",