hermoso 0.1.522 → 0.1.524

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/mcp/tools.mjs +107 -24
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate image & video ad
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **1177 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **1178 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
@@ -242,7 +242,7 @@ block entirely if you signed in above; it is there for CI, where the process can
242
242
 
243
243
  Then ask your agent: *“Generate an image ad with Hermoso.”*
244
244
 
245
- ### What the 1177 tools cover
245
+ ### What the 1178 tools cover
246
246
 
247
247
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
248
248
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/tools.mjs CHANGED
@@ -86,6 +86,7 @@ const creatorLine = (c) => {
86
86
  if (c.source !== 'generated') bits.push(c.consented ? 'likeness consented' : 'NO likeness consent on file');
87
87
  if (c.poses) bits.push(`${c.poses} pose plate${c.poses === 1 ? '' : 's'}`);
88
88
  if (c.lowQualityRef && !c.refAccepted) bits.push('PHOTO TOO UNCLEAR TO CAST — not cast by default; replace it with save_creator (same name, a better photo url) or accept it with save_creator(name, useAnyway: true)');
89
+ if (c.imagesMissing) bits.push(`PHOTOS DELETED — cannot be cast until replaced: ${c.imagesNote || 'regenerate or upload a new photo'} (save_creator with the same name and a new photo url replaces it)`);
89
90
  if (c.voiceClone) bits.push('cloned voice');
90
91
  else if (c.voice) bits.push(`voice ${c.voice}`);
91
92
  return ` • ${c.name} — ${bits.join(', ')}${c.id ? ` [${c.id}]` : ''}\n ${c.image || '(portrait stored in-app as an uploaded photo — cast them by NAME; there is no url to hand a render tool)'}`;
@@ -335,7 +336,7 @@ export const MCP_INSTRUCTIONS = [
335
336
  // is not actually required is a trap in an instruction an agent reads before acting, and this one was not
336
337
  // required: `model` is OPTIONAL on generate_image, generate_video and render_ad, and an unnamed render resolves to
337
338
  // GEN_MODELS.<kind>[0] in workImage/workVideo, which is the catalog's `best: true` row (for image an unnamed generate_image
338
- // here goes further and takes the static_ad job's engine, GPT Image 2.5 since 2026-11-03) and, for video, model-belt.mjs's AUTO_VIDEO_DEFAULT (seedance-2.5, also `video[0]`). The prose below says "the
339
+ // here goes further and takes the static_ad job's engine, GPT Image 2.6 since 2026-11-05, 2.5 from 2026-11-03) and, for video, model-belt.mjs's AUTO_VIDEO_DEFAULT (seedance-2.5, also `video[0]`). The prose below says "the
339
340
  // server's own default" rather than "the best row" on purpose: hermoso_capabilities' own _pickLine states as a RULE
340
341
  // that neither `best` nor the longest-clip row decides where an unnamed ask lands, and two shipped sentences that
341
342
  // disagree are worse than either one. What the user-facing line has to carry is only that omitting `model` works.
@@ -3255,6 +3256,7 @@ const splitRef = (m, tool, word) => {
3255
3256
  // delete names the visibility operation. These run BEFORE the split rewrite, so a precise reference wins over the
3256
3257
  // generic "op / op" list.
3257
3258
  const WIDGET_PROXY_REFS = [[/\s*\/\s*fetch_social_data\b/g, ''], [/go through fetch_social_data with a handle/g, 'go through instagram_profile and search_instagram_hashtag'],
3259
+ [/through fetch_social_data's Instagram profile endpoint/g, 'through instagram_profile'], // search_instagram's local-market step (0aec0d65db4)
3258
3260
  [/, which submit_tiktok_ads_verification can reuse/g, ''], [/the code submit_tiktok_ads_verification needs/g, 'its code'],
3259
3261
  [/SECRET with update_pinterest_board\b/g, 'SECRET with set_pinterest_board_visibility']];
3260
3262
  const widgetProxyHit = (d) => WIDGET_PROXY_REFS.some(([re]) => { re.lastIndex = 0; const hit = re.test(d); re.lastIndex = 0; return hit; });
@@ -3979,6 +3981,7 @@ const TEXT_STYLE_Z = z.union([
3979
3981
  position: z.union([z.string(), z.number()]).optional().describe('top|center|lower|bottom or 0.05-0.95 from the top'), textCase: z.enum(['as-is', 'upper', 'lower', 'title']).optional(),
3980
3982
  italic: z.boolean().optional(), subItalic: z.boolean().optional(), outline: z.boolean().optional(), shadow: z.boolean().optional(),
3981
3983
  tilt: z.number().optional().describe('degrees, ±45'), cardColor: z.string().optional(),
3984
+ highlightColor: z.string().optional().describe('word-timed subtitles only: the word being said is lit in this colour (preset highlight sets it)'),
3982
3985
  }),
3983
3986
  ]);
3984
3987
  // ── WHAT get_job SAYS WHEN IT FINDS NOTHING (chat review 2026-11-04, fi:c9eb3a08e010) ─────────────────────────────────
@@ -22616,30 +22619,37 @@ function buildTools(rawServer, opts = {}, sink = null) {
22616
22619
  server.group('create');
22617
22620
  server.registerTool('generate_image', {
22618
22621
  title: 'Generate ad image',
22619
- description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product. It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched. RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Seconds. Spends credits.',
22622
+ description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product. It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched. AN ANIMATED GIF OR AN SVG: format "gif" (a short looping animation, frames + frameMs) or "svg" (an editable vector) on gpt-image-2.6 / gpt-image-2.6-flare, from the prompt alone (no refImages). RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Seconds. Spends credits.',
22620
22623
  inputSchema: {
22621
22624
  prompt: z.string().optional().describe('REQUIRED on every model EXCEPT the pose rows below. the full image prompt — subject, composition, lighting, and any on-image ad text. ON A POSE MODEL (product-in-hand / try-on) THIS IS EXTRA DIRECTION AND IT IS OPTIONAL — leave it out and the pose is built for you. If you do write one, DESCRIBE THE POSE ("she holds the bottle upright in her right hand at chest height, label to camera"); do NOT phrase it as a swap ("replace the mug with the bottle"), which is REFUSED for free, because the product then comes out the size of whatever it replaced — a 30ml bottle rendered mug-sized in testing.'),
22622
22625
  refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in. ON THE POSE MODELS — any row hermoso_capabilities marks `needsRefs` with a `refsMax`, such as putting your product in someone’s hands or a virtual try-on — THE ORDER IS THE CONTRACT AND IT IS NOT A COMPOSITE: refImages[0] is the PERSON photo, and the rest (up to `refsMax` minus one) are the product or garment photos. Reversed, you get the product wearing the person. A 4th product is dropped and the reply says so. ' + LIKENESS_TERMS),
22623
22626
  useBrand: z.boolean().optional().describe('default true: with no refImages, the server hydrates the SAVED brand’s product/logo references so the output lands on-brand; pass false for a pure prompt-only render'),
22624
22627
  raw: z.boolean().optional().describe('RAW MODEL ACCESS: run the caller’s prompt on the named model with no Hermoso adjustments at all — the prompt reaches the provider byte-identical (no hex-to-colour-name rewrite, no prepended fidelity preamble) and NO saved-brand product photos are attached, so the model you name is the model that renders. Use it to drive the raw catalog; leave it off for an on-brand ad. Billing, the durable Library landing and per-model validation are unchanged.'),
22625
22628
  aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9', '4:5'. Each model draws its own list (hermoso_capabilities prints it per model, e.g. Nano Banana 2 goes to 1:8 and 8:1); a ratio the chosen model cannot draw is refused before anything is charged"),
22626
- model: z.string().optional().describe('image model id from hermoso_capabilities. A model whose `refs.mode` is "edit" there (gpt-image-2.5) takes your refImages on ITS OWN editor, up to its `refs.max`, instead of the default compositor'),
22629
+ model: z.string().optional().describe('image model id from hermoso_capabilities. A model whose `refs.mode` is "edit" there (gpt-image-2.6, gpt-image-2.5) takes your refImages on ITS OWN editor, up to its `refs.max`, instead of the default compositor'),
22627
22630
  job: z.enum(['static_ad', 'product_photo', 'marketplace', 'thumbnail', 'character_sheet', 'vector']).optional().describe('what the picture is for. With no `model` named it renders on the engine judged best for that job (hermoso_capabilities `jobEngines.image` lists them): static_ad (the default when neither `model` nor `job` is given) = a finished image ad with its headline, copy and button on the picture; product_photo = a studio, lifestyle, flat-lay or spotlight photo of the real product; marketplace = a listing card; thumbnail = a video cover (make_thumbnail is the full thumbnail pipeline); character_sheet = a multi-view sheet of one person; vector = a logo, icon or flat illustration as an editable SVG (it takes no reference pictures, so the saved brand\'s photos stay off it). A named `model` always wins'),
22631
+ format: z.enum(['png', 'gif', 'svg']).optional().describe('the file to make (default png). gif = an ANIMATED GIF, a short loop with no sound (describe the subject, the motion and the loop); svg = an editable VECTOR (logo, icon, flat illustration). Both run on gpt-image-2.6 (default) or gpt-image-2.6-flare and take no refImages, mask or markup; the saved brand\'s photos stay off them. hermoso_capabilities prints each one\'s credits under `formats`'),
22632
+ frames: z.number().int().min(2).max(16).optional().describe('format gif only: frames in the loop, 2 to 16 (default 9)'),
22633
+ frameMs: z.number().int().min(20).max(2000).optional().describe('format gif only: milliseconds per frame, 20 to 2000, rounded to 10 (default 200; 9 frames x 200 = a 1.8 s loop)'),
22634
+ complexity: z.enum(['low', 'medium', 'high']).optional().describe('gpt-image-2.6 / 2.6-flare only: how much the model plans before drawing (default high, best for dense text and complex layouts; low suits a small focused change). No separate charge'),
22628
22635
  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'),
22629
22636
  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 re-print costs more, and dryRun:true returns the up-to price). 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.'),
22630
22637
  logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: real logo file laid flat. in_scene: the scene leaves a blank patch on the object and the real file is printed onto it (perspective, light). Never drawn by the model. auto (default): from the prompt'),
22631
- 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.'),
22638
+ 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.6 (`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.'),
22632
22639
  },
22633
22640
  outputSchema: {
22634
22641
  image: z.string().optional().describe('the served absolute URL of the finished image'),
22635
22642
  model: z.string().optional().describe('the product-facing label of the model that rendered it'),
22636
22643
  productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
22637
- logoPlaced: z.any().optional().describe('how the real logo file went on: {applied, mode: in_scene|fix|overlay, object?, fellBack?, notALogo?}; notALogo = the saved logo is a photo, so nothing was added (ask for the logo file)'),
22644
+ logoPlaced: z.any().optional().describe('how the real logo file went on: {applied, mode: in_scene|fix|overlay, object?, fellBack?, notALogo?, asked?}; notALogo = the saved logo is a photo, so nothing was added (ask for the logo file only when the logo was asked for; asked:false means nobody asked for one, so say nothing about it)'),
22645
+ format: z.string().optional().describe('gif or svg when one was asked for'),
22646
+ preview: z.string().optional().describe('an SVG\'s PNG preview, for places that cannot show a vector'),
22647
+ frames: z.number().optional().describe('an animated GIF\'s frame count, read off the delivered file'),
22638
22648
  labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
22639
22649
  },
22640
22650
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
22641
22651
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
22642
- }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement, job }) => {
22652
+ }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement, job, format, frames, frameMs, complexity }) => {
22643
22653
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
22644
22654
  const maskRef = mask ? await toRef(mask) : undefined; // a local mask file travels the same way a local reference does
22645
22655
  // `raw === true` only — a raw render is opt-in and must be stated properly, so a truthy stray value never
@@ -22649,8 +22659,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
22649
22659
  // NO MODEL AND NO JOB = AN IMAGE AD (2026-11-03): this tool is what an agent reaches for to make an image ad, so an
22650
22660
  // unnamed render takes the static_ad job's engine (judged in the job-engine registry) rather than the catalog's
22651
22661
  // general first row. A masked edit keeps its own rule (the server routes a mask to the one engine that takes it).
22652
- if (!job && !model && !maskRef) job = 'static_ad';
22653
- const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}), ...(job ? { job } : {}) }; // fixLabel:false only when stated: the label check is ON by default. job: the image job's engine when no model is named (server: lib/image-job-engines.mjs)
22662
+ if (!job && !model && !maskRef && format !== 'gif' && format !== 'svg') job = 'static_ad'; // a GIF / SVG picks its own engine (GPT Image 2.6)
22663
+ const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}), ...(job ? { job } : {}), ...(format ? { format } : {}), ...(frames != null ? { frames } : {}), ...(frameMs != null ? { frameMs } : {}), ...(complexity ? { complexity } : {}) }; // fixLabel:false only when stated: the label check is ON by default. job: the image job's engine when no model is named (server: lib/image-job-engines.mjs)
22654
22664
  // A CALLER WITH A WAIT BUDGET GETS A QUEUED JOB (2026-09-21, R321). Only `/v1/tools/generate_image?wait=` sets
22655
22665
  // `waitMs`; every other caller renders inside the request exactly as before. The server does the same brand-photo
22656
22666
  // handling either way, then queues the ordinary image job; awaitRenderJob waits what the caller allowed and hands
@@ -22668,12 +22678,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
22668
22678
  const q = await apiPost('/api/generate/image', { ..._imgBody, queue: true });
22669
22679
  const r = await awaitRenderJob({ id: q.jobId });
22670
22680
  if (r.stillRendering) return { content: [{ type: 'text', text: `Image queued as job ${r.jobId}. It is rendering; read it with get_job.${q.productNote ? `\n${q.productNote}` : ''}` }], structuredContent: { jobId: r.jobId, stillRendering: true, ...(q.productNote ? { productNote: q.productNote } : {}), ...(q.brandPhoto ? { brandPhoto: q.brandPhoto } : {}) } };
22671
- const img = await imageBlock(r.url);
22681
+ const img = await imageBlock(r.raw?.preview ? abs(r.raw.preview) : r.url); // an SVG shows its PNG preview inline
22672
22682
  const _qNote = joinQueuedImageNote(q.productNote, r.raw); // the route's note (logo, lineup) + the worker's (crop, the product check); a logo the worker moved is said once, where it landed
22673
- return { content: [{ type: 'text', text: `${_imgHead(r.raw?.productCheck)}${r.url}${r.model ? ` (${r.model})` : ''}${switchNote({ raw: r.raw || {}, chargedCredits: r.chargedCredits ?? null })}${_qNote ? `\n${_qNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { image: r.url, model: r.model, jobId: r.jobId, creditsUsed: r.creditsUsed, ...(_qNote ? { productNote: _qNote } : {}), ...(r.raw?.productCheck ? { productCheck: r.raw.productCheck } : {}), ...(r.raw?.labelPass ? { labelPass: r.raw.labelPass } : {}), ...(r.raw?.logoCheck ? { logoCheck: r.raw.logoCheck } : {}), ...(q.brandPhoto ? { brandPhoto: q.brandPhoto } : {}) } };
22683
+ return { content: [{ type: 'text', text: `${_imgHead(r.raw?.productCheck)}${r.url}${r.model ? ` (${r.model})` : ''}${switchNote({ raw: r.raw || {}, chargedCredits: r.chargedCredits ?? null })}${_qNote ? `\n${_qNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { image: r.url, model: r.model, jobId: r.jobId, creditsUsed: r.creditsUsed, ...(r.raw?.format ? { format: r.raw.format } : {}), ...(r.raw?.preview ? { preview: abs(r.raw.preview) } : {}), ...(r.raw?.frames ? { frames: r.raw.frames, frameMs: r.raw.frameMs } : {}), ...(_qNote ? { productNote: _qNote } : {}), ...(r.raw?.productCheck ? { productCheck: r.raw.productCheck } : {}), ...(r.raw?.labelPass ? { labelPass: r.raw.labelPass } : {}), ...(r.raw?.logoCheck ? { logoCheck: r.raw.logoCheck } : {}), ...(q.brandPhoto ? { brandPhoto: q.brandPhoto } : {}) } };
22674
22684
  }
22675
22685
  const d = await apiPost('/api/generate/image', _imgBody); // explicit boolean so the server's saved-brand hydration default is unambiguous
22676
- const img = await imageBlock(abs(d.image)); // show the actual creative inline in Claude, not just a URL
22686
+ const img = await imageBlock(abs(d.preview || d.image)); // show the actual creative inline in Claude, not just a URL (an SVG shows its PNG preview: an agent's image block takes no vector)
22677
22687
  return { content: [{ type: 'text', text: `${_imgHead(d.productCheck)}${abs(d.image)}${d.model ? ` (${d.model})` : ''}${switchNote({ raw: d })}${d.productNote ? `\n${d.productNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
22678
22688
  }));
22679
22689
 
@@ -22698,6 +22708,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
22698
22708
  removal: z.boolean().optional().describe('true when the edit REMOVES text, branding, a logo, a watermark, a person or an object, so the brand name and logo are not re-added'),
22699
22709
  logo: z.boolean().optional(),
22700
22710
  mask: z.string().optional().describe('optional mask image (URL or local path) marking the region to change: transparent = change, or white = change on an opaque mask'),
22711
+ markup: z.string().optional().describe('MARKUP-GUIDED EDIT: drawn annotations (circles, arrows, scribbles) showing what to change, as a URL or local path: a transparent PNG of just the marks, or a copy of the same image with the marks drawn on it. Say the change in `instruction` ("make the circled vase blue"); the marks are not in the result. Runs on gpt-image-2.6 (or the editor named in `model`). Never with a mask'),
22712
+ model: z.string().optional().describe('the editor, only when the user names one: gpt-image-2.6, gpt-image-2.6-flare (its own faster editor) or gpt-image-2.5. Omit for the default'),
22713
+ complexity: z.enum(['low', 'medium', 'high']).optional().describe('gpt-image-2.6 editors only: how much it plans before drawing (default high; low suits a small focused change)'),
22701
22714
  dryRun: z.boolean().optional().describe('true = return what this edit usually costs in credits and render nothing'),
22702
22715
  fixLabel: z.boolean().optional().describe('false = leave the product label exactly as rendered: no product lookup, no label check, no re-print (default on when the ad shows the brand\u2019s saved product)'),
22703
22716
  },
@@ -22708,10 +22721,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
22708
22721
  productNote: z.string().optional(),
22709
22722
  },
22710
22723
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
22711
- }, wrap(async ({ image, instruction, removal, logo, mask, dryRun, fixLabel }) => {
22724
+ }, wrap(async ({ image, instruction, removal, logo, mask, markup, model, complexity, dryRun, fixLabel }) => {
22712
22725
  const src = await toRef(image);
22713
22726
  const maskRef = mask ? await toRef(mask) : undefined;
22714
- const fl = { ...(fixLabel === false ? { fixLabel: false } : {}), ...(logo === true && removal !== true ? { logo: true } : {}) }; // logo: the file is laid on, no repaint (chat review 2026-11-03)
22727
+ const markupRef = markup ? await toRef(markup) : undefined; // a local markup file travels the same way a local mask does
22728
+ const fl = { ...(fixLabel === false ? { fixLabel: false } : {}), ...(logo === true && removal !== true ? { logo: true } : {}), ...(markupRef ? { markup: markupRef } : {}), ...(model ? { model } : {}), ...(complexity ? { complexity } : {}) }; // logo: the file is laid on, no repaint (chat review 2026-11-03)
22715
22729
  if (dryRun) { const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}), ...fl, dryRun: true }); const _eq = d && d.quote; return ok(quoteText(_eq, 'This image edit (a change to the existing image, not a new render)'), _eq && _eq.priceCredits > 0 ? { credits: _eq.priceCredits, holdCredits: _eq.holdCredits } : {}); }
22716
22730
  const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}), ...fl });
22717
22731
  const img = await imageBlock(abs(d.image));
@@ -23208,7 +23222,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23208
23222
 
23209
23223
  server.registerTool('post_edit', {
23210
23224
  title: 'Post-production edit',
23211
- description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg in seconds, ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, music (a bed UNDER the clip, its own sound kept and ducked under: omit track = a free library track picked by mood, no attribution; or track = an audio link: an upload_file URL, a find_sound or generate_sfx link, a video post's sound; track 'generate' composes it, paid, ONLY when the user asks; start/end, db, replace), voiceover (text spoken OVER it, its sound ducked; ~2.5 words/s; voice female|male; +~1 credit), watermark (brand logo), grain (anti-AI), text (timed words in a native look: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle; start/end), join (this video FOLLOWED BY clips[]: Library URLs, direct files or public post links, as one 1080x1920 video, loudness matched). Presets are shortcuts: text/watermark take any x/y, grain any amount, join any ffmpeg transition, a bridge any sound link. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}], bridge}], and it ALWAYS gets a bridge unless the user asks for a bare cut: {kind:'impact'} cuts the hook just before its payoff (found from the footage; cutAt overrides) and lands our clip on a punch-in and flash, with the payoff sound FROM THE HOOK ITSELF: its own audio carries across the cut, else one generated from its frames (up to 8 credits), else a neutral impact. {kind:'text', text, then?} only when the user asks for words over the cut. No voiceover bridge: have our clip's host say the connecting line. matchCut = where our clip starts. ANY OTHER EDIT, op or bridge (whip, zoom, freeze, wipe, split screen, generated shot) is edit_timeline: free keyframes, any size. NEVER generate_video/render_ad for these.",
23225
+ description: "MECHANICAL post-production on an EXISTING video (URL): ordered primitives run by ffmpeg in seconds, ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, music (a bed UNDER the clip, its own sound kept and ducked under: omit track = a free library track picked by mood, no attribution; or track = an audio link: an upload_file URL, a find_sound or generate_sfx link, a video post's sound; track 'generate' composes it, paid, ONLY when the user asks; start/end, db, replace), voiceover (text spoken OVER it, its sound ducked; ~2.5 words/s; voice female|male; +~1 credit), watermark (brand logo), grain (anti-AI), text (timed words in a native look: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle; start/end, or at:{word} and until:{word} to time them to a spoken phrase, refused when it is never said), join (this video FOLLOWED BY clips[]: Library URLs, direct files or public post links, as one 1080x1920 video, loudness matched). Presets are shortcuts: text/watermark take any x/y, grain any amount, join any ffmpeg transition, a bridge any sound link. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}], bridge}], and it ALWAYS gets a bridge unless the user asks for a bare cut: {kind:'impact'} cuts the hook just before its payoff (found from the footage; cutAt overrides) and lands our clip on a punch-in and flash, with the payoff sound FROM THE HOOK ITSELF: its own audio carries across the cut, else one generated from its frames (up to 8 credits), else a neutral impact. {kind:'text', text, then?} only when the user asks for words over the cut. No voiceover bridge: have our clip's host say the connecting line. matchCut = where our clip starts. ANY OTHER EDIT, op or bridge (whip, zoom, freeze, wipe, split screen, generated shot) is edit_timeline: free keyframes, any size. NEVER generate_video/render_ad for these. Feedback maps to one named move, checked by reading the parameters back, never by re-rendering: quieter/louder (music db, audio_gain), faster/slower (speed; slower = zooms and pans at 0.7x), tighten (trim heads, tails, silences), retime (to a word anchor), recut (order, in-out points), regrade (timeline exposure/contrast/saturation keys).",
23212
23226
  inputSchema: {
23213
23227
  videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
23214
23228
  ops: z.array(z.object({
@@ -23223,6 +23237,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
23223
23237
  y: z.number().optional().describe('vertical centre for text or watermark: 0 to 1 of frame height, or pixels'),
23224
23238
  style: z.union([z.string(), z.object({}).passthrough()]).optional().describe('text: a look name or a textStyle'),
23225
23239
  textStyle: z.union([z.string(), z.object({}).passthrough()]).optional().describe('text op: a textStyle object or preset name, same as style'),
23240
+ at: z.union([z.string(), z.object({ word: z.string().describe('the word or phrase as it is said'), occurrence: z.number().optional().describe('1 = first time said (default), 2 = second, -1 = last') })]).optional().describe('text, instead of start/end: the words appear as this spoken phrase starts (timed from the video\'s own words; refused if it is never said)'),
23241
+ until: z.union([z.string(), z.object({ word: z.string().describe('the word or phrase as it is said'), occurrence: z.number().optional().describe('counted from the at phrase on') })]).optional().describe('text with at: the words leave as this phrase ends'),
23242
+ duration: z.number().optional().describe('text with at and no until: seconds the words stay, 0.3-30 (default 2)'),
23226
23243
  clips: z.array(z.object({ url: z.string().describe('clip to join after the video: Library URL, direct file or public post link'), start: z.number().optional().describe('seconds into this clip to start from'), end: z.number().optional().describe('seconds into this clip to stop at'), match: z.any().optional().describe('seam match for this clip: auto (default), off, or a grade object'), reframe: z.any().optional().describe('shot change at this seam: auto, off, or a step from 1.1 to 1.5') })).optional().describe('join: the clips after this video'),
23227
23244
  reframe: z.any().optional().describe("join: shot change at a same-framing stitch, 'auto' | 'off' | step 1.1-1.5"),
23228
23245
  match: z.any().optional().describe("join: seam match, 'auto' default | 'off' | {grade,level,grain,blur,strength}"),
@@ -23370,7 +23387,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23370
23387
 
23371
23388
  server.registerTool('fix_beat', {
23372
23389
  title: 'Fix a video beat',
23373
- description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this. A FINISHED VIDEO-BLOCKS FILM (make_explainer's explainer or faceless channel; get_job says it is editable) is edited ONE BLOCK at a time instead: pass `job` (its job id) and `block` (1-based) or `atSeconds`, with `prompt` for a different shot and/or `line` for new narration words. Only that block is re-rendered and/or re-read; every other block, take, the music and the captions are the film's own and stay aligned. It is priced as that block alone and charged exactly what it quotes (dryRun). The original film is kept; a new film comes back, and it can be edited again.",
23390
+ description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this. A FINISHED VIDEO-BLOCKS FILM (make_explainer's explainer or faceless channel; get_job says it is editable) is edited ONE BLOCK at a time instead: pass `job` (its job id) and `block` (1-based) or `atSeconds`, with `prompt` for a different shot and/or `line` for new narration words. Only that block is re-rendered and/or re-read; every other block, take, the music and the captions are the film's own and stay aligned. It is priced as that block alone and charged exactly what it quotes (dryRun). The original film is kept; a new film comes back, and it can be edited again. Feedback maps to one named move, checked by reading the parameters back, never by re-rendering: quieter/louder (music db, audio_gain), faster/slower (speed; slower = zooms and pans at 0.7x), tighten (trim heads, tails, silences), retime (to a word anchor), recut (order, in-out points), regrade (timeline exposure/contrast/saturation keys).",
23374
23391
  inputSchema: {
23375
23392
  videoUrl: z.string().optional().describe('the served URL of the master video to fix (not needed with `job`)'),
23376
23393
  startSeconds: z.number().optional().describe('window start in seconds (not needed with `job`)'),
@@ -23444,7 +23461,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
23444
23461
  // upload or someone else's video had no way to get them. The look is the same textStyle vocabulary render_ad speaks.
23445
23462
  server.registerTool('add_subtitles', {
23446
23463
  title: 'Add subtitles to a video',
23447
- description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a YouTube/Vimeo-style page, or a TikTok / Reel / X post link. No speech is refused and refunded. Runs in the background and lands in the Library.",
23464
+ description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Only when asked, textStyle 'highlight' (or a highlightColor) lights the word being said, timed word by word. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a YouTube/Vimeo-style page, or a TikTok / Reel / X post link. No speech is refused and refunded. Runs in the background and lands in the Library.",
23448
23465
  inputSchema: {
23449
23466
  video: z.string().describe('the video to subtitle'),
23450
23467
  textStyle: TEXT_STYLE_Z.optional().describe('the look: words, a preset or fields; omit for the default'),
@@ -24413,7 +24430,7 @@ function memoryNoteVerdict(text) {
24413
24430
  title: 'List saved creators',
24414
24431
  description: 'List this workspace’s SAVED CREATORS — the reusable on-camera cast (AI creators made here, a person pulled from a social profile, a consented photo upload). Read-only, FREE. Each entry gives the name, the PORTRAIT URL, where the portrait came from and whether a real person’s likeness consent is on file, how many extra pose plates exist, and any chosen or cloned voice. TO PUT ONE IN A FINISHED AD, pass their id or name as render_ad’s `creator` — that casts them for the whole spot (and skips the character-portrait render, so it costs less than not casting anyone). THE PORTRAIT URL IS THE REUSE HANDLE for the raw lanes — pass it as generate_avatar’s `image` (a talking clip of them), generate_video’s `refImage` (they star in the scene), recast_motion’s `image` (they perform a reference clip’s motion), or generate_image’s `refImages`. CALL THIS BEFORE OFFERING TO GENERATE A NEW PERSON: re-casting somebody the workspace already has keeps the SAME face across every ad, while a fresh person costs credits and breaks that continuity. An empty answer means the workspace genuinely has no cast yet — say so and offer generate_avatar / save_creator, never invent a roster.',
24415
24432
  inputSchema: { limit: z.number().optional().describe('max creators to return (default 24)'), gender: z.enum(['female', 'male']).optional().describe('filter the PRESET creators by gender (the saved cast is never filtered)') },
24416
- outputSchema: { creators: z.array(z.any()).optional().describe('the saved cast — {id, name, image, source, consented, poses, voice, voiceClone, look}'), count: z.number().optional(), presets: z.array(z.any()).optional().describe('the PRESET AI creators anyone can cast free with no generation — {id, name, gender, age, image, look, refs}') },
24433
+ outputSchema: { creators: z.array(z.any()).optional().describe('the saved cast — {id, name, image, source, consented, poses, voice, voiceClone, look}; a creator whose stored photos were deleted also carries imagesMissing: true and imagesNote, and cannot be cast until save_creator (same name, a new photo url) replaces the photo'), count: z.number().optional(), presets: z.array(z.any()).optional().describe('the PRESET AI creators anyone can cast free with no generation — {id, name, gender, age, image, look, refs}') },
24417
24434
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
24418
24435
  }, wrap(async (a) => {
24419
24436
  const d = await apiGet('/api/creators', { ...(a.limit ? { limit: Math.max(1, Math.min(200, Math.round(+a.limit) || 24)) } : {}), ...(a.gender ? { gender: a.gender } : {}) });
@@ -24465,6 +24482,24 @@ function memoryNoteVerdict(text) {
24465
24482
  const q = r?.refQuality?.score != null ? ` (photo quality ${r.refQuality.score}/100)` : '';
24466
24483
  return ok(r?.lowQualityRef ? `Replaced “${low.name}”’s photo${q}, but this one is still too unclear to cast well — try a sharper, front-facing, well-lit photo, or save_creator(name: "${low.name}", useAnyway: true).` : `Replaced “${low.name}”’s photo${q} — renders now cast them from it.`, { ok: true, id: low.id, creator: { id: low.id, name: low.name, image: abs(low.image), source: low.source }, refQuality: r?.refQuality || null });
24467
24484
  }
24485
+ // PHOTOS DELETED (2026-11-05): a creator whose stored photos are gone (list_creators says imagesMissing) is REPLACED
24486
+ // in place by a new photo under the same name, for the same reason as above: a twin row would make the name ambiguous
24487
+ // and the cast would refuse both. The server says which rows are gone; a check that could not tell flags nothing.
24488
+ if (image && !low) {
24489
+ let gone = null;
24490
+ try { gone = ((await apiGet('/api/creators', { limit: 200 }))?.creators || []).find(c => c && c.imagesMissing && String(c.name || '').trim().toLowerCase() === name.toLowerCase()) || null; } catch { gone = null; }
24491
+ const row = gone ? _l.find(x => x && x.id === gone.id) : null;
24492
+ if (row) {
24493
+ if (!/^https?:\/\//i.test(image) && !image.startsWith('/generated/')) return { content: [{ type: 'text', text: 'The new photo must be a public https url — upload the file with upload_file first, then pass the url it returns.' }], isError: true };
24494
+ const real = row.source === 'upload' || row.source === 'social';
24495
+ const r = real ? await apiPost('/api/creator/ref', { image, name: row.name }) : null;
24496
+ row.image = r?.image || image; row.poses = (Array.isArray(a.poses) ? a.poses : []).filter(p => typeof p === 'string' && p.trim()).slice(0, 4);
24497
+ if (real) { row.refQuality = r?.refQuality || null; row.lowQualityRef = !!r?.lowQualityRef; delete row.refAccepted; await apiPost('/api/likeness/consent', { kind: 'face', via: 'api-implied', lane: 'save_creator', creatorId: row.id, source: row.source, image: row.image }); row.consentAt = Date.now(); row.consentVia = 'api-implied'; }
24498
+ if (String(a.look || '').trim()) row.desc = String(a.look).trim().slice(0, 400);
24499
+ await writeStore('heist.avatars.v1', _l);
24500
+ return ok(`Replaced “${row.name}”’s deleted photo — renders now cast them from the new one.`, { ok: true, id: row.id, creator: { id: row.id, name: row.name, image: abs(row.image), source: row.source }, ...(r?.refQuality ? { refQuality: r.refQuality } : {}) });
24501
+ }
24502
+ }
24468
24503
  }
24469
24504
  if (!name || !image) return { content: [{ type: 'text', text: 'A creator needs both a name and a portrait url.' }], isError: true };
24470
24505
  // The portrait must be FETCHABLE by every render lane that will consume it. A data: blob or a local path is a
@@ -26002,13 +26037,14 @@ function memoryNoteVerdict(text) {
26002
26037
 
26003
26038
  server.registerTool('draft_brand', {
26004
26039
  title: 'Draft profile',
26005
- description: 'Fill in a profile (a business brand or a creator) from a website domain, a free-text description, or a social handle, into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 credits. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
26040
+ description: 'Fill in a profile (a business brand or a creator) from a website domain, a free-text description, or a social handle, into a {name, products, logo, …} object you can pass to plan_ad / generate. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
26006
26041
  inputSchema: {
26007
26042
  domain: z.string().optional().describe('a website to scrape, or a social link (profile, YouTube channel or one post) to draft from the account it names'),
26008
26043
  description: z.string().optional().describe('a free-text brand description (no website)'),
26009
26044
  socialHandle: z.string().optional().describe('a social handle (creators); needs platform, never guessed'),
26010
26045
  platform: z.string().optional().describe('socialHandle platform: instagram/tiktok/youtube/twitter/threads'),
26011
26046
  save: z.boolean().optional().describe('save onto the active profile (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to REPLACE the saved profile (the drafted fields overwrite the saved ones), false to never save'),
26047
+ confirmed: z.boolean().optional().describe('user confirmed it is their brand: read + save site product photos (paid)'),
26012
26048
  },
26013
26049
  outputSchema: {
26014
26050
  name: z.string().optional().describe('the drafted brand name — VERIFY it matches the brand the user meant'),
@@ -26025,8 +26061,14 @@ function memoryNoteVerdict(text) {
26025
26061
  // field, which a user can only undo by re-entering what was there. The default path never overwrites (it saves
26026
26062
  // only into an empty workspace) — but the doc's rule is "even in only select modes", so the hint is honest.
26027
26063
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
26028
- }, wrap(async ({ save, ...a }) => {
26029
- const d = await apiPost('/api/brand/draft', a);
26064
+ }, wrap(async ({ save, confirmed, ...a }) => {
26065
+ // THE PHOTO REVIEW WAITS FOR THE USER'S YES (journey QA 2026-11-05, admin finding fi:2960317705eb): the web onboarding
26066
+ // used to bill the Product-photo review before "Is this you?" was answered, so a "Not me" had already paid for photos
26067
+ // of a brand nobody wanted. This tool cannot ask, and it read the photos in the SAME call that returns the name the
26068
+ // caller is told to verify: the same premature charge. So the site photo read runs only on confirmed:true. A confirmed
26069
+ // call asks the draft to `resume`, so a second call within 30 minutes is answered from the first draft, not run again.
26070
+ const _photosConfirmed = confirmed === true;
26071
+ const d = await apiPost('/api/brand/draft', _photosConfirmed && a.domain ? { ...a, resume: true } : a);
26030
26072
  const p = d.profile || d;
26031
26073
  // ALWAYS TRY THE WEBSITE (2026-07-28). /api/brand/draft returns the PROFILE only — it never fetched a single
26032
26074
  // product photo, so an MCP-onboarded brand was structurally photo-less even with a perfectly good domain, and every
@@ -26042,7 +26084,10 @@ function memoryNoteVerdict(text) {
26042
26084
  // A CREATOR'S SITE IS READ FOR PHOTOS ONLY WHEN THE DRAFT NAMED A PRODUCT (journey QA 2026-11-05, @cookingwithlynja): the
26043
26085
  // photo review is paid, and a handle draft's domain is usually a link-in-bio page. Same rule as the web (brandSellsProduct).
26044
26086
  const _creatorNoProduct = !!a.socialHandle && !(Array.isArray(p?.productNames) && p.productNames.some((x) => typeof x === 'string' && x.trim()));
26045
- if (p && p.domain && p.physical_product !== false && !_creatorNoProduct) {
26087
+ const _photosHeld = !_photosConfirmed && !!(p && p.domain && p.physical_product !== false && !_creatorNoProduct); // would read photos, waiting for the yes
26088
+ let _photosRead = false;
26089
+ if (_photosConfirmed && p && p.domain && p.physical_product !== false && !_creatorNoProduct) {
26090
+ _photosRead = true;
26046
26091
  try {
26047
26092
  const site = await apiGet('/api/site/images', { url: p.domain });
26048
26093
  const imgs = [...(site?.images || [])].filter(Boolean);
@@ -26064,7 +26109,18 @@ function memoryNoteVerdict(text) {
26064
26109
  if (save !== false) {
26065
26110
  try {
26066
26111
  const cur = save === true ? null : await apiGet('/api/brand/current').catch(() => null);
26067
- if (save === true || !cur?.hasBrand) {
26112
+ const _host = (x) => String(x || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/[/?#].*$/, '');
26113
+ const _photoFields = ['productImages', 'product', 'productTitles', 'products', 'logo'].filter((f) => p[f] != null && !(Array.isArray(p[f]) && !p[f].length));
26114
+ if (save !== true && _photosRead && cur?.hasBrand && cur.brand && _host(cur.brand.domain) && _host(cur.brand.domain) === _host(p.domain) && _photoFields.length) {
26115
+ // The confirmed follow-up to a draft that was already saved here (the default save into an empty workspace):
26116
+ // add the photos to that brand, stamping only the photo fields, so every other saved value stays as it is.
26117
+ const _now = Date.now();
26118
+ const _at = { ...(cur.brand._fieldAt || {}) }; const _add = {};
26119
+ for (const f of _photoFields) { _add[f] = p[f]; _at[f] = _now; }
26120
+ const put = await apiPut(`/api/store/${encodeURIComponent(await pk('heist.brand.v1'))}`, { value: JSON.stringify({ ...cur.brand, ..._add, _fieldAt: _at, updatedAt: _now }) });
26121
+ saved = true;
26122
+ if (Array.isArray(put?.keptNewer) && put.keptNewer.length) keptNewer = put.keptNewer;
26123
+ } else if (save === true || !cur?.hasBrand) {
26068
26124
  // The SAME server-resolved namespace every other store write uses. Hand-rolling it from HEIST_PROFILE is
26069
26125
  // what made `create_brand` → `use_brand` → `draft_brand` overwrite the ACCOUNT'S DEFAULT BRAND while
26070
26126
  // leaving the new workspace empty — the sharpest edge of the whole defect, in the exact agency flow
@@ -26082,7 +26138,8 @@ function memoryNoteVerdict(text) {
26082
26138
  }
26083
26139
  } catch {} // saving is best-effort — the drafted profile is still returned either way
26084
26140
  }
26085
- return ok(`Drafted brand: ${p.name || '—'}${p.category ? ' · ' + p.category : ''}.${saved ? (keptNewer ? ` Saved as the workspace brand, EXCEPT ${keptNewer.join(', ')}: a newer edit to those is already stored and was kept.` : ' Saved as the workspace brand — plan_ad/create now use it automatically.') : ' Pass this object to plan_ad.'}`, p);
26141
+ const _photoNote = _photosHeld ? ` Product photos NOT read yet: the website's photo review is paid, so it waits for the user's yes. Ask them whether ${p.name || 'this'} (${p.domain}) is their brand; if it is, call draft_brand again with domain "${p.domain}" and confirmed:true (the draft is reused, not billed again) to read and save the photos. If it is not, nothing was spent on photos.` : '';
26142
+ return ok(`Drafted brand: ${p.name || '—'}${p.category ? ' · ' + p.category : ''}.${saved ? (keptNewer ? ` Saved as the workspace brand, EXCEPT ${keptNewer.join(', ')}: a newer edit to those is already stored and was kept.` : ' Saved as the workspace brand — plan_ad/create now use it automatically.') : ' Pass this object to plan_ad.'}${_photoNote}`, { ...p, ...(_photosHeld ? { photosHeld: true } : {}) });
26086
26143
  }));
26087
26144
 
26088
26145
  // ---------- assets ----------
@@ -26258,7 +26315,7 @@ function memoryNoteVerdict(text) {
26258
26315
 
26259
26316
  server.registerTool('score_ad', {
26260
26317
  title: 'Score ad',
26261
- description: "Virality/performance prediction for a finished ad (image or video URL): overall score, per-dimension breakdown (scroll-stop, hook, clarity, brand/product, CTA, retention, goal fit), strengths, and the single biggest fix. Use BEFORE spending on distribution, or to rank variants.",
26318
+ description: "Virality/performance prediction for a finished ad (image or video URL): overall score, per-dimension breakdown (scroll-stop, hook, clarity, brand/product, CTA, retention, goal fit, own: could another brand run it unchanged), strengths, and the single biggest fix. Use BEFORE spending on distribution, or to rank variants.",
26262
26319
  inputSchema: {
26263
26320
  url: z.string().describe('the ad asset URL (a /generated/ path or public URL)'),
26264
26321
  kind: z.enum(['image', 'video']).optional().describe("'image' (default) or 'video'"),
@@ -26279,6 +26336,32 @@ function memoryNoteVerdict(text) {
26279
26336
  return ok(`Overall ${d.overall}/100 (${d.tier || ''})\n${dims}\nBiggest lever: ${d.top_fix || '—'}`, d);
26280
26337
  }));
26281
26338
 
26339
+ server.registerTool('check_video', {
26340
+ title: 'Check a video',
26341
+ description: "Check a finished video for named, mechanical issues, ONLY when the user asks (never after every render, and it never re-renders): loudness vs a target (default -14 LUFS) and true peak, black and frozen stretches, captions or text outside the platform-safe band, text running off the frame. Optional assertions about named elements: appearsBy {element, bySec}, before {a, b}, staysInFrame {element}, keepsMoving {maxStaticSec}. Each issue comes with a Fix line. The loudness, black and frozen checks are free; the on-screen text and element read is a small paid frame read (frames:false skips it).",
26342
+ inputSchema: {
26343
+ url: z.string().describe('the video: a /generated/ path, a public URL or a post link'),
26344
+ frames: z.boolean().optional().describe('false skips the paid text and element read'),
26345
+ loudnessTarget: z.number().optional().describe('integrated loudness target in LUFS, default -14'),
26346
+ assertions: z.array(z.object({
26347
+ kind: z.enum(['appearsBy', 'before', 'staysInFrame', 'keepsMoving']),
26348
+ element: z.string().optional().describe('an element named in words, e.g. "the logo"'), a: z.string().optional(), b: z.string().optional(),
26349
+ bySec: z.number().optional(), maxStaticSec: z.number().optional(),
26350
+ })).max(12).optional().describe('what must hold, checked on sampled frames'),
26351
+ },
26352
+ outputSchema: {
26353
+ ok: z.boolean().optional().describe('true when no error or warning was found'),
26354
+ issues: z.array(z.any()).optional().describe('each {code, severity, at?, message, fix}'),
26355
+ report: z.string().optional().describe('the issues as text, one Fix line each'),
26356
+ creditsUsed: z.number().optional(),
26357
+ },
26358
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true, idempotentHint: false },
26359
+ }, wrap(async ({ url, frames, loudnessTarget, assertions }) => {
26360
+ const d = await apiPost('/api/video/check', { url, ...(frames === false ? { frames: false } : {}), ...(loudnessTarget != null ? { loudnessTarget } : {}), ...(assertions ? { assertions } : {}) });
26361
+ if (!d) return ok('Could not check that video.');
26362
+ return ok(`${d.report}${d.creditsUsed ? `\n(${d.creditsUsed} credit${d.creditsUsed === 1 ? '' : 's'} for the frame read)` : ''}`, { ok: d.ok, issues: d.issues, report: d.report, creditsUsed: d.creditsUsed || 0 });
26363
+ }));
26364
+
26282
26365
  server.registerTool('reframe_video', {
26283
26366
  title: 'Reframe video',
26284
26367
  description: "Reframe a video to a different aspect ratio (e.g. 16:9 master -> 9:16 vertical): the whole original frame is kept and the new canvas around it is generated, with the sound and timing unchanged. Clips up to 300 seconds. Paid render; returns the served URL of the reframed video. Every placement at once (a free blur-pad by default): repurpose_video. A presenter on any video: add_presenter.",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.522",
3
+ "version": "0.1.524",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1177 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 10 posts a month and every paid plan posts without a limit. 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, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
5
+ "description": "AI marketing agent and AI ad generator, run from your own AI agent. 1178 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 10 posts a month and every paid plan posts without a limit. 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, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"