hermoso 0.1.336 → 0.1.347
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 +17 -2
- package/mcp/tools.mjs +71 -37
- 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
|
|
@@ -452,6 +461,12 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
452
461
|
|
|
453
462
|
const sid = req.headers['mcp-session-id'];
|
|
454
463
|
let entry = sid ? sessions.get(sid) : null;
|
|
464
|
+
// A CONNECT IS COMPLETE WHEN THE NEW TOKEN IS FIRST USED, WHATEVER THE METHOD (2026-09-29). This used to be reported on
|
|
465
|
+
// a session's first tools/list, and ChatGPT never lists on a reconnect — it opens each turn as initialize →
|
|
466
|
+
// initialized → GET → tools/call against the roster it already holds — so every ChatGPT sign-in since 09-25 read as
|
|
467
|
+
// "no completed connect" and paged. Reported once per session (and on each session-less request, which is rare); the
|
|
468
|
+
// watch matches it against the tokens it saw minted (lib/mcp-connect-watch.mjs) and ignores any other key.
|
|
469
|
+
if (!entry || !entry.connectSeen) { if (entry) entry.connectSeen = true; connectEvent(req, { step: 'mcp-use', ok: true, tokenHash: createHash('sha256').update(token).digest('hex') }); }
|
|
455
470
|
|
|
456
471
|
if (!entry) {
|
|
457
472
|
const init = req.method === 'POST' && hasInitialize(req.body);
|
|
@@ -507,7 +522,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
507
522
|
// WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
|
|
508
523
|
// request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
|
|
509
524
|
// tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
|
|
510
|
-
entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0 };
|
|
525
|
+
entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true };
|
|
511
526
|
if (typeof onSessionStart === 'function') { try { onSessionStart({ accountId: user.accountId || null, userId: user.userId || null, client: entry.client, ua: entry.ua, src: entry.src }); } catch {} }
|
|
512
527
|
// LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
|
|
513
528
|
// ever read is a guess. One line per session (not per call) so a new host identifies itself once and
|
|
@@ -541,7 +556,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
541
556
|
// Counted here, before the transport sees the body, so the tally is of what the CLIENT asked and not of what
|
|
542
557
|
// the SDK answered — a refused tools/call is still a call the roster earned.
|
|
543
558
|
for (const m of methodsOf(req.body)) {
|
|
544
|
-
if (m === 'tools/list')
|
|
559
|
+
if (m === 'tools/list') entry.listed = true; // the completed connect is reported above, on the token's first use of any kind
|
|
545
560
|
else if (m === 'tools/call') {
|
|
546
561
|
entry.calls = (entry.calls || 0) + 1;
|
|
547
562
|
// THE FIRST CALL IS THE MILESTONE, NOT THE LISTING (2026-09-07). The connect_mcp reward was granted on "an API
|
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"),
|
|
@@ -17363,7 +17377,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17363
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.',
|
|
17364
17378
|
inputSchema: {
|
|
17365
17379
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
17366
|
-
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),
|
|
17367
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)'),
|
|
17368
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.'),
|
|
17369
17383
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
@@ -17800,7 +17814,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17800
17814
|
inputSchema: {
|
|
17801
17815
|
prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
|
|
17802
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.'),
|
|
17803
|
-
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.'),
|
|
17804
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.'),
|
|
17805
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."),
|
|
17806
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.'),
|
|
@@ -17891,21 +17907,31 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17891
17907
|
|
|
17892
17908
|
server.registerTool('generate_avatar', {
|
|
17893
17909
|
title: 'Generate talking avatar',
|
|
17894
|
-
|
|
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.",
|
|
17895
17915
|
inputSchema: {
|
|
17896
|
-
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),
|
|
17897
17917
|
script: z.string().describe('the words the avatar speaks'),
|
|
17898
|
-
voice: z.string().optional().describe('voice name (
|
|
17899
|
-
|
|
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'),
|
|
17900
17925
|
},
|
|
17901
17926
|
outputSchema: { ...JOB_OUT },
|
|
17902
17927
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17903
17928
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your avatar clip…', 'Avatar clip ready'),
|
|
17904
17929
|
}, wrap(async (a) => {
|
|
17905
17930
|
const image = await toRef(a.image);
|
|
17906
|
-
|
|
17907
|
-
const
|
|
17908
|
-
|
|
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);
|
|
17909
17935
|
}));
|
|
17910
17936
|
|
|
17911
17937
|
server.registerTool('stitch_video', {
|
|
@@ -17986,7 +18012,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17986
18012
|
// long render is received, so every disclosure the direct reply carries has to be here too — the model
|
|
17987
18013
|
// substitution (`switchNote`) explicitly, the vision-QA verdict via `okVideo`'s own `qaLine`. Both resolve the
|
|
17988
18014
|
// payload through `renderPayload`, which is why handing them the JOB works at all.
|
|
17989
|
-
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) : ''}`;
|
|
17990
18016
|
// THE TEXT BLOCK MUST CARRY THE JOB, NOT JUST structuredContent (2026-08-24). ChatGPT hands a widget the
|
|
17991
18017
|
// content array WITHOUT structuredContent, so the render card — which polls this tool over the host bridge —
|
|
17992
18018
|
// received prose it could not read: 219 polls over 9 minutes, every one HTTP 200, against a job that finished
|
|
@@ -18406,12 +18432,12 @@ function memoryNoteVerdict(text) {
|
|
|
18406
18432
|
}, wrap(async (a) => {
|
|
18407
18433
|
const d = await apiGet('/api/creators', a.limit ? { limit: Math.max(1, Math.min(200, Math.round(+a.limit) || 24)) } : {});
|
|
18408
18434
|
const creators = (d.creators || []).map(c => ({ ...c, image: abs(c.image) })); // portraits ride abs() like every other asset url here
|
|
18409
|
-
if (!creators.length) return ok('This workspace has no saved creators yet — there is nobody to re-cast, so do not offer one.
|
|
18410
|
-
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 });
|
|
18411
18437
|
}));
|
|
18412
18438
|
server.registerTool('save_creator', {
|
|
18413
18439
|
title: 'Save a creator',
|
|
18414
|
-
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,
|
|
18415
18441
|
inputSchema: {
|
|
18416
18442
|
name: z.string().describe('what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it'),
|
|
18417
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'),
|
|
@@ -18424,7 +18450,12 @@ function memoryNoteVerdict(text) {
|
|
|
18424
18450
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional(), refQuality: z.any().optional() },
|
|
18425
18451
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18426
18452
|
}, wrap(async (a) => {
|
|
18427
|
-
const name = String(a.name || '').trim()
|
|
18453
|
+
const name = String(a.name || '').trim();
|
|
18454
|
+
// A LINK TO A PHOTO'S WEB PAGE (an Unsplash / Pexels / Pinterest page, 2026-09-29) is saved as the PICTURE behind it,
|
|
18455
|
+
// so the cast never holds a page no render can read; a page with no reachable picture is refused here, saving nothing.
|
|
18456
|
+
const resolveLink = async (u) => (/^https?:\/\//i.test(u) ? ((await apiPost('/api/ref/resolve', { url: u }))?.url || u) : u);
|
|
18457
|
+
const image = await resolveLink(String(a.image || '').trim());
|
|
18458
|
+
if (Array.isArray(a.poses)) a.poses = await Promise.all(a.poses.filter((p) => typeof p === 'string' && p.trim()).slice(0, 4).map((p) => resolveLink(p.trim())));
|
|
18428
18459
|
// A PHOTO FLAGGED TOO UNCLEAR TO CAST (lowQualityRef, 2026-09-22) is REPLACED in place by a new portrait under the
|
|
18429
18460
|
// same name — scored by the same rule the onboarding used — or accepted as it is with useAnyway. A twin row would
|
|
18430
18461
|
// make the creator's own cast ambiguous (two rows named as the brand → nobody is cast).
|
|
@@ -18439,7 +18470,7 @@ function memoryNoteVerdict(text) {
|
|
|
18439
18470
|
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 };
|
|
18440
18471
|
const r = await apiPost('/api/creator/ref', { image, name: low.name });
|
|
18441
18472
|
low.image = r?.image || image; low.refQuality = r?.refQuality || null; low.lowQualityRef = !!r?.lowQualityRef; delete low.refAccepted; low.poses = [];
|
|
18442
|
-
if (low.source === 'upload' || low.source === 'social') {
|
|
18473
|
+
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
|
|
18443
18474
|
await writeStore('heist.avatars.v1', _l);
|
|
18444
18475
|
const q = r?.refQuality?.score != null ? ` (photo quality ${r.refQuality.score}/100)` : '';
|
|
18445
18476
|
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 });
|
|
@@ -18455,13 +18486,13 @@ function memoryNoteVerdict(text) {
|
|
|
18455
18486
|
// IDEMPOTENT ON (name, portrait): a retrying agent must get the SAME creator back, not a twin nobody can tell
|
|
18456
18487
|
// apart in the picker. A same-name creator with a DIFFERENT portrait is a deliberate re-shoot and still saves.
|
|
18457
18488
|
const dupe = list.find(x => x && String(x.name || '').trim().toLowerCase() === name.toLowerCase() && String(x.image || '') === image);
|
|
18458
|
-
if (dupe && (dupe.source === 'upload' || dupe.source === 'social') && !dupe.consentAt) { dupe.consentAt = Date.now(); dupe.consentVia = 'api-implied'; await writeStore('heist.avatars.v1', list);
|
|
18489
|
+
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); }
|
|
18459
18490
|
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 } });
|
|
18460
18491
|
const item = {
|
|
18461
18492
|
id: newId('av'), name, image,
|
|
18462
18493
|
poses: (Array.isArray(a.poses) ? a.poses : []).filter(p => typeof p === 'string' && p.trim()).slice(0, 4),
|
|
18463
18494
|
voice: String(a.voice || '').trim(), source,
|
|
18464
|
-
// A REAL person saved over MCP / API / CLI is consented BY THE CALL (see
|
|
18495
|
+
// A REAL person saved over MCP / API / CLI is consented BY THE CALL (see LIKENESS_TERMS above): stamped with the
|
|
18465
18496
|
// time and `consentVia: 'api-implied'`, and the ledger row carries the account. Never for a synthetic creator,
|
|
18466
18497
|
// which needs none — the same rule as the web's Avatars.add(source) → consentAt after its Confirm step.
|
|
18467
18498
|
consentAt: source !== 'generated' ? Date.now() : null,
|
|
@@ -18469,10 +18500,12 @@ function memoryNoteVerdict(text) {
|
|
|
18469
18500
|
...(String(a.look || '').trim() ? { desc: String(a.look).trim().slice(0, 400) } : {}),
|
|
18470
18501
|
createdAt: Date.now(),
|
|
18471
18502
|
};
|
|
18503
|
+
// A REAL PERSON: the consent is recorded FIRST and awaited, because that call is also where the server refuses a free
|
|
18504
|
+
// plan (2026-09-28) — nothing is written to the roster for a face the rules refuse.
|
|
18505
|
+
if (source !== 'generated') await apiPost('/api/likeness/consent', { kind: 'face', via: 'api-implied', lane: 'save_creator', creatorId: item.id, source, image });
|
|
18472
18506
|
await writeStore('heist.avatars.v1', [item, ...list].slice(0, 200));
|
|
18473
|
-
|
|
18474
|
-
|
|
18475
|
-
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 } });
|
|
18507
|
+
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).' : '';
|
|
18508
|
+
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 } });
|
|
18476
18509
|
}));
|
|
18477
18510
|
server.registerTool('delete_creator', {
|
|
18478
18511
|
title: 'Delete a creator',
|
|
@@ -20146,7 +20179,7 @@ function memoryNoteVerdict(text) {
|
|
|
20146
20179
|
title: 'Recast motion',
|
|
20147
20180
|
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.",
|
|
20148
20181
|
inputSchema: {
|
|
20149
|
-
image: z.string().describe(
|
|
20182
|
+
image: z.string().describe('the actor/character image URL (who should appear). ' + LIKENESS_TERMS),
|
|
20150
20183
|
video: z.string().describe('the reference video whose motion to re-perform'),
|
|
20151
20184
|
prompt: z.string().optional().describe('optional scene/style guidance'),
|
|
20152
20185
|
orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
|
|
@@ -20155,7 +20188,6 @@ function memoryNoteVerdict(text) {
|
|
|
20155
20188
|
outputSchema: { ...JOB_OUT },
|
|
20156
20189
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
20157
20190
|
}, wrap(async ({ image, video, prompt = '', orientation = 'video', tier }) => {
|
|
20158
|
-
recordImpliedLikeness('recast_motion');
|
|
20159
20191
|
const r = await renderJob('motion', { image, video, prompt, orientation, ...(tier ? { tier } : {}) }, 'Motion recast');
|
|
20160
20192
|
return okVideo(`Recast video: ${r.url}`, r);
|
|
20161
20193
|
}));
|
|
@@ -20166,11 +20198,12 @@ function memoryNoteVerdict(text) {
|
|
|
20166
20198
|
// the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
|
|
20167
20199
|
server.registerTool('recast_hook', {
|
|
20168
20200
|
title: 'Recast a viral hook with your subject',
|
|
20169
|
-
description: "RECAST A VIRAL HOOK so the brand's own product or saved creator is its subject (the egg that rolls off the table becomes your product rolling off the same table; the viral move is done by your host), then join it into your clip so the cut links by identity. hook: a public TikTok / Reel / Facebook / X / YouTube post link or a video file (a post link uses its one lookup). Subject: creator (a saved creator's name or id), image (a picture of a product or a person; a real person's photo confirms you have their consent, and it is logged), or neither for the brand's first product photo. mode 'auto' (default): 'swap' replaces the hook's subject inside its own scene with a region edit (camera, set, light, timing, on-screen text and sound kept), 'motion' makes your person perform the hook's moves (motion transfer; needs a person in the hook). A face in the hook or as the subject routes the swap to an editor that takes faces, found by a free local face check. swap: your own words for what changes ('the egg becomes our serum bottle'; look with video_frames first). start/end pick the window (3 s minimum; default the first 10 s). PAID and QUOTED FIRST: without confirm it returns the plan and the credits and renders nothing; call again with confirm:true once the user okays it. Returns one job: get_job until done, then post_edit with videoUrl = the recast hook and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
|
|
20201
|
+
description: "RECAST A VIRAL HOOK so the brand's own product or saved creator is its subject (the egg that rolls off the table becomes your product rolling off the same table; the viral move is done by your host), then join it into your clip so the cut links by identity. hook: a public TikTok / Reel / Facebook / X / YouTube post link or a video file (a post link uses its one lookup). Subject: creator (a saved creator's name or id), image (a picture of a product or a person; a real person's photo confirms you have their consent, and it is logged), or neither for the brand's first product photo. A DUO OR GROUP (2-4 people swapped in one pass, e.g. both performers of a duet): subjects [{creator or image, position: 'left'|'right'|'center' or your own words, refs?}], one per person; each is bound to its place in the frame and keeps that performer's mouth, head and hand movement and timing on the original sound, and every real face is checked like a single one. Up to 15 s per pass: a longer hook is recast in a 15 s window and the reply says which. mode 'auto' (default): 'swap' replaces the hook's subject inside its own scene with a region edit (camera, set, light, timing, on-screen text and sound kept), 'motion' makes your person perform the hook's moves (motion transfer; needs a person in the hook). A face in the hook or as the subject routes the swap to an editor that takes faces, found by a free local face check. swap: your own words for what changes ('the egg becomes our serum bottle'; look with video_frames first). start/end pick the window (3 s minimum; default the first 10 s). PAID and QUOTED FIRST: without confirm it returns the plan and the credits and renders nothing; call again with confirm:true once the user okays it. Returns one job: get_job until done, then post_edit with videoUrl = the recast hook and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
|
|
20170
20202
|
inputSchema: {
|
|
20171
20203
|
hook: z.string().describe('the viral hook: a post link or a video URL'),
|
|
20172
20204
|
creator: z.string().optional().describe("a saved creator's name or id (list_creators)"),
|
|
20173
20205
|
image: z.string().optional().describe('a picture of the subject instead: a product or a person'),
|
|
20206
|
+
subjects: z.array(z.object({ creator: z.string().optional().describe("a saved creator's name or id"), image: z.string().optional().describe('or a picture of this person'), position: z.string().optional().describe("where they are in the hook: 'left', 'right', 'center', or your own words ('who raps first'); all or none"), refs: z.array(z.string()).optional().describe("up to 3 more views of the SAME person (a saved creator's pose plates ride automatically)"), name: z.string().optional() }).passthrough()).max(4).optional().describe('a duo or group recast, 2-4 people, instead of creator/image'),
|
|
20174
20207
|
swap: z.string().optional().describe('what changes, in your own words'),
|
|
20175
20208
|
mode: z.enum(['auto', 'swap', 'motion']).optional(),
|
|
20176
20209
|
start: z.number().optional(), end: z.number().optional(),
|
|
@@ -20184,12 +20217,13 @@ function memoryNoteVerdict(text) {
|
|
|
20184
20217
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20185
20218
|
}, wrap(async (a) => {
|
|
20186
20219
|
let b = {};
|
|
20187
|
-
|
|
20220
|
+
const subs = Array.isArray(a.subjects) ? a.subjects.filter(Boolean) : [];
|
|
20221
|
+
if (a.useBrand !== false && !a.creator && !a.image && !subs.length) { try { b = (await readStore('heist.brand.v1')) || {}; } catch { b = {}; } }
|
|
20188
20222
|
// a person's photo as the subject is recorded as implied likeness consent SERVER-side, only when the free face check finds a face there (a product photo is not a likeness)
|
|
20189
|
-
const body = { hook: a.hook, ...(a.creator ? { creator: a.creator } : {}), ...(a.image ? { image: a.image } : {}), ...(a.swap ? { swap: a.swap } : {}), ...(a.mode && a.mode !== 'auto' ? { mode: a.mode } : {}), ...(a.start != null ? { start: a.start } : {}), ...(a.end != null ? { end: a.end } : {}), ...(a.prompt ? { prompt: a.prompt } : {}), ...(a.model ? { model: a.model } : {}), ...(a.tier ? { tier: a.tier } : {}), ...(b && Array.isArray(b.productImages) && b.productImages.length ? { brand: { name: b.name || '', product: b.sells || b.name || '', productImages: b.productImages.slice(0, 1) } } : {}), ...(a.confirm === true ? { confirm: true } : {}) };
|
|
20223
|
+
const body = { hook: a.hook, ...(a.creator ? { creator: a.creator } : {}), ...(a.image ? { image: a.image } : {}), ...(subs.length ? { subjects: subs } : {}), ...(a.swap ? { swap: a.swap } : {}), ...(a.mode && a.mode !== 'auto' ? { mode: a.mode } : {}), ...(a.start != null ? { start: a.start } : {}), ...(a.end != null ? { end: a.end } : {}), ...(a.prompt ? { prompt: a.prompt } : {}), ...(a.model ? { model: a.model } : {}), ...(a.tier ? { tier: a.tier } : {}), ...(b && Array.isArray(b.productImages) && b.productImages.length ? { brand: { name: b.name || '', product: b.sells || b.name || '', productImages: b.productImages.slice(0, 1) } } : {}), ...(a.confirm === true ? { confirm: true } : {}) };
|
|
20190
20224
|
const r = await apiPost('/api/hooks/recast', body);
|
|
20191
20225
|
const p = r?.data || r;
|
|
20192
|
-
const head = `${p.mode === 'motion' ? 'Motion transfer' : 'Swap'} on ${p.engine}: ${p.hook?.window?.seconds}s of the hook (${p.hook?.window?.start}-${p.hook?.window?.end}s of ${p.hook?.durationSeconds}s). Subject: ${p.subject?.kind}${p.subject?.name ? ` "${p.subject.name}"` : ''}. Face in the hook: ${p.hook?.face}; in the subject: ${p.subject?.face}. Why: ${p.why}.${p.instruction ? `\nInstruction: ${p.instruction}` : ''}${p.consent ? `\n${p.consent}` : ''}`;
|
|
20226
|
+
const head = `${p.mode === 'motion' ? 'Motion transfer' : 'Swap'} on ${p.engine}: ${p.hook?.window?.seconds}s of the hook (${p.hook?.window?.start}-${p.hook?.window?.end}s of ${p.hook?.durationSeconds}s). ${Array.isArray(p.subjects) ? `Subjects: ${p.subjects.map((x) => `${x.element} ${x.position}${x.name ? ` "${x.name}"` : ''} (${x.kind}${x.refs ? `, ${x.refs} more view${x.refs === 1 ? '' : 's'}` : ''})`).join('; ')}` : `Subject: ${p.subject?.kind}${p.subject?.name ? ` "${p.subject.name}"` : ''}`}. Face in the hook: ${p.hook?.face}; in the subject: ${p.subject?.face}. Why: ${p.why}.${p.windowNote ? `\n${p.windowNote}` : ''}${p.instruction ? `\nInstruction: ${p.instruction}` : ''}${p.consent ? `\n${p.consent}` : ''}`;
|
|
20193
20227
|
if (!p.queued) return { content: [{ type: 'text', text: `QUOTE (nothing rendered, nothing charged): about ${p.credits} credits.\n${head}\nTell the user the price; after they okay it, call recast_hook again with the same inputs and confirm:true.` }], structuredContent: { plan: p, credits: p.credits, queued: false } };
|
|
20194
20228
|
return { content: [{ type: 'text', text: `Recast queued as job ${p.job?.id} (about ${p.credits} credits).\n${head}\nCall get_job until it reports done; it has no file until then. ${p.next || ''}` }], structuredContent: { plan: p, job: p.job, credits: p.credits, queued: true } };
|
|
20195
20229
|
}));
|
|
@@ -20313,7 +20347,7 @@ function memoryNoteVerdict(text) {
|
|
|
20313
20347
|
title: 'Clone a static ad',
|
|
20314
20348
|
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.",
|
|
20315
20349
|
inputSchema: {
|
|
20316
|
-
imageUrl: z.string().describe('the URL of the static ad image to clone
|
|
20350
|
+
imageUrl: z.string().describe('the URL of the static ad image to clone. ' + LIKENESS_TERMS),
|
|
20317
20351
|
brandId: z.string().optional().describe('a brand id/name from list_brands to clone for; omit to use the active brand'),
|
|
20318
20352
|
},
|
|
20319
20353
|
outputSchema: {
|
|
@@ -20338,7 +20372,7 @@ function memoryNoteVerdict(text) {
|
|
|
20338
20372
|
title: 'Clone a static ad (old name)',
|
|
20339
20373
|
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.",
|
|
20340
20374
|
inputSchema: {
|
|
20341
|
-
imageUrl: z.string().describe('the URL of the static ad image to clone
|
|
20375
|
+
imageUrl: z.string().describe('the URL of the static ad image to clone. ' + LIKENESS_TERMS),
|
|
20342
20376
|
brandId: z.string().optional().describe('a brand id/name from list_brands to clone for; omit to use the active brand'),
|
|
20343
20377
|
},
|
|
20344
20378
|
outputSchema: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.347",
|
|
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",
|