hermoso 0.1.33 → 0.1.35
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 +23 -10
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -167,11 +167,19 @@ async function renderJob(type, input, label) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// TWO SHAPES REACH THESE READERS, AND ONLY ONE OF THEM HAS `.raw` (fixed 2026-08-03). `renderJob` returns
|
|
171
|
+
// `{jobId, url, model, raw:<result>}`. `get_job` returns the JOB — `{...job, url}` — whose payload sits at
|
|
172
|
+
// `job.result` (or `job.result.data`), so a reader that only knew `.raw` returned nothing for it. That is not an
|
|
173
|
+
// edge case: the hosted transport answers any render outliving one request with `stillRendering`, which makes
|
|
174
|
+
// resume-via-get_job THE ORDINARY delivery path for a long render, and it was exactly the path that dropped the
|
|
175
|
+
// verdict from the text a human reads (it survived only in `structuredContent`, which nothing prints). One
|
|
176
|
+
// resolver, so a note added to either reader inherits both shapes.
|
|
177
|
+
const renderPayload = (r) => (r?.raw ?? jobResult(r) ?? null);
|
|
170
178
|
// A MODEL SUBSTITUTION IS NEVER SILENT (2026-08-01). The server sets `modelNote` on any render whose delivered model
|
|
171
179
|
// is not the model the caller named — an entry-belt coercion, or an in-flight content-filter/quota fallback — and the
|
|
172
180
|
// bill always follows the model that RAN. An agent that asked for one model and reads a one-line "ready" reply would
|
|
173
181
|
// otherwise never learn it got another, so the note rides the reply text every render tool prints.
|
|
174
|
-
const switchNote = (r) => { const n = r?.
|
|
182
|
+
const switchNote = (r) => { const n = renderPayload(r)?.modelNote || r?.modelNote; return n ? `\n⚠ ${n}` : ''; };
|
|
175
183
|
// THE VISION-QA READ-BACK (2026-08-03). The server WATCHES every generatively-rendered clip before it reports it
|
|
176
184
|
// done — free ffmpeg cadence/freeze probes plus ONE vision call over frames sampled at the hook, either side of
|
|
177
185
|
// each cut, and the tail — and puts what it saw on the result as `qaNote` (see vision-qa.mjs). An agent reading a
|
|
@@ -179,7 +187,7 @@ const switchNote = (r) => { const n = r?.raw?.modelNote || r?.modelNote; return
|
|
|
179
187
|
// painted its own end card, so the note rides the reply of EVERY video tool via okVideo. It REPORTS ONLY: nothing
|
|
180
188
|
// was re-rendered and nothing extra was charged, and a check it could not settle says "could not tell" rather
|
|
181
189
|
// than passing by default.
|
|
182
|
-
const qaLine = (r) => { const n = r?.
|
|
190
|
+
const qaLine = (r) => { const n = renderPayload(r)?.qaNote; return n ? `\n${n}` : ''; };
|
|
183
191
|
|
|
184
192
|
// Shared outputSchema fields for the job-based render tools (the renderJob result that becomes structuredContent).
|
|
185
193
|
// Every field is optional so validation can never fail on a sparse or still-rendering result.
|
|
@@ -4437,7 +4445,7 @@ export function registerTools(server) {
|
|
|
4437
4445
|
|
|
4438
4446
|
server.registerTool('make_explainer', {
|
|
4439
4447
|
title: 'Make an explainer video',
|
|
4440
|
-
description: "Turn a TOPIC into a finished narrated
|
|
4448
|
+
description: "Turn a TOPIC into a finished narrated explainer video. Writes a sectioned script, paints a BURST of pictures per section (about one every 1.5s — most of them one-detail edits of the frame before, so it reads as movement rather than a slideshow), narrates each section with TTS, adds gentle Ken-Burns motion, then composites the end card (and any on-screen text you asked for) with the Chrome+ffmpeg engine the ads use (text is never model-painted, so it never garbles). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT — the narration carries the point and the pictures carry the story, so the film ships clean unless the user asks otherwise; `captions:true` adds held key points and `subtitles:true` adds narration-timed CAPS (see both). It is an image film WITH motion, not N video-model renders — that's what keeps it affordable. `style` picks the visual family: the default 'cinematic' is photoreal editorial; every other id is a STYLED, strictly non-photoreal look (illustrated / collage / clay / pixel …) that first renders ONE style-key image and then locks every scene to it, so the whole film holds one look. Cost at the default frame density: a ~130-credit hold for a 60s explainer on the default style, ~100 styled; `frameDensity:'lean'` roughly halves it and `'minimal'` (one picture per section) is ~30. All settle to the exact per-frame image + narration spend (a longer target = more sections = more). Takes SEVERAL minutes — one image render per frame; independent frames are painted concurrently, so it is far faster than the frame count suggests. Needs the writing model and a narration voice engine connected. NOT the tool for a short product ad — use render_ad or generate_video for those, and make_template_ad for the deterministic native formats.",
|
|
4441
4449
|
inputSchema: {
|
|
4442
4450
|
topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
|
|
4443
4451
|
durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
|
|
@@ -4446,9 +4454,9 @@ export function registerTools(server) {
|
|
|
4446
4454
|
style: z.enum(['cinematic', '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', 'mannequin']).optional().describe("visual style. 'cinematic' (default) is photoreal; the rest are non-photoreal styled looks — editorial_collage (halftone cutouts + marker accents), flat_vector, stickman, whiteboard, ink_marker, silhouette, storybook (gouache), paper_diorama, isometric, claymation, pixel_art, watercolor, fluffy_toy (felted plush), low_poly, stylized_3d (matte clay render), studio_3d (preschool toy 3D on a white sweep — the Kids default), mannequin (clay-render reenactment figures — a History alternate). Ask the user which they want rather than picking silently; a styled pick costs more (see the cost note)."),
|
|
4447
4455
|
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'."),
|
|
4448
4456
|
voice: z.string().optional().describe('narration voice name — omit for the default warm read'),
|
|
4449
|
-
captions: z.boolean().optional().describe('burn
|
|
4450
|
-
subtitles: z.boolean().optional().describe('burn CAPS SUBTITLES timed to the narration instead of
|
|
4451
|
-
music: z.string().optional().describe("music bed under the narration,
|
|
4457
|
+
captions: z.boolean().optional().describe('burn ON-SCREEN KEY POINTS — one short line per section (at most 5 words / 32 characters, enforced), held over the picture for that whole section (~7-15s). Drawn by the same burner the subtitles use: slim white CAPS, thin black outline, bottom safe band, NO plate and no box. DEFAULT FALSE: the narration already says the point and the pictures carry it, so the clean film is the better default. Set true only when the user asks for on-screen text.'),
|
|
4458
|
+
subtitles: z.boolean().optional().describe('burn CAPS SUBTITLES timed to the narration instead of the held key points (default false; implies captions). Free — no extra render, no extra credits, and there is NO cue limit: the whole film is subtitled however long it runs. Slim white CAPS, thin black outline, bottom safe band, no plate, at most 5 words / 32 characters per line. 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.'),
|
|
4459
|
+
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."),
|
|
4452
4460
|
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."),
|
|
4453
4461
|
endCard: z.boolean().optional().describe('append the branded end card (default true)'),
|
|
4454
4462
|
brandName: z.string().optional().describe('brand name for the end card — omit to leave it unbranded'),
|
|
@@ -4464,7 +4472,7 @@ export function registerTools(server) {
|
|
|
4464
4472
|
const fin = `${d.music ? ` · ${d.music.source === 'composed' ? 'composed' : 'library'} ${d.music.mood} music bed` : ''}${d.upscaled ? ` · upscaled ${d.upscaled}×` : ''}`;
|
|
4465
4473
|
// The length is whatever ffprobe measured on the DELIVERED file, and `null` means it could not be measured — print
|
|
4466
4474
|
// the `—` placeholder rather than inventing a number from the ask (the read-back law: only a measurement ships).
|
|
4467
|
-
return okVideo(`Explainer ready${d.sections ? ` — ${d.sections} sections, ${d.frames || d.sections} frames, ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}` : ''}${d.lengthNote ? ` (${d.lengthNote})` : ''}${d.style
|
|
4475
|
+
return okVideo(`Explainer ready${d.sections ? ` — ${d.sections} sections, ${d.frames || d.sections} frames, ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}` : ''}${d.lengthNote ? ` (${d.lengthNote})` : ''}${d.style ? ` in the ${String(d.style).replace(/_/g, ' ')} style${d.styleLocked ? '' : ' (style key unavailable — the look rides on the prompt only)'}` : ''}${fin}: ${r.url} [job ${r.jobId}]${d.musicNote ? `\n${d.musicNote}` : ''}${d.captionNote ? `\n⚠ ${d.captionNote}` : ''}${d.upscaleNote ? `\n⚠ ${d.upscaleNote}` : ''}`, r);
|
|
4468
4476
|
}));
|
|
4469
4477
|
|
|
4470
4478
|
server.registerTool('product_sizzle', {
|
|
@@ -4496,7 +4504,7 @@ export function registerTools(server) {
|
|
|
4496
4504
|
|
|
4497
4505
|
server.registerTool('generate_video', {
|
|
4498
4506
|
title: 'Generate video',
|
|
4499
|
-
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 (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).',
|
|
4507
|
+
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 (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. 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 of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).',
|
|
4500
4508
|
inputSchema: {
|
|
4501
4509
|
prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
|
|
4502
4510
|
refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
|
|
@@ -4507,7 +4515,8 @@ export function registerTools(server) {
|
|
|
4507
4515
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'720p' default; '480p' = cheap fast draft pass, '1080p'/'4k' = premium final delivery (more credits)"),
|
|
4508
4516
|
ttsScript: z.string().optional().describe('voiceover script to speak'),
|
|
4509
4517
|
ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
|
|
4510
|
-
musicMood: z.string().optional().describe('
|
|
4518
|
+
musicMood: z.string().optional().describe('WHICH mood the music bed is composed in (upbeat / calm / warm / epic / tense / playful / elegant / hype / chill / dramatic). It does NOT decide WHETHER there is one: a clip that comes back with no audio track — every model hermoso_capabilities lists as "silent", plus any audio model that returned mute — gets a bed composed and CHARGED automatically, at the flat per-track fee hermoso_capabilities reports as explainerMusicCredits, and omitting this field only means the mood defaults to "warm". Pass audio:false for a genuinely silent clip with no bed and no bed charge.'),
|
|
4519
|
+
audio: z.boolean().optional().describe('default true. false = render SILENT: no native model audio, no music bed, and no bed charge held or billed. This is the ONLY way to decline the automatic bed (see musicMood) — leave it alone for anything that should have sound, and do not combine it with ttsScript.'),
|
|
4511
4520
|
},
|
|
4512
4521
|
outputSchema: { ...JOB_OUT,
|
|
4513
4522
|
refused: z.string().optional().describe("set when NOTHING was rendered and nothing charged — currently 'duration_exceeds_single_clip'"),
|
|
@@ -4606,7 +4615,11 @@ export function registerTools(server) {
|
|
|
4606
4615
|
const j = await getJob(id);
|
|
4607
4616
|
const res = jobResult(j);
|
|
4608
4617
|
const url = abs(res?.video || res?.image || res?.url);
|
|
4609
|
-
|
|
4618
|
+
// A RESUMED RENDER READS THE SAME SENTENCE AS AN INLINE ONE. `stillRendering` makes this tool the ordinary way a
|
|
4619
|
+
// long render is received, so every disclosure the direct reply carries has to be here too — the model
|
|
4620
|
+
// substitution (`switchNote`) explicitly, the vision-QA verdict via `okVideo`'s own `qaLine`. Both resolve the
|
|
4621
|
+
// payload through `renderPayload`, which is why handing them the JOB works at all.
|
|
4622
|
+
const text = `Job ${id}: ${j.status}${j.progress ? ` (${Math.round(j.progress * 100)}%)` : ''}${url ? ` → ${url}` : ''}${channelOutcomeLine(res)}${j.error ? ` — ${j.error}` : ''}${j.status === 'done' ? switchNote(j) : ''}`;
|
|
4610
4623
|
if (j.status === 'done' && res?.video) return okVideo(text, { ...j, url }); // resumed video → same inline poster as a direct return
|
|
4611
4624
|
if (j.status === 'done' && res?.image) { const img = await imageBlock(url); return { content: [{ type: 'text', text }, ...(img ? [img] : [])], structuredContent: { ...j, url } }; }
|
|
4612
4625
|
return ok(text, { ...j, url });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI — spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic — then publish to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest and build & manage the ad campaigns behind them on Meta, Google Ads, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads. MCP server (262 tools), CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
|
|
6
6
|
"type": "module",
|