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.
- package/README.md +2 -2
- package/mcp/tools.mjs +107 -24
- 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
|
-
**
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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"
|