hermoso 0.1.424 → 0.1.426

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/client.mjs CHANGED
@@ -137,7 +137,7 @@ export function signInMessage(status, body, msg, isSignedOut = signedOut()) {
137
137
  }
138
138
 
139
139
  // unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
140
- async function unwrap(res) {
140
+ async function unwrap(res, { read = false } = {}) {
141
141
  let body = null;
142
142
  try { body = await res.json(); } catch {}
143
143
  if (!res.ok) {
@@ -148,6 +148,18 @@ async function unwrap(res) {
148
148
  // `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
149
149
  throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}) }); // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
150
150
  }
151
+ // WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
152
+ // sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
153
+ // tally (toolCtx), which wrap() in tools.mjs states once at the end of the reply. Free routes answer 0 or nothing.
154
+ // Two shapes, both measured: a sibling {data, creditsUsed} (the research routes) and creditsUsed INSIDE data (generate_voice
155
+ // and the other generate routes; the first live run of this tally printed nothing on the CLI because of it). Inside-data
156
+ // counts only on a write: a GET that returns a record may carry a past charge, which is not this call's.
157
+ try {
158
+ const sib = body && typeof body === 'object' ? Number(body.creditsUsed) : NaN;
159
+ const inner = !read && body && body.data && typeof body.data === 'object' && !Array.isArray(body.data) ? Number(body.data.creditsUsed) : NaN;
160
+ const c = Number.isFinite(sib) ? sib : inner;
161
+ const st = toolCtx.getStore(); if (st && Number.isFinite(c) && c > 0) st.apiCharged = (st.apiCharged || 0) + c;
162
+ } catch {}
151
163
  return body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body;
152
164
  }
153
165
 
@@ -205,7 +217,7 @@ export async function apiGet(p, query) {
205
217
  const clean = query && Object.fromEntries(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== ''));
206
218
  const qs = clean && Object.keys(clean).length ? '?' + new URLSearchParams(clean).toString() : '';
207
219
  const res = await fetchRead(`${API_BASE}${withBrandScope(`${p}${qs}`)}`, { headers: headers() });
208
- return unwrap(res);
220
+ return unwrap(res, { read: true });
209
221
  }
210
222
 
211
223
  // ── A TRANSPORT FAILURE IS NOT A DEFECT, AND IT MUST NOT READ LIKE ONE (2026-08-31) ────────────────────────────
