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.
Files changed (2) hide show
  1. package/mcp/tools.mjs +57 -5
  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
@@ -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, 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.",
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
- 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);
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.30",
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",