hermoso 0.1.335 → 0.1.345
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/http.mjs +9 -0
- package/mcp/tools.mjs +79 -47
- package/package.json +1 -1
package/mcp/http.mjs
CHANGED
|
@@ -71,6 +71,15 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
|
|
|
71
71
|
});
|
|
72
72
|
app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
|
|
73
73
|
app.get(`/.well-known/oauth-protected-resource${MCP_PATH}`, protectedResourceMetadata);
|
|
74
|
+
// ── AND AT THE APPENDED FORM, /mcp/.well-known/oauth-protected-resource (2026-09-28, Perplexity) ──────────────────
|
|
75
|
+
// Perplexity's connector (UA `Perplexity-MCP/1.0`) probes this URL FIRST on every connect and every session, got our
|
|
76
|
+
// 404, and fell through to the RFC 9728 inserted form above (prod log 2026-09-28 13:10Z: 404 → 200 → AS metadata, on
|
|
77
|
+
// each of its five opens). It is not an RFC 9728 construction (§3.1 only INSERTS the suffix), but it is the naive
|
|
78
|
+
// "resource URL + suffix" one, and it is SAFE to answer with the same document: a client that built it by appending
|
|
79
|
+
// to `${BASE}/mcp` validates `resource` against `${BASE}/mcp` (§3.3), which is exactly what the document says. That
|
|
80
|
+
// is the property the AS metadata lacks (see the asymmetry note above), so this alias is for the PRM only. Same one
|
|
81
|
+
// handler, never a copy. Pinned by the canary (`prm-appended`) and tools/mcp-connect-canary-check.mjs.
|
|
82
|
+
app.get(`${MCP_PATH}/.well-known/oauth-protected-resource`, protectedResourceMetadata);
|
|
74
83
|
|
|
75
84
|
// ── THE STATIC SERVER CARD A DIRECTORY READS INSTEAD OF SCANNING (2026-09-25) ─────────────────────────────────────
|
|
76
85
|
// Smithery's re-scan stopped at "Authentication required": its first probe (UA `SmitheryBot/1.0 (+https://…)`) gets
|
package/mcp/tools.mjs
CHANGED
|
@@ -73,12 +73,15 @@ const creatorLine = (c) => {
|
|
|
73
73
|
// LIKENESS CONSENT OVER MCP / API / CLI IS IMPLIED BY THE CALL (product decision 2026-09-24). There is no consent flag
|
|
74
74
|
// and nothing is refused for a missing one: the Terms say that using a real person's photo through these tools IS the
|
|
75
75
|
// caller's confirmation that they have that person's consent, and each tool that takes one says so in one sentence.
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
// The RECORD is the server's: the spend gate every render passes writes one consent row (via 'api-implied') per real
|
|
77
|
+
// face a paid key's call uses, with the SHA-256 of its bytes and the attestation version (server.js
|
|
78
|
+
// scheduleImpliedLikeness, 2026-09-28). Tools no longer post it themselves: two did and five did not, which is how
|
|
79
|
+
// generate_image / generate_video / clone_static and the CLI used a face with nothing recorded. save_creator still posts
|
|
80
|
+
// it, awaited, because saving a portrait is not a render and must be refused on a free plan BEFORE the roster is written.
|
|
81
|
+
// THE ONE SENTENCE (2026-09-28, owner decision B): every tool that takes a real person's face or voice carries it,
|
|
82
|
+
// byte-identical to LIKENESS_TERMS in lib/likeness.mjs (tools/face-rights-check.mjs pins the copy). The CLI twin cannot
|
|
83
|
+
// import ../lib, so the words live here.
|
|
84
|
+
const LIKENESS_TERMS = 'Using a real person’s face, likeness or voice confirms you are them or have their consent, take full, unlimited responsibility for its use and will cover any claim against Hermoso (hermoso.ai/terms). Paid plan only; recorded.';
|
|
82
85
|
function rowLines(rows, max = 40) {
|
|
83
86
|
const all = Array.isArray(rows) ? rows : [];
|
|
84
87
|
if (!all.length) return ' (no rows)';
|
|
@@ -3297,8 +3300,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3297
3300
|
.sort((a, b) => b.score - a.score || a.k.length - b.k.length || a.k.localeCompare(b.k))
|
|
3298
3301
|
.slice(0, max).map((r) => r.k);
|
|
3299
3302
|
};
|
|
3303
|
+
// A HOST'S OWN PREFIX IS NOT PART OF THE NAME (2026-09-28): agents pass the id their host shows them —
|
|
3304
|
+
// `mcp__hermoso__search_instagram`, `mcp__claude_ai_Hermoso__…`, `hermoso:…`, `Hermoso.…` — and got "does not exist".
|
|
3305
|
+
// The bare name is tried only when the name as given is not itself a tool.
|
|
3306
|
+
const bareToolName = (raw, handleOf) => {
|
|
3307
|
+
if (handleOf[raw]) return raw;
|
|
3308
|
+
const bare = raw.replace(/^mcp__[A-Za-z0-9_-]*?__(?=[a-z])/, '').replace(/^hermoso[:./]/i, '');
|
|
3309
|
+
return handleOf[bare] ? bare : raw;
|
|
3310
|
+
};
|
|
3300
3311
|
const makeCallToolHandler = (ctx) => async ({ name, args } = {}, extra) => {
|
|
3301
|
-
const n = String(name || '').trim();
|
|
3312
|
+
const n = bareToolName(String(name || '').trim(), ctx.handleOf);
|
|
3302
3313
|
const h = ctx.handleOf[n];
|
|
3303
3314
|
// A RETIRED NAME ANSWERS THE SAME SENTENCE HERE AS IT DOES ON A DIRECT CALL (2026-09-17). LEGACY_TOOL_NAMES was
|
|
3304
3315
|
// built for `tools/call` from a host holding an old snapshot and wired ONLY into installHeldToolCalls — so an
|
|
@@ -3652,6 +3663,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3652
3663
|
video: z.boolean().optional().describe('TRUE when video generation is available. A BOOLEAN, never a provider name (same never-leak-the-provider rule as image). The model ids live in options.video.models[].'),
|
|
3653
3664
|
canEdit: z.boolean().optional().describe('whether image editing is enabled on this account'),
|
|
3654
3665
|
canAvatar: z.boolean().optional().describe('whether talking-avatar generation is enabled'),
|
|
3666
|
+
avatarEngines: z.array(z.any()).optional().describe('the talking-avatar engines this account can use (generate_avatar `engine`): id, label, model, default, resolutions, creditsPerSecond, and oneAtATime for an engine that renders one video at a time'),
|
|
3655
3667
|
editCredits: z.number().nullable().optional().describe('credit cost of one image edit (null when image editing is not configured)'),
|
|
3656
3668
|
options: z.any().optional().describe('the live model catalog — image/video/voice/llm model lists with per-model credit costs'),
|
|
3657
3669
|
recipes: z.array(z.any()).optional().describe('the creative recipe catalog (id + label per recipe)'),
|
|
@@ -3699,7 +3711,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3699
3711
|
// recast_motion's two tiers, priced off the same live quote the app's costs page reads (toolExamples), never a number here.
|
|
3700
3712
|
const _mq = (q) => (q ? Object.entries(q).map(([s, c]) => `${s}s=${c}cr`).join(' ') : null);
|
|
3701
3713
|
const _motionLine = _mq(d.toolExamples?.motion) ? `\nRecast motion (recast_motion, billed per output second): tier pro (default) ${_mq(d.toolExamples.motion)}${_mq(d.toolExamples.motionStandard) ? `; tier standard ${_mq(d.toolExamples.motionStandard)}` : ''}` : '';
|
|
3702
|
-
|
|
3714
|
+
// THE TALKING-AVATAR ENGINES (generate_avatar `engine`), priced live per second of speech — never a number here.
|
|
3715
|
+
const _avLine = (d.avatarEngines || []).length ? `\nTalking-avatar engines (generate_avatar engine): ${(d.avatarEngines || []).map(e => `${e.id}${e.default ? ' (default)' : ''} = ${e.label}${e.id === 'natural' ? ` · ${e.model}` : ''}, ~${e.creditsPerSecond}cr per second of speech, ${(e.resolutions || []).join('/')}${e.oneAtATime ? ', renders one video at a time (about ' + e.renderSecondsPerAudioSecond + 'x the speech length plus any wait), speech under 60s' : ''}`).join('; ')}` : '';
|
|
3716
|
+
const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\n${_pickLine}\n${_resLine}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`;
|
|
3703
3717
|
return ok(text + connLine, d);
|
|
3704
3718
|
}));
|
|
3705
3719
|
|
|
@@ -17102,7 +17116,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17102
17116
|
description: 'Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). MULTI-BRAND CAUTION: useBrand hydration pulls the SAVED workspace brand — when working a brand that is NOT the saved one (a fresh draft_brand), pass that brand\'s own productImages/logo as refImages (and useBrand:false) or the output composites the WRONG brand\'s product. NOTE that the saved-brand hydration also decides the ENGINE: attaching product photos routes the render to the compositing model, so a `model` you named is only honoured when no references ride — pass raw:true (or useBrand:false) to render on exactly the model you asked for. model = a catalog id from hermoso_capabilities (omit for the default). PUTTING A REAL PRODUCT IN A REAL PERSON’S HANDS, or a garment on them, is a DIFFERENT KIND OF ROW and you must name it: the ids marked `needsRefs` with a `refsMax` in hermoso_capabilities take a person photo first and up to three product/garment photos after it, and they EDIT THE PHOTOGRAPH rather than compositing — THE PERSON IS RE-POSED to hold or wear the thing, so their stance and hands change while their face, clothing, setting and lighting are kept. That is not an object swap in a fixed frame; if you needed the rest of the photograph untouched, this is the wrong tool. Every finished render says which way it went. RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Fast (seconds). Spends credits.',
|
|
17103
17117
|
inputSchema: {
|
|
17104
17118
|
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.'),
|
|
17105
|
-
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.
|
|
17119
|
+
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),
|
|
17106
17120
|
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'),
|
|
17107
17121
|
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.'),
|
|
17108
17122
|
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"),
|
|
@@ -17156,75 +17170,80 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17156
17170
|
// balance cannot cover it.
|
|
17157
17171
|
const staticItemsText = (d, label) => {
|
|
17158
17172
|
const items = Array.isArray(d.items) ? d.items : [];
|
|
17159
|
-
const rows = items.map((x, i) => `${i + 1}. ${x[label] || ''}${x.image ? ` — ${abs(x.image)}` : ` — not delivered: ${x.error || 'failed'}`}${x.textCheck && x.textCheck.ok === false ? `\n ⚠ ${x.textCheck.note}` : ''}`);
|
|
17173
|
+
const rows = items.map((x, i) => `${i + 1}. ${x[label] || ''}${x.image ? ` — ${abs(x.image)}` : ` — not delivered: ${x.error || 'failed'}`}${x.textCheck && x.textCheck.ok === false ? `\n ⚠ ${x.textCheck.note}` : ''}${x.labelPass && x.labelPass.note ? `\n ${x.labelPass.note}` : ''}`);
|
|
17160
17174
|
return `${rows.join('\n')}\n${d.note || ''}`;
|
|
17161
17175
|
};
|
|
17162
17176
|
const staticImages = async (d) => (await Promise.all((Array.isArray(d.items) ? d.items : []).filter(x => x.image).slice(0, 4).map(x => imageBlock(abs(x.image))))).filter(Boolean);
|
|
17163
17177
|
const staticItemsOut = z.array(z.any()).optional().describe('one entry per output: the image URL (or an error), what changed, and a textCheck flag when the rendered text may not match');
|
|
17164
17178
|
server.registerTool('edit_image', {
|
|
17165
17179
|
title: 'Edit an image',
|
|
17166
|
-
description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's Edit runs: composition, aspect ratio, people and every untouched line of text stay as they are; the saved brand's real name and website are pinned so an added line never invents one, and the brand's real logo is attached when the instruction asks for the logo. Set removal:true when the edit STRIPS text, branding or an object, so nothing branded is put back. For a precise region, pass `mask` (see generate_image). One image edit's credits; returns the new image URL. For a new image from a prompt use generate_image; to rebuild a competitor's ad for your brand use clone_static.",
|
|
17180
|
+
description: "EDIT an existing image in place with a plain-language instruction and keep everything else: 'make the headline bigger', 'add our logo bottom right', 'swap the background for a kitchen', 'remove the person on the left', 'erase all the text'. Pass `image` (URL, Library item, upload_file URL or local path) and `instruction`. The same edit the web Studio's Edit runs: composition, aspect ratio, people and every untouched line of text stay as they are; the saved brand's real name and website are pinned so an added line never invents one, and the brand's real logo is attached when the instruction asks for the logo. Set removal:true when the edit STRIPS text, branding or an object, so nothing branded is put back. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. For a precise region, pass `mask` (see generate_image). One image edit's credits; returns the new image URL. For a new image from a prompt use generate_image; to rebuild a competitor's ad for your brand use clone_static.",
|
|
17167
17181
|
inputSchema: {
|
|
17168
17182
|
image: z.string().describe('the image to edit: URL, Library item URL, upload_file URL or local path'),
|
|
17169
17183
|
instruction: z.string().describe('the change to make, in plain words (pass the user’s own words for a removal or plain photo edit)'),
|
|
17170
17184
|
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'),
|
|
17171
17185
|
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'),
|
|
17172
17186
|
dryRun: z.boolean().optional().describe('true = return the exact credits this edit reserves and render nothing'),
|
|
17187
|
+
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)'),
|
|
17173
17188
|
},
|
|
17174
17189
|
outputSchema: {
|
|
17175
17190
|
image: z.string().optional().describe('the served absolute URL of the edited image'),
|
|
17176
17191
|
model: z.string().optional().describe('the model label that rendered it'),
|
|
17177
17192
|
},
|
|
17178
17193
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17179
|
-
}, wrap(async ({ image, instruction, removal, mask, dryRun }) => {
|
|
17194
|
+
}, wrap(async ({ image, instruction, removal, mask, dryRun, fixLabel }) => {
|
|
17180
17195
|
const src = await toRef(image);
|
|
17181
17196
|
const maskRef = mask ? await toRef(mask) : undefined;
|
|
17182
|
-
|
|
17183
|
-
const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}) });
|
|
17197
|
+
const fl = fixLabel === false ? { fixLabel: false } : {};
|
|
17198
|
+
if (dryRun) { const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}), ...fl, dryRun: true }); return ok(quoteText(d && d.quote, 'This image edit (a change to the existing image, not a new render)'), {}); }
|
|
17199
|
+
const d = await apiPost('/api/static/edit', { image: src, instruction, ...(removal === true ? { removal: true } : {}), ...(maskRef ? { mask: maskRef } : {}), ...fl });
|
|
17184
17200
|
const img = await imageBlock(abs(d.image));
|
|
17185
|
-
return { content: [{ type: 'text', text: `Edited image: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
|
|
17201
|
+
return { content: [{ type: 'text', text: `Edited image: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}${d.productNote ? `\n${d.productNote}` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
|
|
17186
17202
|
}));
|
|
17187
17203
|
server.registerTool('headline_variants', {
|
|
17188
17204
|
title: 'Headline variants of a static ad',
|
|
17189
|
-
description: "Turn ONE finished static ad into several copies that differ ONLY in the headline, for an A/B test: same picture, product, layout, colours and every other line. Pass `image`, and either `headlines` (your own, up to 10) or `count` (default 5, max 10) to have distinct angles written for you in the saved brand's voice (never inventing numbers, prices, ratings or claims the ad or brand does not state); `brief` steers what to test. The ad's text is read first (3 credits), then one image edit per headline; each output is proofread and flagged (textCheck) if the rendered words do not match, never silently re-rendered. The whole batch is priced before anything runs. Returns each headline, its angle and its image URL.",
|
|
17205
|
+
description: "Turn ONE finished static ad into several copies that differ ONLY in the headline, for an A/B test: same picture, product, layout, colours and every other line. Pass `image`, and either `headlines` (your own, up to 10) or `count` (default 5, max 10) to have distinct angles written for you in the saved brand's voice (never inventing numbers, prices, ratings or claims the ad or brand does not state); `brief` steers what to test. The ad's text is read first (3 credits), then one image edit per headline; each output is proofread and flagged (textCheck) if the rendered words do not match, never silently re-rendered. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. The whole batch is priced before anything runs. Returns each headline, its angle and its image URL.",
|
|
17190
17206
|
inputSchema: {
|
|
17191
17207
|
image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
|
|
17192
17208
|
headlines: z.array(z.string()).optional().describe('your own headlines to test (up to 10); omit to have them written'),
|
|
17193
17209
|
count: z.number().int().min(1).max(10).optional().describe('how many headlines to write when `headlines` is omitted (default 5)'),
|
|
17194
17210
|
brief: z.string().optional().describe('what to test, e.g. "price-led vs outcome-led" or "speak to busy parents"'),
|
|
17211
|
+
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)'),
|
|
17195
17212
|
},
|
|
17196
17213
|
outputSchema: { items: staticItemsOut, note: z.string().optional(), original: z.string().optional().describe('the headline read off the source ad') },
|
|
17197
17214
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17198
|
-
}, wrap(async ({ image, headlines, count, brief }) => {
|
|
17199
|
-
const d = await apiPost('/api/static/headlines', { image: await toRef(image), ...(headlines?.length ? { headlines } : {}), ...(count ? { count } : {}), ...(brief ? { brief } : {}) });
|
|
17215
|
+
}, wrap(async ({ image, headlines, count, brief, fixLabel }) => {
|
|
17216
|
+
const d = await apiPost('/api/static/headlines', { image: await toRef(image), ...(headlines?.length ? { headlines } : {}), ...(count ? { count } : {}), ...(brief ? { brief } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) });
|
|
17200
17217
|
const imgs = await staticImages(d);
|
|
17201
17218
|
return { content: [{ type: 'text', text: `Headline variants (original: “${d.original || ''}”):\n${staticItemsText(d, 'headline')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
|
|
17202
17219
|
}));
|
|
17203
17220
|
server.registerTool('resize_ad', {
|
|
17204
17221
|
title: 'Resize a static ad for other placements',
|
|
17205
|
-
description: "Re-lay out ONE finished static ad for other placements: the same ad, product, copy (word for word), logo and style, recomposed natively for each canvas rather than cropped. Pass `image` and optionally `aspectRatios` from 1:1, 4:5, 9:16, 16:9, 3:4, 4:3 (default 1:1, 4:5 and 9:16; the ad's own ratio is skipped). Reads the ad's text first (3 credits) so every line survives, then one image edit per canvas. Priced before it runs. For VIDEO use reframe_video.",
|
|
17222
|
+
description: "Re-lay out ONE finished static ad for other placements: the same ad, product, copy (word for word), logo and style, recomposed natively for each canvas rather than cropped. Pass `image` and optionally `aspectRatios` from 1:1, 4:5, 9:16, 16:9, 3:4, 4:3 (default 1:1, 4:5 and 9:16; the ad's own ratio is skipped). Reads the ad's text first (3 credits) so every line survives, then one image edit per canvas. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. Priced before it runs. For VIDEO use reframe_video.",
|
|
17206
17223
|
inputSchema: {
|
|
17207
17224
|
image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
|
|
17208
17225
|
aspectRatios: z.array(z.enum(['1:1', '4:5', '9:16', '16:9', '3:4', '4:3'])).optional().describe('target canvases (default 1:1, 4:5, 9:16)'),
|
|
17226
|
+
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)'),
|
|
17209
17227
|
},
|
|
17210
17228
|
outputSchema: { items: staticItemsOut, note: z.string().optional(), sourceAspectRatio: z.string().optional() },
|
|
17211
17229
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17212
|
-
}, wrap(async ({ image, aspectRatios }) => {
|
|
17213
|
-
const d = await apiPost('/api/static/resize', { image: await toRef(image), ...(aspectRatios?.length ? { aspectRatios } : {}) });
|
|
17230
|
+
}, wrap(async ({ image, aspectRatios, fixLabel }) => {
|
|
17231
|
+
const d = await apiPost('/api/static/resize', { image: await toRef(image), ...(aspectRatios?.length ? { aspectRatios } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) });
|
|
17214
17232
|
const imgs = await staticImages(d);
|
|
17215
17233
|
return { content: [{ type: 'text', text: `Resized (source ${d.sourceAspectRatio || '?'}):\n${staticItemsText(d, 'aspectRatio')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
|
|
17216
17234
|
}));
|
|
17217
17235
|
server.registerTool('localize_ad', {
|
|
17218
17236
|
title: 'Localize a static ad into other languages',
|
|
17219
|
-
description: "Translate the on-image text of ONE finished static ad into other languages and keep everything else: same picture, layout, typeface, colours, logo and product. Pass `image` and `languages` (up to 5, e.g. [\"Spanish\", \"German\", \"French (Canada)\"]). The ad's text is read (3 credits), translated the way a native copywriter in each market would write it (brand and product names, URLs and prices kept as written), then one image edit per language; each output is proofread and flagged (textCheck) if the words do not match, never silently re-rendered. Priced before it runs. For a VIDEO use dub_video.",
|
|
17237
|
+
description: "Translate the on-image text of ONE finished static ad into other languages and keep everything else: same picture, layout, typeface, colours, logo and product. Pass `image` and `languages` (up to 5, e.g. [\"Spanish\", \"German\", \"French (Canada)\"]). The ad's text is read (3 credits), translated the way a native copywriter in each market would write it (brand and product names, URLs and prices kept as written), then one image edit per language; each output is proofread and flagged (textCheck) if the words do not match, never silently re-rendered. When the saved brand has a product photo and the ad shows that product, the photo rides with the edit and the product's label is read and checked against it: re-printed from the photo only when it came out wrong (the check alone is charged when it is right; one small extra charge finds the product first). fixLabel:false turns that off. Priced before it runs. For a VIDEO use dub_video.",
|
|
17220
17238
|
inputSchema: {
|
|
17221
17239
|
image: z.string().describe('the finished static ad: URL, Library item URL, upload_file URL or local path'),
|
|
17222
17240
|
languages: z.array(z.string()).min(1).max(5).describe('target languages, by name'),
|
|
17241
|
+
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)'),
|
|
17223
17242
|
},
|
|
17224
17243
|
outputSchema: { items: staticItemsOut, note: z.string().optional(), source: z.array(z.string()).optional().describe('the lines read off the source ad') },
|
|
17225
17244
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17226
|
-
}, wrap(async ({ image, languages }) => {
|
|
17227
|
-
const d = await apiPost('/api/static/localize', { image: await toRef(image), languages });
|
|
17245
|
+
}, wrap(async ({ image, languages, fixLabel }) => {
|
|
17246
|
+
const d = await apiPost('/api/static/localize', { image: await toRef(image), languages, ...(fixLabel === false ? { fixLabel: false } : {}) });
|
|
17228
17247
|
const imgs = await staticImages(d);
|
|
17229
17248
|
return { content: [{ type: 'text', text: `Localized:\n${staticItemsText(d, 'language')}` }, ...imgs], structuredContent: { ...d, items: (d.items || []).map(x => ({ ...x, image: x.image ? abs(x.image) : null })) } };
|
|
17230
17249
|
}));
|
|
@@ -17358,7 +17377,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17358
17377
|
description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), an optional brand end card (only when the user asks), a music bed under the voice, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a storyboard that FITS ONE CLIP OF THE RENDER MODEL renders as a single continuous pass; anything longer automatically renders as STITCHED ACTS (the fewest balanced clips, each at most one model clip) — never time-compressed into one clip. That threshold is the render model’s own maximum, not a fixed number: most models cap a clip at 15s and the longest-clip one goes to 30s, so use dryRun:true to see the act split this plan will actually get, for free, before spending. CAST A SAVED CREATOR with `creator` so the SAME person stars in this ad as in the last one (list_creators is the roster) — otherwise every render invents a new face. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
|
|
17359
17378
|
inputSchema: {
|
|
17360
17379
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
17361
|
-
creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default
|
|
17380
|
+
creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
|
|
17362
17381
|
model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
|
|
17363
17382
|
durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so use dryRun:true to see the exact act split for free before spending.'),
|
|
17364
17383
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
@@ -17795,7 +17814,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17795
17814
|
inputSchema: {
|
|
17796
17815
|
prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
|
|
17797
17816
|
raw: z.boolean().optional().describe('RAW MODEL ACCESS: dispatch this prompt to the model BYTE-IDENTICAL — no appended packaging/label guidance, no negative prompt, no reference-binding lines, no hex-to-colour-name rewrite. Use it when you want the model itself rather than Hermoso\'s render craft. Two vendor-required fixes still apply: extra @ImageN tokens are dropped and an over-long prompt is trimmed at a sentence. Billing, durable delivery and per-model validation are unchanged.'),
|
|
17798
|
-
refImage: z.string().optional().describe('local path or URL to anchor the first frame
|
|
17817
|
+
refImage: z.string().optional().describe('local path or URL to anchor the first frame. ' + LIKENESS_TERMS),
|
|
17818
|
+
speak: z.string().optional().describe('A PERSON SAYING THESE EXACT WORDS TO CAMERA — the default for any "make my photo talk" / spokesperson / talk-to-camera ask. Pass with `creator` or a portrait as `refImage`: the video model films them saying it in their own voice, matched to who they are, and the length follows the words (leave durationSeconds out). `prompt` is then the staging (e.g. "natural", "walking in a park"). Real footage, not an animated photo; generate_avatar is the animated-photo look, only when asked for by name'),
|
|
17819
|
+
creator: z.string().optional().describe('STAR A SAVED CREATOR in this clip — their id from list_creators, or the name you know them by. Their saved portrait rides first among the references as the on-camera person, with their saved consent, exactly as render_ad casts them; a real person saved from a photo keeps their real face on camera. An unknown name is refused by name, nothing charged.'),
|
|
17799
17820
|
refImages: z.array(z.string()).optional().describe('SEVERAL reference images (local paths or URLs) — a person, products, a place — that must all appear in the clip. Only models whose `refs.max` in hermoso_capabilities is above 1 use more than one, and each uses at most that many; with `refs.promptAddressed` true, name them in your prompt as Image 1, Image 2… in this order. minimax-h3-max-ref takes up to 9 and keeps each one as a reference rather than a first frame. On a model that takes one image, only the first is used.'),
|
|
17800
17821
|
refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the clip is transformed per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and, on every model hermoso_capabilities marks `sourceLength`, its LENGTH too: those endpoints have no duration parameter, their listed `durations` are the per-second price ladder, and a durationSeconds you send is reported back as unused rather than silently dropped. Trim the source to change the length. Omit `model` for the default editor, or name a model whose videoEdit is true in hermoso_capabilities (a named model that cannot edit is refused, nothing charged); refImage rides along as the look of what the edit adds. With extend:true this is instead the clip to EXTEND. Omit to generate a fresh clip."),
|
|
17801
17822
|
endImage: z.string().optional().describe('local path or URL of the LAST frame: the clip travels from refImage (required with it) to this image. Only models with endFrame true in hermoso_capabilities take it; any other named model is refused by name, nothing charged.'),
|
|
@@ -17886,21 +17907,31 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17886
17907
|
|
|
17887
17908
|
server.registerTool('generate_avatar', {
|
|
17888
17909
|
title: 'Generate talking avatar',
|
|
17889
|
-
|
|
17910
|
+
// ONE ENGINE ON THE PUBLIC SCHEMA (2026-09-28): the natural-motion engine is switched off (server OMNIHUMAN_AVATAR
|
|
17911
|
+
// unset) after its live cert was judged fake (product decision, 2026-09-28). This schema is a byte-twin shipped to npm, so it cannot read the
|
|
17912
|
+
// server's switch: it names no second engine, and `engine` is a free string the SERVER rules on — any engine it
|
|
17913
|
+
// offers is listed live in hermoso_capabilities (avatarEngines), and a name it does not offer is refused, free.
|
|
17914
|
+
description: "ANIMATE A PHOTO so it talks: the presenter in `image` says `script` word for word, a separate voice lip-synced onto the still. Use it ONLY when the user asks for an animated photo / talking photo / lip-sync look by name; for any other talk-to-camera or spokesperson ask use generate_video with `speak` (the person filmed saying it, which reads as real footage). About 1-3 min, holds the pose steady, 480p/720p. The per-second credit price is in hermoso_capabilities (avatarEngines); dryRun:true returns this exact job's hold without rendering. Blocks until done where the host allows, else returns a job id to poll with get_job. Requires canAvatar. Spends credits; a refusal before rendering costs nothing.",
|
|
17890
17915
|
inputSchema: {
|
|
17891
|
-
image: z.string().describe('local path or URL of the presenter portrait
|
|
17916
|
+
image: z.string().describe('local path or URL of the presenter portrait. ' + LIKENESS_TERMS),
|
|
17892
17917
|
script: z.string().describe('the words the avatar speaks'),
|
|
17893
|
-
voice: z.string().optional().describe('voice name (
|
|
17894
|
-
|
|
17918
|
+
voice: z.string().optional().describe('voice name (Aria / Sarah / George / Adam). Leave it out and the voice matches the person in `image`; if nobody can be read, the call is refused free asking for one'),
|
|
17919
|
+
engine: z.string().optional().describe('leave out for the standard engine; only an engine listed in hermoso_capabilities avatarEngines is accepted'),
|
|
17920
|
+
resolution: z.string().optional().describe("'720p' (default) or '480p'"),
|
|
17921
|
+
prompt: z.string().optional().describe('motion direction, only for an engine other than standard that hermoso_capabilities lists'),
|
|
17922
|
+
seed: z.number().int().optional().describe('fixed seed, only for an engine other than standard'),
|
|
17923
|
+
acceptQueue: z.boolean().optional().describe('only for an engine hermoso_capabilities marks oneAtATime: wait in line'),
|
|
17924
|
+
dryRun: z.boolean().optional().describe('return the credits this exact job would hold, without rendering'),
|
|
17895
17925
|
},
|
|
17896
17926
|
outputSchema: { ...JOB_OUT },
|
|
17897
17927
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17898
17928
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your avatar clip…', 'Avatar clip ready'),
|
|
17899
17929
|
}, wrap(async (a) => {
|
|
17900
17930
|
const image = await toRef(a.image);
|
|
17901
|
-
|
|
17902
|
-
const
|
|
17903
|
-
|
|
17931
|
+
const { dryRun, ...rest } = a;
|
|
17932
|
+
if (dryRun) { const q = await quoteJob('avatar', { ...rest, image }); return ok(quoteText(q, `This ${a.engine === 'natural' ? 'natural-motion ' : ''}avatar clip`), { raw: q }); }
|
|
17933
|
+
const r = await renderJob('avatar', { ...rest, image }, 'MCP avatar');
|
|
17934
|
+
return okVideo(`Avatar clip ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
|
|
17904
17935
|
}));
|
|
17905
17936
|
|
|
17906
17937
|
server.registerTool('stitch_video', {
|
|
@@ -17981,7 +18012,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17981
18012
|
// long render is received, so every disclosure the direct reply carries has to be here too — the model
|
|
17982
18013
|
// substitution (`switchNote`) explicitly, the vision-QA verdict via `okVideo`'s own `qaLine`. Both resolve the
|
|
17983
18014
|
// payload through `renderPayload`, which is why handing them the JOB works at all.
|
|
17984
|
-
const text = `Job ${id}: ${j.status}${j.progress ? ` (${Math.round(j.progress * 100)}%)` : ''}${url ? ` → ${url}` : ''}${channelOutcomeLine(res)}${j.error ? ` — ${j.error}` : ''}${j.status === 'done' ? switchNote(j) : ''}`;
|
|
18015
|
+
const text = `Job ${id}: ${j.status}${j.progress ? ` (${Math.round(j.progress * 100)}%)` : ''}${(j.status === 'queued' || j.status === 'running') && j.queue?.line ? ` — ${j.queue.line}` : ''}${url ? ` → ${url}` : ''}${channelOutcomeLine(res)}${j.error ? ` — ${j.error}` : ''}${j.status === 'done' ? switchNote(j) : ''}`;
|
|
17985
18016
|
// THE TEXT BLOCK MUST CARRY THE JOB, NOT JUST structuredContent (2026-08-24). ChatGPT hands a widget the
|
|
17986
18017
|
// content array WITHOUT structuredContent, so the render card — which polls this tool over the host bridge —
|
|
17987
18018
|
// received prose it could not read: 219 polls over 9 minutes, every one HTTP 200, against a job that finished
|
|
@@ -18401,12 +18432,12 @@ function memoryNoteVerdict(text) {
|
|
|
18401
18432
|
}, wrap(async (a) => {
|
|
18402
18433
|
const d = await apiGet('/api/creators', a.limit ? { limit: Math.max(1, Math.min(200, Math.round(+a.limit) || 24)) } : {});
|
|
18403
18434
|
const creators = (d.creators || []).map(c => ({ ...c, image: abs(c.image) })); // portraits ride abs() like every other asset url here
|
|
18404
|
-
if (!creators.length) return ok('This workspace has no saved creators yet — there is nobody to re-cast, so do not offer one.
|
|
18405
|
-
return ok(`${creators.length} saved creator(s) in this workspace:\n${creators.map(creatorLine).join('\n')}\nCast one into a finished ad with render_ad(creator: "<id or name>"); for the raw lanes, pass their portrait url as
|
|
18435
|
+
if (!creators.length) return ok('This workspace has no saved creators yet — there is nobody to re-cast, so do not offer one. generate_video with `speak` films a person from a portrait you supply saying your exact words, and save_creator adds a portrait to the reusable cast so the same person can star in later ads.', { creators: [], count: 0 });
|
|
18436
|
+
return ok(`${creators.length} saved creator(s) in this workspace:\n${creators.map(creatorLine).join('\n')}\nCast one into a finished ad with render_ad(creator: "<id or name>"); for them to say a line to camera use generate_video(creator: "<id or name>", speak: "…"); for the other raw lanes, pass their portrait url as generate_video.refImage / recast_motion.image / generate_image.refImages (or generate_avatar.image, only when the user asks for the animated-photo look by name) — either way that is what keeps the same face across ads.`, { creators, count: creators.length });
|
|
18406
18437
|
}));
|
|
18407
18438
|
server.registerTool('save_creator', {
|
|
18408
18439
|
title: 'Save a creator',
|
|
18409
|
-
description: 'Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s + > Pick a creator > save. Pass the portrait’s public url (a generate_image render of
|
|
18440
|
+
description: 'Add a portrait to this workspace’s reusable CAST so the SAME person can star in future ads — the headless twin of the app’s + > Pick a creator > save. Pass the portrait’s public url (a generate_image render of an AI person, or a photo of a real person you have permission to use, or of yourself; never a photo just because it is public) plus a name to call them by; from then on list_creators returns them and their url can be re-passed to generate_avatar / generate_video / recast_motion. Saving is FREE and renders nothing. LIKENESS: leave `source` "generated" for an AI-made person (free on every plan) and use "upload"/"social" for a REAL person. ' + LIKENESS_TERMS,
|
|
18410
18441
|
inputSchema: {
|
|
18411
18442
|
name: z.string().describe('what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it'),
|
|
18412
18443
|
image: z.string().optional().describe('REQUIRED except with useAnyway. public https url of the portrait (an existing render’s url, or any public photo). Not a local file path — upload it with upload_file first and save the url that returns'),
|
|
@@ -18434,7 +18465,7 @@ function memoryNoteVerdict(text) {
|
|
|
18434
18465
|
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 };
|
|
18435
18466
|
const r = await apiPost('/api/creator/ref', { image, name: low.name });
|
|
18436
18467
|
low.image = r?.image || image; low.refQuality = r?.refQuality || null; low.lowQualityRef = !!r?.lowQualityRef; delete low.refAccepted; low.poses = [];
|
|
18437
|
-
if (low.source === 'upload' || low.source === 'social') {
|
|
18468
|
+
if (low.source === 'upload' || low.source === 'social') { await apiPost('/api/likeness/consent', { kind: 'face', via: 'api-implied', lane: 'save_creator', creatorId: low.id, source: low.source, image: low.image }); low.consentAt = Date.now(); low.consentVia = 'api-implied'; } // awaited: a free plan is refused here, before the store is written
|
|
18438
18469
|
await writeStore('heist.avatars.v1', _l);
|
|
18439
18470
|
const q = r?.refQuality?.score != null ? ` (photo quality ${r.refQuality.score}/100)` : '';
|
|
18440
18471
|
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 });
|
|
@@ -18450,13 +18481,13 @@ function memoryNoteVerdict(text) {
|
|
|
18450
18481
|
// IDEMPOTENT ON (name, portrait): a retrying agent must get the SAME creator back, not a twin nobody can tell
|
|
18451
18482
|
// apart in the picker. A same-name creator with a DIFFERENT portrait is a deliberate re-shoot and still saves.
|
|
18452
18483
|
const dupe = list.find(x => x && String(x.name || '').trim().toLowerCase() === name.toLowerCase() && String(x.image || '') === image);
|
|
18453
|
-
if (dupe && (dupe.source === 'upload' || dupe.source === 'social') && !dupe.consentAt) { dupe.consentAt = Date.now(); dupe.consentVia = 'api-implied'; await writeStore('heist.avatars.v1', list);
|
|
18484
|
+
if (dupe && (dupe.source === 'upload' || dupe.source === 'social') && !dupe.consentAt) { await apiPost('/api/likeness/consent', { kind: 'face', via: 'api-implied', lane: 'save_creator', creatorId: dupe.id, source: dupe.source, image: dupe.image }); dupe.consentAt = Date.now(); dupe.consentVia = 'api-implied'; await writeStore('heist.avatars.v1', list); }
|
|
18454
18485
|
if (dupe) return ok(`“${name}” is already in the workspace cast.`, { ok: true, id: dupe.id, creator: { id: dupe.id, name, image: abs(image), source: dupe.source || source } });
|
|
18455
18486
|
const item = {
|
|
18456
18487
|
id: newId('av'), name, image,
|
|
18457
18488
|
poses: (Array.isArray(a.poses) ? a.poses : []).filter(p => typeof p === 'string' && p.trim()).slice(0, 4),
|
|
18458
18489
|
voice: String(a.voice || '').trim(), source,
|
|
18459
|
-
// A REAL person saved over MCP / API / CLI is consented BY THE CALL (see
|
|
18490
|
+
// A REAL person saved over MCP / API / CLI is consented BY THE CALL (see LIKENESS_TERMS above): stamped with the
|
|
18460
18491
|
// time and `consentVia: 'api-implied'`, and the ledger row carries the account. Never for a synthetic creator,
|
|
18461
18492
|
// which needs none — the same rule as the web's Avatars.add(source) → consentAt after its Confirm step.
|
|
18462
18493
|
consentAt: source !== 'generated' ? Date.now() : null,
|
|
@@ -18464,10 +18495,12 @@ function memoryNoteVerdict(text) {
|
|
|
18464
18495
|
...(String(a.look || '').trim() ? { desc: String(a.look).trim().slice(0, 400) } : {}),
|
|
18465
18496
|
createdAt: Date.now(),
|
|
18466
18497
|
};
|
|
18498
|
+
// A REAL PERSON: the consent is recorded FIRST and awaited, because that call is also where the server refuses a free
|
|
18499
|
+
// plan (2026-09-28) — nothing is written to the roster for a face the rules refuse.
|
|
18500
|
+
if (source !== 'generated') await apiPost('/api/likeness/consent', { kind: 'face', via: 'api-implied', lane: 'save_creator', creatorId: item.id, source, image });
|
|
18467
18501
|
await writeStore('heist.avatars.v1', [item, ...list].slice(0, 200));
|
|
18468
|
-
|
|
18469
|
-
|
|
18470
|
-
return ok(`Saved “${item.name}” to the workspace cast — they now show up in list_creators and in the app’s creator picker. Star them in a finished ad with render_ad(creator: "${item.name}"), or re-cast them in a raw render by passing ${abs(item.image)} as generate_avatar.image / generate_video.refImage / recast_motion.image.${warn}`, { ok: true, id: item.id, creator: { id: item.id, name: item.name, image: abs(item.image), source } });
|
|
18502
|
+
const warn = source !== 'generated' ? ' Saved as a real person: this is recorded as your confirmation that you are this person or have their consent, and that you take full, unlimited responsibility for their use (hermoso.ai/terms).' : '';
|
|
18503
|
+
return ok(`Saved “${item.name}” to the workspace cast — they now show up in list_creators and in the app’s creator picker. Star them in a finished ad with render_ad(creator: "${item.name}"), have them say a line with generate_video(creator: "${item.name}", speak: "…"), or re-cast them in a raw render by passing ${abs(item.image)} as generate_video.refImage / recast_motion.image (generate_avatar.image only for the animated-photo look, when asked for by name).${warn}`, { ok: true, id: item.id, creator: { id: item.id, name: item.name, image: abs(item.image), source } });
|
|
18471
18504
|
}));
|
|
18472
18505
|
server.registerTool('delete_creator', {
|
|
18473
18506
|
title: 'Delete a creator',
|
|
@@ -20141,7 +20174,7 @@ function memoryNoteVerdict(text) {
|
|
|
20141
20174
|
title: 'Recast motion',
|
|
20142
20175
|
description: "Motion transfer: re-perform a reference video's motion with a different person/character (supply their image). The reference clip drives the movement; the image supplies the identity. Paid render, billed per output second (the output is as long as the reference clip, 3-30s); a 5s clip takes about 5 minutes. Runs on the Pro tier by default: 1080p, and the person really handles the object the reference performer handles.",
|
|
20143
20176
|
inputSchema: {
|
|
20144
|
-
image: z.string().describe(
|
|
20177
|
+
image: z.string().describe('the actor/character image URL (who should appear). ' + LIKENESS_TERMS),
|
|
20145
20178
|
video: z.string().describe('the reference video whose motion to re-perform'),
|
|
20146
20179
|
prompt: z.string().optional().describe('optional scene/style guidance'),
|
|
20147
20180
|
orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
|
|
@@ -20150,7 +20183,6 @@ function memoryNoteVerdict(text) {
|
|
|
20150
20183
|
outputSchema: { ...JOB_OUT },
|
|
20151
20184
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
20152
20185
|
}, wrap(async ({ image, video, prompt = '', orientation = 'video', tier }) => {
|
|
20153
|
-
recordImpliedLikeness('recast_motion');
|
|
20154
20186
|
const r = await renderJob('motion', { image, video, prompt, orientation, ...(tier ? { tier } : {}) }, 'Motion recast');
|
|
20155
20187
|
return okVideo(`Recast video: ${r.url}`, r);
|
|
20156
20188
|
}));
|
|
@@ -20308,7 +20340,7 @@ function memoryNoteVerdict(text) {
|
|
|
20308
20340
|
title: 'Clone a static ad',
|
|
20309
20341
|
description: "One-click STATIC-AD CLONE (the web app calls it Clone): rebuild a competitor/reference STATIC (image) ad as an on-brand version — SAME layout, composition and energy, but YOUR product, brand colours, logo and voice, with every trace of the source brand removed. Pass `imageUrl` = the static ad image to clone. Uses your saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). IMAGES ONLY — for a video ad use clone_video with its link, then render_ad. Bills as one image generation.",
|
|
20310
20342
|
inputSchema: {
|
|
20311
|
-
imageUrl: z.string().describe('the URL of the static ad image to clone
|
|
20343
|
+
imageUrl: z.string().describe('the URL of the static ad image to clone. ' + LIKENESS_TERMS),
|
|
20312
20344
|
brandId: z.string().optional().describe('a brand id/name from list_brands to clone for; omit to use the active brand'),
|
|
20313
20345
|
},
|
|
20314
20346
|
outputSchema: {
|
|
@@ -20333,7 +20365,7 @@ function memoryNoteVerdict(text) {
|
|
|
20333
20365
|
title: 'Clone a static ad (old name)',
|
|
20334
20366
|
description: "The OLD NAME of clone_static, kept so agents that already call it keep working. It is the same tool with the same inputs, result and cost; prefer clone_static.",
|
|
20335
20367
|
inputSchema: {
|
|
20336
|
-
imageUrl: z.string().describe('the URL of the static ad image to clone
|
|
20368
|
+
imageUrl: z.string().describe('the URL of the static ad image to clone. ' + LIKENESS_TERMS),
|
|
20337
20369
|
brandId: z.string().optional().describe('a brand id/name from list_brands to clone for; omit to use the active brand'),
|
|
20338
20370
|
},
|
|
20339
20371
|
outputSchema: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.345",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 864 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. 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",
|