hermoso 0.1.224 → 0.1.225
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/tools.mjs +39 -12
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -427,7 +427,9 @@ async function renderJob(type, input, label) {
|
|
|
427
427
|
deliveredWidth: result?.deliveredWidth ?? null, deliveredHeight: result?.deliveredHeight ?? null,
|
|
428
428
|
raw: result };
|
|
429
429
|
} catch (e) {
|
|
430
|
-
|
|
430
|
+
// not an error — resume via get_job. On EVERY transport (2026-09-11): a 20–24s long-clip render queued behind two
|
|
431
|
+
// others outlived the local 10-minute poll, and the tool reported "Render timed out" while the job finished fine.
|
|
432
|
+
if (e?.jobId) return { jobId: job.id, url: null, stillRendering: true, raw: null };
|
|
431
433
|
throw e;
|
|
432
434
|
}
|
|
433
435
|
}
|
|
@@ -506,7 +508,8 @@ const refWatchedLine = (w) => {
|
|
|
506
508
|
if (!w) return '';
|
|
507
509
|
const src = { tiktok: 'TikTok', instagram: 'Instagram', facebook: 'Facebook', x: 'X', youtube: 'YouTube' }[w.platform] || 'video';
|
|
508
510
|
const got = [w.durationSeconds ? `${w.durationSeconds}s` : '', w.frames ? `${w.frames} ${w.footage ? 'frames' : 'thumbnail'}` : '', w.transcript ? 'transcript' : ''].filter(Boolean).join(' · ');
|
|
509
|
-
|
|
511
|
+
const shot = { 'raw-self-filmed': 'self-filmed on a phone, so the remix is too', 'creator-polished': 'a creator on a phone', 'produced-commercial': 'a produced commercial' }[w.register] || '';
|
|
512
|
+
return `\nWatched: ${src}${got ? ` — ${got}` : ''}${shot ? `\nShot as: ${shot}` : ''}${w.note ? `\n⚠ ${w.note}` : ''}`;
|
|
510
513
|
};
|
|
511
514
|
|
|
512
515
|
// ── THE ATTRIBUTION PAIR EVERY PUBLISH TOOL CARRIES (2026-08-05) ─────────────────────────────────────────────────
|
|
@@ -15895,7 +15898,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15895
15898
|
server.group('create');
|
|
15896
15899
|
server.registerTool('render_ad', {
|
|
15897
15900
|
title: 'Render ad video',
|
|
15898
|
-
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), brand end card, 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.',
|
|
15901
|
+
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.',
|
|
15899
15902
|
inputSchema: {
|
|
15900
15903
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
15901
15904
|
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. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if the plan has nobody on camera, or if they are a REAL person with no likeness consent on file.'),
|
|
@@ -15903,10 +15906,23 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
15903
15906
|
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.'),
|
|
15904
15907
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
15905
15908
|
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."),
|
|
15906
|
-
captions: z.boolean().optional().describe('burn the plan\'s per-scene on-screen words as caption pills. DEFAULT FALSE —
|
|
15907
|
-
endCard: z.boolean().optional().describe('branded end card on
|
|
15909
|
+
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'),
|
|
15910
|
+
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)'),
|
|
15908
15911
|
music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
|
|
15909
|
-
lockup: z.boolean().optional().describe('
|
|
15912
|
+
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'),
|
|
15913
|
+
textStyle: z.union([
|
|
15914
|
+
z.enum(['pill', 'editorial', 'bold', 'minimal', 'handwritten', 'boxed']),
|
|
15915
|
+
z.object({
|
|
15916
|
+
preset: z.enum(['pill', 'editorial', 'bold', 'minimal', 'handwritten', 'boxed']).optional(),
|
|
15917
|
+
font: z.enum(['sans', 'serif', 'elegant', 'condensed', 'hand']).optional(),
|
|
15918
|
+
subFont: z.enum(['sans', 'serif', 'elegant', 'condensed', 'hand']).optional(),
|
|
15919
|
+
weight: z.number().optional(), size: z.union([z.enum(['s', 'm', 'l', 'xl']), z.number()]).optional(),
|
|
15920
|
+
color: z.string().optional().describe('#hex'), background: z.string().optional().describe('"none", "pill", or a #hex box'),
|
|
15921
|
+
position: z.enum(['top', 'center', 'lower', 'bottom']).optional(), textCase: z.enum(['as-is', 'upper', 'lower', 'title']).optional(),
|
|
15922
|
+
italic: z.boolean().optional(), subItalic: z.boolean().optional(), outline: z.boolean().optional(), shadow: z.boolean().optional(),
|
|
15923
|
+
tilt: z.number().optional().describe('degrees, ±12'), cardColor: z.string().optional().describe('#hex end card background'),
|
|
15924
|
+
}),
|
|
15925
|
+
]).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.'),
|
|
15910
15926
|
ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
|
|
15911
15927
|
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'),
|
|
15912
15928
|
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'),
|
|
@@ -16093,7 +16109,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16093
16109
|
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.'),
|
|
16094
16110
|
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 — 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."),
|
|
16095
16111
|
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."),
|
|
16096
|
-
endCard: z.boolean().optional().describe('append the branded end card
|
|
16112
|
+
endCard: z.boolean().optional().describe('append the branded end card. DEFAULT FALSE — set true ONLY when the user asks for one'),
|
|
16097
16113
|
brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
|
|
16098
16114
|
},
|
|
16099
16115
|
outputSchema: { ...JOB_OUT },
|
|
@@ -18497,15 +18513,24 @@ function memoryNoteVerdict(text) {
|
|
|
18497
18513
|
axis: z.enum(['hook', 'subject', 'channel', 'media', 'hour']).optional().describe('what to group by — default hook'),
|
|
18498
18514
|
channel: z.string().optional().describe('restrict to one channel'),
|
|
18499
18515
|
},
|
|
18500
|
-
outputSchema: { axis: z.string().optional(), groups: z.array(z.any()).optional(), finding: z.any().optional(), excludedUnattributed: z.number().optional(), minN: z.number().optional(), totalPosts: z.number().optional() },
|
|
18516
|
+
outputSchema: { axis: z.string().optional(), groups: z.array(z.any()).optional(), finding: z.any().optional(), excludedUnattributed: z.number().optional(), minN: z.number().optional(), totalPosts: z.number().optional(), trend: z.any().optional(), health: z.any().optional() },
|
|
18501
18517
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
18502
18518
|
}, wrap(async (a) => {
|
|
18503
18519
|
const d = await apiGet('/api/posts/performance', { ...(a.axis ? { axis: a.axis } : {}), ...(a.channel ? { channel: a.channel } : {}) });
|
|
18504
18520
|
const gs = d.groups || [];
|
|
18505
|
-
|
|
18521
|
+
// OVER TIME + MEASUREMENT HEALTH (2026-09-11): week-by-week medians per channel and why unmeasured posts have no
|
|
18522
|
+
// numbers. Printed even when no hook comparison exists yet — "is it getting better" does not need five hooks.
|
|
18523
|
+
const fmtN = (v) => (v == null ? '—' : Number(v).toLocaleString('en-US', { maximumFractionDigits: v < 10 ? 1 : 0 }));
|
|
18524
|
+
const trendTxt = (d.trend?.channels || []).slice(0, 10).map(c => {
|
|
18525
|
+
const recent = c.buckets.slice(-4).map(b => `${b.week.slice(5)}: ${b.posts ? `${fmtN(c.reachUnit ? b.medianReach : b.medianEngagement)} (${b.posts}p${b.maturing ? ', maturing' : ''})` : '·'}`).join(' | ');
|
|
18526
|
+
return `• ${c.channel} — median ${c.reachUnit || 'engagement'}/post by week: ${recent}. ${c.direction?.verdict ? c.direction.why : `No direction yet: ${c.direction?.why || 'not enough data'}`}`;
|
|
18527
|
+
});
|
|
18528
|
+
const healthTxt = (d.health || []).filter(h => h.coverage == null || h.coverage < 0.8 || h.failed || h.neverRead).slice(0, 10).map(h => `• ${h.channel}: ${h.measured}/${h.posts} measured${h.coverage == null ? '' : ` (${Math.round(h.coverage * 100)}% of what could be)`}${h.neverRead ? ` · ${h.neverRead} never read` : ''}${h.empty ? ` · ${h.empty} read but empty${h.topEmpty ? ` (${h.topEmpty.message})` : ''}` : ''}${h.failed ? ` · ${h.failed} failed${h.topError ? ` — ${h.topError.message}` : ''}` : ''}${h.pending ? ` · ${h.pending} too new` : ''}`);
|
|
18529
|
+
const overTime = `${trendTxt.length ? `\n\nOVER TIME (last ${d.trend.weeks.length} weeks, 7-day readings where they exist):\n${trendTxt.join('\n')}` : ''}${healthTxt.length ? `\n\nMEASUREMENT GAPS:\n${healthTxt.join('\n')}` : ''}`;
|
|
18530
|
+
if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim() + overTime, d);
|
|
18506
18531
|
const rows = gs.map(g => `• "${g.key}" · ${g.channel} — ${g.meanRate == null ? (g.meanEngagement == null ? 'no measurable engagement' : `${g.meanEngagement.toFixed(1)} engagements (no reach denominator on this channel, so no rate)`) : `${(g.meanRate * 100).toFixed(2)}% engagement`} · ${g.n} post(s), ${g.nRated} measured${g.verdict === 'ready' ? '' : ` — ${g.suppressed}`}`);
|
|
18507
18532
|
const head = d.finding?.finding ? `FINDING: ${d.finding.finding}` : `NO FINDING YET: ${d.finding?.why || 'not enough measured posts'}`;
|
|
18508
|
-
return ok(`${head}\n\nBy ${d.axis}:\n${rows.join('\n')}${d.excludedUnattributed ? `\n\n${d.excludedUnattributed} post(s) were excluded from this axis because no hook was recorded for them — they still count toward channel and format totals.` : ''}`, d);
|
|
18533
|
+
return ok(`${head}\n\nBy ${d.axis}:\n${rows.join('\n')}${d.excludedUnattributed ? `\n\n${d.excludedUnattributed} post(s) were excluded from this axis because no hook was recorded for them — they still count toward channel and format totals.` : ''}${overTime}`, d);
|
|
18509
18534
|
}));
|
|
18510
18535
|
|
|
18511
18536
|
server.registerTool('diagnose_posts', {
|
|
@@ -18531,11 +18556,13 @@ function memoryNoteVerdict(text) {
|
|
|
18531
18556
|
inputSchema: {
|
|
18532
18557
|
includeMetered: z.boolean().optional().describe('also read X, which BILLS CREDITS per post read — ask the user first'),
|
|
18533
18558
|
max: z.number().optional().describe('cap how many posts to read in this run (default 40)'),
|
|
18559
|
+
remeasure: z.boolean().optional().describe('ALSO re-read posts older than 7 days whose every reading came back empty or failed — use after post_performance reports posts "read but empty", or once a channel\'s reader has been fixed. Otherwise those windows stay closed.'),
|
|
18534
18560
|
},
|
|
18535
|
-
outputSchema: { collected: z.number().optional(), due: z.number().optional(), remaining: z.number().optional(), couldNotTell: z.number().optional(), skippedMetered: z.number().optional(), meteredNote: z.string().optional(), windows: z.array(z.any()).optional() },
|
|
18561
|
+
outputSchema: { collected: z.number().optional(), due: z.number().optional(), remaining: z.number().optional(), couldNotTell: z.number().optional(), skippedMetered: z.number().optional(), meteredNote: z.string().optional(), windows: z.array(z.any()).optional(), remeasured: z.number().optional(), remeasuredWithNumbers: z.number().optional() },
|
|
18536
18562
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
18537
18563
|
}, wrap(async (a) => {
|
|
18538
|
-
const d = await apiPost('/api/posts/collect', { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}) });
|
|
18564
|
+
const d = await apiPost('/api/posts/collect', { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}), ...(a.remeasure ? { remeasure: true } : {}) });
|
|
18565
|
+
if (a.remeasure) return ok(`Re-read ${d.remeasured || 0} old post(s) whose earlier readings were empty or failed; ${d.remeasuredWithNumbers || 0} now have numbers. Read ${d.collected} post(s) in total${d.remaining ? `, ${d.remaining} still waiting — run it again to continue` : ''}.${d.meteredNote ? ` ${d.meteredNote}` : ''}`, d);
|
|
18539
18566
|
const bits = [`Read ${d.collected} post(s)`, d.couldNotTell ? `${d.couldNotTell} could NOT be read (that is "could not tell", not zero engagement)` : null, d.gone ? `${d.gone} no longer exist at the platform (deleted or taken down) and will not be read again` : null, d.remaining ? `${d.remaining} still due — call again` : null, d.meteredNote || null].filter(Boolean);
|
|
18540
18567
|
return ok(`${bits.join('. ')}.${d.collected ? ' Ask post_performance which hooks are winning.' : ''}`, d);
|
|
18541
18568
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.225",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI ad studio and marketing MCP server with 823 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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",
|