package/mcp/tools.mjs CHANGED
@@ -517,8 +517,15 @@ const wrap = (fn) => {
517
517
  // because that is what the caller experienced, whoever authored the refusal.
518
518
  const _t0 = Date.now();
519
519
  try {
520
- const r = await toolCtx.run({ tool: _tool }, () => fn(args, extra));
520
+ const _st = { tool: _tool };
521
+ const r = await toolCtx.run(_st, () => fn(args, extra));
521
522
  try { recordToolOutcome(_tool, { ok: !r?.isError, ms: Date.now() - _t0 }); } catch {}
523
+ // the charge, once, on every paid reply that did not already say it (chargeNote above)
524
+ try {
525
+ const _c = Math.round((_st.apiCharged || 0) + (_st.jobCharged || 0));
526
+ const _txt = Array.isArray(r?.content) ? r.content.find((b) => b && b.type === 'text' && typeof b.text === 'string') : null;
527
+ if (_c > 0 && !_st.chargeSaid && _txt && !CHARGED_RE.test(_txt.text)) _txt.text += r?.structuredContent?.stillRendering ? `\nCharged ${_c} credit${_c === 1 ? '' : 's'} so far; the render's own charge is reported by get_job when it finishes.` : chargeSentence(_c);
528
+ } catch {}
522
529
  return r;
523
530
  }
524
531
  catch (e) {
@@ -631,6 +638,7 @@ async function awaitRenderJob(job) {
631
638
  if (waitMs <= 0) return { jobId: job.id, url: null, stillRendering: true, raw: null };
632
639
  try {
633
640
  const { result, job: doneJob } = await pollJob(job.id, { timeoutMs: waitMs });
641
+ { const c = Number(doneJob?.chargedCredits ?? result?.creditsUsed); const st = toolCtx.getStore(); if (st && Number.isFinite(c) && c > 0) st.jobCharged = (st.jobCharged || 0) + c; } // the tool call's tally (chargeNote / wrap)
634
642
  const url = abs(result?.video || result?.image || result?.url);
635
643
  // creditsUsed AND the delivered geometry ride out here deliberately. The widget's own registered
636
644
  // description promises "the model that rendered it and credits spent", and the pill only ever appeared
@@ -664,8 +672,15 @@ const renderPayload = (r) => (r?.raw ?? jobResult(r) ?? null);
664
672
  // WHAT A FINISHED RENDER WAS CHARGED (2026-10-19). Pre-run prices are estimates now, so the reply that delivers a render
665
673
  // says the real charge: the job's own chargedCredits (every leg it billed), else the worker's settle. Rides switchNote
666
674
  // because switchNote is already on every render tool's delivery sentence, inline and resumed via get_job alike.
675
+ // ONE SENTENCE FOR EVERY PAID TOOL (2026-10-19: recast_motion, and most job tools, delivered with no charge at all, because
676
+ // only the replies that happened to carry switchNote said it). The tool call keeps a tally (toolCtx): every paid route's
677
+ // creditsUsed (client.mjs unwrap) and every job it waited for (awaitRenderJob). chargeNote states the job's charge plus
678
+ // the call's other paid requests (a planning call before a render) and marks it said; wrap() states the tally on any
679
+ // reply that has not, so a tool needs no line of its own and cannot forget one.
680
+ const chargeSentence = (c) => `\nCharged ${c} credit${c === 1 ? '' : 's'} (the actual cost of this run).`;
681
+ const CHARGED_RE = /\nCharged \d[\d,]* credits? \(/;
667
682
  const chargedOf = (r) => { const v = r?.chargedCredits ?? r?.creditsUsed ?? renderPayload(r)?.creditsUsed ?? r?.result?.creditsUsed; return Number.isFinite(+v) && +v > 0 ? Math.round(+v) : null; };
668
- const chargeNote = (r) => { const c = chargedOf(r); return c != null ? `\nCharged ${c} credit${c === 1 ? '' : 's'} (the actual cost of this run).` : ''; };
683
+ const chargeNote = (r) => { const st = toolCtx.getStore(); const tally = Math.round((st?.jobCharged || 0) + (st?.apiCharged || 0)); const c = tally > 0 ? tally : (chargedOf(r) || 0); if (!(c > 0)) return ''; if (st) st.chargeSaid = true; return chargeSentence(c); }; // the tally when this call has one (it already holds the job's charge and any paid request beside it), else the record handed in (get_job reads a job it did not wait for)
669
684
  const switchNote = (r) => { const n = renderPayload(r)?.modelNote || r?.modelNote; return (n ? `\n⚠ ${n}` : '') + chargeNote(r); };
670
685
  // THE VISION-QA READ-BACK (2026-08-03). The server WATCHES every generatively-rendered clip before it reports it
671
686
  // done — free ffmpeg cadence/freeze probes plus ONE vision call over frames sampled at the hook, either side of
@@ -18388,9 +18403,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
18388
18403
  // local path on the CLI (sent as bytes, stored once by the server). An id or path that resolves to nothing is refused
18389
18404
  // by name, free, before anything is planned or rendered.
18390
18405
  const INSERTS_Z = z.array(z.object({
18391
- image: z.string().describe('the picture: an https URL, an upload_file URL, a Library id from list_library (L0, L1…), or a local file path on the CLI'),
18392
- start: z.number().describe('second of the finished video it appears at'),
18393
- end: z.number().describe('second it ends at (at least half a second after start)'),
18406
+ image: z.string().describe('an https or upload_file URL, a Library id (L0, L1…) or a local path on the CLI'),
18407
+ start: z.number().describe('second it appears'),
18408
+ end: z.number().describe('second it ends (≥0.5 s after start)'),
18394
18409
  })).max(8);
18395
18410
  const insertsResolved = async (list) => {
18396
18411
  if (!Array.isArray(list)) return undefined;
@@ -18427,7 +18442,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18427
18442
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
18428
18443
  talent: z.enum(['auto', 'creator', 'product_only']).optional().describe("who is on camera; omit to follow the format. Say 'someone new' in product to skip reusing a saved creator."),
18429
18444
  draft: z.object({ model: z.string(), durationSeconds: z.number() }).optional().describe("ONLY after a video refusal that offered a light draft: the {model, durationSeconds} it named. The plan is then authored to that length and priced on that model. Never invent one — a video the account cannot cover is refused BEFORE planning with the three options (image / add credits / this draft when one fits), and the user chooses."),
18430
- inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's OWN pictures (app or report screenshots, a web page, a chart) shown FULL-SCREEN over the video at these seconds while its voice carries on underneath. The plan keeps the shot under each window on camera with no screen in it (the real picture covers it, never a drawn copy), the pictures are never used as references for the video itself, and they ride the returned creative as `inserts` so render_ad lays them in right after the render, in the same job (about 2 credits more). Each picture goes over the seconds that talk about what it shows; at most 8, never two at once. A picture the planned length has no room for is left out and the reply says so."),
18445
+ inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's own pictures (screenshots, a page, a chart) shown FULL-SCREEN at these seconds, the voice running underneath. The plan keeps those shots free of screens, never uses the pictures as references, and returns them as creative.inserts for render_ad to lay in (about 2 credits). At most 8, never two at once; one the planned length has no room for is left out and the reply says so."),
18431
18446
  },
18432
18447
  outputSchema: {
18433
18448
  format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
@@ -18567,7 +18582,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18567
18582
  imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
18568
18583
  fixLabel: z.boolean().optional().describe('default true: when the saved brand\'s product photo rides in this render, the product\'s label on the finished image is READ and compared with the photo, and re-printed from the photo at close range ONLY if it came out wrong (a label that is already right costs only the check, a credit or two; a re-print adds about ten). The reply says whether the label was checked, fixed or left as rendered (`labelPass`). Pass false when the user wants the packaging left exactly as generated: nothing is checked or re-printed.'),
18569
18584
  logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: real logo file laid flat. in_scene: on something in the scene, from the file, checked. auto (default): from the prompt'),
18570
- mask: z.string().optional().describe('MASKED EDIT — change ONE region of an image and keep the rest: a local path or URL of a mask image for refImages[0] (the image being edited). Either convention works and the reply says which it read: TRANSPARENT pixels = change, or, on a mask with no transparency, WHITE = change and black = keep. Any size; it is scaled to the image. The mask GUIDES the edit rather than stencilling it: the new content can blend a little past its edge. Runs on the model hermoso_capabilities marks `refs.mask` (gpt-image-2.5): leave `model` empty or name that one — any other named model is refused, free. Needs refImages; the result keeps the source image\'s own frame, so aspectRatio is not applied.'),
18585
+ mask: z.string().optional().describe('MASKED EDIT — change ONE region and keep the rest: a mask (path or URL, any size) for refImages[0]. TRANSPARENT = change, or on a mask with no transparency WHITE = change, black = keep; the reply says which it read. The mask guides rather than stencils, so the change can blend a little past its edge. Runs on gpt-image-2.5 (`refs.mask` in hermoso_capabilities): leave `model` empty or name it; another named model is refused, free. Needs refImages; keeps the source image\'s frame, so aspectRatio is not applied.'),
18571
18586
  },
18572
18587
  outputSchema: {
18573
18588
  image: z.string().optional().describe('the served absolute URL of the finished image'),
@@ -18833,12 +18848,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
18833
18848
  description: 'Use this when the user wants a finished video ad for their brand. RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), an optional brand end card (only when the user asks), no music unless asked, real product references. UGC and cinematic plans are rendered from a painted STORYBOARD board (cleaned, then bound first, ahead of the creator and the product); a cinematic spot paints a location anchor first (and a hero when someone is on camera). A product-only commercial renders from the real product photo by default; board:true or foundations:\'choose\' adds a product identity sheet, a four-up moodboard and a board. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. The user’s own pictures that plan_ad was given (creative.inserts) are laid in full-screen at their seconds by the same job, or pass `inserts` here. Spends credits.',
18834
18849
  inputSchema: {
18835
18850
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
18836
- creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”), or a PRESET AI creator from list_creators presets by exact name or id (free, no generation). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
18851
+ creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”), or a PRESET AI creator from list_creators presets by exact name or id (free, no generation). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand (casting skips the portrait we would otherwise generate). Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
18837
18852
  faceRoute: z.enum(['face_lane']).optional().describe(FACE_ROUTE_DESC),
18838
18853
  model: z.string().optional().describe('video model id from hermoso_capabilities (default: the engine the router picks for the plan: seedance-2.5 for every ad, a person talking to camera included). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
18839
- durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so use dryRun:true to see the exact act split for free before spending.'),
18854
+ durationSeconds: z.number().optional().describe('total ad length in seconds, 4–180 (clamped). Omit to keep the plan’s own length, almost always right: this only RE-TIMES the authored board; to change the ad’s length, re-run plan_ad with durationSeconds. A length that fits ONE clip renders in one pass; longer is stitched from acts of the model’s clip maximum (15s on most, 30s on the longest-clip one), remainder last. dryRun:true shows the split free.'),
18840
18855
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
18841
- resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
18856
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default; '480p'/'720p' cheaper drafts, '4k' premium (more credits). A model renders only the tiers in its own `resolutions` list in hermoso_capabilities (the 30s model tops out at 720p); a tier it lacks renders at its best one without a word, so check before promising 1080p or 4k."),
18842
18857
  captions: z.boolean().optional().describe('burn the plan\'s per-scene on-screen words as caption pills. DEFAULT FALSE on every recipe — set true ONLY when the user asks for on-screen text or captions; no recipe turns them on by itself'),
18843
18858
  endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE on every recipe — set true ONLY when the user asks for an end card (a clone of a video that had none should not grow one)'),
18844
18859
  music: z.union([z.boolean(), z.string()]).optional().describe("music bed: OFF unless asked. true = the plan's own music line; or the bed in words ('lo-fi jazz, brushed drums'); false = none"),
@@ -18851,7 +18866,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18851
18866
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
18852
18867
  dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) and the estimated credits the real render reserves, WITHOUT submitting a render — free, nothing charged'),
18853
18868
  allowGenericProduct: z.boolean().optional().describe('proceed even though this brand has NO product photo on file and the ad features a product — the packaging will be INVENTED. Only pass true after telling the user that and hearing they are fine with a generic stand-in'),
18854
- inserts: INSERTS_Z.optional().describe("the user's OWN pictures (screenshots, a page, a chart) laid FULL-SCREEN over the finished video at these seconds, its sound running underneath, by the same job right after the render (about 2 credits more). Omit to use the ones plan_ad was given (creative.inserts); [] renders without them. Passing them here when plan_ad never saw them still keeps the shots under them free of screens, but plan_ad with inserts writes the board around them."),
18869
+ inserts: INSERTS_Z.optional().describe("the user's own pictures laid FULL-SCREEN over the finished video at these seconds, sound running underneath (about 2 credits). Omit to use plan_ad's creative.inserts; [] renders without them. plan_ad with inserts writes the board around them."),
18855
18870
  },
18856
18871
  outputSchema: {
18857
18872
  ...JOB_OUT,
@@ -19317,19 +19332,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
19317
19332
  speak: z.string().optional().describe('A PERSON SAYING THESE EXACT WORDS TO CAMERA — the default for any "make my photo talk" / spokesperson / talk-to-camera ask. Pass with `creator` or a portrait as `refImage`: the video model films them saying it in their own voice, matched to who they are, and the length follows the words (leave durationSeconds out). `prompt` is then the staging (e.g. "natural", "walking in a park"). Real footage, not an animated photo; generate_avatar is the animated-photo look, only when asked for by name'),
19318
19333
  creator: z.string().optional().describe('STAR A SAVED CREATOR in this clip — their id from list_creators, or the name you know them by, or a PRESET AI creator from list_creators presets (exact name or id; free, no generation). Their saved portrait rides first among the references as the on-camera person, with their saved consent, exactly as render_ad casts them; a real person saved from a photo keeps their real face on camera. An unknown name is refused by name, nothing charged.'),
19319
19334
  angles: z.boolean().optional().describe('OPTIONAL, OFF BY DEFAULT: with `creator`, also send the extra views that creator already has saved (their pose plates, up to 2) beside their portrait. A test showed no visible improvement over the portrait alone, and each extra view adds about 25 s before the render starts, so leave it off unless asked. Views are skipped when the face library is busy, and the render always goes ahead on the portrait.'),
19320
- refImages: z.array(z.string()).optional().describe('SEVERAL reference images (local paths or URLs) — a person, products, a place — that must all appear in the clip. Only models whose `refs.max` in hermoso_capabilities is above 1 use more than one, and each uses at most that many; with `refs.promptAddressed` true, name them in your prompt as Image 1, Image 2… in this order. minimax-h3-max-ref takes up to 9 and keeps each one as a reference rather than a first frame. On a model that takes one image, only the first is used.'),
19321
- refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the clip is transformed per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and, on every model hermoso_capabilities marks `sourceLength`, its LENGTH too: those endpoints have no duration parameter, their listed `durations` are the per-second price ladder, and a durationSeconds you send is reported back as unused rather than silently dropped. Trim the source to change the length. Omit `model` for the default editor, or name a model whose videoEdit is true in hermoso_capabilities (a named model that cannot edit is refused, nothing charged); refImage rides along as the look of what the edit adds. With extend:true this is instead the clip to EXTEND. Omit to generate a fresh clip."),
19335
+ refImages: z.array(z.string()).optional().describe('SEVERAL reference images (paths or URLs: a person, products, a place) that must all appear. A model uses up to its `refs.max` in hermoso_capabilities (one-image models take the first); with `refs.promptAddressed`, name them Image 1, Image 2… in order. minimax-h3-max-ref takes 9 and keeps each as a reference, not a first frame.'),
19336
+ refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the clip is transformed per your prompt, keeping the SOURCE clip’s aspect ratio and, on models hermoso_capabilities marks `sourceLength`, its LENGTH (their `durations` are the per-second price ladder; a durationSeconds sent is reported as unused). Trim the source to change the length. Omit `model` for the default editor, or name a model whose videoEdit is true in hermoso_capabilities (a named model that cannot edit is refused, nothing charged); refImage rides along as the look of what the edit adds. With extend:true this is instead the clip to EXTEND. Omit to generate a fresh clip."),
19322
19337
  endImage: z.string().optional().describe('local path or URL of the LAST frame: the clip travels from refImage (required with it) to this image. Only models with endFrame true in hermoso_capabilities take it; any other named model is refused by name, nothing charged.'),
19323
19338
  loop: z.boolean().optional().describe('true = a seamless loop whose last frame flows back into its first. Only models with loop true in hermoso_capabilities; needs refImage and cannot be combined with endImage.'),
19324
- shots: z.array(z.object({ prompt: z.string().describe('what happens in this shot'), seconds: z.number().int().min(1).max(15).describe('this shot’s length in whole seconds') })).optional().describe('MULTI-SHOT: one clip cut into these shots, in order. The seconds must add up to a length the model renders; replaces `prompt` (send either). Only models with multiShot true in hermoso_capabilities. Each shot prompt is capped per model (shotPromptMaxChars there; Kling 3.0: 512 characters); a longer one is refused free, never cut.'),
19325
- extend: z.boolean().optional().describe('true = EXTEND refVideo: the same clip continues per your prompt for durationSeconds more (each model’s extend.minSeconds..maxSeconds in hermoso_capabilities), delivered as ONE clip, source then continuation. Needs model named (a model with extend in hermoso_capabilities).'),
19339
+ shots: z.array(z.object({ prompt: z.string().describe('what happens in this shot'), seconds: z.number().int().min(1).max(15).describe('this shot’s length in whole seconds') })).optional().describe('MULTI-SHOT: one clip cut into these shots, in order; the seconds add up to a length the model renders. Replaces `prompt`. Only models with multiShot true in hermoso_capabilities; a shot prompt over that model’s shotPromptMaxChars (Kling 3.0: 512) is refused free, never cut.'),
19340
+ extend: z.boolean().optional().describe('true = EXTEND refVideo: the same clip continues per your prompt for durationSeconds more (each model’s extend.minSeconds..maxSeconds in hermoso_capabilities), delivered as ONE clip, source then continuation. Omit model for the certified default.'),
19326
19341
  interactionId: z.string().optional().describe('with extend:true on an Omni model: the interactionId returned by an earlier render on that model — continues it from its own stored context instead of re-uploading refVideo.'),
19327
- durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds — pick one of the CHOSEN model’s listed durations from hermoso_capabilities (never a generic guess: the lists differ per model, from 4–8s on the short models up to 30s on the longest-clip one). This is a single continuous generation, so it CANNOT exceed that model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). Past ~15s only the long-clip models qualify, and an unnamed render is routed by the narrower auto-pool — so NAME the model in `model` when you are asking for a long single take. For a spot longer than any one clip, use plan_ad with durationSeconds then render_ad, which stitches acts of at most one model clip each (on a 15s-clip model, 40s = 15+15+10).'),
19342
+ durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds: one of the CHOSEN model’s listed durations in hermoso_capabilities (they differ per model, 4–8s on the short ones up to 30s). One continuous generation, so a longer ask than the model’s longest clip is REFUSED, nothing charged (never quietly truncated). Past ~15s NAME a long-clip model in `model`. For a longer spot use plan_ad with durationSeconds then render_ad, which stitches acts (on a 15s model, 40s = 15+15+10).'),
19328
19343
  aspectRatio: z.string().optional().describe("default '9:16'"),
19329
19344
  model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick; its default per job (jobEngines in hermoso_capabilities, from watched side-by-side tests) is seedance-2.5 for product, lifestyle, a person talking to camera and 30s single takes (cheaper engines broke the product there). A model the user picks is kept; never switch it'),
19330
19345
  faceRoute: z.enum(['face_lane']).optional().describe(FACE_ROUTE_DESC),
19331
- resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render. Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
19332
- cameraMove: z.enum(['orbit', 'orbit_left', 'orbit_half', 'orbit_full', 'rise', 'crane_up', 'push_in', 'pull_back', 'reveal']).optional().describe('A named camera move around the still in refImage, spelled exactly as the enum gives it: an orbit (a quarter turn, the default), orbiting left, a half turn or a full turntable, a rise, a crane up, a push in, a pull back, or a reveal. Only the camera-controls model renders one (minimax-h3-max-camera, listed with its moves in hermoso_capabilities): pass it with model omitted and that model is picked, or with that model named; any other named model is refused by name with nothing charged. Needs refImage.'),
19346
+ resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default; '480p'/'720p' cheaper drafts, '4k' premium (more credits). A model renders only the tiers in its own `resolutions` list in hermoso_capabilities; a tier it lacks renders at its best one without a word, so check before promising 1080p or 4k."),
19347
+ cameraMove: z.enum(['orbit', 'orbit_left', 'orbit_half', 'orbit_full', 'rise', 'crane_up', 'push_in', 'pull_back', 'reveal']).optional().describe('A named camera move around the still in refImage (orbit = a quarter turn, the default; orbit_half / orbit_full = a half turn / a turntable). Only minimax-h3-max-camera renders one: with model omitted it is picked; any other named model is refused, nothing charged. Needs refImage.'),
19333
19348
  cameraTrajectory: z.array(z.object({
19334
19349
  time: z.number().min(0).max(1).describe('when this pose is reached, 0 = start of the clip, 1 = end'),
19335
19350
  azimuth: z.number().describe('horizontal angle around the subject in degrees (0 = where the still was taken; the sign turns the camera the other way; at most 32 full turns of total travel)'),
@@ -19338,7 +19353,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19338
19353
  })).min(2).max(12).optional().describe('Your own ordered camera path, 2 to 12 keyframes, for the camera-controls model only (same rule as cameraMove; overrides it). The first pose is held until its time and the last pose is held to the end. A value outside these bounds is refused by name, nothing charged.'),
19339
19354
  ttsScript: z.string().optional().describe('voiceover script to speak'),
19340
19355
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
19341
- 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.'),
19356
+ musicMood: z.string().optional().describe('the mood of the music bed (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic; default warm). It does not decide WHETHER there is one: a clip with no audio track (a "silent" model in hermoso_capabilities, or one that came back mute) gets a bed composed and CHARGED automatically at explainerMusicCredits. audio:false = silent, no bed, no charge.'),
19342
19357
  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.'),
19343
19358
  },
19344
19359
  outputSchema: { ...JOB_OUT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.424",
3
+ "version": "0.1.426",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 922 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",