hermoso 0.1.281 → 0.1.292
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/mcp/registry.mjs +10 -4
- package/mcp/tools.mjs +328 -80
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**863 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**Most of it costs nothing.** Publishing and scheduling posts, building and managing paid campaigns, analytics
|
|
@@ -216,7 +216,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
216
216
|
|
|
217
217
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
218
218
|
|
|
219
|
-
### What the
|
|
219
|
+
### What the 863 tools cover
|
|
220
220
|
|
|
221
221
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
222
222
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/registry.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
18
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
19
19
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
20
|
-
import { registerTools, MCP_INSTRUCTIONS, TOOL_GROUP_NAMES, TOOL_GROUPS } from './tools.mjs';
|
|
20
|
+
import { registerTools, MCP_INSTRUCTIONS, TOOL_GROUP_NAMES, TOOL_GROUPS, canonToolGroups } from './tools.mjs';
|
|
21
21
|
|
|
22
22
|
export { TOOL_GROUP_NAMES, TOOL_GROUPS };
|
|
23
23
|
|
|
@@ -89,13 +89,19 @@ export const groupLabel = (g) => (g === undefined || g === null ? '?' : g);
|
|
|
89
89
|
/**
|
|
90
90
|
* The whole inventory in one pass-set: every registered name, its group, its title/description.
|
|
91
91
|
*
|
|
92
|
-
* ONE registration
|
|
93
|
-
* rather than declared.
|
|
92
|
+
* ONE registration of the full roster; each tool's group is the server.group() marker that registration recorded,
|
|
93
|
+
* so every number in it is measured rather than declared.
|
|
94
94
|
*/
|
|
95
95
|
export function inventory() {
|
|
96
96
|
const full = rosterFor(ALL_GROUPS);
|
|
97
|
+
// THE MARKER THE TOOL WAS WRITTEN UNDER, as the build recorded it (2026-09-24). Asking "which single-group roster
|
|
98
|
+
// lists it" was a proxy for that, and it broke on ON_DEMAND_TOOLS: held out of every roster short of all groups,
|
|
99
|
+
// they came back `?` although each sits under server.group('create'). Falls back to the per-group passes only if
|
|
100
|
+
// the canon is somehow absent, so a missing read can never file a tool under the wrong group.
|
|
101
|
+
const recorded = canonToolGroups();
|
|
97
102
|
const groupOf = Object.create(null);
|
|
98
|
-
|
|
103
|
+
if (recorded) Object.assign(groupOf, recorded);
|
|
104
|
+
else for (const g of ALL_GROUPS) {
|
|
99
105
|
for (const [name, h] of rosterFor([g])) {
|
|
100
106
|
// `core` rides in every scoped roster (a roster without discovery is undriveable), so the FIRST group that
|
|
101
107
|
// claims a tool wins and core is asked first — otherwise every core tool would be relabelled by the last
|
package/mcp/tools.mjs
CHANGED
|
@@ -70,6 +70,15 @@ const creatorLine = (c) => {
|
|
|
70
70
|
else if (c.voice) bits.push(`voice ${c.voice}`);
|
|
71
71
|
return ` • ${c.name} — ${bits.join(', ')}${c.id ? ` [${c.id}]` : ''}\n ${c.image || '(portrait stored in-app as an uploaded photo — cast them by NAME; there is no url to hand a render tool)'}`;
|
|
72
72
|
};
|
|
73
|
+
// LIKENESS CONSENT OVER MCP / API / CLI IS IMPLIED BY THE CALL (product decision 2026-09-24). There is no consent flag
|
|
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
|
+
// caller's confirmation that they have that person's consent, and each tool that takes one says so in one sentence.
|
|
76
|
+
// What we DO is record it: one `likeness_consent` row in the server's durable ledger (via 'api-implied'), stamped
|
|
77
|
+
// there with the account, user, brand and time from the caller's own bearer. Best effort by design, never awaited
|
|
78
|
+
// into a failure: a lost audit row must not fail a render the user asked for.
|
|
79
|
+
function recordImpliedLikeness(lane, meta = {}) {
|
|
80
|
+
try { Promise.resolve(apiPost('/api/signal', { type: 'likeness_consent', meta: { via: 'api-implied', lane, ...meta } })).catch(() => {}); } catch {}
|
|
81
|
+
}
|
|
73
82
|
function rowLines(rows, max = 40) {
|
|
74
83
|
const all = Array.isArray(rows) ? rows : [];
|
|
75
84
|
if (!all.length) return ' (no rows)';
|
|
@@ -450,7 +459,7 @@ const wrap = (fn) => {
|
|
|
450
459
|
// Read from the STRUCTURED signal, exactly as the sentence above is — never from the prose.
|
|
451
460
|
// META'S SECURITY HOLD (code 31/3858385): the server's sentence already carries the steps; this names the move
|
|
452
461
|
// so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
|
|
453
|
-
if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso,
|
|
462
|
+
if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso, on a computer (not the Ads Manager app), create a NEW draft ad in Ads Manager, click Start authentication (or Fix errors) on the error it shows and complete it, or follow a red banner if one is shown; then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
|
|
454
463
|
if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
|
|
455
464
|
}
|
|
456
465
|
// ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
|
|
@@ -1943,7 +1952,10 @@ export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
|
|
|
1943
1952
|
// 2026-08-20 run, and mixing methodologies inside one table would make the rows incomparable.
|
|
1944
1953
|
// RE-MEASURED 2026-09-17, every row at once, by tools/tool-group-truth-check.mjs §8's own method (name + description +
|
|
1945
1954
|
// inputSchema JSON at 4 chars/token, net of core) — `create` had reached 1.32× its row and four more sat past 1.2×.
|
|
1946
|
-
|
|
1955
|
+
// create RE-MEASURED 2026-09-24 (26000 → 42000): edit_timeline (the open edit primitive, 11,026 by this method, about
|
|
1956
|
+
// 2,500 on the wire) and video_frames joined it. Both are on-demand (ON_DEMAND_TOOLS), so they only cost a session that
|
|
1957
|
+
// names `create` (or all); this figure is what that session pays.
|
|
1958
|
+
export const TOOL_GROUP_TOKENS = { core: 4300, research: 6600, create: 42000, channels: 37100, channel_admin: 66300, analytics: 39100, files: 10600, workspace: 9200, ads: 246700 };
|
|
1947
1959
|
|
|
1948
1960
|
// THE DEFAULT ROSTER IS EVERYTHING EXCEPT `ads` AND `analytics`. Paid-campaign management across eleven platforms
|
|
1949
1961
|
// is ~236K tokens on its own — more than everything else put together — because each platform carries a full
|
|
@@ -2056,6 +2068,11 @@ export function parseToolScope(raw) {
|
|
|
2056
2068
|
// Tools an Apps-SDK host (ChatGPT) must not be offered. See the seam in registerTools for why, and note this is
|
|
2057
2069
|
// a HOST rule, not a capability we removed: every other surface still offers both.
|
|
2058
2070
|
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload']);
|
|
2071
|
+
// HELD OUT OF THE DEFAULT LIST ON SIZE, ALWAYS CALLABLE (2026-09-24): the open edit primitive carries a full
|
|
2072
|
+
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
2073
|
+
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
2074
|
+
// edit_timeline, and edit_timeline's names video_frames, so a caller that starts from post_edit reaches both.
|
|
2075
|
+
export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check']); // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check
|
|
2059
2076
|
|
|
2060
2077
|
// ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
|
|
2061
2078
|
// ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
|
|
@@ -2264,6 +2281,12 @@ export function defForHost(name, def, widgetHost) {
|
|
|
2264
2281
|
// session state without it.
|
|
2265
2282
|
let TOOL_CANON = null; // [{ name, group, def, handler, factory }] — the one canonical roster, built on first use
|
|
2266
2283
|
let CANON_HOSTED = false; // which surface TOOL_CANON was built for — see registerTools
|
|
2284
|
+
// THE GROUP EACH TOOL WAS WRITTEN UNDER, read off the canon the last complete build recorded (2026-09-24). The CLI
|
|
2285
|
+
// inventory used to infer it by registering one group at a time and asking which tools came back ENABLED, which
|
|
2286
|
+
// labelled every ON_DEMAND_TOOLS entry `?`: those are held out of every roster short of all groups, so no single-group
|
|
2287
|
+
// pass ever listed them although each sits under server.group('create'). Reading the recorded marker answers the
|
|
2288
|
+
// question actually asked, and a tool registered above the first marker still reads null (reported as `?`).
|
|
2289
|
+
export const canonToolGroups = () => (TOOL_CANON ? Object.fromEntries(TOOL_CANON.map((e) => [e.name, e.group ?? null])) : null);
|
|
2267
2290
|
|
|
2268
2291
|
// Declare a handler that MUST be rebuilt per session. `factory(ctx)` receives {enabledGroups, groupOf, handleOf}.
|
|
2269
2292
|
// ── SWITCHING BRAND MUST RE-GATE THE ROSTER (2026-09-01) ────────────────────────────────────────────────────────
|
|
@@ -2493,6 +2516,14 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
|
|
|
2493
2516
|
try { h.enable(); n++; enabledNames.push(name); } catch {}
|
|
2494
2517
|
}
|
|
2495
2518
|
}
|
|
2519
|
+
// ON-DEMAND TOOLS (ON_DEMAND_TOOLS): held out of the list on size even inside a group that is already on, so asking
|
|
2520
|
+
// for their group BY NAME (or for 'all') lists them, whether or not the group was on before; ?tools=all lists them
|
|
2521
|
+
// too, so the two routes to "everything" agree.
|
|
2522
|
+
for (const [name, grp] of Object.entries(groupOf)) {
|
|
2523
|
+
if (!ON_DEMAND_TOOLS.has(name) || !(want.includes(grp) || want.includes('all')) || enabledNames.includes(name)) continue;
|
|
2524
|
+
const h = handles[name]; if (!h || h.enabled) continue;
|
|
2525
|
+
try { h.enable(); n++; enabledNames.push(name); } catch {}
|
|
2526
|
+
}
|
|
2496
2527
|
const enabled = TOOL_GROUP_NAMES.filter((g) => active.has(g));
|
|
2497
2528
|
// ── "CALLABLE NOW" IS A CLAIM ABOUT THE CLIENT, NOT ABOUT US (2026-08-24) ────────────────────────────────────
|
|
2498
2529
|
// Server-side this genuinely works: the SDK handles are enabled and the very next tools/list on this session
|
|
@@ -2600,6 +2631,7 @@ export const listedInCoreFirst = (name, group, ctx) => ctx.enabledGroups.has(gro
|
|
|
2600
2631
|
function applyToolGates(h, name, group, ctx, opts) {
|
|
2601
2632
|
if (!h) return;
|
|
2602
2633
|
if (!listedInCoreFirst(name, group, ctx)) { try { h.disable(); } catch {} }
|
|
2634
|
+
if (ON_DEMAND_TOOLS.has(name) && !TOOL_GROUP_NAMES.every((g) => ctx.enabledGroups.has(g))) { try { h.disable(); } catch {} } // (5) listed on demand only (an explicit 'all' is a demand) — see ON_DEMAND_TOOLS
|
|
2603
2635
|
if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && opts.widgetHost) { try { h.disable(); } catch {} }
|
|
2604
2636
|
if (toolHeldBackByConnectors(name, ctx.conn)) { try { h.disable(); } catch {} }
|
|
2605
2637
|
if (toolHeldBackByDirectory(name, group, ctx)) { try { h.disable(); } catch {} } // (4) the directory cage — see DIRECTORY_GROUPS
|
|
@@ -2715,6 +2747,21 @@ function accountLine(label, handle, d) {
|
|
|
2715
2747
|
|
|
2716
2748
|
const LI_TARGETING_FACETS = ['LANGUAGE','LOCATION','AUDIENCE','AGE','GENDER','COMPANY','EDUCATION','JOB','INTERESTS','TRAITS'];
|
|
2717
2749
|
|
|
2750
|
+
// THE LOOK OF TEXT ON A VIDEO, OPEN (2026-09-24, the rigidity audit). The six presets and five faces are shortcuts;
|
|
2751
|
+
// the look can also be written in WORDS (translated server-side) or given as raw fields — any CSS colour, any Google
|
|
2752
|
+
// Fonts family, a pixel size. The server validates every field and refuses a bad one by name, free.
|
|
2753
|
+
const TEXT_STYLE_Z = z.union([
|
|
2754
|
+
z.string(),
|
|
2755
|
+
z.object({
|
|
2756
|
+
preset: z.string().optional(), describe: z.string().optional().describe('the look in words'),
|
|
2757
|
+
font: z.string().optional().describe('sans|serif|elegant|condensed|hand or any Google Fonts family'), subFont: z.string().optional(),
|
|
2758
|
+
weight: z.number().optional(), size: z.union([z.string(), z.number()]).optional().describe("s|m|l|xl, '120px', or a 0.015-0.15 frame fraction"),
|
|
2759
|
+
color: z.string().optional().describe('any CSS colour'), outlineColor: z.string().optional(), background: z.string().optional().describe('none|pill|a colour'),
|
|
2760
|
+
position: z.union([z.string(), z.number()]).optional().describe('top|center|lower|bottom or 0.05-0.95 from the top'), textCase: z.enum(['as-is', 'upper', 'lower', 'title']).optional(),
|
|
2761
|
+
italic: z.boolean().optional(), subItalic: z.boolean().optional(), outline: z.boolean().optional(), shadow: z.boolean().optional(),
|
|
2762
|
+
tilt: z.number().optional().describe('degrees, ±45'), cardColor: z.string().optional(),
|
|
2763
|
+
}),
|
|
2764
|
+
]);
|
|
2718
2765
|
export function registerTools(rawServer, opts = {}) {
|
|
2719
2766
|
const server = wellFormedServer(rawServer); // the ONE crossing — see above
|
|
2720
2767
|
// THE CACHE HOLDS SCHEMAS, SO A FLAG THAT CHANGES A SCHEMA MUST INVALIDATE IT (2026-09-16). `hosted` decides
|
|
@@ -4174,7 +4221,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4174
4221
|
const mkLine = (c) => { const f = (v) => v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(v >= 1e5 ? 0 : 1)}K` : String(v); const b = []; if (c.followers != null) b.push(`${f(c.followers)} followers`); if (c.country) b.push(c.country); if (c.badges && c.badges.length) b.push(`badges: ${c.badges.join(', ')}`); if (c.email) b.push(c.email); return `• @${c.handle}${c.verified ? ' (verified)' : ''}${b.length ? `: ${b.join(', ')}` : ''}${c.bio ? `. ${String(c.bio).slice(0, 120)}` : ''}`; };
|
|
4175
4222
|
server.registerTool('find_instagram_marketplace_creators', {
|
|
4176
4223
|
title: 'Search Instagram’s creator marketplace',
|
|
4177
|
-
description: "SEARCH INSTAGRAM'S CREATOR MARKETPLACE: Meta's own directory of creators who partner with brands, searched AS the brand's Instagram account, so the recommendations are personalised to it. Use it when the user wants creators with first-party data (followers, audience, badges such as Partnership ads / Branded content / Strong hooks / Responsive, marketplace email) or Meta's recommendations. Search by query (keywords), by similarTo (up to 5 handles, not with query), by recommendation (most_relevant_for_me, high_ad_performance, most_ads_experience, similar_brands, similar_audience, interested_in_collaboration), or filters on the creator (countries, states with exactly one country, minFollowers/maxFollowers bands, ageBucket, gender, up to 5 interests, minEngaged/maxEngaged, language, followerGrowth, lastPostWithin, verifiedAccount, hasPortfolio, hasPublicContactEmail, featuredInPaidAds, excludeMessagedCreators) and their audience (audienceCountries, audienceStates, audienceAgeBucket, audienceGender, audienceDevices), or customAudienceId from list_instagram_marketplace_audiences. username looks up one creator (no other filter allowed). find_creators ranks creators by their public posts in a niche and takes marketplace:true to add this source beside it. Needs the Meta (Facebook Login) connector with the Page linked to the brand's Instagram, and Meta's instagram_creator_marketplace_discovery permission; when it cannot run it says exactly why and what fixes it. Free.",
|
|
4224
|
+
description: "SEARCH INSTAGRAM'S CREATOR MARKETPLACE: Meta's own directory of creators who partner with brands, searched AS the brand's Instagram account, so the recommendations are personalised to it. Use it when the user wants creators with first-party data (followers, audience, badges such as Partnership ads / Branded content / Strong hooks / Responsive, marketplace email) or Meta's recommendations. Search by query (keywords), by similarTo (up to 5 handles, not with query), by recommendation (most_relevant_for_me, high_ad_performance, most_ads_experience, similar_brands, similar_audience, interested_in_collaboration), or filters on the creator (countries, states with exactly one country, minFollowers/maxFollowers bands, ageBucket, gender, up to 5 interests, minEngaged/maxEngaged, language, followerGrowth, lastPostWithin, verifiedAccount, hasPortfolio, hasPublicContactEmail, featuredInPaidAds, excludeMessagedCreators) and their audience (audienceCountries, audienceStates, audienceAgeBucket, audienceGender, audienceDevices), or customAudienceId from list_instagram_marketplace_audiences. username looks up one creator (no other filter allowed). find_creators ranks creators by their public posts in a niche and takes marketplace:true to add this source beside it. Needs the Meta (Facebook Login) connector with the Page linked to the brand's Instagram, and Meta's instagram_creator_marketplace_discovery permission; when it cannot run it says exactly why and what fixes it, and when the reason is that Meta has not approved Hermoso's app for that permission yet (read LIVE from Meta, and named under Meta by list_connectors) it says so: that is Meta's decision about Hermoso, never a reason to tell the user to reconnect. Free.",
|
|
4178
4225
|
inputSchema: {
|
|
4179
4226
|
query: z.string().optional().describe('keywords, e.g. "skincare" or "trail running"'),
|
|
4180
4227
|
username: z.string().optional().describe('one creator handle; no other filter allowed with it'),
|
|
@@ -7210,7 +7257,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7210
7257
|
}));
|
|
7211
7258
|
server.registerTool('list_instagram_shopping_catalogs', {
|
|
7212
7259
|
title: 'What Instagram Shopping can tag',
|
|
7213
|
-
description: 'Whether this Instagram account can tag products at all, and which catalogs its SHOP can tag from. CALL THIS FIRST: product tagging needs an APPROVED INSTAGRAM SHOP, and if the account does not have one, tagging fails AFTER the photo is already uploaded. The reply says which of three things is true — eligible, not eligible (a Commerce Manager approval nothing in Hermoso can grant, and not a sign anything is broken), or "could not tell", which is NOT the same as not eligible. AN EMPTY CATALOG LIST IS NOT AN EMPTY CATALOG: Instagram reaches a catalog through the account\'s SHOP, while list_meta_catalogs reads the business PORTFOLIO — a merchant can have a full catalog there and nothing available here until the shop is approved. Read-only, 0 credits.',
|
|
7260
|
+
description: 'Whether this Instagram account can tag products at all, and which catalogs its SHOP can tag from. CALL THIS FIRST: product tagging needs an APPROVED INSTAGRAM SHOP, and if the account does not have one, tagging fails AFTER the photo is already uploaded. The reply says which of three things is true — eligible, not eligible (a Commerce Manager approval nothing in Hermoso can grant, and not a sign anything is broken), or "could not tell", which is NOT the same as not eligible. AN EMPTY CATALOG LIST IS NOT AN EMPTY CATALOG: Instagram reaches a catalog through the account\'s SHOP, while list_meta_catalogs reads the business PORTFOLIO — a merchant can have a full catalog there and nothing available here until the shop is approved. Read-only, 0 credits. Whether Meta has approved Hermoso\'s app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool\'s refusal says Meta hasn\'t approved Hermoso for it yet. That is Meta\'s decision about Hermoso, not the user\'s connection, so never tell them to reconnect for it.',
|
|
7214
7261
|
inputSchema: { pageId: z.string().optional().describe('Facebook Page id — omit when only one Page is connected. Its linked Instagram account is the one that gets tagged.') },
|
|
7215
7262
|
outputSchema: { igId: z.string().optional(), account: z.string().optional(), eligible: z.boolean().nullable().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), eligibilityNote: z.string().optional(), note: z.string().optional(), readError: z.string().optional(), limits: z.any().optional() },
|
|
7216
7263
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -7223,7 +7270,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7223
7270
|
}));
|
|
7224
7271
|
server.registerTool('search_instagram_shopping_products', {
|
|
7225
7272
|
title: 'Find products to tag on Instagram',
|
|
7226
|
-
description: 'The products in one catalog that can actually be TAGGED on an Instagram post — this is where the product_id for a product tag comes from. Omit `q` to see everything tag-eligible; pass a product name or SKU to narrow it. THIS IS A SMALLER SET THAN THE CATALOG HOLDS: a product can be in the catalog, counted by list_meta_catalog_products, and still not be taggable, so an empty answer here is never evidence the catalog is empty. Meta only SHOWS a tag whose product review_status is "approved" — an unapproved one is accepted, stored and shown to nobody, so the reply flags them. Read-only, 0 credits.',
|
|
7273
|
+
description: 'The products in one catalog that can actually be TAGGED on an Instagram post — this is where the product_id for a product tag comes from. Omit `q` to see everything tag-eligible; pass a product name or SKU to narrow it. THIS IS A SMALLER SET THAN THE CATALOG HOLDS: a product can be in the catalog, counted by list_meta_catalog_products, and still not be taggable, so an empty answer here is never evidence the catalog is empty. Meta only SHOWS a tag whose product review_status is "approved" — an unapproved one is accepted, stored and shown to nobody, so the reply flags them. Read-only, 0 credits. Whether Meta has approved Hermoso\'s app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool\'s refusal says Meta hasn\'t approved Hermoso for it yet. That is Meta\'s decision about Hermoso, not the user\'s connection, so never tell them to reconnect for it.',
|
|
7227
7274
|
inputSchema: {
|
|
7228
7275
|
catalogId: z.string().describe('from list_instagram_shopping_catalogs. Meta REQUIRES it — there is no search-every-catalog form.'),
|
|
7229
7276
|
q: z.string().optional().describe('product name or SKU. Omit it to list every tag-eligible product, which is a real ask rather than a missing argument.'),
|
|
@@ -7239,7 +7286,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7239
7286
|
}));
|
|
7240
7287
|
server.registerTool('manage_instagram_product_tags', {
|
|
7241
7288
|
title: 'Read or update the product tags on a published Instagram post',
|
|
7242
|
-
description: 'READ the product tags on a post the brand has already published, or ADD/MOVE tags on it. Pass `tags` to update: Meta\'s own behaviour is "updates coordinates if the product is already tagged; otherwise adds new tag" — so it is ADD-OR-MOVE, never replace-all, and it CANNOT be used to take a tag off. THERE IS NO WAY TO REMOVE A PRODUCT TAG: Meta documents Creating, Reading and Updating on this edge and no delete at all, so Hermoso will not guess at one — deleting the post is the only thing that removes its tags, and the reply says so rather than implying otherwise. The answer is always READ BACK from Instagram, and it separates tags that are STORED from tags that will actually be SHOWN — only an "approved" product ever appears on a published post. Read is free; the update costs 0 credits too.',
|
|
7289
|
+
description: 'READ the product tags on a post the brand has already published, or ADD/MOVE tags on it. Pass `tags` to update: Meta\'s own behaviour is "updates coordinates if the product is already tagged; otherwise adds new tag" — so it is ADD-OR-MOVE, never replace-all, and it CANNOT be used to take a tag off. THERE IS NO WAY TO REMOVE A PRODUCT TAG: Meta documents Creating, Reading and Updating on this edge and no delete at all, so Hermoso will not guess at one — deleting the post is the only thing that removes its tags, and the reply says so rather than implying otherwise. The answer is always READ BACK from Instagram, and it separates tags that are STORED from tags that will actually be SHOWN — only an "approved" product ever appears on a published post. Read is free; the update costs 0 credits too. Whether Meta has approved Hermoso\'s app for the permission this needs is read LIVE from Meta: when it has not, list_connectors says so under Meta and this tool\'s refusal says Meta hasn\'t approved Hermoso for it yet. That is Meta\'s decision about Hermoso, not the user\'s connection, so never tell them to reconnect for it.',
|
|
7243
7290
|
inputSchema: {
|
|
7244
7291
|
mediaId: z.string().describe('the numeric Instagram media id, from list_instagram_media'),
|
|
7245
7292
|
tags: z.array(z.object({ product_id: z.string(), x: z.number(), y: z.number() })).optional().describe('ADD or MOVE these tags. x and y are FRACTIONS of the image, 0.0 (left/top) to 1.0 (right/bottom) — 0.5,0.5 is the middle. Omit to just read. Max 20 on a feed post.'),
|
|
@@ -16903,7 +16950,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16903
16950
|
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.',
|
|
16904
16951
|
inputSchema: {
|
|
16905
16952
|
prompt: z.string().describe('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.'),
|
|
16906
|
-
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.'),
|
|
16953
|
+
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. A real person’s photo confirms their likeness consent.'),
|
|
16907
16954
|
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'),
|
|
16908
16955
|
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.'),
|
|
16909
16956
|
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"),
|
|
@@ -17026,7 +17073,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17026
17073
|
title: 'Make video thumbnail',
|
|
17027
17074
|
description: "Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover — the full production pipeline (concept framework -> casting -> scene -> render -> surgical tweaks -> text), not a bare image prompt. Use this for any \"thumbnail\", \"video cover\", \"video preview\" or MrBeast-style packaging ask INSTEAD of generate_image. About 9 credits per variant; the headline overlay is free.\n\nCONCEPT — every thumbnail must open an INFORMATION GAP (the image raises a question the title answers) while staying truthful to the video. Brainstorm ≥5 concepts across the 16 frameworks before you pick, and feel free to combine two. Frameworks (pass as `framework`): before_after · social_ui · three_step · screenshot · posed_portrait (the default) · posed_action · specific_day · graphical · landscape · map_aerial · product · adding_text · repetition · size_difference · news_clip · amplified_reality. Call hermoso_capabilities for each one's full 'realize it with' note plus the emotion, overlay-style, font and rim-colour catalogs.\n\nTHREE GATES, all BEFORE you render:\n1. WHO IS IN FRAME — never assume and never silently substitute a stranger. If the framework puts a person in frame and no face photo is attached, the tool refuses (nothing rendered, nothing charged) and tells you to ask the user once: themselves (send a face photo -> the identity gets locked), a generated person (`castGenericPerson:true`), or a people-free framework.\n2. TEXT — the default is a CLEAN render with the headline TYPESET OVER THE TOP afterwards (free, always legible, correctly spelled). Just pass `headline`. Only set `bakeText:true` if the user explicitly asks for the words painted INTO the image — verified live, that renders the asked-for words correctly but leaks garbled invented text across the rest of the frame. Never infer text intent from the topic or the framework.\n3. HOW MANY — ask once whether they want one thumbnail or a SET (offer 4: the same concept at different emotions and/or camera takes). Default is 1; `variants` caps at 16.\n\nIDENTITY LOCK is automatic for every attached face photo. `emotion` is the single biggest CTR lever on a face: shock · hype · fear · confusion · determination · smug · charisma · disgust · awe · rage · laugh (or your own phrase). Finished thumbnail needs a fix? Re-call with `tweak` + `sourceImage` for a surgical, pixel-faithful edit (emotion / background / background_color / rim_light) instead of re-rendering — tweaks chain. ALWAYS check the returned postRenderCheck against the image before you present it.\n\nPROMPT LANGUAGE — write every DESCRIPTIVE field in ENGLISH (`sceneBrief`, `keyElements`, `location`, `composition`, `background`, `topic`, each person's `describe`, and every `reference` field), translating the user's wording where needed: the image models are trained on English and a non-English scene description renders noticeably worse. Text that gets BAKED OR TYPESET stays verbatim in the user's own language — `headline`, `headlineLines` and `bakedUiText` are never translated.",
|
|
17028
17075
|
inputSchema: {
|
|
17029
|
-
framework: z.string().optional().describe("concept framework id (default 'posed_portrait')
|
|
17076
|
+
framework: z.string().optional().describe("concept framework id (default 'posed_portrait'), or your own concept in words"),
|
|
17030
17077
|
frameworkRequested: z.boolean().optional().describe('true ONLY when the USER named this framework — it is what authorizes a text-carrying framework (social_ui / news_clip / specific_day / map_aerial) to bake its short UI label'),
|
|
17031
17078
|
sceneBrief: z.string().optional().describe('what the thumbnail depicts — the concept in one dense sentence, rendered exactly'),
|
|
17032
17079
|
topic: z.string().optional().describe("the video's topic — used to pick the hero object when you don't name keyElements"),
|
|
@@ -17034,9 +17081,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17034
17081
|
headlineLines: z.array(z.string()).optional().describe('explicit headline lines (up to 3) — overrides splitting `headline` on newlines'),
|
|
17035
17082
|
bakeText: z.boolean().optional().describe('default false. true paints the headline INTO the generation — only on an explicit user ask; it leaks garbled text elsewhere in the frame'),
|
|
17036
17083
|
bakedUiText: z.string().optional().describe('short label for a text-carrying framework (a chat bubble, a DAY N badge, a news lower-third, a map callout) — needs frameworkRequested:true'),
|
|
17037
|
-
overlayStyle: z.string().optional().describe("headline style:
|
|
17038
|
-
font: z.string().optional().describe("headline font (default
|
|
17039
|
-
headlinePlace: z.
|
|
17084
|
+
overlayStyle: z.string().optional().describe("headline style: beast (default), fire, neon-lime, clean-glass, marker, or your own CSS declarations"),
|
|
17085
|
+
font: z.string().optional().describe("headline font: Anton (default) or any Google Fonts family"),
|
|
17086
|
+
headlinePlace: z.string().optional().describe("bottom (default), top, center, or a 0-1 fraction from the top; never over the face"),
|
|
17040
17087
|
faceImages: z.array(z.string()).optional().describe('up to 3 face photos (URLs or local paths) — each becomes a locked CHARACTER identity, in order'),
|
|
17041
17088
|
people: z.array(z.object({ describe: z.string() }).passthrough()).optional().describe('people described in prose instead of by photo (each still gets the chosen expression)'),
|
|
17042
17089
|
castGenericPerson: z.boolean().optional().describe('pass true only after the user has explicitly chosen a generated stranger over their own face'),
|
|
@@ -17055,7 +17102,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17055
17102
|
logo3d: z.boolean().optional().describe('first turn the flat logo into a volumetric 3D render (one extra billed image), then composite that'),
|
|
17056
17103
|
split: z.object({ mode: z.enum(['plain', 'before_after', 'versus', 'custom']), panels: z.array(z.string()).optional() }).passthrough().optional().describe('split/panel LAYOUT — only when the user asks for one ("split", "before/after", "versus screen"). "X vs Y" as a SCENE stays one unified frame'),
|
|
17057
17104
|
reference: z.object({}).passthrough().optional().describe("fields YOU extracted by eye from a reference thumbnail. Extract ALL of: brief (one dense sentence on the concept), subject (pose/action generically, NEVER a specific identity), elements, location, composition, background, split (boolean), split_count, person_count (0-3), emotion (one of the 11 presets or 'other'), emotion_detail (one vivid sentence covering eyes, brows, mouth, head angle). emotion + emotion_detail carry the reference's actual facial performance, which is the single biggest CTR lever on a face; split/split_count reproduce its panel structure. The reference image itself is never sent to the model"),
|
|
17058
|
-
tweak: z.object({ kind: z.
|
|
17105
|
+
tweak: z.object({ kind: z.string(), value: z.string() }).describe('surgical pixel-faithful edit of a FINISHED thumbnail (needs sourceImage): kind emotion / background / background_color / rim_light, or any other kind with the edit in words as value').optional(),
|
|
17059
17106
|
sourceImage: z.string().optional().describe('the finished thumbnail URL a `tweak` edits; tweaks chain, so feed each accepted output into the next'),
|
|
17060
17107
|
forceGenerate: z.boolean().optional().describe("render the 'screenshot' framework anyway (it is normally a real video frame, not a generation)"),
|
|
17061
17108
|
},
|
|
@@ -17112,6 +17159,17 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17112
17159
|
return ok(`Voice clip ready — ${d.voice || 'the engine’s own voice'}${d.model ? ` · ${d.model}` : ''}: ${abs(d.audio)}${d.voiceNote ? `\n⚠ ${d.voiceNote}` : ''}`, { ...d, audio: abs(d.audio) });
|
|
17113
17160
|
}));
|
|
17114
17161
|
|
|
17162
|
+
server.registerTool('generate_music', {
|
|
17163
|
+
title: 'Generate music',
|
|
17164
|
+
description: "RAW music: describe it (genre, mood, instruments, tempo), get an instrumental MP3. Flat fee: explainerMusicCredits in hermoso_capabilities.",
|
|
17165
|
+
inputSchema: { prompt: z.string().describe("the music in words, e.g. 'lo-fi jazz, brushed drums, 80 bpm'") },
|
|
17166
|
+
outputSchema: { audio: z.string().optional(), durationSeconds: z.number().nullable().optional(), creditsUsed: z.number().optional() },
|
|
17167
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17168
|
+
}, wrap(async ({ prompt }) => {
|
|
17169
|
+
const d = await apiPost('/api/generate/music', { prompt });
|
|
17170
|
+
return ok(`Music ready${d.durationSeconds ? ` (${d.durationSeconds}s)` : ''}: ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
|
|
17171
|
+
}));
|
|
17172
|
+
|
|
17115
17173
|
server.registerTool('generate_text', {
|
|
17116
17174
|
title: 'Generate text',
|
|
17117
17175
|
description: "Text generation against the writing-model catalog (Claude, Gemini, GPT, Llama, DeepSeek…) — ad copy, hooks, scripts, rewrites, brainstorms. Prompt-only, no ad assembly (for a finished on-brand creative use plan_ad -> render_ad). BY DEFAULT the model answers as a marketing copywriter (a short house system prompt is applied, which is what you want for ad copy); pass raw:true for a plain, unstyled answer from the model itself with NO system prompt at all. model = a writing-model id from hermoso_capabilities (omit for the default Claude orchestrator). Paid (a credit or two by length).",
|
|
@@ -17138,28 +17196,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17138
17196
|
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), licensed music bed, 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.',
|
|
17139
17197
|
inputSchema: {
|
|
17140
17198
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
17141
|
-
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 on any plan with a person on camera, 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
|
|
17199
|
+
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 on any plan with a person on camera, 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, or if an explicitly named creator is cast on a plan with nobody on camera. Casting a REAL person confirms their likeness consent.'),
|
|
17142
17200
|
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)'),
|
|
17143
17201
|
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.'),
|
|
17144
17202
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
17145
17203
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
|
|
17146
17204
|
captions: z.boolean().optional().describe('burn the plan\'s per-scene on-screen words as caption pills. DEFAULT FALSE on every recipe — set true ONLY when the user asks for on-screen text or captions; no recipe turns them on by itself'),
|
|
17147
17205
|
endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE on every recipe — set true ONLY when the user asks for an end card (a clone of a video that had none should not grow one)'),
|
|
17148
|
-
music: z.boolean().optional().describe(
|
|
17206
|
+
music: z.union([z.boolean(), z.string()]).optional().describe("music bed: on (default), false, or the bed described in words ('lo-fi jazz, brushed drums')"),
|
|
17149
17207
|
lockup: z.boolean().optional().describe('brand wordmark + tagline composited over the closing seconds. DEFAULT FALSE — set true ONLY when the user asks for branding on the close'),
|
|
17150
|
-
textStyle:
|
|
17151
|
-
z.enum(['pill', 'editorial', 'bold', 'minimal', 'handwritten', 'boxed']),
|
|
17152
|
-
z.object({
|
|
17153
|
-
preset: z.enum(['pill', 'editorial', 'bold', 'minimal', 'handwritten', 'boxed']).optional(),
|
|
17154
|
-
font: z.enum(['sans', 'serif', 'elegant', 'condensed', 'hand']).optional(),
|
|
17155
|
-
subFont: z.enum(['sans', 'serif', 'elegant', 'condensed', 'hand']).optional(),
|
|
17156
|
-
weight: z.number().optional(), size: z.union([z.enum(['s', 'm', 'l', 'xl']), z.number()]).optional(),
|
|
17157
|
-
color: z.string().optional().describe('#hex'), background: z.string().optional().describe('"none", "pill", or a #hex box'),
|
|
17158
|
-
position: z.enum(['top', 'center', 'lower', 'bottom']).optional(), textCase: z.enum(['as-is', 'upper', 'lower', 'title']).optional(),
|
|
17159
|
-
italic: z.boolean().optional(), subItalic: z.boolean().optional(), outline: z.boolean().optional(), shadow: z.boolean().optional(),
|
|
17160
|
-
tilt: z.number().optional().describe('degrees, ±12'), cardColor: z.string().optional().describe('#hex end card background'),
|
|
17161
|
-
}),
|
|
17162
|
-
]).optional().describe('THE LOOK of captions and the end card — only meaningful with captions:true or endCard:true, and only when the user described a look. Presets: editorial (a large elegant serif title mid-frame with a small italic line under it, no box), bold (tall condensed caps with a black outline), minimal (small lowercase near the bottom), handwritten (tilted marker), boxed (dark words on a white box), pill (the plain default). Pass a preset name, or an object with a preset plus overrides. A caption written "TITLE · small line" puts the part after the middle dot on a second line. An invalid field is refused by name before anything renders.'),
|
|
17208
|
+
textStyle: TEXT_STYLE_Z.optional().describe('THE LOOK of captions and the end card, only with captions or endCard and only when the user described one: the look in WORDS ("chunky yellow comic letters, purple outline"), a preset (editorial: big serif title + small italic line; bold: condensed caps, outline; minimal; handwritten; boxed; pill, the default), or fields. "TITLE · small line" puts the part after the dot on a second line.'),
|
|
17163
17209
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
17164
17210
|
dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) WITHOUT submitting a render — free, nothing charged'),
|
|
17165
17211
|
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'),
|
|
@@ -17187,7 +17233,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17187
17233
|
// roster; a half-remembered name that matched nobody, matched two people, or belongs to an unconsented real
|
|
17188
17234
|
// person never reaches here at all (the assemble route refuses, free, before a job exists). So this line names
|
|
17189
17235
|
// who is actually in the ad, and it names them from the resolution — the same law the ads tree follows.
|
|
17190
|
-
const _castLine = (creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consented ? ', likeness consent on file' : '') : ''}.` : '') + (ownRefNotice ? `\n⚠ ${ownRefNotice.text} ${ownRefNotice.fix}` : ''); // a creator's own photo too poor to cast is never cast silently (lowQualityRef) — say so and name the fix
|
|
17236
|
+
const _castLine = (creator ? `\nCast: ${creator.name} (${creator.id}) — ${creator.source === 'generated' ? 'AI creator' : creator.source === 'social' ? 'from a social profile' : 'uploaded photo'}${creator.source !== 'generated' ? (creator.consentVia === 'api-implied' ? ', likeness consent confirmed by this call and recorded' : creator.consented ? ', likeness consent on file' : '') : ''}.` : '') + (ownRefNotice ? `\n⚠ ${ownRefNotice.text} ${ownRefNotice.fix}` : ''); // a creator's own photo too poor to cast is never cast silently (lowQualityRef) — say so and name the fix
|
|
17191
17237
|
// LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
|
|
17192
17238
|
// (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
|
|
17193
17239
|
// acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
|
|
@@ -17209,15 +17255,19 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17209
17255
|
|
|
17210
17256
|
server.registerTool('make_template_ad', {
|
|
17211
17257
|
title: 'Make template ad',
|
|
17212
|
-
description: "
|
|
17258
|
+
description: "An ad or post rendered from HTML: no AI model, ~30s, a couple of credits. 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 https URL; notes[] lists what failed to load. YOU author preset copy: short, casual, believable, finished phrases within budget. '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) }; 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. config.music on a VIDEO: omit for the format's free library bed, 'off' for silence, or any words (a mood or a description) to compose a bed to them (a flat music fee, in hermoso_capabilities). Image URLs may be any public URL.",
|
|
17213
17259
|
inputSchema: {
|
|
17214
|
-
config: z.object({}).passthrough().describe("
|
|
17260
|
+
config: z.object({}).passthrough().describe("MUST include config.template: 'custom' or a preset id above, plus its fields"),
|
|
17215
17261
|
},
|
|
17216
17262
|
outputSchema: { ...JOB_OUT },
|
|
17217
17263
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17218
17264
|
_meta: openaiMeta(AD_RESULT_URI, 'Building your template ad…', 'Template ad ready'),
|
|
17219
17265
|
}, wrap(async (a) => {
|
|
17220
|
-
|
|
17266
|
+
// A custom design fills {{logo}} {{brandName}} {{domain}} {{accent}} from the workspace brand, the same record the web
|
|
17267
|
+
// client sends; presets keep their explicit config fields (endCard etc.) exactly as before.
|
|
17268
|
+
let _brand;
|
|
17269
|
+
if (String(a.config?.template || '') === 'custom') { let b = await readStore('heist.brand.v1').catch(() => null); if (!b || typeof b !== 'object') b = {}; const pal = (Array.isArray(b.palette) ? b.palette : []).filter((c) => /^#[0-9a-f]{6}$/i.test(String(c || ''))); _brand = { name: b.name || '', domain: b.domain || '', logo: b.logo || '', accent: pal[0] || '' }; }
|
|
17270
|
+
const r = await renderJob('templatead', { config: a.config, ...(_brand ? { brand: _brand } : {}) }, 'MCP template ad');
|
|
17221
17271
|
if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
|
|
17222
17272
|
const urls = r.raw.images.map((u) => abs(u));
|
|
17223
17273
|
const first = await imageBlock(urls[0]).catch(() => null);
|
|
@@ -17225,8 +17275,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17225
17275
|
const notes = Array.isArray(r.raw.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : '';
|
|
17226
17276
|
return { content: [{ type: 'text', text: `${String(a.config?.template || '') === 'slideshow' ? 'Slideshow' : 'Carousel'} ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')}${vid}${notes} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ?? {} };
|
|
17227
17277
|
}
|
|
17228
|
-
|
|
17229
|
-
|
|
17278
|
+
// notes = what the render could not honour (a custom design's resource that did not load); music = the bed that
|
|
17279
|
+
// actually landed, or that it did not — both READ BACK off the result, so the agent can judge its own design
|
|
17280
|
+
const _tn = Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : '';
|
|
17281
|
+
const _tm = r?.raw?.music ? (r.raw.music.landed === false ? `\nMusic: no bed landed for "${r.raw.music.mood}" (nothing charged for it).` : `\nMusic: ${r.raw.music.source} bed, "${r.raw.music.mood}".`) : '';
|
|
17282
|
+
if (r?.raw?.image || /\.png($|\?)/.test(r?.url || '')) { const img = r?.url ? await imageBlock(r.url) : null; return { content: [{ type: 'text', text: `Template ad ready: ${r.url} [job ${r.jobId}]${_tn}` }, ...(img ? [img] : [])], structuredContent: r ?? {} }; }
|
|
17283
|
+
return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_tm}${_tn}`, r);
|
|
17230
17284
|
}));
|
|
17231
17285
|
|
|
17232
17286
|
server.registerTool('finish_video', {
|
|
@@ -17251,7 +17305,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17251
17305
|
|
|
17252
17306
|
server.registerTool('post_edit', {
|
|
17253
17307
|
title: 'Post-production edit',
|
|
17254
|
-
description: "MECHANICAL post-production on an EXISTING video (its URL): an ordered plan of whitelisted primitives run by ffmpeg (+ Chrome
|
|
17308
|
+
description: "MECHANICAL post-production on an EXISTING video (its URL): an ordered plan of whitelisted primitives run by ffmpeg (+ Chrome) in seconds for ~2 credits flat, NO AI model, as a NEW video. Ops: a branded end card (adds its seconds), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, watermark (corner logo), grain (anti-AI), text (timed words over the clip in a native look, no branding: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle, position, start/end), join (this video FOLLOWED BY clips[], each a Library URL, a direct file or a public TikTok / Reel / Facebook / X / YouTube post link, as one 1080x1920 video with matched loudness; transition 'cut' or 'crossfade'). Up to 6 ops, in order. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}], bridge}], and it ALWAYS gets a bridge unless the user asks for a bare cut: {kind:'impact'} cuts the hook just before its payoff (found from the footage; cutAt overrides) and lands our clip on a punch-in and flash, with the payoff sound taken FROM THE HOOK ITSELF: its own audio carries across the cut, else a sound generated from its frames (then up to 8 credits), else a neutral impact (sound 'auto' default | 'own' never a model | 'impact' | 'whoosh' | 'none'). {kind:'text', text, then?} only when the user asks for words over the cut. No voiceover bridge: best, make our clip's host say the connecting line. matchCut = where our clip starts. Other edits, transitions: edit_timeline. NEVER generate_video/render_ad for these.",
|
|
17255
17309
|
inputSchema: {
|
|
17256
17310
|
videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
|
|
17257
17311
|
ops: z.array(z.object({
|
|
@@ -17261,8 +17315,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17261
17315
|
text: z.string().optional().describe('text: the words, verbatim'),
|
|
17262
17316
|
position: z.enum(['top', 'center', 'lower', 'bottom']).optional().describe('text: where'),
|
|
17263
17317
|
style: z.union([z.string(), z.object({}).passthrough()]).optional().describe('text: a look name or a textStyle'),
|
|
17318
|
+
textStyle: z.union([z.string(), z.object({}).passthrough()]).optional(),
|
|
17264
17319
|
clips: z.array(z.object({ url: z.string(), start: z.number().optional(), end: z.number().optional() })).optional().describe('join: the clips after this video'),
|
|
17265
17320
|
transition: z.enum(['cut', 'crossfade']).optional().describe('join'),
|
|
17321
|
+
bridge: z.object({ kind: z.enum(['impact', 'text']), cutAt: z.number().optional().describe('omit: found from the footage'), matchCut: z.number().optional(), sound: z.enum(['auto', 'own', 'impact', 'whoosh', 'none']).optional(), flash: z.boolean().optional(), shake: z.boolean().optional(), text: z.string().optional(), then: z.string().optional() }).optional().describe('join: connects the hook to the first clip'),
|
|
17266
17322
|
factor: z.number().optional().describe('speed 0.5-2'),
|
|
17267
17323
|
db: z.number().optional().describe('audio_gain -20..+6 dB'),
|
|
17268
17324
|
seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s / crossfade 0.2-1.5s'),
|
|
@@ -17287,6 +17343,93 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17287
17343
|
return okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.applied) ? ` (${r.raw.applied.join(', ')})` : ''}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : ''} [job ${r.jobId}]`, r);
|
|
17288
17344
|
}));
|
|
17289
17345
|
|
|
17346
|
+
// THE OPEN EDIT PRIMITIVE (2026-09-24). Dave: "I hate being rigid, the whole point of this is being able to do basically
|
|
17347
|
+
// anything they want", and: "their super smart AI agent should have hermoso, access all our tools and be able to make
|
|
17348
|
+
// what it thinks is best for the user". So there is no list of named transitions: the caller COMPOSES any edit from
|
|
17349
|
+
// segments + keyframes + overlay HTML, and the server (lib/timeline.mjs) validates every value and compiles the
|
|
17350
|
+
// filtergraph itself. It rides the same postedit job as post_edit (same flat price, same source guards). Held out of
|
|
17351
|
+
// the default LIST on size (ON_DEMAND_TOOLS): find_tools finds it, call_tool and a direct call run it.
|
|
17352
|
+
// a constant, or keyframes [{t | src, v, ease}] (shape spelled out on `segments`: one description beats thirteen copies)
|
|
17353
|
+
const KF = z.union([z.number(), z.array(z.any())]);
|
|
17354
|
+
server.registerTool('edit_timeline', {
|
|
17355
|
+
title: 'Compose an edit (timeline)',
|
|
17356
|
+
description: "Compose ANY edit or transition yourself; there is no preset list. Segments on an output timeline (later ones drawn on top; overlapping ones ARE the transition), each animated by keyframes, plus your own HTML overlays and masks, compiled server-side into one render. Whip pan, zoom through, push, spin, speed ramp, slow motion, freeze frame, reverse, flash, J-cut / L-cut, circle or shape wipe, split screen, picture in picture: all composed from the fields below. Local render, the flat post_edit price (~2 credits, overlays included); a `generate` segment (a paid generated transition shot) only when the user explicitly asks for one. "
|
|
17357
|
+
+ "LOOK FIRST AND AFTER: video_frames(url, start, end, fps) shows exact frames free, so find the moment (the second the egg tips) before you cut, and check the seam of what you rendered before you call it done. "
|
|
17358
|
+
+ "SOURCES: src is 'this' (videoUrl) or any Library / render URL, direct file or PUBLIC TikTok / Reel / Facebook / X / YouTube post link (a viral hook's link works as is). in/out are SOURCE seconds; out:'payoff' cuts just before the hook's payoff, found from its motion. "
|
|
17359
|
+
+ "AUDIO: audio.tail:'payoff' carries the hook's OWN payoff sound across the cut (the egg's real splat over our clip); tail seconds = an L-cut; lead = a J-cut; gain dB or keys; mute. "
|
|
17360
|
+
+ "A VIRAL HOOK + THEIR PRODUCT: start from the hook. A clip matched to an unrelated hook never reads as one video, so write the clip AFTER the hook for it: a linking script + shot brief (the first line answers the hook, e.g. 'still waiting for the egg to land... anyway, come check out our restaurant'; what to film so it follows on; 5-15 s; then the pitch). The user records it, or you generate it (render_ad / generate_video, cost quoted first, on their OK); then join here: the hook with out:'payoff' and audio.tail:'payoff', then their clip. Match an existing unrelated clip only if they insist. A {generate:{prompt, seconds 3-8}} segment (a generated transition-only shot, paid, postEditTimeline) is never suggested; build it only when they explicitly ask for one. "
|
|
17361
|
+
+ "LINK FIRST: an effect alone never connects two unrelated clips; the link comes from what is in the frames. (1) Match cut, the default: video_frames (with its MOTION readout) on the hook's last second and across the other clip; pick the out-point AND the in-point (in: seconds, not always 0) where a motion direction, a screen position or size, a shape, a surface, a gesture or a gaze carries across, then ride the effect on that shared motion. (2) Its host names the hook in the first line. If the two share nothing, say so and offer the follow clip made for the hook. "
|
|
17362
|
+
+ "PRO, NOT IMOVIE: ease every curve (never linear on a move); keep the picture filling the frame through a move (scale up while it moves: two frames sliding side by side with a seam is the amateur tell); hide the handoff under the fastest, blurriest frames; carry direction into the next shot; cut on motion; end every effect cleanly; 0.2-0.6 s in total; a sound whose peak lands on the handoff (sfx whoosh at handoff minus 0.45 s, or the hook's own payoff sound). Moving segments get a real shutter blur automatically (motionBlur). "
|
|
17363
|
+
+ "RECIPES (c = the cut second, adapt freely): whip pan: A over its last 0.22 s x 0 to -0.22, scale 1 to 1.35, mblur 0 to 220, all ease in; B overlap 0.08, opacity 0 to 1 over 0.08, x 0.22 to 0, scale 1.35 to 1, mblur 220 to 0, all ease out over 0.3 s; whoosh at c-0.45. Zoom through: A over its last 0.35 s scale 1 to 3 ease in anchored on the object, blur 0 to 10; B overlap 0.12, opacity 0 to 1, scale 1.5 to 1 and blur 10 to 0 ease out over 0.4 s. Cut on action: A out ON the motion, B scale 1.08 to 1 ease out over 0.25 s, audio.lead 0.2. Speed ramp: speed [{src:t0,v:1},{src:t0+0.25,v:0.3}] then [{src:t1,v:0.3},{src:t1+0.1,v:2}] into the cut. Circle wipe: B overlap 0.5 + overlays [{mode:'mask', segment:1, start, end, html: a white div whose clip-path circle grows via @keyframes}]. "
|
|
17364
|
+
+ "SELF-CRITIQUE: the reply carries a vision REVIEW of each seam (pro / ok / amateur, linked or not, with fixes; about 2 credits, review:false skips it) and the seam frames. When it says ok or amateur, fix what it names and re-run the same sources (twice at most) before presenting; on a re-run give a generated segment {src: its URL, between: true} so it is not paid for twice. "
|
|
17365
|
+
+ "FOLLOW-UPS ('cut earlier', 'no splat', 'whip pan instead', 'use the second hook') re-run this with the SAME sources and the one change; the result echoes the resolved timeline (e.g. the found payoff cut) to edit from. Refusals are free and name the field.",
|
|
17366
|
+
inputSchema: {
|
|
17367
|
+
videoUrl: z.string().optional().describe("the video 'this' refers to (optional when every segment names its own src)"),
|
|
17368
|
+
segments: z.array(z.object({
|
|
17369
|
+
src: z.string().optional().describe("'this' or a video URL / public post link"),
|
|
17370
|
+
image: z.string().optional().describe('a still instead (URL), with seconds'),
|
|
17371
|
+
generate: z.object({ prompt: z.string(), seconds: z.number().optional().describe('3-8'), model: z.string().optional().describe('a model id that takes start AND end frames') }).optional().describe('PAID: a new shot between its neighbours'),
|
|
17372
|
+
in: z.number().optional().describe('source second to start from (default 0)'),
|
|
17373
|
+
out: z.union([z.number(), z.literal('payoff')]).optional().describe("source second to stop at, or 'payoff' (default: the end)"),
|
|
17374
|
+
at: z.number().optional().describe('output second it starts (default: right after the previous)'),
|
|
17375
|
+
overlap: z.number().optional().describe('seconds it starts before the previous ends: the transition window'),
|
|
17376
|
+
seconds: z.number().optional().describe('image segments: how long'),
|
|
17377
|
+
fit: z.enum(['auto', 'fill', 'contain', 'blur']).optional(),
|
|
17378
|
+
crop: z.union([z.literal('auto'), z.object({ x: z.number().optional(), y: z.number().optional(), w: z.number().optional(), h: z.number().optional() })]).optional().describe("fractions of the source frame, or 'auto': the picture only, without letterbox bars and their caption"),
|
|
17379
|
+
anchor: z.object({ x: z.number(), y: z.number() }).optional().describe('canvas point (0-1) that stays put while it scales'),
|
|
17380
|
+
speed: z.union([z.number(), z.array(z.object({ src: z.number(), v: z.number(), ease: z.enum(['linear', 'hold']).optional() }))]).optional().describe('0.05-8, or a ramp over source seconds'),
|
|
17381
|
+
hold: z.array(z.object({ src: z.number(), seconds: z.number() })).optional().describe('freeze frames'),
|
|
17382
|
+
reverse: z.boolean().optional(),
|
|
17383
|
+
between: z.boolean().optional().describe('a bridge clip between its neighbours (a re-used generated shot): trimmed and graded to them automatically'),
|
|
17384
|
+
slowmo: z.enum(['blend', 'hold', 'flow']).optional().describe('how slow motion fills frames (flow = motion-interpolated)'),
|
|
17385
|
+
scale: KF.optional(), x: KF.optional().describe('canvas widths'), y: KF.optional().describe('canvas heights'), rotate: KF.optional().describe('degrees'),
|
|
17386
|
+
opacity: KF.optional(), blur: KF.optional(), mblur: KF.optional().describe('directional motion blur px'), mblurAngle: z.number().optional(),
|
|
17387
|
+
brightness: KF.optional().describe('-1..1, a flash'), exposure: KF.optional().describe('× the light, 1 = as shot (a fine grade)'), contrast: KF.optional(), saturation: KF.optional(),
|
|
17388
|
+
audio: z.object({ mute: z.boolean().optional(), gain: KF.optional().describe('dB'), lead: z.number().optional(), tail: z.union([z.number(), z.literal('payoff')]).optional(), fadeIn: z.number().optional(), fadeOut: z.number().optional(), level: z.enum(['match', 'keep']).optional() }).optional(),
|
|
17389
|
+
})).describe("the clips on the output, max 12, later ones on top. Every animated field (scale x y rotate opacity exposure blur mblur brightness contrast saturation, audio.gain) is a constant or keyframes [{t: seconds into this segment | src: a second of the source, v, ease: linear|in|out|inout|hold}]; x and y are in canvas widths / heights, rotate in degrees, mblur in px along mblurAngle"),
|
|
17390
|
+
overlays: z.array(z.object({ html: z.string().describe('HTML/CSS animated with @keyframes; no script, no network (images as data: URIs)'), start: z.number(), end: z.number(), mode: z.enum(['over', 'mask']).optional().describe("'mask': its opaque pixels are where `segment` shows"), segment: z.number().optional() })).optional(),
|
|
17391
|
+
sfx: z.array(z.object({ sound: z.enum(['whoosh', 'impact', 'payoff']), at: z.number(), gain: z.number().optional(), segment: z.number().optional().describe("payoff: the segment whose OWN payoff sound (the egg's real splat) lands at `at`") })).optional(),
|
|
17392
|
+
size: z.enum(['9:16', '1:1', '4:5', '16:9']).optional(),
|
|
17393
|
+
fps: z.number().optional(),
|
|
17394
|
+
motionBlur: z.boolean().optional().describe('default true: anything that moves gets a real shutter blur along its path'),
|
|
17395
|
+
review: z.boolean().optional().describe('default true: a vision read of each seam (about 2 credits) comes back with the render, with the seam frames'),
|
|
17396
|
+
},
|
|
17397
|
+
outputSchema: { ...JOB_OUT },
|
|
17398
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17399
|
+
}, wrap(async (a) => {
|
|
17400
|
+
const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}) };
|
|
17401
|
+
const r = await renderJob('postedit', { ...(a.videoUrl ? { videoUrl: a.videoUrl } : {}), ops: [op] }, 'MCP timeline');
|
|
17402
|
+
const tl = r?.raw?.timeline;
|
|
17403
|
+
const gen = Array.isArray(r?.raw?.generatedShots) && r.raw.generatedShots.length ? `\nGENERATED SHOT: ${r.raw.generatedShots.map((g) => `${g.model} ${g.seconds}s ${abs(g.video)}`).join('; ')} (billed as its own render)` : '';
|
|
17404
|
+
// THE SELF-CRITIQUE comes back WITH the render: the verdict, the fixes, and the seam frames the reviewer saw, inline,
|
|
17405
|
+
// so the calling agent can see its own transition before it shows the user anything.
|
|
17406
|
+
const rv = r?.raw?.review;
|
|
17407
|
+
const rvText = rv ? `\nREVIEW (${rv.verdict || 'unread'}${rv.score != null ? `, ${rv.score}/10` : ''}${rv.linked === false ? ', NOT LINKED: the two clips read as unrelated; say so and offer a follow clip made for the hook (a brief they record, or one generated with the cost quoted first)' : rv.linked ? ', linked' : ''}): ${(rv.issues || []).map((x) => `seam ${x.seam}: ${x.problem} → ${x.fix}`).join(' | ') || 'no issues'}. ${rv.verdict === 'amateur' || rv.verdict === 'ok' ? 'Fix what it names and re-run with the same sources (twice at most) before presenting it.' : 'Look at the seam frames too before presenting it.'}` : '';
|
|
17408
|
+
const res = await okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : ''}${tl ? `\nRESOLVED: ${tl.segments.map((sg) => `[${sg.i}] ${sg.in != null ? `${sg.in}-${sg.out}s` : `${sg.seconds}s`} @${sg.at}s`).join(', ')} (${tl.seconds}s)` : ''}${gen}${rvText} [job ${r.jobId}]`, r);
|
|
17409
|
+
const sheet = rv?.seamSheets?.[0]?.url ? await imageBlock(abs(rv.seamSheets[0].url)) : null;
|
|
17410
|
+
if (sheet && Array.isArray(res.content)) res.content.push({ type: 'text', text: `Seam 1 frames (${rv.seamSheets[0].at}s, 15 fps, left to right):` }, sheet);
|
|
17411
|
+
return res;
|
|
17412
|
+
}));
|
|
17413
|
+
|
|
17414
|
+
// THE EYES (2026-09-24): exact frames as one contact sheet, free. Pairs with edit_timeline: look, cut, render, look.
|
|
17415
|
+
server.registerTool('video_frames', {
|
|
17416
|
+
title: 'Look at video frames',
|
|
17417
|
+
description: "LOOK at a video's exact frames, free: one contact-sheet image of the frames from start to end at fps (e.g. a seam, 3.0-3.8 s at 10 fps) or at the listed times, tiles in reading order with each tile's second listed. Use it to find a moment before an edit and to check a render's seam after it. Takes a Library / render URL, a direct file or a public post link.",
|
|
17418
|
+
inputSchema: {
|
|
17419
|
+
url: z.string().describe('the video'),
|
|
17420
|
+
start: z.number().optional(), end: z.number().optional(),
|
|
17421
|
+
fps: z.number().optional().describe('frames per second in the window (default ~16 frames across it)'),
|
|
17422
|
+
times: z.array(z.number()).optional().describe('exact seconds instead of a window (max 48)'),
|
|
17423
|
+
},
|
|
17424
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
17425
|
+
}, wrap(async (a) => {
|
|
17426
|
+
const d = await apiGet('/api/video/sheet', { url: a.url, ...(a.start != null ? { start: a.start } : {}), ...(a.end != null ? { end: a.end } : {}), ...(a.fps != null ? { fps: a.fps } : {}), ...(Array.isArray(a.times) && a.times.length ? { times: a.times.join(',') } : {}) });
|
|
17427
|
+
const [head, b64] = String(d.image || '').split(',');
|
|
17428
|
+
const mv = (d.motion || []).filter(Boolean).map((m) => `${m.t.toFixed(2)}s ${m.region ? `subject at (${m.region.x}, ${m.region.y})${m.heading ? ` moving ${m.heading}` : ''}` : 'no moving subject'}${m.camera !== 'still' ? `, camera ${m.camera}` : ''}`).join('; ');
|
|
17429
|
+
const text = `${d.times.length} frames, ${d.columns} per row (left to right, top to bottom) at: ${d.times.map((t) => t.toFixed(2) + 's').join(', ')}. Video length ${d.durationSeconds}s.${mv ? `\nMOTION (x,y are 0-1 of the frame): ${mv}` : ''}`;
|
|
17430
|
+
return { content: [{ type: 'text', text }, ...(b64 ? [{ type: 'image', data: b64, mimeType: head.slice(5).split(';')[0] || 'image/jpeg' }] : [])], structuredContent: { times: d.times, columns: d.columns, rows: d.rows, durationSeconds: d.durationSeconds, motion: d.motion || [] } };
|
|
17431
|
+
}));
|
|
17432
|
+
|
|
17290
17433
|
server.registerTool('fix_beat', {
|
|
17291
17434
|
title: 'Fix a video beat',
|
|
17292
17435
|
description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this.",
|
|
@@ -17317,7 +17460,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17317
17460
|
inputSchema: {
|
|
17318
17461
|
video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
|
|
17319
17462
|
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
17320
|
-
aspectRatio: z.
|
|
17463
|
+
aspectRatio: z.string().optional().describe("clip shape: '9:16' (default), '1:1', '16:9', any 'W:H', or 'keep' for the source framing"),
|
|
17321
17464
|
captions: z.boolean().optional().describe('burn subtitles into every clip. DEFAULT TRUE — a clip cut from a podcast or a talk is watched on mute, and the words are the product. Set false for clean footage. A clip whose window carries no readable speech is delivered bare rather than captioned with a guess, and the result says which.'),
|
|
17322
17465
|
},
|
|
17323
17466
|
outputSchema: { ...JOB_OUT },
|
|
@@ -17345,32 +17488,24 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17345
17488
|
// upload or someone else's video had no way to get them. The look is the same textStyle vocabulary render_ad speaks.
|
|
17346
17489
|
server.registerTool('add_subtitles', {
|
|
17347
17490
|
title: 'Add subtitles to a video',
|
|
17348
|
-
description: "Burn subtitles into ANY
|
|
17491
|
+
description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a YouTube/Vimeo-style page, or a TikTok / Reel / X post link. No speech is refused and refunded. Runs in the background and lands in the Library.",
|
|
17349
17492
|
inputSchema: {
|
|
17350
17493
|
video: z.string().describe('the video to subtitle'),
|
|
17351
|
-
textStyle:
|
|
17352
|
-
z.enum(['pill', 'editorial', 'bold', 'minimal', 'handwritten', 'boxed']),
|
|
17353
|
-
z.object({
|
|
17354
|
-
preset: z.enum(['pill', 'editorial', 'bold', 'minimal', 'handwritten', 'boxed']).optional(),
|
|
17355
|
-
font: z.enum(['sans', 'serif', 'elegant', 'condensed', 'hand']).optional(),
|
|
17356
|
-
weight: z.number().optional(), size: z.union([z.enum(['s', 'm', 'l', 'xl']), z.number()]).optional(),
|
|
17357
|
-
color: z.string().optional().describe('#hex'), background: z.string().optional().describe('none | pill | #hex box'),
|
|
17358
|
-
position: z.enum(['top', 'center', 'lower', 'bottom']).optional(), textCase: z.enum(['as-is', 'upper', 'lower', 'title']).optional(),
|
|
17359
|
-
italic: z.boolean().optional(), outline: z.boolean().optional(), shadow: z.boolean().optional(),
|
|
17360
|
-
tilt: z.number().optional().describe('degrees, ±12'),
|
|
17361
|
-
}),
|
|
17362
|
-
]).optional().describe('the look: a preset or overrides; omit for the default'),
|
|
17494
|
+
textStyle: TEXT_STYLE_Z.optional().describe('the look: words, a preset or fields; omit for the default'),
|
|
17363
17495
|
burn: z.boolean().optional().describe('false = only the .srt'),
|
|
17496
|
+
auto: z.enum(['sentences', 'words']).optional(), wordsPerCue: z.number().optional(),
|
|
17497
|
+
cues: z.array(z.object({ text: z.string(), start: z.number(), end: z.number() })).max(400).optional()
|
|
17498
|
+
.describe('your own lines in seconds, no overlap, max 90 chars, no emoji'),
|
|
17364
17499
|
},
|
|
17365
17500
|
outputSchema: { ...JOB_OUT },
|
|
17366
17501
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17367
17502
|
}, wrap(async (a) => {
|
|
17368
|
-
const r = await renderJob('subtitles', { video: a.video, textStyle: a.textStyle, burn: a.burn }, 'MCP subtitles');
|
|
17503
|
+
const r = await renderJob('subtitles', { video: a.video, textStyle: a.textStyle, burn: a.burn, ...(a.cues ? { cues: a.cues } : {}), ...(a.auto ? { auto: a.auto } : {}), ...(a.wordsPerCue != null ? { wordsPerCue: a.wordsPerCue } : {}) }, 'MCP subtitles');
|
|
17369
17504
|
if (r.stillRendering) return okVideo('', r); // resumable handle — get_job carries the result when it lands
|
|
17370
17505
|
const raw = r?.raw || {};
|
|
17371
17506
|
// THE HEADLINE IS THE READ-BACK: "burned" only when the returned file really carries the subtitles.
|
|
17372
17507
|
const head = raw.captionsBurned && raw.video
|
|
17373
|
-
? `Subtitles burned in (${raw.captionStyle || 'default'} look, approximate per-sentence timing, not word-level sync): ${abs(raw.video)}`
|
|
17508
|
+
? `Subtitles burned in (${raw.captionStyle || 'default'} look, ${raw.source === 'your cues' ? 'your own lines and times, exactly as written' : raw.source === 'word timings' ? 'word-timed phrases' : 'approximate per-sentence timing, not word-level sync'}): ${abs(raw.video)}`
|
|
17374
17509
|
: 'No subtitled video came back — the subtitle file is below.';
|
|
17375
17510
|
const note = raw.captionNote ? `\nNOTE: ${raw.captionNote}` : '';
|
|
17376
17511
|
const trunc = raw.truncated ? `\nNOTE: only the first ${Math.round((raw.analyzedSeconds || 0) / 60)} min of ${Math.round((raw.sourceDuration || 0) / 60)} min was transcribed, so the subtitles stop there.` : '';
|
|
@@ -17387,12 +17522,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17387
17522
|
durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
|
|
17388
17523
|
frameDensity: z.enum(['standard', 'lean', 'minimal']).optional().describe("how many pictures per second of narration, and therefore what it costs. 'standard' (default) is a frame about every 1.5s — the density a stills film needs to read as a film rather than a slideshow; 'lean' is one about every 2.5s (the longest hold that still reads as a film, ~40% of the frames and ~40% of the cost); 'minimal' is ONE picture per narration section, which is cheapest and is frankly a slideshow. Only drop below the default if the user asked for something cheaper."),
|
|
17389
17524
|
aspectRatio: z.enum(['9:16', '16:9', '1:1', '4:5', '3:4']).optional().describe("'9:16' default"),
|
|
17390
|
-
style: z.
|
|
17525
|
+
style: z.string().optional().describe("visual style: 'cinematic' (default, photoreal); styled shortcuts editorial_collage, flat_vector, stickman, whiteboard, ink_marker, silhouette, storybook, paper_diorama, isometric, claymation, pixel_art, watercolor, fluffy_toy, low_poly, stylized_3d, studio_3d (the Kids default), mannequin; or ANY look described in words ('80s anime cel animation'), locked across every frame. Ask rather than pick silently; a styled look costs more."),
|
|
17391
17526
|
channel: z.enum(['explainer', 'history', 'kids', 'fairytale']).optional().describe("the CHANNEL TYPE — it sets the pacing, the narration register and the default look, and is orthogonal to `style` (a named style always wins): explainer (casual second-person, fast cuts), history (witty chronological retelling / documentary), kids (fastest, question-first, warm teacher), fairytale (slow, atmospheric myth or folklore). Default 'explainer'."),
|
|
17392
17527
|
voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
|
|
17393
17528
|
captions: z.boolean().optional().describe('turn ON-SCREEN TEXT on. DEFAULT FALSE, and leave it false unless the user asks — the narration already says the point and the pictures carry it, so the clean film is the better default. `captions:true` on its own burns SUBTITLES (see below), because that is what a caption is for: showing what is being said when the phone is on mute. Slim white CAPS, thin black outline, bottom safe band, no plate, no box.'),
|
|
17394
17529
|
subtitles: z.boolean().optional().describe('which on-screen text, once `captions` is on. LEAVE IT UNSET (or true) for SUBTITLES — every spoken word, in order, timed to the narration; free, no extra render, no extra credits, and there is NO cue limit, so the whole film is subtitled however long it runs (at most 5 words / 32 characters a line). Set it FALSE only if the user explicitly wants section HEADINGS instead: one short summary label held over each ~7-15s section. That is NOT what is being said — it is a label about it — so it is the wrong answer to "add captions" and to anyone watching on mute. `subtitles:true` also implies `captions:true`. TIMING: each cue is anchored to that section’s REAL measured narration length and distributed inside the section by character count — exact at every section boundary, approximate to a few tenths of a second within one. It is not a word-level speech clock, so never promise frame-accurate sync.'),
|
|
17395
|
-
music: z.string().optional().describe("music bed under the narration, measured to sit about 14 dB under the voice and sidechain-ducked beneath it. Omit and the KIDS and FAIRYTALE channels get their recommended bed COMPOSED for this film — those two are the only channels a bed is due on unasked, and it costs a small flat fee; every other channel ships dry. 'off' forces silence. 'library' takes a free curated track only, and ships dry when none is on file. NAME A MOOD
|
|
17530
|
+
music: z.string().optional().describe("music bed under the narration, measured to sit about 14 dB under the voice and sidechain-ducked beneath it. Omit and the KIDS and FAIRYTALE channels get their recommended bed COMPOSED for this film — those two are the only channels a bed is due on unasked, and it costs a small flat fee; every other channel ships dry. 'off' forces silence. 'library' takes a free curated track only, and ships dry when none is on file. NAME A MOOD (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic) or DESCRIBE it in words to compose one on ANY channel, at the same fee. hermoso_capabilities reports the exact figure as explainerMusicCredits; quote it before you turn a bed on or pick a mood."),
|
|
17396
17531
|
upscale: z.number().optional().describe("optional FINAL upscale — 2 doubles each side, 4 quadruples. Captions and the end card are burned BEFORE it so they upscale with the frame. It is priced BY LENGTH and it is the expensive part — several times the cost of rendering the film itself. hermoso_capabilities reports the exact figures per length as explainerUpscaleCredits. Never turn it on unasked: quote the number and let the user choose."),
|
|
17397
17532
|
endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE — set true ONLY when the user asks for one'),
|
|
17398
17533
|
brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
|
|
@@ -17444,7 +17579,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17444
17579
|
inputSchema: {
|
|
17445
17580
|
prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
|
|
17446
17581
|
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.'),
|
|
17447
|
-
refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
|
|
17582
|
+
refImage: z.string().optional().describe('local path or URL to anchor the first frame; a real person’s photo here or in refImages confirms their likeness consent'),
|
|
17448
17583
|
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.'),
|
|
17449
17584
|
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."),
|
|
17450
17585
|
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.'),
|
|
@@ -17537,7 +17672,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17537
17672
|
title: 'Generate talking avatar',
|
|
17538
17673
|
description: 'Render a TALKING-AVATAR / creator lip-sync clip from a portrait image + a script. Blocks until done (1–3 min). Requires the avatar capability (canAvatar in hermoso_capabilities). Spends credits.',
|
|
17539
17674
|
inputSchema: {
|
|
17540
|
-
image: z.string().describe('local path or URL of the presenter portrait'),
|
|
17675
|
+
image: z.string().describe('local path or URL of the presenter portrait; a real person’s photo confirms their likeness consent'),
|
|
17541
17676
|
script: z.string().describe('the words the avatar speaks'),
|
|
17542
17677
|
voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
|
|
17543
17678
|
resolution: z.string().optional().describe("'1080p' (default) or '480p'/'720p' draft"),
|
|
@@ -17547,6 +17682,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17547
17682
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your avatar clip…', 'Avatar clip ready'),
|
|
17548
17683
|
}, wrap(async (a) => {
|
|
17549
17684
|
const image = await toRef(a.image);
|
|
17685
|
+
recordImpliedLikeness('generate_avatar');
|
|
17550
17686
|
const r = await renderJob('avatar', { ...a, image }, 'MCP avatar');
|
|
17551
17687
|
return okVideo(`Avatar clip ready: ${r.url} [job ${r.jobId}]`, r);
|
|
17552
17688
|
}));
|
|
@@ -18041,12 +18177,11 @@ function memoryNoteVerdict(text) {
|
|
|
18041
18177
|
}));
|
|
18042
18178
|
server.registerTool('save_creator', {
|
|
18043
18179
|
title: 'Save a creator',
|
|
18044
|
-
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 a person, a headshot, any public photo) 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
|
|
18180
|
+
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 a person, a headshot, any public photo) 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 and use "upload"/"social" for a REAL person. Saving a real person confirms you have their consent to use their likeness (or are them).',
|
|
18045
18181
|
inputSchema: {
|
|
18046
18182
|
name: z.string().describe('what to call this creator (e.g. “Sarah”) — list_creators and the app’s picker match on it'),
|
|
18047
18183
|
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'),
|
|
18048
18184
|
source: z.enum(['generated', 'upload', 'social']).optional().describe('"generated" (default) = an AI-made person; "upload" / "social" = a REAL person'),
|
|
18049
|
-
consented: z.boolean().optional().describe('REAL people only: the user has confirmed that person consented to their likeness being used in ads'),
|
|
18050
18185
|
voice: z.string().optional().describe('a default voice name for this persona (engines + voices are in hermoso_capabilities)'),
|
|
18051
18186
|
poses: z.array(z.string()).optional().describe('up to 4 extra full-body / angle plates of the SAME person (public urls) — they make a wider shot hold the identity'),
|
|
18052
18187
|
look: z.string().optional().describe('their canonical wardrobe/appearance in words — reused to hold the look steady across ads'),
|
|
@@ -18070,6 +18205,7 @@ function memoryNoteVerdict(text) {
|
|
|
18070
18205
|
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 };
|
|
18071
18206
|
const r = await apiPost('/api/creator/ref', { image, name: low.name });
|
|
18072
18207
|
low.image = r?.image || image; low.refQuality = r?.refQuality || null; low.lowQualityRef = !!r?.lowQualityRef; delete low.refAccepted; low.poses = [];
|
|
18208
|
+
if (low.source === 'upload' || low.source === 'social') { low.consentAt = Date.now(); low.consentVia = 'api-implied'; recordImpliedLikeness('save_creator', { creatorId: low.id, source: low.source }); }
|
|
18073
18209
|
await writeStore('heist.avatars.v1', _l);
|
|
18074
18210
|
const q = r?.refQuality?.score != null ? ` (photo quality ${r.refQuality.score}/100)` : '';
|
|
18075
18211
|
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 });
|
|
@@ -18085,19 +18221,23 @@ function memoryNoteVerdict(text) {
|
|
|
18085
18221
|
// IDEMPOTENT ON (name, portrait): a retrying agent must get the SAME creator back, not a twin nobody can tell
|
|
18086
18222
|
// apart in the picker. A same-name creator with a DIFFERENT portrait is a deliberate re-shoot and still saves.
|
|
18087
18223
|
const dupe = list.find(x => x && String(x.name || '').trim().toLowerCase() === name.toLowerCase() && String(x.image || '') === image);
|
|
18224
|
+
if (dupe && (dupe.source === 'upload' || dupe.source === 'social') && !dupe.consentAt) { dupe.consentAt = Date.now(); dupe.consentVia = 'api-implied'; await writeStore('heist.avatars.v1', list); recordImpliedLikeness('save_creator', { creatorId: dupe.id, source: dupe.source }); }
|
|
18088
18225
|
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 } });
|
|
18089
18226
|
const item = {
|
|
18090
18227
|
id: newId('av'), name, image,
|
|
18091
18228
|
poses: (Array.isArray(a.poses) ? a.poses : []).filter(p => typeof p === 'string' && p.trim()).slice(0, 4),
|
|
18092
18229
|
voice: String(a.voice || '').trim(), source,
|
|
18093
|
-
//
|
|
18094
|
-
//
|
|
18095
|
-
|
|
18230
|
+
// A REAL person saved over MCP / API / CLI is consented BY THE CALL (see recordImpliedLikeness): stamped with the
|
|
18231
|
+
// time and `consentVia: 'api-implied'`, and the ledger row carries the account. Never for a synthetic creator,
|
|
18232
|
+
// which needs none — the same rule as the web's Avatars.add(source) → consentAt after its Confirm step.
|
|
18233
|
+
consentAt: source !== 'generated' ? Date.now() : null,
|
|
18234
|
+
...(source !== 'generated' ? { consentVia: 'api-implied' } : {}),
|
|
18096
18235
|
...(String(a.look || '').trim() ? { desc: String(a.look).trim().slice(0, 400) } : {}),
|
|
18097
18236
|
createdAt: Date.now(),
|
|
18098
18237
|
};
|
|
18099
18238
|
await writeStore('heist.avatars.v1', [item, ...list].slice(0, 200));
|
|
18100
|
-
|
|
18239
|
+
if (source !== 'generated') recordImpliedLikeness('save_creator', { creatorId: item.id, source });
|
|
18240
|
+
const warn = source !== 'generated' ? ' Saving this real person is recorded as your confirmation that you have their consent to use their face and likeness (or are them).' : '';
|
|
18101
18241
|
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 } });
|
|
18102
18242
|
}));
|
|
18103
18243
|
server.registerTool('delete_creator', {
|
|
@@ -18228,6 +18368,15 @@ function memoryNoteVerdict(text) {
|
|
|
18228
18368
|
// cannot word one fact three ways. `unknown` prints NOTHING — a grant we could not read must never be relayed
|
|
18229
18369
|
// as a limitation ([[failed-read-is-not-empty]]).
|
|
18230
18370
|
const posting = on.filter(c => c && c.grantMode === 'posting' && String(c.grantModeNote || '').trim());
|
|
18371
|
+
// ── WAITING ON META APPROVING HERMOSO, NOT ON THE USER (2026-09-24) ─────────────────────────────────────────
|
|
18372
|
+
// A feature whose permission Meta has not approved Hermoso's app for yet, read live by the server off Meta's own
|
|
18373
|
+
// answer about the app. It is the OPPOSITE instruction to the permission gap above: nothing the user does fixes
|
|
18374
|
+
// it, so it is never phrased as a reconnect. The sentence is the SERVER'S (`awaitingApprovalNote`).
|
|
18375
|
+
const waiting = on.filter(c => c && String(c.awaitingApprovalNote || '').trim());
|
|
18376
|
+
const waitingNote = waiting.length
|
|
18377
|
+
? '\n\n' + waiting.map(c => `ℹ ${c.provider}: ${c.awaitingApprovalNote}`).join('\n')
|
|
18378
|
+
+ '\nThose tools stay callable (they work for people with a role on the Hermoso app), but for this user Meta will refuse them until it approves Hermoso: say that plainly and never suggest reconnecting for it.'
|
|
18379
|
+
: '';
|
|
18231
18380
|
const lines = on.map(c => ` • ${c.provider}${c.agentLabel ? ` — ${c.agentLabel}` : ''} (${c.status || 'active'})${c.grantMode === 'posting' ? ' · POSTING ONLY' : ''}${c.scopeDrift?.status === 'missing' ? ` ⚠ missing ${c.scopeDrift.missing.length} permission(s) — needs a reconnect` : ''}`);
|
|
18232
18381
|
const postingNote = posting.length
|
|
18233
18382
|
? '\n\n' + posting.map(c => `ℹ ${c.grantModeNote}`).join('\n')
|
|
@@ -18239,7 +18388,7 @@ function memoryNoteVerdict(text) {
|
|
|
18239
18388
|
+ '\nThis is not something an agent can fix: re-authorizing is an OAuth consent screen, so the user has to do it in a browser — Workspace ▸ Connectors ▸ the connector ▸ Reconnect. Everything the connection already carries keeps working until then.' + gap.map(c => `\n Reconnect ${c.provider}: ${linkFor(c.provider)}`).join('')
|
|
18240
18389
|
: '';
|
|
18241
18390
|
const safe = { ...d, connectors: (d.connectors || []).map(({ accountLabel, ...c }) => c) };
|
|
18242
|
-
return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, connectable from here with connect_connector or in the app: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : empty}${gapNote}${postingNote}`, safe);
|
|
18391
|
+
return ok(`${on.length} connected:\n${lines.join('\n') || ' (none)'}\nAvailable to connect through a sign-in screen (needs a browser; the link is ${linkFor('<provider>')}): ${(d.providers || []).join(', ') || '(none configured)'}.\nPaste-a-key, connectable from here with connect_connector or in the app: ${Object.keys(KEY_CONNECTORS).join(', ')}.${on.length ? '' : empty}${gapNote}${waitingNote}${postingNote}`, safe);
|
|
18243
18392
|
}));
|
|
18244
18393
|
// ── CONNECTOR WRITES. Connecting needs a browser (OAuth consent) and is correctly NOT headless — but the other two
|
|
18245
18394
|
// halves of connector management are, and were web-only: DISCONNECTING, and choosing WHICH accounts a brand may
|
|
@@ -19481,22 +19630,71 @@ function memoryNoteVerdict(text) {
|
|
|
19481
19630
|
server.group('create');
|
|
19482
19631
|
server.registerTool('analyze_video', {
|
|
19483
19632
|
title: 'Analyze video',
|
|
19484
|
-
description: "
|
|
19485
|
-
inputSchema: {
|
|
19633
|
+
description: "A video ad's structure: verbatim transcript (voiceover + on-screen text), beat list, duration and sampled frame times; study a reference before remixing it. ~A transcription call.",
|
|
19634
|
+
inputSchema: {
|
|
19635
|
+
url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)'),
|
|
19636
|
+
frames: z.boolean().optional().describe('also return the frames as images'),
|
|
19637
|
+
words: z.any().optional().describe("true | 'only': each spoken word's start/end"),
|
|
19638
|
+
anchors: z.array(z.any()).optional().describe('{afterWord|beforeWord|atWord, occurrence?, offset?} -> s'),
|
|
19639
|
+
},
|
|
19486
19640
|
outputSchema: {
|
|
19487
19641
|
durationSeconds: z.number().optional().describe('the video length in seconds'),
|
|
19488
19642
|
frameTimes: z.array(z.number()).optional().describe('timestamps (seconds) of the sampled frames'),
|
|
19489
19643
|
transcript: z.string().nullable().optional().describe('verbatim voiceover + on-screen text with a beat list (null when silent/unreachable)'),
|
|
19490
|
-
|
|
19491
|
-
|
|
19492
|
-
|
|
19493
|
-
|
|
19644
|
+
words: z.array(z.any()).optional(), anchors: z.array(z.any()).optional(), cues: z.array(z.any()).optional(),
|
|
19645
|
+
},
|
|
19646
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
19647
|
+
}, wrap(async ({ url, frames, words, anchors }) => {
|
|
19648
|
+
const wantWords = words === true || words === 'only' || anchors != null;
|
|
19649
|
+
const wr = wantWords ? apiPost('/api/video/words', { url, ...(anchors ? { anchors } : {}) }).then(r => ({ r }), e => ({ e })) : null;
|
|
19650
|
+
const wordsText = (w) => {
|
|
19651
|
+
if (!w) return '';
|
|
19652
|
+
if (w.e) return `\n\nWord timings could not be read: ${w.e.message || w.e}`;
|
|
19653
|
+
const d = w.r || {};
|
|
19654
|
+
const list = (d.words || []).slice(0, 3000).map(x => `[${x.start.toFixed(2)}-${x.end.toFixed(2)}]${x.word}`).join(' ');
|
|
19655
|
+
const anc = Array.isArray(d.anchors) ? `\n\nAnchors:\n${d.anchors.map(a => a.error ? `- ${JSON.stringify(a.anchor)}: ${a.error}` : `- ${a.kind} "${a.word}" (${a.occurrence} of ${a.of}): ${a.time}s [word ${a.start}-${a.end}s]`).join('\n')}\n${d.editRule || ''}` : '';
|
|
19656
|
+
const cq = Array.isArray(d.cues) ? `\n\nCaption cues for add_subtitles:\n${JSON.stringify(d.cues)}` : '';
|
|
19657
|
+
return `\n\nWord timings (${d.wordCount || 0} words, ${d.timing || 'word-level'}):\n${list || `(none: ${d.note || 'no speech was heard'})`}${(d.words || []).length > 3000 ? ' …' : ''}${anc}${cq}`;
|
|
19658
|
+
};
|
|
19659
|
+
const wordOut = (w) => (w && w.r ? { words: w.r.words, ...(w.r.anchors ? { anchors: w.r.anchors } : {}), ...(w.r.cues ? { cues: w.r.cues } : {}), durationSeconds: w.r.durationSeconds } : {});
|
|
19660
|
+
if (words === 'only') {
|
|
19661
|
+
const w = await wr;
|
|
19662
|
+
if (w.e) throw w.e;
|
|
19663
|
+
return ok(`Duration: ${w.r.durationSeconds}s${wordsText(w)}`, wordOut(w));
|
|
19664
|
+
}
|
|
19665
|
+
const [fr, tr, w] = await Promise.all([
|
|
19494
19666
|
apiGet(`/api/video/frames?n=auto&url=${encodeURIComponent(url)}`).catch(() => null),
|
|
19495
19667
|
apiGet(`/api/video/transcript?url=${encodeURIComponent(url)}`).catch(() => null),
|
|
19668
|
+
wr,
|
|
19496
19669
|
]);
|
|
19497
19670
|
const dur = fr?.durationSeconds, times = fr?.times || [];
|
|
19498
19671
|
const transcript = tr?.transcript || '(no transcript — the video may be silent or unreachable)';
|
|
19499
|
-
|
|
19672
|
+
const r = ok(`Duration: ${dur ? Math.round(dur) + 's' : 'unknown'} · frames sampled at: ${times.map(t => Math.round(t * 10) / 10 + 's').join(', ') || 'n/a'}\n\nTranscript & beats:\n${transcript}${wordsText(w)}`, { durationSeconds: dur, frameTimes: times, transcript: tr?.transcript || null, ...wordOut(w), ...(dur ? { durationSeconds: dur } : {}) });
|
|
19673
|
+
// THE EYES (2026-09-24): an agent that composed its own design, edit or render can SEE the frames, not just their times.
|
|
19674
|
+
if (frames) for (const f of (fr?.frames || []).slice(0, 8)) { const m = /^data:(image\/[a-z]+);base64,(.+)$/.exec(String(f)); if (m) r.content.push({ type: 'image', data: m[2], mimeType: m[1] }); }
|
|
19675
|
+
return r;
|
|
19676
|
+
}));
|
|
19677
|
+
|
|
19678
|
+
// A SOUND FOR AN EDIT + THE INSERT CLIP THAT CARRIES IT (2026-09-24): found per edit, never a library.
|
|
19679
|
+
server.registerTool('find_sound', {
|
|
19680
|
+
title: 'Find a sound',
|
|
19681
|
+
description: "A sound for an edit by name ('the FAAAA sound'), by the moment ('bad news reaction') or by link (TikTok sound, meme-sound page, post, audio file): a durable mp3 and where it starts and lands. Named/described sounds come from what TikTok uses now; pick takes another candidate.",
|
|
19682
|
+
inputSchema: { query: z.string().optional(), url: z.string().optional(), pick: z.number().optional() },
|
|
19683
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19684
|
+
}, wrap(async (a) => {
|
|
19685
|
+
const d = await apiPost('/api/sound/find', a);
|
|
19686
|
+
const cands = (d.candidates || []).length ? `\nCandidates: ${d.candidates.map(c => `${c.pick}. ${c.title}${c.author ? ` (${c.author})` : ''}${c.uses ? `, ${c.uses} uses` : ''}${c.durationSeconds ? `, ${c.durationSeconds}s` : ''}`).join('; ')}` : '';
|
|
19687
|
+
return ok(`Sound: ${abs(d.audio)} - "${d.title || 'sound'}"${d.author ? ` by ${d.author}` : ''} (${d.source})${d.note ? `\n${d.note}` : ''}${d.beat ? `\nStarts ${d.beat.onset}s, lands ${d.beat.resolves}s (${d.beat.seconds}s)` : ''}${cands}\n${d.howToUse || ''}`, { ...d, audio: abs(d.audio) });
|
|
19688
|
+
}));
|
|
19689
|
+
|
|
19690
|
+
server.registerTool('make_insert', {
|
|
19691
|
+
title: 'Make an insert clip',
|
|
19692
|
+
description: 'A reaction picture (image, or video from videoStart) with its sound, cut to when the sound lands (or seconds): a 1080x1920 clip for post_edit join.',
|
|
19693
|
+
inputSchema: { image: z.string().optional(), video: z.string().optional(), videoStart: z.number().optional(), sound: z.string(), soundStart: z.number().optional(), soundEnd: z.number().optional(), seconds: z.number().optional() },
|
|
19694
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
19695
|
+
}, wrap(async (a) => {
|
|
19696
|
+
const d = await apiPost('/api/video/insert', a);
|
|
19697
|
+
return okVideo(`Insert clip (${d.seconds}s): ${abs(d.video)}\n${d.howToUse || ''}`, { ...d, url: abs(d.video), video: abs(d.video) });
|
|
19500
19698
|
}));
|
|
19501
19699
|
|
|
19502
19700
|
server.registerTool('score_ad', {
|
|
@@ -19579,7 +19777,7 @@ function memoryNoteVerdict(text) {
|
|
|
19579
19777
|
inputSchema: {
|
|
19580
19778
|
video: z.string().describe('the source video URL'),
|
|
19581
19779
|
count: z.number().optional().describe('how many variants, 1-12 (default 6)'),
|
|
19582
|
-
axes: z.array(z.
|
|
19780
|
+
axes: z.array(z.string()).optional().describe('what to vary: character, outfit, location, objects (default all four), or your own, e.g. "season"'),
|
|
19583
19781
|
notes: z.string().optional().describe('anything the variants must respect, e.g. "keep it women 25-40", "no gyms"'),
|
|
19584
19782
|
regions: z.array(z.string()).optional().describe('markets to restyle for, one or more variants each, e.g. ["Berlin","Tokyo","São Paulo"] — visuals only; audio is never translated here'),
|
|
19585
19783
|
dryRun: z.boolean().optional().describe('true = return the plan and the quote, render nothing'),
|
|
@@ -19608,7 +19806,7 @@ function memoryNoteVerdict(text) {
|
|
|
19608
19806
|
// (the fix_beat worker at 0s: the hook renders silent and is spliced on the picture only). lib/hook-variants.mjs.
|
|
19609
19807
|
server.registerTool('hook_variants', {
|
|
19610
19808
|
title: 'New opening hooks for a video',
|
|
19611
|
-
description: "HOOK MULTIPLIER: give ONE finished video ad N NEW OPENING HOOKS
|
|
19809
|
+
description: "HOOK MULTIPLIER: give ONE finished video ad N NEW OPENING HOOKS, N complete versions to A/B test. A planned hook replaces the first ~1.5-4 s (ends at the source's first cut there, else 3 s; hookSeconds overrides) with a new silent shot on a DIFFERENT named mechanic (list_hooks); the rest of the footage and the WHOLE original soundtrack stay, so every version keeps the source's length, and nobody in the hook talks or shows text. hooks[] adds your own openings: {url} a FOUND viral hook (post link or file) joined in front with the approved bridge (cut before its payoff, its own payoff sound carried across; post_edit price), {prompt} an opening described in words, {mechanic} a named one; with hooks and no count only those are made. To make the found hook's subject your product or creator first: recast_hook. Pass the video's FILE URL (a render, job result, list_library or upload_file). 1-5 versions, default 3. Refused free before billing: a source over 120 s, too short for a 1.5 s hook plus 2 s after it, unreadable, or a social post as the SOURCE (clone_video remakes someone else's ad). COST: a small planning read, then each planned version is billed like fix_beat for the hook's seconds; the reply quotes credits, and dryRun:true returns the plan and quote without rendering (pass that `plan` back to render exactly those). Returns ONE JOB PER VERSION; call get_job on each until done, never describe a version before its URL arrives. Hooks that show the product use the brand's product photo (productImage overrides; useBrand:false sends none).",
|
|
19612
19810
|
inputSchema: {
|
|
19613
19811
|
video: z.string().describe('the finished video to give new hooks: its served file URL'),
|
|
19614
19812
|
count: z.number().optional().describe('how many hook versions, 1-5 (default 3)'),
|
|
@@ -19619,25 +19817,27 @@ function memoryNoteVerdict(text) {
|
|
|
19619
19817
|
useBrand: z.boolean().optional().describe('false = send no brand name or product photo (for a video that is not this workspace brand’s)'),
|
|
19620
19818
|
plan: z.any().optional().describe('the `plan` object a previous dryRun returned, to render exactly those hooks without planning again'),
|
|
19621
19819
|
dryRun: z.boolean().optional().describe('true = return the plan and the quote, render nothing'),
|
|
19820
|
+
hooks: z.array(z.object({ url: z.string().optional(), prompt: z.string().optional(), mechanic: z.string().optional(), start: z.number().optional().describe('url: skip the video’s own first seconds'), cutAt: z.number().optional().describe('url: override the found payoff cut') })).optional().describe('your own openings, one version each'),
|
|
19622
19821
|
},
|
|
19623
|
-
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), hookSeconds: z.number().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
|
|
19822
|
+
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), hookSeconds: z.number().nullable().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
|
|
19624
19823
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
19625
|
-
}, wrap(async ({ video, count, notes, hookSeconds, resolution, productImage, useBrand, plan, dryRun }) => {
|
|
19824
|
+
}, wrap(async ({ video, count, notes, hookSeconds, resolution, productImage, useBrand, plan, dryRun, hooks }) => {
|
|
19626
19825
|
const src = String(video || '').trim();
|
|
19627
19826
|
if (!src) return { content: [{ type: 'text', text: 'Pass the finished video as a URL: a previous render, a job result, an entry from list_library, or an upload_file link.' }], isError: true };
|
|
19628
19827
|
let b = {};
|
|
19629
19828
|
if (useBrand !== false) { try { b = (await readStore('heist.brand.v1')) || {}; } catch { b = {}; } }
|
|
19630
19829
|
const prod = String(productImage || '').trim() || (useBrand !== false && Array.isArray(b.productImages) ? String(b.productImages[0] || '') : '');
|
|
19631
|
-
const body = { video: src, ...(count != null ? { count } : {}), ...(notes ? { notes } : {}), ...(hookSeconds != null ? { hookSeconds } : {}), ...(resolution ? { resolution } : {}), ...(prod ? { productImage: prod } : {}), ...(useBrand !== false && b.name ? { brand: { name: b.name, sells: b.sells || b.description || '' } } : {}), ...(plan ? { plan } : {}), ...(dryRun ? { dryRun: true } : {}) };
|
|
19830
|
+
const body = { video: src, ...(count != null ? { count } : {}), ...(notes ? { notes } : {}), ...(hookSeconds != null ? { hookSeconds } : {}), ...(resolution ? { resolution } : {}), ...(prod ? { productImage: prod } : {}), ...(useBrand !== false && b.name ? { brand: { name: b.name, sells: b.sells || b.description || '' } } : {}), ...(plan ? { plan } : {}), ...(dryRun ? { dryRun: true } : {}), ...(Array.isArray(hooks) && hooks.length ? { hooks } : {}) };
|
|
19632
19831
|
const r = await apiPost('/api/hooks/variants', body);
|
|
19633
19832
|
const p = r?.data || r;
|
|
19634
19833
|
const vs = Array.isArray(p.variants) ? p.variants : [];
|
|
19635
|
-
const
|
|
19636
|
-
const
|
|
19637
|
-
const
|
|
19834
|
+
const found = Array.isArray(p.found) ? p.found : [];
|
|
19835
|
+
const lines = vs.map((v, i) => `${i + 1}. ${v.label} [${v.mechanicLabel || v.mechanic}]: ${v.shot}`).concat(found.map((f, i) => `${vs.length + i + 1}. Found hook ${i + 1}: ${f.url} joined in front, cut before its payoff, its own payoff sound carried across`));
|
|
19836
|
+
const head = `Source: ${p.source?.durationSeconds}s at ${p.source?.width}x${p.source?.height}.${p.hookSeconds ? ` New hooks replace 0-${p.hookSeconds}s (${p.cutAligned ? 'ending on the source’s own first cut' : 'no cut in range, so the hook ends mid-shot'}); the rest of the video and all of its audio stay as they are.` : ''}${found.length ? ` A found hook plays first, so its version is longer than the source by the hook's seconds.` : ''}`;
|
|
19837
|
+
const quote = `${vs.length ? `~${p.perVariantCredits} credits per new hook` : ''}${vs.length && found.length ? ' · ' : ''}${found.length ? `~${p.foundCredits} per found hook (up to ${p.foundMaxCredits} if its sound has to be generated)` : ''} · ~${p.totalCredits} for ${(p.jobs || []).length || vs.length + found.length}`;
|
|
19638
19838
|
if (dryRun) return { content: [{ type: 'text', text: `Plan (nothing rendered). ${head}\n${lines.join('\n')}\n${quote}. Run again with plan set to this plan (and no dryRun) to render exactly these.${p.note ? '\nNOTE: ' + p.note : ''}` }], structuredContent: { plan: p.plan, hookSeconds: p.hookSeconds, perVariantCredits: p.perVariantCredits, totalCredits: p.totalCredits, dryRun: true } };
|
|
19639
19839
|
const jobs = (p.jobs || []).map(j => ({ id: j.id, label: j.label, mechanic: j.mechanic }));
|
|
19640
|
-
return { content: [{ type: 'text', text: `${head}\nQueued ${jobs.length} hook version${jobs.length === 1 ? '' : 's'}. ${quote}.\n${jobs.map((j, i) => `${i + 1}. ${j.label} [${vs[i]?.mechanicLabel || j.mechanic}] → job ${j.id}`).join('\n')}\nEach renders
|
|
19840
|
+
return { content: [{ type: 'text', text: `${head}\nQueued ${jobs.length} hook version${jobs.length === 1 ? '' : 's'}. ${quote}.\n${jobs.map((j, i) => `${i + 1}. ${j.label} [${vs[i]?.mechanicLabel || (j.mechanic === 'found' ? 'found hook' : j.mechanic)}] → job ${j.id}`).join('\n')}\nEach renders its new opening and splices it in, or joins a found hook in front (usually 2-5 minutes). Call get_job with each id until it reports done; a version has NO file until then.${p.note ? '\nNOTE: ' + p.note : ''}` }], structuredContent: { jobs, plan: p.plan, hookSeconds: p.hookSeconds, perVariantCredits: p.perVariantCredits, totalCredits: p.totalCredits } };
|
|
19641
19841
|
}));
|
|
19642
19842
|
|
|
19643
19843
|
server.registerTool('dub_video', {
|
|
@@ -19676,7 +19876,7 @@ function memoryNoteVerdict(text) {
|
|
|
19676
19876
|
title: 'Recast motion',
|
|
19677
19877
|
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.",
|
|
19678
19878
|
inputSchema: {
|
|
19679
|
-
image: z.string().describe("the actor/character image URL (who should appear)"),
|
|
19879
|
+
image: z.string().describe("the actor/character image URL (who should appear); a real person’s photo confirms their likeness consent"),
|
|
19680
19880
|
video: z.string().describe('the reference video whose motion to re-perform'),
|
|
19681
19881
|
prompt: z.string().optional().describe('optional scene/style guidance'),
|
|
19682
19882
|
orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
|
|
@@ -19685,10 +19885,58 @@ function memoryNoteVerdict(text) {
|
|
|
19685
19885
|
outputSchema: { ...JOB_OUT },
|
|
19686
19886
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
19687
19887
|
}, wrap(async ({ image, video, prompt = '', orientation = 'video', tier }) => {
|
|
19888
|
+
recordImpliedLikeness('recast_motion');
|
|
19688
19889
|
const r = await renderJob('motion', { image, video, prompt, orientation, ...(tier ? { tier } : {}) }, 'Motion recast');
|
|
19689
19890
|
return okVideo(`Recast video: ${r.url}`, r);
|
|
19690
19891
|
}));
|
|
19691
19892
|
|
|
19893
|
+
// RECAST A VIRAL HOOK WITH OUR OWN SUBJECT (2026-09-24): the found hook's subject becomes the brand's product or saved
|
|
19894
|
+
// creator, so the cut into our clip links by identity. Quote first (no confirm = a free quote), then confirm:true queues
|
|
19895
|
+
// one `videoedit` (swap) or `motion` job. The server (planHookRecast) reads the hook, face-checks both sides and picks
|
|
19896
|
+
// the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
|
|
19897
|
+
server.registerTool('recast_hook', {
|
|
19898
|
+
title: 'Recast a viral hook with your subject',
|
|
19899
|
+
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'}}].",
|
|
19900
|
+
inputSchema: {
|
|
19901
|
+
hook: z.string().describe('the viral hook: a post link or a video URL'),
|
|
19902
|
+
creator: z.string().optional().describe("a saved creator's name or id (list_creators)"),
|
|
19903
|
+
image: z.string().optional().describe('a picture of the subject instead: a product or a person'),
|
|
19904
|
+
swap: z.string().optional().describe('what changes, in your own words'),
|
|
19905
|
+
mode: z.enum(['auto', 'swap', 'motion']).optional(),
|
|
19906
|
+
start: z.number().optional(), end: z.number().optional(),
|
|
19907
|
+
prompt: z.string().optional().describe('extra direction for the render'),
|
|
19908
|
+
model: z.string().optional().describe('swap: a clip editor id (hermoso_capabilities); omit for the default'),
|
|
19909
|
+
tier: z.enum(['pro', 'standard']).optional().describe('motion: the motion-transfer tier (default pro)'),
|
|
19910
|
+
useBrand: z.boolean().optional().describe('false = no brand product photo'),
|
|
19911
|
+
confirm: z.boolean().optional().describe('true = render it (after the user okayed the quote)'),
|
|
19912
|
+
},
|
|
19913
|
+
outputSchema: { plan: z.any().optional(), job: z.any().optional(), credits: z.number().optional(), queued: z.boolean().optional() },
|
|
19914
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
19915
|
+
}, wrap(async (a) => {
|
|
19916
|
+
let b = {};
|
|
19917
|
+
if (a.useBrand !== false && !a.creator && !a.image) { try { b = (await readStore('heist.brand.v1')) || {}; } catch { b = {}; } }
|
|
19918
|
+
// 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)
|
|
19919
|
+
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 } : {}) };
|
|
19920
|
+
const r = await apiPost('/api/hooks/recast', body);
|
|
19921
|
+
const p = r?.data || r;
|
|
19922
|
+
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}` : ''}`;
|
|
19923
|
+
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 } };
|
|
19924
|
+
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 } };
|
|
19925
|
+
}));
|
|
19926
|
+
|
|
19927
|
+
// THE FREE FACE CHECK (2026-09-24): a local face detector on our own CPU (lib/face-detect.mjs), no model call, 0 credits.
|
|
19928
|
+
server.registerTool('face_check', {
|
|
19929
|
+
title: 'Check a reference for a face (free)',
|
|
19930
|
+
description: "FREE, before any paid render: does a picture or video contain a face, and which video models refuse it (they cannot take a real person's face; a render there fails after it starts) or were measured to take one, plus the model a face-bearing render is routed to. A local detector, no model call, 0 credits. It cannot tell a photo from a drawing, so a drawn face counts. Takes a picture or video URL, a Library item or a public post link (a post link uses its one lookup).",
|
|
19931
|
+
inputSchema: { url: z.string().describe('the picture or video to check') },
|
|
19932
|
+
outputSchema: { face: z.string().optional(), best: z.number().nullable().optional(), refuse: z.array(z.any()).optional(), accept: z.array(z.any()).optional(), route: z.any().optional(), summary: z.string().optional() },
|
|
19933
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
19934
|
+
}, wrap(async ({ url }) => {
|
|
19935
|
+
const r = await apiPost('/api/face/check', { url });
|
|
19936
|
+
const d = r?.data || r;
|
|
19937
|
+
return ok(`${d.summary}${d.refuse?.length ? `\nRefuse a face: ${d.refuse.map(x => `${x.label} (${x.id})`).join(', ')}.` : ''}${d.face !== 'no' && d.accept?.length ? `\nMeasured to take a face: ${d.accept.map(x => `${x.label} (${x.id})`).join(', ')}.` : ''}${Array.isArray(d.frames) ? `\nFrames: ${d.frames.map(f => `${f.t}s ${f.face}`).join(', ')}` : ''}`, d);
|
|
19938
|
+
}));
|
|
19939
|
+
|
|
19692
19940
|
server.registerTool('plan_variations', {
|
|
19693
19941
|
title: 'Plan ad variations',
|
|
19694
19942
|
description: "Fan a brief into N DISTINCT ad angles (different hooks/mechanics/audiences), each with its own headline + visual brief — then render each with generate_image and rank with score_ad. LLM planning only; renders nothing itself.",
|
|
@@ -19795,7 +20043,7 @@ function memoryNoteVerdict(text) {
|
|
|
19795
20043
|
title: 'Clone a static ad',
|
|
19796
20044
|
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.",
|
|
19797
20045
|
inputSchema: {
|
|
19798
|
-
imageUrl: z.string().describe('the URL of the static ad image to clone'),
|
|
20046
|
+
imageUrl: z.string().describe('the URL of the static ad image to clone; a real person in it confirms their likeness consent'),
|
|
19799
20047
|
brandId: z.string().optional().describe('a brand id/name from list_brands to clone for; omit to use the active brand'),
|
|
19800
20048
|
},
|
|
19801
20049
|
outputSchema: {
|
|
@@ -19819,7 +20067,7 @@ function memoryNoteVerdict(text) {
|
|
|
19819
20067
|
title: 'Clone a static ad (old name)',
|
|
19820
20068
|
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.",
|
|
19821
20069
|
inputSchema: {
|
|
19822
|
-
imageUrl: z.string().describe('the URL of the static ad image to clone'),
|
|
20070
|
+
imageUrl: z.string().describe('the URL of the static ad image to clone; a real person in it confirms their likeness consent'),
|
|
19823
20071
|
brandId: z.string().optional().describe('a brand id/name from list_brands to clone for; omit to use the active brand'),
|
|
19824
20072
|
},
|
|
19825
20073
|
outputSchema: {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.292",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "Marketing on autopilot, run from your own AI agent.
|
|
5
|
+
"description": "Marketing on autopilot, run from your own AI agent. 863 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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|