hermoso 0.1.382 → 0.1.385
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/bin/hermoso.mjs +32 -6
- package/mcp/client.mjs +2 -0
- package/mcp/static-batch.mjs +190 -0
- package/mcp/tools.mjs +148 -14
- 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
|
+
**901 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
|
|
@@ -231,7 +231,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
231
231
|
|
|
232
232
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
233
233
|
|
|
234
|
-
### What the
|
|
234
|
+
### What the 901 tools cover
|
|
235
235
|
|
|
236
236
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
237
237
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/bin/hermoso.mjs
CHANGED
|
@@ -43,15 +43,26 @@ async function saveConfig(c) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// ---- minimal arg parser: positionals + --flags (--flag value | --flag=value | boolean --flag) ----
|
|
46
|
+
// `--json` MEANS TWO THINGS, AND GIVEN BOTH WAYS THE PAYLOAD MUST SURVIVE (2026-10-06). On `call`, `--json '{…}'` is the
|
|
47
|
+
// arguments; everywhere, a bare `--json` asks for machine output. `call make_static_ads --json '{"dryRun":true}' --json`
|
|
48
|
+
// used to keep only the LAST one — `true` — so the arguments were dropped in silence and the tool ran with none: a
|
|
49
|
+
// paid tool queued four renders nobody asked for, dryRun and all. A repeated --json now keeps the object as the
|
|
50
|
+
// arguments and records the bare one as the output request (`__jsonOut`), whichever order they came in.
|
|
46
51
|
function parse(argv) {
|
|
47
52
|
const pos = [], flags = {};
|
|
53
|
+
const set = (k, v) => {
|
|
54
|
+
if (k === 'json' && Object.prototype.hasOwnProperty.call(flags, 'json') && (flags.json === true) !== (v === true)) {
|
|
55
|
+
flags.json = v === true ? flags.json : v; flags.__jsonOut = true; return;
|
|
56
|
+
}
|
|
57
|
+
flags[k] = v;
|
|
58
|
+
};
|
|
48
59
|
for (let i = 0; i < argv.length; i++) {
|
|
49
60
|
const a = argv[i];
|
|
50
61
|
if (a.startsWith('--')) {
|
|
51
62
|
const eq = a.indexOf('=');
|
|
52
|
-
if (eq >= 0) {
|
|
53
|
-
else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
54
|
-
else {
|
|
63
|
+
if (eq >= 0) { set(a.slice(2, eq), a.slice(eq + 1)); }
|
|
64
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { set(a.slice(2), argv[++i]); }
|
|
65
|
+
else { set(a.slice(2), true); }
|
|
55
66
|
} else pos.push(a);
|
|
56
67
|
}
|
|
57
68
|
return { pos, flags };
|
|
@@ -296,7 +307,15 @@ add --json to any command for machine output.`);
|
|
|
296
307
|
// `--raw` is a real property on generate_image / generate_text / generate_video, and reserving it would make three
|
|
297
308
|
// tools uncallable from the shorthand. A tool that ever needs a `--json` or `--args` argument can still pass it
|
|
298
309
|
// inside --args '{"json":…}'.
|
|
299
|
-
const CALL_FLAGS = new Set(['json', 'args', 'args-file', 'structured']);
|
|
310
|
+
const CALL_FLAGS = new Set(['json', 'args', 'args-file', 'structured', '__jsonOut']); // __jsonOut: a second, bare --json beside a --json payload (see parse)
|
|
311
|
+
|
|
312
|
+
// How long `call` waits for a tool, in ms: 20 minutes unless HERMOSO_CALL_TIMEOUT_MS names a positive number. Pure, so
|
|
313
|
+
// tools/cli-call-timeout-check.mjs lifts and runs it; a garbled value falls back rather than becoming "no wait".
|
|
314
|
+
const CLI_CALL_TIMEOUT_MS = 20 * 60 * 1000;
|
|
315
|
+
function cliCallTimeoutMs(env) {
|
|
316
|
+
const n = Number(env);
|
|
317
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : CLI_CALL_TIMEOUT_MS;
|
|
318
|
+
}
|
|
300
319
|
|
|
301
320
|
async function readArgsPayload(flags) {
|
|
302
321
|
let src = null;
|
|
@@ -376,9 +395,16 @@ async function runTool(reg, name, flags, extraPos = [], preset = null) {
|
|
|
376
395
|
if (extraPos.length) bad.push(`unexpected value${extraPos.length > 1 ? 's' : ''} ${extraPos.map((v) => JSON.stringify(v)).join(', ')} . Every argument is a --flag or goes inside --json`);
|
|
377
396
|
if (bad.length) return die(`${bad.join('\n ')}\n Run: hermoso tools ${name}`);
|
|
378
397
|
|
|
379
|
-
|
|
398
|
+
// THE SDK'S 60-SECOND DEFAULT IS NOT OUR TIMEOUT (2026-10-06). client.callTool() with no options gives up after
|
|
399
|
+
// DEFAULT_REQUEST_TIMEOUT_MSEC (60s) and throws "MCP error -32001: Request timed out" — while the tool, running in
|
|
400
|
+
// this same process, carries on, finishes and BILLS. Measured: `hermoso call generate_image` on the default model
|
|
401
|
+
// took ~100s, printed the timeout and exited, and both images were then sitting in the Library, paid for. The
|
|
402
|
+
// registry is in-process, so the only clock that should bound a call is the tool's own (render tools wait up to
|
|
403
|
+
// their job timeout and then hand back a job id). HERMOSO_CALL_TIMEOUT_MS overrides; it is not a --flag because
|
|
404
|
+
// every --flag here is a tool argument.
|
|
405
|
+
const res = await client.callTool({ name, arguments: args }, undefined, { timeout: cliCallTimeoutMs(process.env.HERMOSO_CALL_TIMEOUT_MS) });
|
|
380
406
|
const text = (res.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
|
|
381
|
-
if (flags.json === true) console.log(JSON.stringify(res, null, 2));
|
|
407
|
+
if (flags.json === true || flags.__jsonOut === true) console.log(JSON.stringify(res, null, 2));
|
|
382
408
|
else if (flags.structured) console.log(JSON.stringify(res.structuredContent ?? null, null, 2));
|
|
383
409
|
else if (text) console.log(text);
|
|
384
410
|
else console.log(JSON.stringify(res.structuredContent ?? res, null, 2));
|
package/mcp/client.mjs
CHANGED
|
@@ -454,6 +454,8 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
|
|
|
454
454
|
export async function apiSSE(p, body = {}) {
|
|
455
455
|
const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
|
|
456
456
|
if (!res.ok) { let e; try { e = (await res.json()).error; } catch {} throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
|
|
457
|
+
// A server that answered with plain JSON (an older revision) is read as JSON, never as an empty stream.
|
|
458
|
+
if (!/text\/event-stream/i.test(res.headers.get('content-type') || '')) { const j = await res.json(); return j && Object.prototype.hasOwnProperty.call(j, 'data') ? j.data : j; }
|
|
457
459
|
const reader = res.body.getReader(); const dec = new TextDecoder();
|
|
458
460
|
let buf = '', done = null, error = null; const progress = [];
|
|
459
461
|
for (;;) {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// STATIC ADS AT SCALE (2026-10-06) — the pure half of `make_static_ads`.
|
|
2
|
+
//
|
|
3
|
+
// A competing ads studio shipped on 2026-09-30: a brand kit from a website, the products imported from the site, pick
|
|
4
|
+
// one or several products and a count (up to 20 per product), and get back a batch of FINISHED static ads, each a
|
|
5
|
+
// different selling angle with its headline and CTA on the image, in 1:1, 4:5 or 9:16. We had every part of that
|
|
6
|
+
// (draft_brand, the product library, the batch planner the web Studio fans into up to 30 ranked statics) but an agent
|
|
7
|
+
// had to stitch it together itself: plan_variations (capped at 8, plans only), then one generate_image per variant —
|
|
8
|
+
// and a CLI generate_image held past 60s died client-side while the render still billed. This is the one-call shape.
|
|
9
|
+
//
|
|
10
|
+
// Everything here is PURE so tools/static-ads-batch-check.mjs runs the real functions. Byte-twin: cli/mcp/static-batch.mjs.
|
|
11
|
+
|
|
12
|
+
export const STATIC_BATCH = Object.freeze({
|
|
13
|
+
COUNT_MAX: 20, // ads per product, the ceiling the market offers
|
|
14
|
+
COUNT_DEFAULT: 4,
|
|
15
|
+
PRODUCTS_MAX: 10, // products in one call
|
|
16
|
+
RATIOS: Object.freeze(['1:1', '4:5', '9:16']),
|
|
17
|
+
RATIO_DEFAULT: '4:5',
|
|
18
|
+
// The server bounds queued+running renders per account (JOB_QUEUE_MAX, 16). One call never queues more than this;
|
|
19
|
+
// the rest of the plan comes back as `remaining`, to be sent again as `plan` once some have finished.
|
|
20
|
+
QUEUE_PER_CALL: 12,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
24
|
+
const STOP = /^(the|and|for|with|of|by|a|an|in|on)$/;
|
|
25
|
+
const tokens = (s) => norm(s).split(' ').filter((w) => w.length > 1 && !STOP.test(w));
|
|
26
|
+
|
|
27
|
+
// The brand's catalog as rows {title, image}. A saved brand holds products in several historical shapes, and none of
|
|
28
|
+
// them is authoritative alone: productTitles [{image,title}] (a drafted site's own product names), products (strings
|
|
29
|
+
// or {title,image}), productNames (the hero names the drafter picked), productImages + product (photos, the hero
|
|
30
|
+
// first). A row with no image is still a row: the planner can write for it; the render then grounds on the hero.
|
|
31
|
+
export function catalogOf(brand) {
|
|
32
|
+
const b = brand && typeof brand === 'object' ? brand : {};
|
|
33
|
+
const rows = [];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const add = (title, image) => {
|
|
36
|
+
const t = String(title || '').trim();
|
|
37
|
+
const img = typeof image === 'string' && /^https?:\/\//i.test(image) ? image : '';
|
|
38
|
+
if (!t && !img) return;
|
|
39
|
+
const k = norm(t) || img;
|
|
40
|
+
if (seen.has(k)) { const r = rows.find((x) => (norm(x.title) || x.image) === k); if (r && !r.image && img) r.image = img; return; }
|
|
41
|
+
seen.add(k);
|
|
42
|
+
rows.push({ title: t, image: img });
|
|
43
|
+
};
|
|
44
|
+
for (const p of Array.isArray(b.productTitles) ? b.productTitles : []) if (p && typeof p === 'object') add(p.title, p.image);
|
|
45
|
+
for (const p of Array.isArray(b.products) ? b.products : []) {
|
|
46
|
+
if (typeof p === 'string') add(p, '');
|
|
47
|
+
else if (p && typeof p === 'object') add(p.title || p.name, p.image || (Array.isArray(p.images) ? p.images[0] : ''));
|
|
48
|
+
}
|
|
49
|
+
for (const n of Array.isArray(b.productNames) ? b.productNames : []) add(n, '');
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The brand's default product photo (what every render grounds on when nothing names another product).
|
|
54
|
+
export function heroImageOf(brand) {
|
|
55
|
+
const b = brand && typeof brand === 'object' ? brand : {};
|
|
56
|
+
return (typeof b.product === 'string' && b.product) || (Array.isArray(b.productImages) ? b.productImages.find(Boolean) : '') || '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Match a product the caller NAMED against the catalog. Exact normalized title first; otherwise coverage of the asked
|
|
60
|
+
// words minus a penalty per extra word in the title (an extra word is usually a different item: "Wool Runner" must not
|
|
61
|
+
// land on "Wool Runner Mizzles" when a plain "Wool Runner" exists). Below two matched words (or all of a one-word ask)
|
|
62
|
+
// it is no match — a wrong product photo on a finished ad is worse than none.
|
|
63
|
+
export function matchCatalogProduct(catalog, name) {
|
|
64
|
+
const q = tokens(name);
|
|
65
|
+
if (!q.length || !Array.isArray(catalog) || !catalog.length) return null;
|
|
66
|
+
const qn = norm(name);
|
|
67
|
+
let best = null, bestScore = 0;
|
|
68
|
+
for (const row of catalog) {
|
|
69
|
+
const t = norm(row.title);
|
|
70
|
+
if (!t) continue;
|
|
71
|
+
let score;
|
|
72
|
+
if (t === qn) score = 1000;
|
|
73
|
+
else {
|
|
74
|
+
const tt = tokens(row.title);
|
|
75
|
+
const matched = q.filter((w) => tt.includes(w)).length;
|
|
76
|
+
if (matched < Math.min(2, q.length)) continue;
|
|
77
|
+
const extras = tt.filter((w) => !q.includes(w)).length;
|
|
78
|
+
score = matched * 10 - extras * 2 + (t.startsWith(qn) ? 3 : 0) + (row.image ? 1 : 0);
|
|
79
|
+
}
|
|
80
|
+
if (score > bestScore) { bestScore = score; best = row; }
|
|
81
|
+
}
|
|
82
|
+
return best;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Which products a call is for. Named products resolve against the catalog (an unmatched name is still advertised,
|
|
86
|
+
// by its own words, with no product photo of its own); `all` takes the catalog rows that have a photo; nothing named
|
|
87
|
+
// means the brand's hero.
|
|
88
|
+
export function resolveProducts(brand, products) {
|
|
89
|
+
const catalog = catalogOf(brand);
|
|
90
|
+
const asked = Array.isArray(products) ? products.map((p) => String(p || '').trim()).filter(Boolean) : [];
|
|
91
|
+
if (asked.length === 1 && /^(all|every|everything|all products)$/i.test(asked[0])) {
|
|
92
|
+
const withPhoto = catalog.filter((r) => r.image);
|
|
93
|
+
return (withPhoto.length ? withPhoto : catalog).slice(0, STATIC_BATCH.PRODUCTS_MAX).map((r) => ({ name: r.title || 'the product', image: r.image, matched: true }));
|
|
94
|
+
}
|
|
95
|
+
if (!asked.length) {
|
|
96
|
+
const b = brand && typeof brand === 'object' ? brand : {};
|
|
97
|
+
const name = (Array.isArray(b.productNames) && b.productNames[0]) || b.sells || b.name || '';
|
|
98
|
+
return name ? [{ name: String(name), image: '', matched: false, hero: true }] : [];
|
|
99
|
+
}
|
|
100
|
+
return asked.slice(0, STATIC_BATCH.PRODUCTS_MAX).map((n) => {
|
|
101
|
+
const hit = matchCatalogProduct(catalog, n);
|
|
102
|
+
return hit ? { name: hit.title || n, asked: n, image: hit.image, matched: true } : { name: n, image: '', matched: false };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function normalizeRatios(list) {
|
|
107
|
+
const want = (Array.isArray(list) ? list : [list]).map((r) => String(r || '').trim()).filter(Boolean);
|
|
108
|
+
const out = [...new Set(want.filter((r) => STATIC_BATCH.RATIOS.includes(r)))];
|
|
109
|
+
return out.length ? out : [STATIC_BATCH.RATIO_DEFAULT];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function clampCount(n) {
|
|
113
|
+
const v = Math.round(Number(n));
|
|
114
|
+
if (!Number.isFinite(v)) return STATIC_BATCH.COUNT_DEFAULT;
|
|
115
|
+
return Math.max(1, Math.min(STATIC_BATCH.COUNT_MAX, v));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The brief the planner sees for one product: the product, the caller's steer, and the angles they asked for.
|
|
119
|
+
export function plannerBrief(product, { brief = '', angles = [] } = {}) {
|
|
120
|
+
const a = (Array.isArray(angles) ? angles : []).map((x) => String(x || '').trim()).filter(Boolean).slice(0, 12);
|
|
121
|
+
return [
|
|
122
|
+
String(product || '').trim(),
|
|
123
|
+
brief ? `What to lean into: ${String(brief).trim().slice(0, 600)}` : '',
|
|
124
|
+
a.length ? `Spread the variants across these angles (one angle per variant, in this order, repeating if there are more variants than angles): ${a.join('; ')}.` : '',
|
|
125
|
+
].filter(Boolean).join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// One render's prompt: the planner's visual brief, then the exact words, then (with no logo on file) the no-logo rule. The words are QUOTED so
|
|
129
|
+
// the model prints them as given; a static whose headline is misspelled is not finished.
|
|
130
|
+
export function staticAdPrompt(v, { logo = 'server', productPhoto = false } = {}) {
|
|
131
|
+
const x = v && typeof v === 'object' ? v : {};
|
|
132
|
+
const lines = [String(x.prompt || x.visual || '').trim()];
|
|
133
|
+
const words = [
|
|
134
|
+
x.headline ? `headline "${String(x.headline).trim()}"` : '',
|
|
135
|
+
x.supporting ? `supporting line "${String(x.supporting).trim()}"` : '',
|
|
136
|
+
x.cta ? `call to action "${String(x.cta).trim()}"` : '',
|
|
137
|
+
].filter(Boolean);
|
|
138
|
+
if (words.length) lines.push(`On-image text, spelled exactly as given and nothing else: ${words.join(', ')}. Bold ad typography with a clear hierarchy, crisp and correctly spelled. A finished, ready-to-run static ad.`);
|
|
139
|
+
if (productPhoto) lines.push('The first reference image is the REAL product: keep its shape, colours and label exactly.');
|
|
140
|
+
// THE LOGO IS LAID ON, NEVER PAINTED (2026-10-06): 'overlay' (and the old 'attached' / 'server') add nothing here. The
|
|
141
|
+
// render goes with brandLogo:true, and the image route tells the model to keep a corner clear and lays the brand's real
|
|
142
|
+
// logo file over the finished ad (lib/logo-intent.mjs). The model is never handed the logo to re-draw.
|
|
143
|
+
if (!['overlay', 'attached', 'server'].includes(logo)) lines.push('Do not draw or invent any logo or wordmark.');
|
|
144
|
+
return lines.filter(Boolean).join('\n\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The render list for a plan: every variant once per ratio, in plan order, each carrying what it renders.
|
|
148
|
+
export function renderList(plan, ratios) {
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const p of Array.isArray(plan) ? plan : []) {
|
|
151
|
+
for (const v of Array.isArray(p?.variants) ? p.variants : []) {
|
|
152
|
+
for (const r of normalizeRatios(ratios)) out.push({ product: p.product, image: p.image || '', variant: v, aspectRatio: r });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Split a render list at what one call may queue; the rest goes back to the caller as a plan of its own.
|
|
159
|
+
export function splitForQueue(list, cap = STATIC_BATCH.QUEUE_PER_CALL) {
|
|
160
|
+
const n = Math.max(0, Math.floor(Number(cap) || 0));
|
|
161
|
+
return { now: list.slice(0, n), later: list.slice(n) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Rebuild a plan (products → variants) from leftover render items, so `remaining` can be passed straight back as `plan`
|
|
165
|
+
// with the same `aspectRatios`. A variant appears once even when several of its ratios were left over.
|
|
166
|
+
export function planFromItems(items) {
|
|
167
|
+
const byProduct = new Map();
|
|
168
|
+
for (const it of Array.isArray(items) ? items : []) {
|
|
169
|
+
const key = `${it.product}\u0000${it.image || ''}`;
|
|
170
|
+
if (!byProduct.has(key)) byProduct.set(key, { product: it.product, image: it.image || '', variants: [] });
|
|
171
|
+
const p = byProduct.get(key);
|
|
172
|
+
if (!p.variants.includes(it.variant)) p.variants.push(it.variant);
|
|
173
|
+
}
|
|
174
|
+
return [...byProduct.values()];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Credits for one image on the model that will run, read off GET /api/generate/status. null = could not tell, which is
|
|
178
|
+
// said as such and never rendered as a number. NOT the `best` row: every ad here carries the product photo and/or the
|
|
179
|
+
// logo, and a referenced render with no model named composites on the catalog's FIRST row (Nano Banana Pro). Measured
|
|
180
|
+
// 2026-10-06: `best` was GPT Image 2.5 at 7 credits, the quote said 7, and each job settled at 20 (17 for the render +
|
|
181
|
+
// the label check) on Nano Banana Pro.
|
|
182
|
+
export function perAdCredits(status, model = '') {
|
|
183
|
+
const rows = status?.options?.image?.models;
|
|
184
|
+
if (!Array.isArray(rows) || !rows.length) return null;
|
|
185
|
+
const row = (model && rows.find((m) => m && m.id === model)) || rows[0];
|
|
186
|
+
if (!row) return null;
|
|
187
|
+
const bySize = row.creditsBySize && (row.creditsBySize['2K'] ?? row.creditsBySize['1K']);
|
|
188
|
+
const c = Number(bySize ?? row.credits);
|
|
189
|
+
return Number.isFinite(c) && c > 0 ? c : null;
|
|
190
|
+
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { withHints, videoChoiceText, videoChoiceHints, neutralCreditsText, credi
|
|
|
26
26
|
// behind HERMOSO_CHATGPT_EXT=1 + `?ext=1` on a widget host — see registerChatGPTExtensions() below and
|
|
27
27
|
// ./openai-extensions.mjs. Same twin-safe specifier rule.
|
|
28
28
|
import { registerLibraryApp, installEntrypointIcons, chatgptExtOn, EXT_PREFS_KEY } from './openai-extensions.mjs';
|
|
29
|
+
// STATIC ADS AT SCALE (2026-10-06): the pure catalog/plan/prompt helpers behind make_static_ads. Same twin-safe specifier rule.
|
|
30
|
+
import { STATIC_BATCH, resolveProducts, heroImageOf, normalizeRatios, clampCount, plannerBrief, staticAdPrompt, renderList, splitForQueue, planFromItems, perAdCredits } from './static-batch.mjs';
|
|
29
31
|
|
|
30
32
|
const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
|
|
31
33
|
// /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
|
|
@@ -2094,7 +2096,9 @@ export const TOOL_GROUP_NAMES = ['core', ...Object.keys(TOOL_GROUPS)];
|
|
|
2094
2096
|
// create RE-MEASURED 2026-09-24 (26000 → 42000): edit_timeline (the open edit primitive, 11,026 by this method, about
|
|
2095
2097
|
// 2,500 on the wire) and video_frames joined it. Both are on-demand (ON_DEMAND_TOOLS), so they only cost a session that
|
|
2096
2098
|
// names `create` (or all); this figure is what that session pays.
|
|
2097
|
-
|
|
2099
|
+
// create RE-MEASURED 2026-10-06 (42000 → 55000, measured 54,974 by tools/tool-group-truth-check.mjs): make_static_ads
|
|
2100
|
+
// joined it (on-demand), on top of the create tools added since 09-24 that had used up the 1.3× tolerance.
|
|
2101
|
+
export const TOOL_GROUP_TOKENS = { core: 4300, research: 6600, create: 55000, channels: 37100, channel_admin: 66300, analytics: 39100, files: 10600, workspace: 9200, ads: 246700 };
|
|
2098
2102
|
|
|
2099
2103
|
// THE DEFAULT ROSTER IS EVERYTHING EXCEPT `ads` AND `analytics`. Paid-campaign management across every ad platform
|
|
2100
2104
|
// is ~236K tokens on its own — more than everything else put together — because each platform carries a full
|
|
@@ -2265,7 +2269,7 @@ export const widgetHostHides = (name, widgetHost) => (widgetHost
|
|
|
2265
2269
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
2266
2270
|
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
2267
2271
|
// edit_timeline, and edit_timeline's names video_frames, so a caller that starts from post_edit reaches both.
|
|
2268
|
-
export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft']); // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine)
|
|
2272
|
+
export const ON_DEMAND_TOOLS = new Set(['edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads']); // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine)
|
|
2269
2273
|
|
|
2270
2274
|
// ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
|
|
2271
2275
|
// ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
|
|
@@ -4996,7 +5000,16 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4996
5000
|
// status, and /api/errors/report will not read a marker off an untrusted reporter — see toRef in client.mjs,
|
|
4997
5001
|
// which this sentence is the model for.
|
|
4998
5002
|
if (isRemote()) throw Object.assign(new Error('`path` only works when Hermoso runs on your own machine (stdio/CLI). On the hosted connector I can\'t read your files — put the file at a public https URL and pass `url`, or pass `dataUri` for something small.'), { status: 400, _userInput: true });
|
|
4999
|
-
buf = await readFile(a.path);
|
|
5003
|
+
try { buf = await readFile(a.path); }
|
|
5004
|
+
catch (e) {
|
|
5005
|
+
// A PATH THAT IS NOT THERE IS THE CALLER'S TO FIX, AND THE MESSAGE HAS TO SAY HOW (2026-10-06, fp 59547e05-39a:
|
|
5006
|
+
// a bare "ENOENT: no such file or directory, open '/tmp/p04boss.mp4'" 22 times from one agent in five hours,
|
|
5007
|
+
// filed as `unknown`). The usual cause is an agent in a SANDBOX: its /tmp is not the /tmp this process reads.
|
|
5008
|
+
// Authored 400 + _userInput, so the ledger files it as the caller's and the agent is told exactly what to change.
|
|
5009
|
+
const why = { ENOENT: 'there is no file at that path', EACCES: 'this process is not allowed to read it', EPERM: 'this process is not allowed to read it', EISDIR: 'that path is a folder, not a file' }[e?.code];
|
|
5010
|
+
if (!why) throw e;
|
|
5011
|
+
throw Object.assign(new Error(`Can't read \`${String(a.path).slice(0, 300)}\`: ${why} on the machine Hermoso runs on (working folder ${process.cwd()}). If you wrote the file from a sandboxed tool, its temp folder is probably not this one: save it somewhere both can read and pass that absolute path, or pass \`url\` (a public link) or \`dataUri\` instead. Retrying the same path will fail the same way.`), { status: 400, _userInput: true });
|
|
5012
|
+
}
|
|
5000
5013
|
fileName = fileName || String(a.path).split(/[\\/]/).pop();
|
|
5001
5014
|
contentType = EXT_MIME[(fileName.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
|
|
5002
5015
|
}
|
|
@@ -5775,7 +5788,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5775
5788
|
}, wrap(async (a) => {
|
|
5776
5789
|
if (a.redo && a.redo.id) {
|
|
5777
5790
|
const r = a.redo;
|
|
5778
|
-
const d = await apiPost('/api/schedule/refill/redo', { id: r.id, feedback: r.feedback || '', part: r.change || 'both', remember: r.remember !== false, confirm: r.confirm === true });
|
|
5791
|
+
const d = await (r.confirm === true ? apiSSE : apiPost)('/api/schedule/refill/redo', { id: r.id, feedback: r.feedback || '', part: r.change || 'both', remember: r.remember !== false, confirm: r.confirm === true }); // streamed when it renders: past the edge's 100s limit (2026-10-06)
|
|
5779
5792
|
if (d.needsConfirm) return ok(`${d.summary}\nAsk the user before spending; call run_post_refill again with redo.confirm:true only on a yes.`, d);
|
|
5780
5793
|
return ok([d.summary || '', d.draft ? `The new version (${d.draft.id}, ${d.draft.kind}, ${d.draft.at}):\n ${String(d.draft.message || '').replace(/\n+/g, ' / ').slice(0, 300)}\n ${d.draft.media || ''}\nIt is still a draft: approve it with run_post_refill approve.` : ''].filter(Boolean).join('\n'), d);
|
|
5781
5794
|
}
|
|
@@ -5789,19 +5802,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5789
5802
|
d.discarded ? `Discarded ${d.discarded} draft(s).` : '',
|
|
5790
5803
|
d.edited ? `Edited ${d.edited} draft(s).` : '',
|
|
5791
5804
|
(d.editErrors || []).length ? `Edits refused: ${d.editErrors.map(x => `${x.draftId} — ${x.error}`).join(' · ')}` : '',
|
|
5805
|
+
(d.editNotes || []).length ? d.editNotes.map(x => `${x.draftId}: ${x.note}`).join('\n') : '',
|
|
5792
5806
|
(d.unknown || []).length ? `No such draft: ${d.unknown.join(', ')}` : '',
|
|
5793
5807
|
(d.learned || []).length ? `Saved for future batches: ${d.learned.map((x) => `"${x}"`).join(', ')}.` : '',
|
|
5794
5808
|
`${d.remaining} draft(s) still waiting for approval.`,
|
|
5795
5809
|
].filter(Boolean).join('\n'), d);
|
|
5796
5810
|
}
|
|
5797
|
-
const d = await apiPost('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force });
|
|
5811
|
+
const d = await (a.dryRun === false ? apiSSE : apiPost)('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force }); // a live run renders for minutes: streamed past the edge's 100s limit (2026-10-06)
|
|
5798
5812
|
const lines = (d.posts || []).map((p, i) => ` ${i + 1}. ${p.at} → ${(p.channels || []).map(c => `${c}[${(p.visibilityByChannel || {})[c] || 'public'}]`).join(', ')}\n ${String(p.message || '').replace(/\n+/g, ' / ').slice(0, 220)}\n new ${p.kind}: ${p.media || `(made when it runs live) ${String(p.asset?.scene || '').slice(0, 160)}`} · ${p.asset?.credits || 0} credits`);
|
|
5799
5813
|
const q = (d.queued || []).filter(x => x && x.id).length, qFail = (d.queued || []).filter(x => x && x.error);
|
|
5800
5814
|
return ok([
|
|
5801
5815
|
d.summary || '',
|
|
5802
5816
|
lines.length ? `\nThe posts:\n${lines.join('\n')}` : '',
|
|
5803
5817
|
(d.noChannels || d.zeroBudget || (d.enabled === false && !(d.posts || []).length)) ? '' : d.dryRun ? `\nNOTHING WAS RENDERED OR QUEUED — this was a preview${d.copyCredits ? ` (writing its copy cost ${d.copyCredits} credit(s))` : ''}. Show it to the user; call again with dryRun:false only if they say yes.`
|
|
5804
|
-
: (d.mode === 'auto' ? `\n${q} post(s) scheduled. Pull any of them before it goes out with cancel_scheduled.` :
|
|
5818
|
+
: (d.mode === 'auto' ? `\n${q} post(s) scheduled. Pull any of them before it goes out with cancel_scheduled.` : ((d.drafts || []).length ? `\n${d.drafts.length} new draft(s) waiting for approval — approve, edit or discard them with run_post_refill.` : '\nNo new drafts were made by this run (get_post_refill lists any drafts already waiting).')),
|
|
5805
5819
|
qFail.length ? `\n${qFail.length} could not be queued: ${qFail.map(x => `${x.at} — ${x.error}`).join(' · ')}` : '',
|
|
5806
5820
|
(d.notes || []).length ? `\nNotes: ${d.notes.join(' · ')}` : '',
|
|
5807
5821
|
].filter(Boolean).join('\n'), d);
|
|
@@ -11549,7 +11563,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11549
11563
|
const alOut = { ok: z.boolean().optional(), id: z.string().optional(), verified: z.boolean().optional(), note: z.string().optional() };
|
|
11550
11564
|
server.registerTool('list_applovin_ads_campaigns', {
|
|
11551
11565
|
title: 'List AppLovin Ads campaigns',
|
|
11552
|
-
description: 'The brand’s AppLovin Ads campaigns: id, name, LIVE/PAUSED, daily budget, goal, countries, landing page. Read-only, free.' + AL_CM_NOTE,
|
|
11566
|
+
description: 'The brand’s AppLovin Ads campaigns: id, name, LIVE/PAUSED, daily budget, goal, countries, landing page. Rename or edit one with update_applovin_ads_campaign; pause or go live with set_applovin_ads_status. Read-only, free.' + AL_CM_NOTE,
|
|
11553
11567
|
inputSchema: { campaignIds: alIds.describe('only these ids (up to 100)'), page: z.number().optional().describe('from 1'), size: z.number().optional().describe('1–100, default 100') },
|
|
11554
11568
|
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), campaigns: z.array(z.any()).optional(), page: z.number().optional(), note: z.string().optional() },
|
|
11555
11569
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -11581,7 +11595,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11581
11595
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/campaign', a); return ok(d.note, d); }));
|
|
11582
11596
|
server.registerTool('update_applovin_ads_campaign', {
|
|
11583
11597
|
title: 'Update an AppLovin Ads campaign',
|
|
11584
|
-
description: '
|
|
11598
|
+
description: 'Rename an AppLovin campaign or change its budget, goal value, countries, US states or metros, end date or landing page, read back. On a LIVE campaign a budget, goal, country or end-date change needs confirm:true. Status is set_applovin_ads_status; goal type, bidding and start date cannot change.' + AL_CM_NOTE,
|
|
11585
11599
|
inputSchema: {
|
|
11586
11600
|
campaignId: z.string(), name: z.string().optional(), dailyBudget: z.number().optional(), countryBudgets: z.record(z.number()).optional(),
|
|
11587
11601
|
goalValue: z.number().optional(), countryGoals: z.record(z.number()).optional(), countries: alIds, usRegions: alIds, usMetros: alIds,
|
|
@@ -11606,7 +11620,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11606
11620
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
11607
11621
|
}, wrap(async (a) => {
|
|
11608
11622
|
const d = await apiGet('/api/applovin-ads/creative-sets', { ...a, ...(a.creativeSetIds ? { creativeSetIds: a.creativeSetIds.join(',') } : {}) });
|
|
11609
|
-
return ok([d.note, ...(d.creativeSets || []).map(c => `• ${c.name} (${c.id}) — ${c.status || '?'}: ${(c.assets || []).map(x => `${x.type || x.resourceType} ${x.id} ${x.status}`).join('; ')}`)].join('\n'), d);
|
|
11623
|
+
return ok([d.note, ...(d.creativeSets || []).map(c => `• ${c.name} (${c.id}) — ${c.status || '?'}, in campaign(s) ${[...new Set([...(c.campaignIds || []), ...(c.campaignId ? [c.campaignId] : [])])].join(', ') || 'none'}: ${(c.assets || []).map(x => `${x.type || x.resourceType} ${x.id} ${x.status}`).join('; ')}`)].join('\n'), d);
|
|
11610
11624
|
}));
|
|
11611
11625
|
server.registerTool('create_applovin_ads_creative_set', {
|
|
11612
11626
|
title: 'Create an AppLovin creative set (paused)',
|
|
@@ -11622,6 +11636,27 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11622
11636
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), removedFrom: z.array(z.string()).optional(), stillIn: z.array(z.string()).optional(), verdict: z.string().optional(), note: z.string().optional() },
|
|
11623
11637
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
11624
11638
|
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/remove', a); return ok(d.note, d); }));
|
|
11639
|
+
server.registerTool('update_applovin_ads_creative_set', {
|
|
11640
|
+
title: 'Edit an AppLovin creative set',
|
|
11641
|
+
description: 'Edit an AppLovin creative set, read back: rename it, change its countries or languages (an empty list means all), its landing page (url; "" falls back to the campaign’s), replace its assets (assetIds, checked against AppLovin’s own asset types first), or add it to more campaigns (addToCampaignIds, up to 20). Adding a LIVE set to a LIVE campaign needs confirm:true. Status is set_applovin_ads_status.' + AL_CM_NOTE,
|
|
11642
|
+
inputSchema: { creativeSetId: z.string(), name: z.string().optional(), countries: alIds.describe('ISO codes; [] = all countries'), languages: alIds.describe('e.g. ENGLISH; [] = all'), url: z.string().optional().describe('landing page for this set; "" = the campaign’s'), assetIds: alIds.describe('REPLACES the set’s assets'), addToCampaignIds: alIds.describe('also run it in these campaigns (up to 20)'), confirm: z.boolean().optional().describe('required to add a LIVE set to a LIVE campaign') },
|
|
11643
|
+
outputSchema: { ...alOut, changed: z.array(z.string()).optional(), creativeSet: z.any().optional() },
|
|
11644
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
11645
|
+
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/update', a); return ok(d.note, d); }));
|
|
11646
|
+
server.registerTool('clone_applovin_ads_creative_set', {
|
|
11647
|
+
title: 'Copy an AppLovin creative set into another campaign (paused)',
|
|
11648
|
+
description: 'Copy an AppLovin creative set into another campaign, born PAUSED and read back. AppLovin only clones a set that already belongs to a campaign.' + AL_CM_NOTE,
|
|
11649
|
+
inputSchema: { creativeSetId: z.string(), campaignId: z.string().describe('the campaign to copy it into') },
|
|
11650
|
+
outputSchema: { ...alOut, sourceId: z.string().optional(), campaignId: z.string().optional(), version: z.string().optional(), creativeSet: z.any().optional(), pausedAfterCreate: z.boolean().optional() },
|
|
11651
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
11652
|
+
}, wrap(async (a) => { const d = await apiPost('/api/applovin-ads/creative-set/clone', a); return ok(d.note, d); }));
|
|
11653
|
+
server.registerTool('list_applovin_ads_catalogs', {
|
|
11654
|
+
title: 'List AppLovin catalogs',
|
|
11655
|
+
description: 'The AppLovin account’s product catalogs and their variant sets: the catalogId and variantSetId a dynamic-ads campaign (create_applovin_ads_campaign) needs. Website accounts. Read-only, free.' + AL_CM_NOTE,
|
|
11656
|
+
inputSchema: {},
|
|
11657
|
+
outputSchema: { ok: z.boolean().optional(), count: z.number().optional(), catalogs: z.array(z.any()).optional(), note: z.string().optional() },
|
|
11658
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
11659
|
+
}, wrap(async () => { const d = await apiGet('/api/applovin-ads/catalogs', {}); return ok(d.note, d); }));
|
|
11625
11660
|
server.registerTool('list_applovin_ads_assets', {
|
|
11626
11661
|
title: 'List AppLovin assets',
|
|
11627
11662
|
description: 'Assets on the AppLovin account: id, type (VID_LONG_P, VID_SHORT_P, IMG_INTER_P, IMG_BANNER, HOSTED_HTML), review status and rejection reasons. Read-only, free.' + AL_CM_NOTE,
|
|
@@ -11646,7 +11681,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
11646
11681
|
since: z.string().optional().describe('YYYY-MM-DD, default 7 days ago'), until: z.string().optional().describe('YYYY-MM-DD, default today'),
|
|
11647
11682
|
columns: alIds.describe('e.g. day, campaign, campaign_id_external, creative_set, country, impressions, clicks, ctr, cost, sales, roas_7d, chka_usd_7d'),
|
|
11648
11683
|
attribution: z.enum(['click', 'click_and_view']).optional().describe('web only, default click'), filters: z.record(z.array(z.string())).optional().describe('column -> values'),
|
|
11649
|
-
cohort: z.boolean().optional().describe('attribute to impression day'), limit: z.number().optional().describe('1–10000, default 1000'), offset: z.number().optional(),
|
|
11684
|
+
cohort: z.boolean().optional().describe('attribute to impression day'), notZero: z.boolean().optional().describe('drop rows whose metrics are all zero'), limit: z.number().optional().describe('1–10000, default 1000'), offset: z.number().optional(),
|
|
11650
11685
|
},
|
|
11651
11686
|
outputSchema: { ok: z.boolean().optional(), type: z.string().optional(), since: z.string().optional(), until: z.string().optional(), columns: z.array(z.string()).optional(), count: z.number().optional(), rows: z.array(z.any()).optional(), totals: z.any().optional(), note: z.string().optional() },
|
|
11652
11687
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
@@ -17955,24 +17990,26 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17955
17990
|
model: z.string().optional().describe('image model id from hermoso_capabilities. A model whose `refs.mode` is "edit" there (gpt-image-2.5) takes your refImages on ITS OWN editor, up to its `refs.max`, instead of the default compositor'),
|
|
17956
17991
|
imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
|
|
17957
17992
|
fixLabel: z.boolean().optional().describe('default true: when the saved brand\'s product photo rides in this render, the product\'s label on the finished image is READ and compared with the photo, and re-printed from the photo at close range ONLY if it came out wrong (a label that is already right costs only the check, a credit or two; a re-print adds about ten). The reply says whether the label was checked, fixed or left as rendered (`labelPass`). Pass false when the user wants the packaging left exactly as generated: nothing is checked or re-printed.'),
|
|
17993
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: the real logo file laid flat (lockup, corner). in_scene: ON something in the scene (cup, shirt, sign, moving or angled), from the file, then checked. auto (default): from the prompt, none unless asked'),
|
|
17958
17994
|
mask: z.string().optional().describe('MASKED EDIT — change ONE region of an image and keep the rest: a local path or URL of a mask image for refImages[0] (the image being edited). Either convention works and the reply says which it read: TRANSPARENT pixels = change, or, on a mask with no transparency, WHITE = change and black = keep. Any size; it is scaled to the image. The mask GUIDES the edit rather than stencilling it: the new content can blend a little past its edge. Runs on the model hermoso_capabilities marks `refs.mask` (gpt-image-2.5): leave `model` empty or name that one — any other named model is refused, free. Needs refImages; the result keeps the source image\'s own frame, so aspectRatio is not applied.'),
|
|
17959
17995
|
},
|
|
17960
17996
|
outputSchema: {
|
|
17961
17997
|
image: z.string().optional().describe('the served absolute URL of the finished image'),
|
|
17962
17998
|
model: z.string().optional().describe('the product-facing label of the model that rendered it'),
|
|
17963
17999
|
productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
|
|
18000
|
+
logoCheck: z.any().optional().describe('after an in_scene logo: {verdict: match|drift|absent|unclear, where, issues[], note}'),
|
|
17964
18001
|
labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
|
|
17965
18002
|
},
|
|
17966
18003
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
17967
18004
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
|
|
17968
|
-
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel }) => {
|
|
18005
|
+
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement }) => {
|
|
17969
18006
|
const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
|
|
17970
18007
|
const maskRef = mask ? await toRef(mask) : undefined; // a local mask file travels the same way a local reference does
|
|
17971
18008
|
// `raw === true` only — a raw render is opt-in and must be stated properly, so a truthy stray value never
|
|
17972
18009
|
// silently turns off the brand pipeline on an on-brand ad (the same rule lib/raw-passthrough.mjs's predicate uses).
|
|
17973
18010
|
// A MASKED EDIT IS AN EDIT OF THE CALLER'S OWN IMAGE: the saved brand's product photos must not be hydrated in front
|
|
17974
18011
|
// of it, so a mask implies useBrand:false (the server also refuses a mask with no refImages, free).
|
|
17975
|
-
const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) }; // fixLabel:false only when stated: the label check is ON by default
|
|
18012
|
+
const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}) }; // fixLabel:false only when stated: the label check is ON by default
|
|
17976
18013
|
// A CALLER WITH A WAIT BUDGET GETS A QUEUED JOB (2026-09-21, R321). Only `/v1/tools/generate_image?wait=` sets
|
|
17977
18014
|
// `waitMs`; every other caller renders inside the request exactly as before. The server does the same brand-photo
|
|
17978
18015
|
// handling either way, then queues the ordinary image job; awaitRenderJob waits what the caller allowed and hands
|
|
@@ -18113,6 +18150,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18113
18150
|
restrainedGrade: z.boolean().optional().describe('true for a calm / premium / muted look instead of the default punchy poster grade'),
|
|
18114
18151
|
logo: z.string().optional().describe('a brand logo URL or path to place into the composition'),
|
|
18115
18152
|
logo3d: z.boolean().optional().describe('first turn the flat logo into a volumetric 3D render (one extra billed image), then composite that'),
|
|
18153
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene']).optional().describe('overlay: the real logo file laid flat, exact. in_scene: placed into the composition from the file, then checked. auto (default): in_scene with logo3d, else overlay'),
|
|
18116
18154
|
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'),
|
|
18117
18155
|
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"),
|
|
18118
18156
|
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(),
|
|
@@ -18686,7 +18724,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18686
18724
|
description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration — the longest-clip model in the catalog today renders a full multi-beat spot of up to 30 SECONDS in ONE unbroken take with native synchronized audio, so never assume a generic 8–10s cap and never stitch something that fits one clip; durationSeconds must be one of the model’s durations from hermoso_capabilities, which is the live list. TO GET A SPECIFIC MODEL, NAME IT in `model`: an unnamed render is routed by the server’s own auto-pool, which is narrower than the catalog, so the longest-clip and highest-resolution models are reached by naming them. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. AUDIO IS NOT FREE AND NOT OPTIONAL BY DEFAULT: a clip delivered with no audio of its own gets a music bed composed and CHARGED on top of the render (see musicMood and audio) — on a cheap short draft the bed can cost as much as the clip. Pass refVideo (a clip URL) to EDIT an existing video instead — the omni engine transforms that clip per your prompt, inheriting its canvas + length (aspectRatio/durationSeconds are ignored for an edit). RAW MODEL ACCESS: by default a few small guards are appended (packaging/label safety with no reference image, a negative prompt where the model takes one, reference-binding lines) and hex colour codes become colour names; ' + RAW_TOOL_NOTE + ' Spends credits (Starter plan is video-blocked server-side).',
|
|
18687
18725
|
inputSchema: {
|
|
18688
18726
|
prompt: z.string().optional().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction); optional with `shots`'),
|
|
18689
|
-
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.'),
|
|
18727
|
+
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 (on Veo it is refused with the length and the cap instead). Billing, durable delivery and per-model validation are unchanged.'),
|
|
18690
18728
|
refImage: z.string().optional().describe('local path or URL to anchor the first frame. ' + LIKENESS_TERMS),
|
|
18691
18729
|
speak: z.string().optional().describe('A PERSON SAYING THESE EXACT WORDS TO CAMERA — the default for any "make my photo talk" / spokesperson / talk-to-camera ask. Pass with `creator` or a portrait as `refImage`: the video model films them saying it in their own voice, matched to who they are, and the length follows the words (leave durationSeconds out). `prompt` is then the staging (e.g. "natural", "walking in a park"). Real footage, not an animated photo; generate_avatar is the animated-photo look, only when asked for by name'),
|
|
18692
18730
|
creator: z.string().optional().describe('STAR A SAVED CREATOR in this clip — their id from list_creators, or the name you know them by, or a PRESET AI creator from list_creators presets (exact name or id; free, no generation). Their saved portrait rides first among the references as the on-camera person, with their saved consent, exactly as render_ad casts them; a real person saved from a photo keeps their real face on camera. An unknown name is refused by name, nothing charged.'),
|
|
@@ -21202,7 +21240,7 @@ function memoryNoteVerdict(text) {
|
|
|
21202
21240
|
|
|
21203
21241
|
server.registerTool('plan_variations', {
|
|
21204
21242
|
title: 'Plan ad variations',
|
|
21205
|
-
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.",
|
|
21243
|
+
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. To plan AND render a batch of finished static ads for one or several products in one call (up to 20 per product), use make_static_ads.",
|
|
21206
21244
|
inputSchema: {
|
|
21207
21245
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
|
|
21208
21246
|
product: z.string().describe('what to advertise'),
|
|
@@ -21222,6 +21260,102 @@ function memoryNoteVerdict(text) {
|
|
|
21222
21260
|
return ok(text, d);
|
|
21223
21261
|
}));
|
|
21224
21262
|
|
|
21263
|
+
// STATIC ADS AT SCALE (2026-10-06). The ads-studio shape in one call: products × count → finished statics,
|
|
21264
|
+
// each a different selling angle with its headline and CTA on the image. Planning runs on the same batch planner the
|
|
21265
|
+
// web Studio's make_variations fans out from (/api/batch/plan); every render is the ordinary QUEUED image job
|
|
21266
|
+
// (/api/generate/image queue:true), so brand photos, the product check and the label pass behave exactly as in
|
|
21267
|
+
// generate_image and nothing is held inside this request. Pure half: ./static-batch.mjs.
|
|
21268
|
+
server.registerTool('make_static_ads', {
|
|
21269
|
+
title: 'Make static ads at scale',
|
|
21270
|
+
description: "Make a BATCH of finished static image ads for one or several products in one call: each ad a genuinely different selling angle (offer, material or ingredient story, how it is used, a lifestyle moment, an objection, proof the brand really has) with its headline, supporting line and CTA printed on the image, the brand's real product photo and logo composited in. `products` names products from the saved brand's catalog (get_brand lists them; ['all'] takes every product with a photo; omit for the hero product; a name not in the catalog is still advertised, drawn from its words). `count` is ads PER PRODUCT (1-20). `aspectRatios` any of 1:1, 4:5, 9:16 (each ad is rendered natively once per ratio). `brief` steers (a sale, a season, an audience) and is never turned into a price, discount or claim the brand does not state; `angles` names the angles to spread across. ALWAYS PRICED FIRST: dryRun:true returns the plan and the quote for the planning call only; then pass that `plan` back (no dryRun) to render exactly it without planning again. Renders are QUEUED jobs, at most " + STATIC_BATCH.QUEUE_PER_CALL + " per call: anything past that comes back as `remaining`, to send again as `plan` once some finish. Read each job with get_job; never describe an ad before its URL arrives. For variants of ONE finished ad use headline_variants / resize_ad; to copy a competitor's static use clone_static.",
|
|
21271
|
+
inputSchema: {
|
|
21272
|
+
products: z.array(z.string()).optional().describe("product names from the saved brand's catalog (get_brand), ['all'] for every product with a photo, or omit for the hero product"),
|
|
21273
|
+
count: z.number().int().min(1).max(STATIC_BATCH.COUNT_MAX).optional().describe(`ads per product, 1-${STATIC_BATCH.COUNT_MAX} (default ${STATIC_BATCH.COUNT_DEFAULT})`),
|
|
21274
|
+
aspectRatios: z.array(z.enum(['1:1', '4:5', '9:16'])).optional().describe("canvases; every ad is rendered once per ratio (default ['4:5'])"),
|
|
21275
|
+
brief: z.string().optional().describe('what to lean into: a sale the brand is really running, a season, an audience, a tone'),
|
|
21276
|
+
angles: z.array(z.string()).optional().describe("the selling angles to spread across, e.g. ['price or offer', 'material story', 'how it is used', 'lifestyle moment']; omit and the planner picks a spread"),
|
|
21277
|
+
language: z.string().optional().describe('language of the on-image copy (default English)'),
|
|
21278
|
+
model: z.string().optional().describe('image model id from hermoso_capabilities; omit for the default'),
|
|
21279
|
+
brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile (passing it pins it like use_brand)'),
|
|
21280
|
+
plan: z.array(z.any()).optional().describe('the `plan` (or `remaining`) a previous call returned: render exactly those ads, no new planning'),
|
|
21281
|
+
dryRun: z.boolean().optional().describe('true = plan and quote only, nothing rendered'),
|
|
21282
|
+
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay (default): the real logo file laid flat on each ad. in_scene: printed on something in the ad, then checked. none. auto: from each visual'),
|
|
21283
|
+
},
|
|
21284
|
+
outputSchema: {
|
|
21285
|
+
plan: z.array(z.any()).optional().describe('[{product, image, variants:[{angle, headline, supporting, cta, prompt}]}]'),
|
|
21286
|
+
jobs: z.array(z.any()).optional().describe('one queued image job per ad × ratio: {id, product, angle, headline, aspectRatio}'),
|
|
21287
|
+
remaining: z.array(z.any()).optional().describe('the part of the plan not queued this call — pass it back as `plan`'),
|
|
21288
|
+
perAdCredits: z.number().nullable().optional(),
|
|
21289
|
+
totalCredits: z.number().nullable().optional(),
|
|
21290
|
+
dryRun: z.boolean().optional(),
|
|
21291
|
+
notes: z.array(z.string()).optional(),
|
|
21292
|
+
},
|
|
21293
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
21294
|
+
}, wrap(async ({ products, count, aspectRatios, brief, angles, language, model, brandId, plan, dryRun, logoPlacement }) => {
|
|
21295
|
+
const brand = await activeBrand(brandId);
|
|
21296
|
+
const ratios = normalizeRatios(aspectRatios);
|
|
21297
|
+
const notes = [];
|
|
21298
|
+
let thePlan = Array.isArray(plan) && plan.length ? plan.filter((p) => p && Array.isArray(p.variants) && p.variants.length) : null;
|
|
21299
|
+
if (!thePlan) {
|
|
21300
|
+
const targets = resolveProducts(brand, products);
|
|
21301
|
+
if (!targets.length) throw Object.assign(new Error('Name the product to advertise in `products` (or save a brand first with draft_brand). Nothing was charged.'), { _userInput: true });
|
|
21302
|
+
const n = clampCount(count);
|
|
21303
|
+
thePlan = [];
|
|
21304
|
+
for (const t of targets) {
|
|
21305
|
+
if (!t.matched && !t.hero) notes.push(`"${t.name}" is not in the brand's catalog, so its ads are drawn from its name with no product photo of its own.`);
|
|
21306
|
+
const d = await apiPost('/api/batch/plan', { product: plannerBrief(t.name, { brief, angles }), count: n, language: language || '' });
|
|
21307
|
+
const vs = (d?.variants || []).slice(0, n).map((v) => ({ angle: v.angle || '', headline: v.headline || '', supporting: v.supporting || '', cta: v.cta || '', prompt: v.prompt || '' }));
|
|
21308
|
+
thePlan.push({ product: t.name, image: t.image || '', hero: !!t.hero, variants: vs });
|
|
21309
|
+
}
|
|
21310
|
+
}
|
|
21311
|
+
const list = renderList(thePlan, ratios);
|
|
21312
|
+
let status = null; try { status = await apiGet('/api/generate/status'); } catch {}
|
|
21313
|
+
const per = perAdCredits(status, model);
|
|
21314
|
+
const total = per == null ? null : per * list.length;
|
|
21315
|
+
const quote = per == null ? 'the per-ad price could not be read just now (each render reserves its own price when queued)' : `about ${per} credits per ad, plus about 3 where the product label is checked · about ${total}-${total + 3 * list.length} for ${list.length}`;
|
|
21316
|
+
const lines = thePlan.flatMap((p) => p.variants.map((v, i) => `${p.product} #${i + 1} [${v.angle}] "${v.headline}" · ${v.cta}`));
|
|
21317
|
+
if (dryRun) {
|
|
21318
|
+
return { content: [{ type: 'text', text: `PLAN (nothing rendered): ${list.length} ad${list.length === 1 ? '' : 's'} (${ratios.join(', ')}).\n${lines.join('\n')}\nQuote: ${quote}.${notes.length ? '\n' + notes.join('\n') : ''}\nTo render exactly these, call make_static_ads again with plan set to this plan and the same aspectRatios.` }], structuredContent: { plan: thePlan, perAdCredits: per, totalCredits: total, dryRun: true, ...(notes.length ? { notes } : {}) } };
|
|
21319
|
+
}
|
|
21320
|
+
if (total != null) {
|
|
21321
|
+
let bal = null; try { bal = (await apiGet('/api/credits'))?.balance; } catch {}
|
|
21322
|
+
if (bal != null && Number.isFinite(+bal) && +bal < total) throw Object.assign(new Error(`This batch needs about ${total} credits (${list.length} ads at about ${per}) and the balance is ${bal}. Nothing was charged. Ask for fewer ads, or top up (buy_credits).`), { status: 402, _userInput: true });
|
|
21323
|
+
}
|
|
21324
|
+
const { now, later } = splitForQueue(list);
|
|
21325
|
+
const heroImg = heroImageOf(brand);
|
|
21326
|
+
const logo = typeof brand?.logo === 'string' && /^https?:\/\//i.test(brand.logo) && !/\.svg(\?|#|$)/i.test(brand.logo) ? brand.logo : '';
|
|
21327
|
+
// THE LOGO, PER AD (2026-10-06): overlay by default (a flat lockup of the real file); in_scene puts it on something in
|
|
21328
|
+
// the ad from the real file and checks it; none leaves it off; auto reads each ad's visual. Never handed over as a
|
|
21329
|
+
// plain reference for the model to re-draw.
|
|
21330
|
+
const lmode = !logo || logoPlacement === 'none' ? 'none' : 'overlay';
|
|
21331
|
+
const lbody = lmode === 'none' ? (logo ? { logoPlacement: 'none' } : {}) : { brandLogo: true, logoPlacement: logoPlacement || 'overlay' };
|
|
21332
|
+
const jobs = [];
|
|
21333
|
+
let stoppedAt = null;
|
|
21334
|
+
for (let i = 0; i < now.length; i++) {
|
|
21335
|
+
const it = now[i];
|
|
21336
|
+
const isHero = it.image ? it.image === heroImg : true;
|
|
21337
|
+
let body;
|
|
21338
|
+
if (isHero && (it.image || brand)) {
|
|
21339
|
+
body = { prompt: staticAdPrompt(it.variant, { logo: lmode }), useBrand: true, ...lbody }; // the server attaches the hero photo and puts the real logo in the way lmode says
|
|
21340
|
+
} else if (it.image) {
|
|
21341
|
+
body = { prompt: staticAdPrompt(it.variant, { logo: lmode, productPhoto: true }), refImages: [it.image], useBrand: true, ...lbody };
|
|
21342
|
+
} else {
|
|
21343
|
+
body = logo ? { prompt: staticAdPrompt(it.variant, { logo: lmode }), useBrand: true, ...lbody } : { prompt: staticAdPrompt(it.variant, { logo: 'none' }), useBrand: false };
|
|
21344
|
+
}
|
|
21345
|
+
try {
|
|
21346
|
+
const q = await apiPost('/api/generate/image', { ...body, aspectRatio: it.aspectRatio, ...(model ? { model } : {}), queue: true });
|
|
21347
|
+
jobs.push({ id: q.jobId, product: it.product, angle: it.variant.angle, headline: it.variant.headline, aspectRatio: it.aspectRatio });
|
|
21348
|
+
} catch (e) {
|
|
21349
|
+
if (e && (e.status === 429 || e.status === 402) && jobs.length) { stoppedAt = i; notes.push(e.status === 429 ? 'The render queue is full, so the rest was not queued.' : String(e.message || '')); break; }
|
|
21350
|
+
throw e;
|
|
21351
|
+
}
|
|
21352
|
+
}
|
|
21353
|
+
const left = [...(stoppedAt == null ? [] : now.slice(stoppedAt)), ...later];
|
|
21354
|
+
const remaining = left.length ? planFromItems(left) : [];
|
|
21355
|
+
const text = `Queued ${jobs.length} static ad${jobs.length === 1 ? '' : 's'} (${quote}).\n${jobs.map((j, i) => `${i + 1}. ${j.product} [${j.angle}] "${j.headline}" ${j.aspectRatio} → job ${j.id}`).join('\n')}${remaining.length ? `\n${left.length} more not queued yet: call make_static_ads again with plan set to \`remaining\` and the same aspectRatios once some of these finish.` : ''}${notes.length ? '\n' + notes.join('\n') : ''}\nRead each with get_job until it reports done; there is no image until then.`;
|
|
21356
|
+
return { content: [{ type: 'text', text }], structuredContent: { plan: thePlan, jobs, ...(remaining.length ? { remaining } : {}), perAdCredits: per, totalCredits: total, ...(notes.length ? { notes } : {}) } };
|
|
21357
|
+
}));
|
|
21358
|
+
|
|
21225
21359
|
// ---------- research analysis & creative remix (webapp Create-chat parity — the last four app-only chat tools, now headless) ----------
|
|
21226
21360
|
server.group('research');
|
|
21227
21361
|
// The web Studio versions of these read the CLIENT's chat/creative state; the MCP variants take explicit inputs and
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.385",
|
|
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. 901 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|