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.
Files changed (2) hide show
  1. package/mcp/tools.mjs +39 -12
  2. 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
- if (remote && e?.jobId) return { jobId: job.id, url: null, stillRendering: true, raw: null }; // not an error — resume via get_job
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
- return `\nWatched: ${src}${got ? ` — ${got}` : ''}${w.note ? `\n⚠ ${w.note}` : ''}`;
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 — leave it off unless the user asks for on-screen text (no captions, or true subtitles of what is said; never scene or emphasis labels); a recipe whose format IS on-screen text keeps its text either way'),
15907
- endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
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('persistent brand-logo lockup overlay on/off'),
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 (default true)'),
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
- if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim(), d);
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.224",
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",