hermoso 0.1.428 → 0.1.430
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 +11 -3
- package/mcp/tools.mjs +55 -13
- package/package.json +1 -1
package/mcp/http.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { mcpCtx, connectedProviders } from './client.mjs';
|
|
|
26
26
|
// beside cli/, whose package.json carries the release; inside the npm package there is no ../cli/ and ../package.json
|
|
27
27
|
// IS the CLI package. One resolver, so the byte-identical twins agree.
|
|
28
28
|
const PKG_VERSION = (() => { const r = createRequire(import.meta.url); for (const p of ['../cli/package.json', '../package.json']) { try { const v = r(p).version; if (v) return v; } catch {} } return '0.0.0'; })();
|
|
29
|
-
export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null, onFirstCall = null, onConnectEvent = null } = {}) {
|
|
29
|
+
export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null, onFirstCall = null, onConnectEvent = null, firstRunFor = null } = {}) {
|
|
30
30
|
// Outcomes of a real connect, for the host's connect watch (lib/mcp-connect-watch.mjs): a bearer we reject, and the
|
|
31
31
|
// first tools/list of a signed-in session (keyed by the token's hash, so a FRESH token's first listing marks a
|
|
32
32
|
// completed connect). Never throws into the request.
|
|
@@ -532,7 +532,15 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
532
532
|
// WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
|
|
533
533
|
// request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
|
|
534
534
|
// tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
|
|
535
|
-
|
|
535
|
+
// A CHATGPT ACCOUNT THAT HAS MADE NOTHING YET (2026-10-19). Read once per session, ChatGPT hosts only, bounded so a slow
|
|
536
|
+
// read can never hold the connect: null (no note) is the answer to every failure. The first successful tool reply of
|
|
537
|
+
// the session then offers one concrete next ask (firstRunNote in tools.mjs). 9 of 13 ChatGPT signups since the
|
|
538
|
+
// 10-15 publish spent nothing, and a live "make me a video ad" chat on a connected account never called us.
|
|
539
|
+
let firstRun = null;
|
|
540
|
+
if (widgetHost && typeof firstRunFor === 'function' && user.accountId) {
|
|
541
|
+
try { firstRun = await Promise.race([Promise.resolve(firstRunFor({ accountId: user.accountId })).catch(() => null), new Promise((r) => { const t = setTimeout(() => r(null), 1500); if (t && typeof t.unref === 'function') t.unref(); })]); } catch { firstRun = null; }
|
|
542
|
+
}
|
|
543
|
+
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, firstRun: firstRun && typeof firstRun === 'object' ? firstRun : null };
|
|
536
544
|
if (typeof onSessionStart === 'function') { try { onSessionStart({ accountId: user.accountId || null, userId: user.userId || null, client: entry.client, ua: entry.ua, src: entry.src }); } catch {} }
|
|
537
545
|
// LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
|
|
538
546
|
// ever read is a guess. One line per session (not per call) so a new host identifies itself once and
|
|
@@ -595,7 +603,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
595
603
|
? setTimeout(() => { if (res.writableEnded) return; console.error(`[mcp] tools/call open past ${MCP_CALL_WALL_MS}ms — ending the response (${inflightNameOf(req.body)})`); try { res.end(); } catch {} }, MCP_CALL_WALL_MS)
|
|
596
604
|
: null;
|
|
597
605
|
if (wall && typeof wall.unref === 'function') wall.unref();
|
|
598
|
-
try { await mcpCtx.run({ token, remote: true, client: entry.client || '' }, () => entry.transport.handleRequest(req, res, req.body)); }
|
|
606
|
+
try { await mcpCtx.run({ token, remote: true, client: entry.client || '', firstRun: entry.firstRun || null }, () => entry.transport.handleRequest(req, res, req.body)); }
|
|
599
607
|
finally { if (wall) clearTimeout(wall); }
|
|
600
608
|
});
|
|
601
609
|
|
package/mcp/tools.mjs
CHANGED
|
@@ -526,6 +526,21 @@ const wrap = (fn) => {
|
|
|
526
526
|
const _txt = Array.isArray(r?.content) ? r.content.find((b) => b && b.type === 'text' && typeof b.text === 'string') : null;
|
|
527
527
|
if (_c > 0 && !_st.chargeSaid && _txt && !CHARGED_RE.test(_txt.text)) _txt.text += r?.structuredContent?.stillRendering ? `\nCharged ${_c} credit${_c === 1 ? '' : 's'} so far; the render's own charge is reported by get_job when it finishes.` : chargeSentence(_c);
|
|
528
528
|
} catch {}
|
|
529
|
+
// The FIRST successful reply of a ChatGPT session, on an account that has made nothing yet, offers one concrete
|
|
530
|
+
// next ask (firstRunNote). Spent once per session whatever the tool was, so a session that is already creating
|
|
531
|
+
// is never told to.
|
|
532
|
+
try {
|
|
533
|
+
const _fr = mcpCtx.getStore()?.firstRun;
|
|
534
|
+
if (_fr && !_fr.said && r && !r.isError) {
|
|
535
|
+
_fr.said = true;
|
|
536
|
+
const _n = ((_st.apiCharged || 0) + (_st.jobCharged || 0)) > 0 ? null : firstRunNote(_tool, _fr, r);
|
|
537
|
+
if (_n) {
|
|
538
|
+
const _last = [...r.content].reverse().find((b) => b && b.type === 'text' && typeof b.text === 'string');
|
|
539
|
+
if (_last) _last.text += `\n\n${_n.text}`; else r.content.push({ type: 'text', text: _n.text });
|
|
540
|
+
return withHints(r, [_n.hint]);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} catch {}
|
|
529
544
|
return r;
|
|
530
545
|
}
|
|
531
546
|
catch (e) {
|
|
@@ -2544,6 +2559,28 @@ export const withStaleNote = (out, ctx) => {
|
|
|
2544
2559
|
if (last) last.text = `${last.text}\n\n${STALE_ROSTER_NOTE}`; else out.content.push({ type: 'text', text: STALE_ROSTER_NOTE });
|
|
2545
2560
|
return out;
|
|
2546
2561
|
};
|
|
2562
|
+
// ── A CONNECTED ACCOUNT THAT HAS MADE NOTHING IS OFFERED ONE CONCRETE FIRST ASK (2026-10-19) ─────────────────────────
|
|
2563
|
+
// Since the ChatGPT app was published (10-15), 9 of 13 accounts whose agent was ChatGPT spent nothing, and a live "make me
|
|
2564
|
+
// a video ad for my coffee shop" on a connected account was answered by ChatGPT's own slideshow with no Hermoso call. So
|
|
2565
|
+
// the first successful reply of a ChatGPT session on an account with no image or video render (the server decides:
|
|
2566
|
+
// mcpFirstRunFor, read once per session in http.mjs) ends with one next step the user can say yes to: their brand's
|
|
2567
|
+
// video ad when a brand is saved, else drafting the brand from their site. A tool that is already making something
|
|
2568
|
+
// never carries it. Agent-facing, like STALE_ROSTER_NOTE; the same advice rides _meta as a hint. Pure, so a check RUNS it.
|
|
2569
|
+
export const FIRST_RUN_QUIET_TOOLS = new Set(['plan_ad', 'render_ad', 'generate_image', 'generate_video', 'make_template_ad', 'clone_video', 'clone_static', 'remix_static', 'draft_brand', 'edit_image', 'multiply_ad', 'get_job', 'list_jobs']);
|
|
2570
|
+
export const FIRST_RUN_APP_URL = 'https://app.hermoso.ai';
|
|
2571
|
+
export function firstRunNote(tool, fr, r) {
|
|
2572
|
+
if (!fr || fr.fresh !== true || !r || r.isError || !Array.isArray(r.content) || FIRST_RUN_QUIET_TOOLS.has(String(tool || ''))) return null;
|
|
2573
|
+
const name = String(fr.brand?.name || '').replace(/["“”\n\r]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
2574
|
+
const ask = name ? `Make a 10-second video ad for ${name}` : 'Set up my brand from my website';
|
|
2575
|
+
const how = name ? 'plan_ad with durationSeconds 10, then render_ad' : 'draft_brand with their website, or a one-line description when they have none';
|
|
2576
|
+
const claim = fr.unclaimed === true ? ` It has no credits yet: the free starter credits are claimed once in the Hermoso app at ${FIRST_RUN_APP_URL} (a quick check that they are a person), so offer that link first.` : '';
|
|
2577
|
+
return {
|
|
2578
|
+
text: `Note for the assistant, not for the user: this account has not made anything with Hermoso yet.${claim} After you answer, offer the user one concrete next step they can say yes to, for example "${ask}" (${how}).`,
|
|
2579
|
+
hint: fr.unclaimed === true ? { do: `send the user to ${FIRST_RUN_APP_URL} to claim the free starter credits`, why: 'the balance is zero until they do, so every paid first step would be refused' }
|
|
2580
|
+
: name ? { do: 'plan_ad({ durationSeconds: 10 }), then render_ad', why: `nothing has been made on this account yet; a short video ad for ${name} is the first thing to offer` }
|
|
2581
|
+
: { do: "draft_brand({ domain: '<their website>' })", why: 'nothing has been made and no brand is saved; with the brand drafted, every ad is made for it' },
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2547
2584
|
// ── AN UNDECLARED ARGUMENT IS REFUSED, NEVER STRIPPED (2026-09-30) ──────────────────────────────────────────────
|
|
2548
2585
|
// Measured on the hosted connector: `call_tool {name:'update_brand', args:{brand:'pmuojpxnfrx0', voice:…}}` — update_brand
|
|
2549
2586
|
// did not declare `brand`, zod's default object STRIPS unknown keys, so the call validated, `brand` vanished, and the
|
|
@@ -18404,10 +18441,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18404
18441
|
// image may be an https URL (an upload_file URL included), a Library id from list_library (L0, L1… newest first), or a
|
|
18405
18442
|
// local path on the CLI (sent as bytes, stored once by the server). An id or path that resolves to nothing is refused
|
|
18406
18443
|
// by name, free, before anything is planned or rendered.
|
|
18444
|
+
// HOW IT LOOKS (2026-10-19): a screen sits in a rounded card over the footage, which keeps playing around it (look
|
|
18445
|
+
// 'card', the default; 'full' covers the frame, only when the user asked); a photo of a person, product or place (kind
|
|
18446
|
+
// 'photo') is never laid over the film. Never in the first second or the last 1.5 s; 2 s each at most (lib/insert-windows.mjs).
|
|
18407
18447
|
const INSERTS_Z = z.array(z.object({
|
|
18408
18448
|
image: z.string().describe('an https or upload_file URL, a Library id (L0, L1…) or a local path on the CLI'),
|
|
18409
|
-
start: z.number().describe('second it appears'),
|
|
18410
|
-
end: z.number().describe('second it ends (
|
|
18449
|
+
start: z.number().describe('second it appears (never before 1 s: the film opens on moving footage)'),
|
|
18450
|
+
end: z.number().describe('second it ends: at most 2 s after start (2.5 s full-screen), never in the last 1.5 s of the film'),
|
|
18451
|
+
kind: z.enum(['screen', 'photo']).optional().describe("screen (default): an app, a page, a dashboard, a document, a review, a chart. photo: a person, a product or a place, which is NOT laid over the video (a product photo goes in as the brand's product photo, a person to star as a saved creator)"),
|
|
18452
|
+
look: z.enum(['card', 'full']).optional().describe("card (default): a rounded card over the upper middle of the footage, which keeps playing. full: covers the whole frame, ONLY when the user asked for full-screen"),
|
|
18411
18453
|
})).max(8);
|
|
18412
18454
|
const insertsResolved = async (list) => {
|
|
18413
18455
|
if (!Array.isArray(list)) return undefined;
|
|
@@ -18424,11 +18466,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18424
18466
|
image = String(a.url);
|
|
18425
18467
|
} else image = await toRef(raw);
|
|
18426
18468
|
if (!image) throw Object.assign(new Error(`The picture "${raw.slice(0, 80)}" could not be read: pass an https URL, an upload_file URL or a Library id (L0…). Nothing was planned, rendered or charged.`), { status: 400, _userInput: true });
|
|
18427
|
-
out.push({ image: /^\/generated\//.test(image) ? abs(image) : image, start: +x.start, end: +x.end });
|
|
18469
|
+
out.push({ image: /^\/generated\//.test(image) ? abs(image) : image, start: +x.start, end: +x.end, ...(x.kind === 'photo' ? { kind: 'photo' } : {}), ...(x.look === 'full' ? { look: 'full' } : {}) });
|
|
18428
18470
|
}
|
|
18429
18471
|
return out;
|
|
18430
18472
|
};
|
|
18431
|
-
const insertsLine = (ins, verb = 'are laid') => (Array.isArray(ins) && ins.length ? `\nCut-ins: ${ins.length === 1 ? 'the user\'s picture' : `${ins.length} of the user's pictures`} ${ins.length === 1 ? verb.replace(/^are/, 'is') : verb}
|
|
18473
|
+
const insertsLine = (ins, verb = 'are laid') => (Array.isArray(ins) && ins.length ? `\nCut-ins: ${ins.length === 1 ? 'the user\'s picture' : `${ins.length} of the user's pictures`} ${ins.length === 1 ? verb.replace(/^are/, 'is') : verb} at ${ins.map((x) => `${x.start}–${x.end}s${x.look === 'full' ? ' (full-screen)' : ''}`).join(', ')}${ins.some((x) => x.look !== 'full') ? ', as a card over the footage, which keeps playing' : ''}.` : '');
|
|
18432
18474
|
server.registerTool('plan_ad', {
|
|
18433
18475
|
title: 'Plan an ad concept',
|
|
18434
18476
|
description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. The user’s OWN screenshots or pictures cut into the video full-screen go in `inserts`: the board keeps those seconds free of any screen, and render_ad lays the real pictures in. Spends credits.',
|
|
@@ -18444,12 +18486,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18444
18486
|
language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
|
|
18445
18487
|
talent: z.enum(['auto', 'creator', 'product_only']).optional().describe("who is on camera; omit to follow the format. Say 'someone new' in product to skip reusing a saved creator."),
|
|
18446
18488
|
draft: z.object({ model: z.string(), durationSeconds: z.number() }).optional().describe("ONLY after a video refusal that offered a light draft: the {model, durationSeconds} it named. The plan is then authored to that length and priced on that model. Never invent one — a video the account cannot cover is refused BEFORE planning with the three options (image / add credits / this draft when one fits), and the user chooses."),
|
|
18447
|
-
inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's own
|
|
18489
|
+
inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's own SCREENS (an app, a page, a dashboard, a document, a review, a chart) shown at these seconds as a rounded card over the upper middle of the footage, which keeps playing around it (look 'full' covers the frame, only when the user asked), the voice running underneath. Fewer and shorter: about one per 4 s of film, 1.2-2 s each, on the line that talks about it; never in the first second or the last 1.5 s. The plan frames those shots for the card, keeps them free of screens, and returns the pictures as creative.inserts for render_ad to lay in (about 2 credits). A photo of a person, product or place (kind 'photo') is not laid in: pass a product photo as the brand's product photo and a person as a saved creator. One that does not fit is left out and the reply says so."),
|
|
18448
18490
|
},
|
|
18449
18491
|
outputSchema: {
|
|
18450
18492
|
format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
|
|
18451
|
-
inserts: z.array(z.any()).optional().describe("the user's pictures render_ad will lay in
|
|
18452
|
-
inserts_note: z.string().optional().describe('set when a picture was left out
|
|
18493
|
+
inserts: z.array(z.any()).optional().describe("the user's pictures render_ad will lay in: [{image, start, end, look}] (look card = over the playing footage, full = full-screen), fitted to the planned length"),
|
|
18494
|
+
inserts_note: z.string().optional().describe('set when a picture was left out (the planned length has no room for it, or it was a photo, which is never laid over the video)'),
|
|
18453
18495
|
concept: z.string().optional().describe('the one-line creative concept'),
|
|
18454
18496
|
format_note: z.string().optional().describe("the plan's own read-back when it chose an IMAGE on an open format because the balance could not cover the default video — states the video's real credit price"),
|
|
18455
18497
|
budget_pick: z.any().optional().describe('{format, reason, videoCredits, balance, seconds} when format_note is set'),
|
|
@@ -18573,7 +18615,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18573
18615
|
server.group('create');
|
|
18574
18616
|
server.registerTool('generate_image', {
|
|
18575
18617
|
title: 'Generate ad image',
|
|
18576
|
-
description: 'Use this when the user
|
|
18618
|
+
description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product. It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched. RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Seconds. Spends credits.',
|
|
18577
18619
|
inputSchema: {
|
|
18578
18620
|
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.'),
|
|
18579
18621
|
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),
|
|
@@ -18847,7 +18889,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18847
18889
|
server.group('create');
|
|
18848
18890
|
server.registerTool('render_ad', {
|
|
18849
18891
|
title: 'Render ad video',
|
|
18850
|
-
description: 'Use this when the user
|
|
18892
|
+
description: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product. It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). UGC and cinematic plans render from a painted STORYBOARD (a cinematic spot paints a location anchor first, and a hero when someone is on camera); a product-only spot renders from the real product photo, and board:true or foundations:\'choose\' adds an identity sheet, a moodboard and a board. A plan that fits one clip of the render model renders as one continuous pass; a longer one as STITCHED ACTS (the fewest balanced clips), never time-compressed. Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. 1–3 min; poll get_job while it renders. Spends credits.',
|
|
18851
18893
|
inputSchema: {
|
|
18852
18894
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
18853
18895
|
creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”), or a PRESET AI creator from list_creators presets by exact name or id (free, no generation). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand (casting skips the portrait we would otherwise generate). Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
|
|
@@ -18868,7 +18910,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18868
18910
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
18869
18911
|
dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) and the estimated credits the real render reserves, WITHOUT submitting a render — free, nothing charged'),
|
|
18870
18912
|
allowGenericProduct: z.boolean().optional().describe('proceed even though this brand has NO product photo on file and the ad features a product — the packaging will be INVENTED. Only pass true after telling the user that and hearing they are fine with a generic stand-in'),
|
|
18871
|
-
inserts: INSERTS_Z.optional().describe("the user's own
|
|
18913
|
+
inserts: INSERTS_Z.optional().describe("the user's own screens laid over the finished video at these seconds, as a card over the playing footage by default (look 'full' covers the frame, only when asked), sound running underneath (about 2 credits). The film always opens and ends on moving footage. Omit to use plan_ad's creative.inserts; [] renders without them. plan_ad with inserts writes the board around them."),
|
|
18872
18914
|
},
|
|
18873
18915
|
outputSchema: {
|
|
18874
18916
|
...JOB_OUT,
|
|
@@ -18933,7 +18975,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18933
18975
|
|
|
18934
18976
|
server.registerTool('make_template_ad', {
|
|
18935
18977
|
title: 'Make template ad',
|
|
18936
|
-
description: "
|
|
18978
|
+
description: "Use this when the user asks for a quick, low-cost ad or post built from their words and pictures: a slideshow, carousel, iMessage or ChatGPT chat, Notes, kinetic type, myth vs fact, an app tour, or their own HTML design. Rendered from HTML, no AI model, ~30s, a couple of credits, ready to post. Presets are SHORTCUTS; 'custom' is YOUR OWN design as config.html (+ css), so no layout, type, colour or motion is unsupported. custom: { html, css?, size? ('9:16' default | '4:5' | '1:1' | '16:9' | any 'W:H' | {w,h} px), durationSeconds? (1-60 = VIDEO; CSS/SVG animation and <video> are frame-stepped, scripts stripped), slides?:[{html, css?}] (2-35 = carousel) }; {{logo}} {{brandName}} {{domain}} {{accent}} fill from the brand; images and fonts load by public https URL; notes[] lists what failed to load. YOU write preset copy: short, casual, believable, finished phrases within budget. Preset ids and their fields are listed on `config`. config.music on a VIDEO: omit for a free library bed matched to its mood when the library is stocked (hermoso_capabilities hasMusic; else only its own sound effects), 'off' for silence, or words (a mood or description) to compose one for a flat fee.",
|
|
18937
18979
|
inputSchema: {
|
|
18938
18980
|
config: z.object({}).passthrough().describe("MUST include config.template: 'custom' or a preset id, plus its fields. PRESETS: 'slideshow' (IMAGES, TikTok photo mode / Reels 1080x1920, or size:'4:5' feed carousels; no branding): { slides:[{text, sub?, image?, blur?, background?, position?}] (2-35; words never rewritten), style? ('tiktok-classic'|'clean-minimal'|'note-style' or a look in words), textStyle?, video?:true (+ an MP4, which takes config.music like any video) }; 2 credits, +1 per slide past 5, +2 for the MP4. 'imessage-chat' (VIDEO ~15s): { thread:{contactName, messages:[{from:'them'|'me', text?, product?:{image,title,domain}}]}, theme?, endCard }. 'chatgpt-chat' (VIDEO): { question, answer (may **bold** the brand), productImage?, endCard }. 'apple-notes' (VIDEO): { title, lines[], theme?, endCard }. 'value-prop' (VIDEO ~17s): { hook ≤40ch, claims[3-5 ≤34ch], productImages[2-3], palette[], endCard }. 'static-mockup' (IMAGE): { style:'imessage'|'notes'|'card', size?:{w,h}, ...fields }. 'airdrop-carousel' (VIDEO): { brandName, products:[{image, title?}] (3-16), endCard }. 'app-ui-tour' (VIDEO): { hook?, appName, iconImage?, beats:[{screenImage, caption}] (2-6), endCard }. 'imessage-cascade' (VIDEO): { notifications:[{sender, text}] (4-8), backgroundImage?, endCard }. 'photo-grid' (VIDEO): { title?, photos:[{image, label?}] (4-9), endCard }. 'vignette' (VIDEO): { hook, lines[2-4 ≤40ch], heroImage, endCard }. 'kinetic-type' (VIDEO, own SFX): { phrases[3-6 ≤34ch], productImages?[≤4], endCard }. 'myth-vs-fact' (VIDEO with a real VOICEOVER, small extra charge): { pairs:[{myth ≤50ch, fact ≤60ch}] (2-4; [brackets] accent), endCard }, real truths only. 'carousel' (IMAGES, 5-10 branded 1080x1080): { cover:{hook?, title}, slides:[{headline, support?, stat?:{value, label}}] (3-8), cta:{headline, cta?, domain?}, productImage?, logo? }. endCard = { headline, cta, domain?, logo?, color? }; palette and fontStack optional."),
|
|
18939
18981
|
},
|
|
@@ -19326,7 +19368,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19326
19368
|
|
|
19327
19369
|
server.registerTool('generate_video', {
|
|
19328
19370
|
title: 'Generate video',
|
|
19329
|
-
description: 'Use this when the user
|
|
19371
|
+
description: 'Use this when the user asks for a video clip, b-roll or a shot from a description, or names a video model. For a finished video ad, Reel, TikTok ad or UGC video for their business, use render_ad. It renders one continuous clip on a real video model and returns its mp4 URL. A clip runs up to the model’s longest listed duration (the longest takes a multi-beat 30s spot in ONE take with native audio), so never assume an 8–10s cap or stitch what fits one clip; durationSeconds must be one of the model’s durations in hermoso_capabilities. NAME A MODEL in `model` to get it: an unnamed render uses a narrower auto-pool. 1–3 min. refImage anchors the first frame; ttsScript adds a voiceover. AUDIO IS NOT FREE BY DEFAULT: a clip with no audio of its own gets a music bed composed and CHARGED on top (see musicMood and audio), which can cost as much as a cheap draft clip. refVideo (a clip URL) EDITS that clip instead, keeping its canvas and length. RAW MODEL ACCESS: by default small guards are added (label safety with no reference image, a negative prompt where the model takes one, reference lines) and hex colours become names; ' + RAW_TOOL_NOTE + ' Spends credits (blocked on the Starter plan).',
|
|
19330
19372
|
inputSchema: {
|
|
19331
19373
|
prompt: z.string().optional().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction); optional with `shots`'),
|
|
19332
19374
|
raw: z.boolean().optional().describe('Send the prompt to the model exactly as written, without the wording Hermoso usually adds (packaging and label guidance, reference lines, colour names for hex codes, quality hints). Use it when you want the model\'s own interpretation 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 (on Veo it is refused with the length and the cap instead). Billing, durable delivery and per-model validation are unchanged.'),
|
|
@@ -19565,7 +19607,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19565
19607
|
}
|
|
19566
19608
|
const seamLine = seamsText(res); // a timeline / join's measured seams + intro budget ('' for any other video)
|
|
19567
19609
|
// a render that laid the user's pictures in (render_ad inserts): say they are in it, or that they could not be
|
|
19568
|
-
const insLine = res?.inserts?.laid ? `\nCut-ins: ${res.inserts.laid === 1 ? 'the user\'s picture is' : `${res.inserts.laid} of the user's pictures are`} laid in
|
|
19610
|
+
const insLine = res?.inserts?.laid ? `\nCut-ins: ${res.inserts.laid === 1 ? 'the user\'s picture is' : `${res.inserts.laid} of the user's pictures are`} laid in at ${(res.inserts.windows || []).map((x) => `${x.start}–${x.end}s${x.look === 'full' ? ' (full-screen)' : ''}`).join(', ')}.` : (res?.inserts?.failed && res.note ? `\nTELL THE USER: ${res.note}` : '');
|
|
19569
19611
|
if (j.status === 'done' && res?.video) return okVideo(wireText + seamLine + insLine, { ...j, url }); // resumed video → same inline poster as a direct return
|
|
19570
19612
|
if (j.status === 'done' && res?.image) { const img = await imageBlock(url); return { content: [{ type: 'text', text: wireText }, ...(img ? [img] : [])], structuredContent: { ...j, url } }; }
|
|
19571
19613
|
return ok(wireText, { ...j, url });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.430",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 922 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|