hermoso 0.1.345 → 0.1.355

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/http.mjs CHANGED
@@ -461,6 +461,12 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
461
461
 
462
462
  const sid = req.headers['mcp-session-id'];
463
463
  let entry = sid ? sessions.get(sid) : null;
464
+ // A CONNECT IS COMPLETE WHEN THE NEW TOKEN IS FIRST USED, WHATEVER THE METHOD (2026-09-29). This used to be reported on
465
+ // a session's first tools/list, and ChatGPT never lists on a reconnect — it opens each turn as initialize →
466
+ // initialized → GET → tools/call against the roster it already holds — so every ChatGPT sign-in since 09-25 read as
467
+ // "no completed connect" and paged. Reported once per session (and on each session-less request, which is rare); the
468
+ // watch matches it against the tokens it saw minted (lib/mcp-connect-watch.mjs) and ignores any other key.
469
+ if (!entry || !entry.connectSeen) { if (entry) entry.connectSeen = true; connectEvent(req, { step: 'mcp-use', ok: true, tokenHash: createHash('sha256').update(token).digest('hex') }); }
464
470
 
465
471
  if (!entry) {
466
472
  const init = req.method === 'POST' && hasInitialize(req.body);
@@ -516,7 +522,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
516
522
  // WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
517
523
  // request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
518
524
  // tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
519
- entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0 };
525
+ entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true };
520
526
  if (typeof onSessionStart === 'function') { try { onSessionStart({ accountId: user.accountId || null, userId: user.userId || null, client: entry.client, ua: entry.ua, src: entry.src }); } catch {} }
521
527
  // LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
522
528
  // ever read is a guess. One line per session (not per call) so a new host identifies itself once and
@@ -550,7 +556,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
550
556
  // Counted here, before the transport sees the body, so the tally is of what the CLIENT asked and not of what
551
557
  // the SDK answered — a refused tools/call is still a call the roster earned.
552
558
  for (const m of methodsOf(req.body)) {
553
- if (m === 'tools/list') { if (!entry.listed) connectEvent(req, { step: 'tools-list', ok: true, tokenHash: createHash('sha256').update(token).digest('hex'), client: entry.client || '' }); entry.listed = true; }
559
+ if (m === 'tools/list') entry.listed = true; // the completed connect is reported above, on the token's first use of any kind
554
560
  else if (m === 'tools/call') {
555
561
  entry.calls = (entry.calls || 0) + 1;
556
562
  // THE FIRST CALL IS THE MILESTONE, NOT THE LISTING (2026-09-07). The connect_mcp reward was granted on "an API
package/mcp/tools.mjs CHANGED
@@ -81,6 +81,8 @@ const creatorLine = (c) => {
81
81
  // THE ONE SENTENCE (2026-09-28, owner decision B): every tool that takes a real person's face or voice carries it,
82
82
  // byte-identical to LIKENESS_TERMS in lib/likeness.mjs (tools/face-rights-check.mjs pins the copy). The CLI twin cannot
83
83
  // import ../lib, so the words live here.
84
+ // "I OWN THE RIGHTS TO THIS FACE" (2026-09-29): the first choice a flagged face offers (server lib/likeness-refusal.mjs).
85
+ const FACE_ROUTE_DESC = "ONLY after a render came back saying the video model's safety check flagged a person's face: 'face_lane' is the user's choice \"I own the rights to this face\". Send the SAME request again with it and the same face is rendered on Seedance through the face library, at the normal price. Sending it is the user's confirmation that they have the rights to that face (paid plans, like every real face). Never set it on your own; the other choices that refusal names are a different model (`model`) or another creator (`creator`).";
84
86
  const LIKENESS_TERMS = 'Using a real person’s face, likeness or voice confirms you are them or have their consent, take full, unlimited responsibility for its use and will cover any claim against Hermoso (hermoso.ai/terms). Paid plan only; recorded.';
85
87
  function rowLines(rows, max = 40) {
86
88
  const all = Array.isArray(rows) ? rows : [];
@@ -16992,6 +16994,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16992
16994
  recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
16993
16995
  reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post, YouTube Short/video or a direct video file — which is WATCHED first (frames + voiceover/on-screen-text transcript) so the concept keeps its hook, structure and pacing. To remake one video for this brand at its own length, clone_video is the direct tool'),
16994
16996
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
16997
+ talent: z.enum(['auto', 'creator', 'product_only']).optional().describe("who is on camera; omit to follow the format. Say 'someone new' in product to skip reusing a saved creator."),
16995
16998
  draft: z.object({ model: z.string(), durationSeconds: z.number() }).optional().describe("ONLY after a video refusal that offered a light draft: the {model, durationSeconds} it named. The plan is then authored to that length and priced on that model. Never invent one — a video the account cannot cover is refused BEFORE planning with the three options (image / add credits / this draft when one fits), and the user chooses."),
16996
16999
  },
16997
17000
  outputSchema: {
@@ -17009,9 +17012,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
17009
17012
  imodel: z.string().optional().describe('the image model id to render with'),
17010
17013
  vmodel: z.string().optional().describe('the video model id to render with'),
17011
17014
  brand: z.any().optional().describe('the brand grounding embedded in the creative (name, logo, palette, productImages)'),
17015
+ presence_note: z.string().optional(),
17012
17016
  },
17013
17017
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17014
- }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft }) => {
17018
+ }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent }) => {
17015
17019
  // LENGTH SOVEREIGNTY over MCP (found live 2026-07-31: a 40-second brief came back as render_plan.duration_seconds
17016
17020
  // 15, structure single_clip, scenes summing to 15 — the 40 was silently dropped because this tool declared no
17017
17021
  // duration at all). /api/create has honored `durationSeconds` all along (it becomes the planner's "Target video
@@ -17031,7 +17035,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17031
17035
  try { const cur = await apiGet('/api/brand/current'); if (cur?.hasBrand && cur.brand && _n(cur.brand.name) && _n(cur.brand.name) === _n(brand)) brandObj = cur.brand; } catch {}
17032
17036
  }
17033
17037
  const _draft = (draft && typeof draft === 'object' && String(draft.model || '').trim()) ? { model: String(draft.model).trim(), durationSeconds: Math.round(+draft.durationSeconds || 0) } : null; // the accepted light draft rides as itself; its length is the plan's length
17034
- const d = await apiPost('/api/create', { brand: brandObj, product, format: _draft ? 'video' : format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '', ...(_draft ? { draft: _draft, durationSeconds: _draft.durationSeconds || _len || undefined } : (_len ? { durationSeconds: _len } : {})), hook: hook || '', setting: setting || '', userAsk: String(product || '') });
17038
+ const d = await apiPost('/api/create', { brand: brandObj, product, format: _draft ? 'video' : format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '', ...(_draft ? { draft: _draft, durationSeconds: _draft.durationSeconds || _len || undefined } : (_len ? { durationSeconds: _len } : {})), hook: hook || '', setting: setting || '', userAsk: String(product || ''), ...(talent && talent !== 'auto' ? { talent } : {}) });
17035
17039
  const c = d.creative || d;
17036
17040
  // EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
17037
17041
  // grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
@@ -17056,7 +17060,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17056
17060
  + (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
17057
17061
  + (_len && _planned && Math.abs(_planned - _len) > 1 ? ` — ⚠ this does NOT match the ${_len}s you asked for; tell the user before rendering, or re-plan` : '');
17058
17062
  }
17059
- const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${c.format_note ? '\n' + c.format_note : ''}${c.concept_count > 1 ? `\nYou asked for ${c.concept_count} concepts: the concept line names each; this plan builds out the first (variants = ${c.variants || c.concept_count}). Call plan_ad again with a different angle for the others, or render_ad with variants for takes of this one.` : ''}${refWatchedLine(c.reference_watched)}${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
17063
+ const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"${c.format_note ? '\n' + c.format_note : ''}${c.presence_note ? '\n' + c.presence_note : ''}${c.concept_count > 1 ? `\nYou asked for ${c.concept_count} concepts: the concept line names each; this plan builds out the first (variants = ${c.variants || c.concept_count}). Call plan_ad again with a different angle for the others, or render_ad with variants for takes of this one.` : ''}${refWatchedLine(c.reference_watched)}${_lenLine}${_hookLine}\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a storyboard that fits ONE clip of the render model renders as a single continuous pass, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
17060
17064
  return ok(text, c);
17061
17065
  }));
17062
17066
 
@@ -17374,17 +17378,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
17374
17378
  server.group('create');
17375
17379
  server.registerTool('render_ad', {
17376
17380
  title: 'Render ad video',
17377
- 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), a music bed under the voice, 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.',
17381
+ 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), no music unless asked, real product references. UGC and cinematic plans are rendered from a painted STORYBOARD board (cleaned, then bound first, ahead of the creator and the product); a cinematic spot paints a location anchor first (and a hero when someone is on camera). A product-only commercial renders from the real product photo by default; board:true or foundations:\'choose\' adds a product identity sheet, a four-up moodboard and a board. 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.',
17378
17382
  inputSchema: {
17379
17383
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
17380
- creator: z.string().optional().describe('CAST A SAVED CREATOR in this ad — their id from list_creators, or the name you know them by (“Sarah”). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
17384
+ 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”), or a PRESET AI creator from list_creators presets by exact name or id (free, no generation). Their saved portrait becomes the on-camera identity for the whole spot, so the same face carries across every act and across every ad you render for this brand — and because we already have their picture, the character portrait this pipeline would otherwise generate is skipped, so casting somebody costs LESS than not casting them. Omit to let the ad cast a fresh person — EXCEPT for a CREATOR account (onboarded from their own @handle): their own saved likeness is cast by default when the plan has a person on camera and the account is on a paid Hermoso plan (a free account gets a fresh AI person), and the read-back says `default:true`; pass "none" to render without them. Refused for free, with nothing rendered, if the name matches nobody or more than one creator, if an explicitly named creator is cast on a plan with nobody on camera, or if a REAL person is cast on a free plan. ' + LIKENESS_TERMS),
17385
+ faceRoute: z.enum(['face_lane']).optional().describe(FACE_ROUTE_DESC),
17381
17386
  model: z.string().optional().describe('video model id from hermoso_capabilities (default: the plan’s pick). Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback)'),
17382
17387
  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.'),
17383
17388
  aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
17384
17389
  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."),
17385
17390
  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'),
17386
17391
  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)'),
17387
- music: z.union([z.boolean(), z.string()]).optional().describe("music bed: on (default), false, or the bed described in words ('lo-fi jazz, brushed drums')"),
17392
+ music: z.union([z.boolean(), z.string()]).optional().describe("music bed: OFF unless asked. true = the plan's own music line; or the bed in words ('lo-fi jazz, brushed drums'); false = none"),
17393
+ board: z.boolean().optional().describe('paint the storyboard board first (default: on for UGC and cinematic spots, off for a product-only commercial); false = render from the text shot list'),
17394
+ foundations: z.enum(['choose']).optional().describe("product commercial / cinematic: 'choose' paints ONLY the foundations (identity sheet + four-up moodboard, or hero + location) and returns them, no video — then call again with moodboardPick + foundationImages"),
17395
+ moodboardPick: z.number().int().min(1).max(4).optional().describe('which moodboard panel (1-4, left to right, top to bottom) the storyboard follows; default 1'),
17396
+ foundationImages: z.object({ identitySheet: z.string().optional(), moodboard: z.string().optional(), hero: z.string().optional(), location: z.string().optional() }).optional().describe("images a foundations:'choose' call returned, reused as-is (not repainted)"),
17388
17397
  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'),
17389
17398
  textStyle: TEXT_STYLE_Z.optional().describe('THE LOOK of captions and the end card, only with captions or endCard and only when the user described one: the look in WORDS ("chunky yellow comic letters, purple outline"), a preset (editorial: big serif title + small italic line; bold: condensed caps, outline; minimal; handwritten; boxed; pill, the default), or fields. "TITLE · small line" puts the part after the dot on a second line.'),
17390
17399
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
@@ -17436,8 +17445,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
17436
17445
  if (needsProductPhoto && !a.allowGenericProduct) {
17437
17446
  return ok(`NOTHING WAS RENDERED and nothing was charged.${notes || ''}\n\nDo this now: tell the user in ONE short line that you have no photo of their product and the packaging would be invented, then either (a) lock a real photo with set_product_image and call render_ad again, or (b) if they say a generic stand-in is fine, call render_ad again with allowGenericProduct:true. Do NOT describe or claim any render — none happened.`, { needsProductPhoto: true, jobType: jobType || 'video' });
17438
17447
  }
17439
- const r = await renderJob(jobType === 'stitch' ? 'stitch' : 'video', input, 'MCP ad render');
17440
- return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_clampNote}${switchNote(r)}${_castLine}\n${notes || ''}`, { ...r, ...(creator ? { creator } : {}) });
17448
+ const r = await renderJob(jobType === 'stitch' ? 'stitch' : 'video', a.faceRoute === 'face_lane' ? { ...input, faceRoute: 'face_lane' } : input, 'MCP ad render');
17449
+ // FOUNDATIONS ONLY (foundations:'choose'): no video was rendered — hand back the pictures to pick from.
17450
+ const _fd = renderPayload(r);
17451
+ if (_fd && _fd.foundationsOnly) { const F = _fd.foundations || {}; return ok(`Foundations painted (${_fd.boardCreditsUsed || 0} credits, billed as images) [job ${r.jobId}]:\n${Object.entries(F).filter(([, v]) => typeof v === 'string').map(([k, v]) => `• ${k}: ${abs(v)}`).join('\n')}\n${_fd.note || ''}`, { ...r, foundations: F }); }
17452
+ return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_clampNote}${switchNote(r)}${_castLine}${Array.isArray(_fd?.boards) && _fd.boards.length ? `\nStoryboard: ${_fd.boards.map((u) => abs(u)).join(' , ')}${_fd.boardCreditsUsed ? ` (${_fd.boardCreditsUsed} credits of image paints)` : ''}` : ''}\n${notes || ''}`, { ...r, ...(creator ? { creator } : {}) });
17441
17453
  }));
17442
17454
 
17443
17455
 
@@ -17749,9 +17761,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
17749
17761
 
17750
17762
  server.registerTool('make_explainer', {
17751
17763
  title: 'Make an explainer video',
17752
- 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, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE — a slow move on a still shimmers), 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.",
17753
- inputSchema: {
17754
- topic: z.string().describe('what the explainer should teach or explain — a topic or a short brief'),
17764
+ description: "AI HOST EPISODE: format 'host_episode' = one presenter talking to camera, the camera changing every piece (frontal, three-quarter, close), the same host and set throughout, native voice, 16:9. Host = `creator` (saved or preset) or `hostImage`; words = `topic` (written for you) or `script` (verbatim). It returns a 480p DRAFT; HD (720p) is a SEPARATE call with `fromDraft` (quote it with dryRun, run it only when the user asks). Otherwise: turn a TOPIC into a finished narrated explainer video, in one of TWO LANES (`lane`). 'blocks' (the default) = 10-second VIDEO blocks, one narrated line per block, hard cuts, a music bed under the voice: an EXPLAINER renders on Gemini Omni at 720p (16:9 by default, or 9:16), a FACELESS CHANNEL video (`format:'faceless_channel'`, or channel history / kids / fairytale) on MiniMax H3 at 2K (16:9 by default) with five cuts per block. 'stills' = a picture film: 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, holds each picture PERFECTLY STILL for its own slice of the narration (the motion is the CUT RATE — a slow move on a still shimmers), 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). The stills lane 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.",
17765
+ inputSchema: {
17766
+ topic: z.string().optional().describe('what the explainer should teach or explain — a topic or a short brief (host_episode: or pass `script`)'),
17767
+ lane: z.enum(['blocks', 'stills']).optional().describe("'blocks' (default) = 10-second video blocks, real motion, one narrated line per block; 'stills' = the picture film (a still about every 1.5s, narrated, no video model; cheaper). Quote either with dryRun."),
17768
+ format: z.enum(['explainer', 'faceless_channel', 'host_episode']).optional().describe("blocks lane: 'explainer' (default; Gemini Omni 720p, 16:9 or 9:16, runs exactly the length asked) or 'faceless_channel' (a YouTube/TikTok faceless channel video; MiniMax H3 at 2K, 16:9 by default, five hard cuts per 10s block, a whole number of blocks). Omit and a history / kids / fairytale channel is a faceless channel video. 'host_episode' = the AI host episode (see the top)."),
17769
+ creator: z.string().optional().describe('host_episode: the host, a saved creator or a preset by name or id (list_creators)'),
17770
+ hostImage: z.string().optional().describe("host_episode: a photo URL of the host instead (upload_file for a local file); a real person's face needs a paid plan"),
17771
+ script: z.string().optional().describe('host_episode: the exact words, said verbatim and split at natural breaks into 4-30s pieces'),
17772
+ fromDraft: z.string().optional().describe("host_episode: a finished draft's job id, to render it in HD (720p) with the same script, cameras, set and host"),
17773
+ setting: z.string().optional().describe('host_episode: the set in words (default: written to fit the topic)'),
17774
+ cameras: z.array(z.string()).optional().describe("host_episode: the rotation, ids frontal / three_quarter / close or framings in words; one entry = one fixed camera"),
17775
+ thumbnail: z.boolean().optional().describe('host_episode: one thumbnail of the host (default true)'),
17755
17776
  durationSeconds: z.number().optional().describe('target length 20-120s (default 60); drives the section count — ~10s of narration each, 3-8 sections'),
17756
17777
  frameDensity: z.enum(['standard', 'lean', 'minimal']).optional().describe("how many pictures per second of narration, and therefore what it costs. 'standard' (default) is a frame about every 1.5s — the density a stills film needs to read as a film rather than a slideshow; 'lean' is one about every 2.5s (the longest hold that still reads as a film, ~40% of the frames and ~40% of the cost); 'minimal' is ONE picture per narration section, which is cheapest and is frankly a slideshow. Only drop below the default if the user asked for something cheaper."),
17757
17778
  aspectRatio: z.enum(['9:16', '16:9', '1:1', '4:5', '3:4']).optional().describe("'9:16' default"),
@@ -17769,8 +17790,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
17769
17790
  outputSchema: { ...JOB_OUT },
17770
17791
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17771
17792
  }, wrap(async (a) => {
17772
- if (a.dryRun) { const q = await quoteJob('explainer', { topic: a.topic, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, style: a.style, channel: a.channel, frameDensity: a.frameDensity, music: a.music, upscale: a.upscale }); return ok(quoteText(q, `This ${Math.min(120, Math.max(20, Math.round(+a.durationSeconds || 60)))}s narrated explainer${a.frameDensity ? ` at '${a.frameDensity}' frame density` : ''}`), { raw: q }); }
17773
- const r = await renderJob('explainer', { topic: a.topic, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, style: a.style, channel: a.channel, frameDensity: a.frameDensity, subtitles: a.subtitles, voice: a.voice, captions: a.captions, music: a.music, upscale: a.upscale, endCard: a.endCard, brandName: a.brandName }, 'MCP explainer');
17793
+ // THE AI HOST EPISODE: the same job type, its own fields, its own read-back (the host and draft are resolved server-side).
17794
+ const _host = a.format === 'host_episode' || !!a.fromDraft;
17795
+ const _hostIn = { format: 'host_episode', topic: a.topic, script: a.script, creator: a.creator, hostImage: a.hostImage, fromDraft: a.fromDraft, setting: a.setting, cameras: a.cameras, thumbnail: a.thumbnail, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, music: a.music, captions: a.captions };
17796
+ if (_host && a.dryRun) { const q = await quoteJob('explainer', _hostIn); return ok(quoteText(q, a.fromDraft ? 'The HD (720p) render of that AI host episode draft' : `This ${Math.round(+a.durationSeconds || 45)}s AI host episode draft (480p, plus one thumbnail)`), { raw: q }); }
17797
+ if (_host) {
17798
+ if (!a.fromDraft && !a.topic && !a.script) throw new Error('Give the episode a `topic` (it is scripted for you) or the `script` the host should say.');
17799
+ const r = await renderJob('explainer', _hostIn, 'MCP host episode');
17800
+ const d = r?.raw || {};
17801
+ const cams = (d.pieces || []).map((p) => String(p.camera).replace(/_/g, '-')).join(' → ');
17802
+ return okVideo(`AI host episode ${d.draft ? 'DRAFT (480p)' : 'HD (720p)'} ready — ${d.pieces?.length || '?'} pieces (${cams}), ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}, ${d.aspectRatio}${d.loudness?.measuredLufs != null ? `, ${d.loudness.measuredLufs} LUFS` : ''}${d.music ? ', music bed' : ''}: ${r.url} [job ${r.jobId}]${d.thumbnail ? `\nThumbnail: ${d.thumbnail}` : ''}${d.hd ? `\nHD: ${d.hd.how}` : ''}${(d.divergences || []).length ? `\n${d.divergences.join('\n')}` : ''}`, r);
17803
+ }
17804
+ if (!a.topic) throw new Error('Tell me what the explainer should be about (`topic`).');
17805
+ if (a.dryRun) { const q = await quoteJob('explainer', { topic: a.topic, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, style: a.style, channel: a.channel, lane: a.lane, format: a.format, frameDensity: a.frameDensity, music: a.music, upscale: a.upscale }); const _kind = /video blocks/i.test(q?.label || '') ? (/faceless/i.test(q.label) ? 'faceless channel video in 10-second video blocks (MiniMax H3, 2K)' : 'explainer in 10-second video blocks (Gemini Omni, 720p)') : `narrated explainer as a picture film${a.frameDensity ? ` at '${a.frameDensity}' frame density` : ''}`; return ok(quoteText(q, `This ${Math.min(120, Math.max(20, Math.round(+a.durationSeconds || 60)))}s ${_kind}`), { raw: q }); }
17806
+ const r = await renderJob('explainer', { topic: a.topic, durationSeconds: a.durationSeconds, aspectRatio: a.aspectRatio, style: a.style, channel: a.channel, lane: a.lane, format: a.format, frameDensity: a.frameDensity, subtitles: a.subtitles, voice: a.voice, captions: a.captions, music: a.music, upscale: a.upscale, endCard: a.endCard, brandName: a.brandName }, 'MCP explainer');
17774
17807
  const d = r?.raw || {};
17775
17808
  // MUSIC + UPSCALE are REPORTED off the result, never assumed from the ask: a composed bed that failed to mux
17776
17809
  // ships the film dry and is not billed, and a failed upscale delivers at the render resolution. `musicNote`
@@ -17778,6 +17811,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17778
17811
  const fin = `${d.music ? ` · ${d.music.source === 'composed' ? 'composed' : 'library'} ${d.music.mood} music bed` : ''}${d.upscaled ? ` · upscaled ${d.upscaled}×` : ''}`;
17779
17812
  // The length is whatever ffprobe measured on the DELIVERED file, and `null` means it could not be measured — print
17780
17813
  // the `—` placeholder rather than inventing a number from the ask (the read-back law: only a measurement ships).
17814
+ if (d.lane === 'blocks') return okVideo(`Explainer ready — ${d.blocks} video blocks of ${d.blockSeconds}s on ${d.videoModel === 'omni' ? 'Gemini Omni 720p' : 'MiniMax H3 2K'}${d.cutsPerBlock ? `, ${d.cutsPerBlock} cuts each` : ''}, ${d.durationSeconds == null ? '—' : d.durationSeconds + 's'}${fin}: ${r.url} [job ${r.jobId}]${(d.divergences || []).length ? `\n${d.divergences.join('\n')}` : ''}`, r);
17781
17815
  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);
17782
17816
  }));
17783
17817
 
@@ -17816,7 +17850,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
17816
17850
  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.'),
17817
17851
  refImage: z.string().optional().describe('local path or URL to anchor the first frame. ' + LIKENESS_TERMS),
17818
17852
  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'),
17819
- creator: z.string().optional().describe('STAR A SAVED CREATOR in this clip — their id from list_creators, or the name you know them by. 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.'),
17853
+ 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.'),
17854
+ angles: z.boolean().optional().describe('OPTIONAL, OFF BY DEFAULT: with `creator`, also send the extra views that creator already has saved (their pose plates, up to 2) beside their portrait. A test showed no visible improvement over the portrait alone, and each extra view adds about 25 s before the render starts, so leave it off unless asked. Views are skipped when the face library is busy, and the render always goes ahead on the portrait.'),
17820
17855
  refImages: z.array(z.string()).optional().describe('SEVERAL reference images (local paths or URLs) — a person, products, a place — that must all appear in the clip. Only models whose `refs.max` in hermoso_capabilities is above 1 use more than one, and each uses at most that many; with `refs.promptAddressed` true, name them in your prompt as Image 1, Image 2… in this order. minimax-h3-max-ref takes up to 9 and keeps each one as a reference rather than a first frame. On a model that takes one image, only the first is used.'),
17821
17856
  refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the clip is transformed per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and, on every model hermoso_capabilities marks `sourceLength`, its LENGTH too: those endpoints have no duration parameter, their listed `durations` are the per-second price ladder, and a durationSeconds you send is reported back as unused rather than silently dropped. Trim the source to change the length. Omit `model` for the default editor, or name a model whose videoEdit is true in hermoso_capabilities (a named model that cannot edit is refused, nothing charged); refImage rides along as the look of what the edit adds. With extend:true this is instead the clip to EXTEND. Omit to generate a fresh clip."),
17822
17857
  endImage: z.string().optional().describe('local path or URL of the LAST frame: the clip travels from refImage (required with it) to this image. Only models with endFrame true in hermoso_capabilities take it; any other named model is refused by name, nothing charged.'),
@@ -17827,6 +17862,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17827
17862
  durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds — pick one of the CHOSEN model’s listed durations from hermoso_capabilities (never a generic guess: the lists differ per model, from 4–8s on the short models up to 30s on the longest-clip one). This is a single continuous generation, so it CANNOT exceed that model’s longest clip: a longer ask is REFUSED with nothing rendered and nothing charged (it is never quietly truncated). Past ~15s only the long-clip models qualify, and an unnamed render is routed by the narrower auto-pool — so NAME the model in `model` when you are asking for a long single take. For a spot longer than any one clip, use plan_ad with durationSeconds then render_ad, which stitches acts of at most one model clip each (on a 15s-clip model, 40s = 15+15+10).'),
17828
17863
  aspectRatio: z.string().optional().describe("default '9:16'"),
17829
17864
  model: z.string().optional().describe('video model id from hermoso_capabilities; a named model is never swapped without asking. Omit to let the router pick'),
17865
+ faceRoute: z.enum(['face_lane']).optional().describe(FACE_ROUTE_DESC),
17830
17866
  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. 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."),
17831
17867
  cameraMove: z.enum(['orbit', 'orbit_left', 'orbit_half', 'orbit_full', 'rise', 'crane_up', 'push_in', 'pull_back', 'reveal']).optional().describe('A named camera move around the still in refImage, spelled exactly as the enum gives it: an orbit (a quarter turn, the default), orbiting left, a half turn or a full turntable, a rise, a crane up, a push in, a pull back, or a reveal. Only the camera-controls model renders one (minimax-h3-max-camera, listed with its moves in hermoso_capabilities): pass it with model omitted and that model is picked, or with that model named; any other named model is refused by name with nothing charged. Needs refImage.'),
17832
17868
  cameraTrajectory: z.array(z.object({
@@ -18426,14 +18462,18 @@ function memoryNoteVerdict(text) {
18426
18462
  server.registerTool('list_creators', {
18427
18463
  title: 'List saved creators',
18428
18464
  description: 'List this workspace’s SAVED CREATORS — the reusable on-camera cast (AI creators made here, a person pulled from a social profile, a consented photo upload). Read-only, FREE. Each entry gives the name, the PORTRAIT URL, where the portrait came from and whether a real person’s likeness consent is on file, how many extra pose plates exist, and any chosen or cloned voice. TO PUT ONE IN A FINISHED AD, pass their id or name as render_ad’s `creator` — that casts them for the whole spot (and skips the character-portrait render, so it costs less than not casting anyone). THE PORTRAIT URL IS THE REUSE HANDLE for the raw lanes — pass it as generate_avatar’s `image` (a talking clip of them), generate_video’s `refImage` (they star in the scene), recast_motion’s `image` (they perform a reference clip’s motion), or generate_image’s `refImages`. CALL THIS BEFORE OFFERING TO GENERATE A NEW PERSON: re-casting somebody the workspace already has keeps the SAME face across every ad, while a fresh person costs credits and breaks that continuity. An empty answer means the workspace genuinely has no cast yet — say so and offer generate_avatar / save_creator, never invent a roster.',
18429
- inputSchema: { limit: z.number().optional().describe('max creators to return (default 24)') },
18430
- outputSchema: { creators: z.array(z.any()).optional().describe('the saved cast — {id, name, image, source, consented, poses, voice, voiceClone, look}'), count: z.number().optional() },
18465
+ inputSchema: { limit: z.number().optional().describe('max creators to return (default 24)'), gender: z.enum(['female', 'male']).optional().describe('filter the PRESET creators by gender (the saved cast is never filtered)') },
18466
+ outputSchema: { creators: z.array(z.any()).optional().describe('the saved cast — {id, name, image, source, consented, poses, voice, voiceClone, look}'), count: z.number().optional(), presets: z.array(z.any()).optional().describe('the PRESET AI creators anyone can cast free with no generation — {id, name, gender, age, image, look, refs}') },
18431
18467
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
18432
18468
  }, wrap(async (a) => {
18433
- const d = await apiGet('/api/creators', a.limit ? { limit: Math.max(1, Math.min(200, Math.round(+a.limit) || 24)) } : {});
18469
+ const d = await apiGet('/api/creators', { ...(a.limit ? { limit: Math.max(1, Math.min(200, Math.round(+a.limit) || 24)) } : {}), ...(a.gender ? { gender: a.gender } : {}) });
18434
18470
  const creators = (d.creators || []).map(c => ({ ...c, image: abs(c.image) })); // portraits ride abs() like every other asset url here
18435
- if (!creators.length) return ok('This workspace has no saved creators yet — there is nobody to re-cast, so do not offer one. generate_video with `speak` films a person from a portrait you supply saying your exact words, and save_creator adds a portrait to the reusable cast so the same person can star in later ads.', { creators: [], count: 0 });
18436
- return ok(`${creators.length} saved creator(s) in this workspace:\n${creators.map(creatorLine).join('\n')}\nCast one into a finished ad with render_ad(creator: "<id or name>"); for them to say a line to camera use generate_video(creator: "<id or name>", speak: "…"); for the other raw lanes, pass their portrait url as generate_video.refImage / recast_motion.image / generate_image.refImages (or generate_avatar.image, only when the user asks for the animated-photo look by name) — either way that is what keeps the same face across ads.`, { creators, count: creators.length });
18471
+ // PRESET AI CREATORS (2026-09-29): a curated library of photoreal AI people, cast FREE with no generation and no consent
18472
+ // (render_ad / generate_video `creator`: their exact name or id). Listed beside the saved cast, never mixed into it.
18473
+ const presets = (d.presets || []).map(c => ({ id: c.id, name: c.name, gender: c.gender, age: c.age, image: abs(c.image), look: c.look, refs: (c.refs || []).map(abs) }));
18474
+ const presetText = presets.length ? `\nPRESET AI creators (free to cast, no generation — pass the name or id as render_ad / generate_video \`creator\`):\n${presets.map(c => ` • ${c.name} — ${c.gender}, ${c.age} [${c.id}] ${c.image}`).join('\n')}` : '';
18475
+ if (!creators.length) return ok(`This workspace has no saved creators yet.${presets.length ? '' : ' There is nobody to re-cast, so do not offer one.'} generate_video with \`speak\` films a person from a portrait you supply saying your exact words, and save_creator adds a portrait to the reusable cast so the same person can star in later ads.${presetText}`, { creators: [], count: 0, presets });
18476
+ return ok(`${creators.length} saved creator(s) in this workspace:\n${creators.map(creatorLine).join('\n')}\nCast one into a finished ad with render_ad(creator: "<id or name>"); for them to say a line to camera use generate_video(creator: "<id or name>", speak: "…"); a PRESET creator (below) is cast the same way; for the other raw lanes, pass their portrait url as generate_video.refImage / recast_motion.image / generate_image.refImages (or generate_avatar.image, only when the user asks for the animated-photo look by name) — either way that is what keeps the same face across ads.${presetText}`, { creators, count: creators.length, presets });
18437
18477
  }));
18438
18478
  server.registerTool('save_creator', {
18439
18479
  title: 'Save a creator',
@@ -18450,7 +18490,12 @@ function memoryNoteVerdict(text) {
18450
18490
  outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional(), refQuality: z.any().optional() },
18451
18491
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
18452
18492
  }, wrap(async (a) => {
18453
- const name = String(a.name || '').trim(), image = String(a.image || '').trim();
18493
+ const name = String(a.name || '').trim();
18494
+ // A LINK TO A PHOTO'S WEB PAGE (an Unsplash / Pexels / Pinterest page, 2026-09-29) is saved as the PICTURE behind it,
18495
+ // so the cast never holds a page no render can read; a page with no reachable picture is refused here, saving nothing.
18496
+ const resolveLink = async (u) => (/^https?:\/\//i.test(u) ? ((await apiPost('/api/ref/resolve', { url: u }))?.url || u) : u);
18497
+ const image = await resolveLink(String(a.image || '').trim());
18498
+ if (Array.isArray(a.poses)) a.poses = await Promise.all(a.poses.filter((p) => typeof p === 'string' && p.trim()).slice(0, 4).map((p) => resolveLink(p.trim())));
18454
18499
  // A PHOTO FLAGGED TOO UNCLEAR TO CAST (lowQualityRef, 2026-09-22) is REPLACED in place by a new portrait under the
18455
18500
  // same name — scored by the same rule the onboarding used — or accepted as it is with useAnyway. A twin row would
18456
18501
  // make the creator's own cast ambiguous (two rows named as the brand → nobody is cast).
@@ -20193,11 +20238,12 @@ function memoryNoteVerdict(text) {
20193
20238
  // the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
20194
20239
  server.registerTool('recast_hook', {
20195
20240
  title: 'Recast a viral hook with your subject',
20196
- description: "RECAST A VIRAL HOOK so the brand's own product or saved creator is its subject (the egg that rolls off the table becomes your product rolling off the same table; the viral move is done by your host), then join it into your clip so the cut links by identity. hook: a public TikTok / Reel / Facebook / X / YouTube post link or a video file (a post link uses its one lookup). Subject: creator (a saved creator's name or id), image (a picture of a product or a person; a real person's photo confirms you have their consent, and it is logged), or neither for the brand's first product photo. mode 'auto' (default): 'swap' replaces the hook's subject inside its own scene with a region edit (camera, set, light, timing, on-screen text and sound kept), 'motion' makes your person perform the hook's moves (motion transfer; needs a person in the hook). A face in the hook or as the subject routes the swap to an editor that takes faces, found by a free local face check. swap: your own words for what changes ('the egg becomes our serum bottle'; look with video_frames first). start/end pick the window (3 s minimum; default the first 10 s). PAID and QUOTED FIRST: without confirm it returns the plan and the credits and renders nothing; call again with confirm:true once the user okays it. Returns one job: get_job until done, then post_edit with videoUrl = the recast hook and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
20241
+ description: "RECAST A VIRAL HOOK so the brand's own product or saved creator is its subject (the egg rolling off the table becomes your product; your host does the viral move), then join it into your clip so the cut links by identity. hook: a public post link (TikTok, Reel, Facebook, X, YouTube; one lookup) or a video file. Subject: creator (a saved creator's name or id), image (a product or a person; a real person's photo confirms their consent, logged), or neither for the brand's first product photo. A DUO OR GROUP (2-4 people at once, e.g. both performers of a duet): subjects [{creator or image, position: 'left'|'right'|'center' or your own words, refs?}], one per person; each is bound to its place in the frame and keeps that performer's movement and timing on the original sound; every real face is checked like a single one. FULL LENGTH IN ONE CALL: past the editor's one pass (15 s with a face) the window is recast in passes with the same references and instruction, cut at the stillest moment, hard-cut joined under the original sound: one job, one video, up to 60 s, quoted as passes plus join; the reply names the cut to watch (no seed: a face can shift slightly there). mode 'auto' (default): 'swap' replaces the hook's subject inside its own scene with a region edit (camera, set, light, timing, on-screen text and sound kept), 'motion' makes your person perform the hook's moves (motion transfer; needs a person in the hook). A face on either side routes to an editor that takes faces (free local check). swap: your own words for what changes ('the egg becomes our serum bottle'; look with video_frames first). start/end pick the window (3 s minimum; default: a duo or group takes the whole clip, one subject the first 10 s). PAID, QUOTED FIRST: without confirm it returns the plan and credits and renders nothing; call again with confirm:true once the user okays it. Returns one job: get_job until done, then post_edit with videoUrl = the recast hook and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
20197
20242
  inputSchema: {
20198
20243
  hook: z.string().describe('the viral hook: a post link or a video URL'),
20199
20244
  creator: z.string().optional().describe("a saved creator's name or id (list_creators)"),
20200
20245
  image: z.string().optional().describe('a picture of the subject instead: a product or a person'),
20246
+ subjects: z.array(z.object({ creator: z.string().optional().describe("a saved creator's name or id"), image: z.string().optional().describe('or a picture of this person'), position: z.string().optional().describe("where they are in the hook: 'left', 'right', 'center', or your own words ('who raps first'); all or none"), refs: z.array(z.string()).optional().describe("up to 3 more views of the SAME person (a saved creator's pose plates ride automatically)"), name: z.string().optional() }).passthrough()).max(4).optional().describe('a duo or group recast, 2-4 people, instead of creator/image'),
20201
20247
  swap: z.string().optional().describe('what changes, in your own words'),
20202
20248
  mode: z.enum(['auto', 'swap', 'motion']).optional(),
20203
20249
  start: z.number().optional(), end: z.number().optional(),
@@ -20211,12 +20257,13 @@ function memoryNoteVerdict(text) {
20211
20257
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
20212
20258
  }, wrap(async (a) => {
20213
20259
  let b = {};
20214
- if (a.useBrand !== false && !a.creator && !a.image) { try { b = (await readStore('heist.brand.v1')) || {}; } catch { b = {}; } }
20260
+ const subs = Array.isArray(a.subjects) ? a.subjects.filter(Boolean) : [];
20261
+ if (a.useBrand !== false && !a.creator && !a.image && !subs.length) { try { b = (await readStore('heist.brand.v1')) || {}; } catch { b = {}; } }
20215
20262
  // a person's photo as the subject is recorded as implied likeness consent SERVER-side, only when the free face check finds a face there (a product photo is not a likeness)
20216
- const body = { hook: a.hook, ...(a.creator ? { creator: a.creator } : {}), ...(a.image ? { image: a.image } : {}), ...(a.swap ? { swap: a.swap } : {}), ...(a.mode && a.mode !== 'auto' ? { mode: a.mode } : {}), ...(a.start != null ? { start: a.start } : {}), ...(a.end != null ? { end: a.end } : {}), ...(a.prompt ? { prompt: a.prompt } : {}), ...(a.model ? { model: a.model } : {}), ...(a.tier ? { tier: a.tier } : {}), ...(b && Array.isArray(b.productImages) && b.productImages.length ? { brand: { name: b.name || '', product: b.sells || b.name || '', productImages: b.productImages.slice(0, 1) } } : {}), ...(a.confirm === true ? { confirm: true } : {}) };
20263
+ const body = { hook: a.hook, ...(a.creator ? { creator: a.creator } : {}), ...(a.image ? { image: a.image } : {}), ...(subs.length ? { subjects: subs } : {}), ...(a.swap ? { swap: a.swap } : {}), ...(a.mode && a.mode !== 'auto' ? { mode: a.mode } : {}), ...(a.start != null ? { start: a.start } : {}), ...(a.end != null ? { end: a.end } : {}), ...(a.prompt ? { prompt: a.prompt } : {}), ...(a.model ? { model: a.model } : {}), ...(a.tier ? { tier: a.tier } : {}), ...(b && Array.isArray(b.productImages) && b.productImages.length ? { brand: { name: b.name || '', product: b.sells || b.name || '', productImages: b.productImages.slice(0, 1) } } : {}), ...(a.confirm === true ? { confirm: true } : {}) };
20217
20264
  const r = await apiPost('/api/hooks/recast', body);
20218
20265
  const p = r?.data || r;
20219
- const head = `${p.mode === 'motion' ? 'Motion transfer' : 'Swap'} on ${p.engine}: ${p.hook?.window?.seconds}s of the hook (${p.hook?.window?.start}-${p.hook?.window?.end}s of ${p.hook?.durationSeconds}s). Subject: ${p.subject?.kind}${p.subject?.name ? ` "${p.subject.name}"` : ''}. Face in the hook: ${p.hook?.face}; in the subject: ${p.subject?.face}. Why: ${p.why}.${p.instruction ? `\nInstruction: ${p.instruction}` : ''}${p.consent ? `\n${p.consent}` : ''}`;
20266
+ const head = `${p.mode === 'motion' ? 'Motion transfer' : 'Swap'} on ${p.engine}: ${p.hook?.window?.seconds}s of the hook (${p.hook?.window?.start}-${p.hook?.window?.end}s of ${p.hook?.durationSeconds}s). ${Array.isArray(p.subjects) ? `Subjects: ${p.subjects.map((x) => `${x.element} ${x.position}${x.name ? ` "${x.name}"` : ''} (${x.kind}${x.refs ? `, ${x.refs} more view${x.refs === 1 ? '' : 's'}` : ''})`).join('; ')}` : `Subject: ${p.subject?.kind}${p.subject?.name ? ` "${p.subject.name}"` : ''}`}. Face in the hook: ${p.hook?.face}; in the subject: ${p.subject?.face}. Why: ${p.why}.${Array.isArray(p.passes) ? `\nPasses: ${p.passes.map((w) => `${w.start}-${w.end}s${w.credits != null ? ` (${w.credits} credits)` : ''}`).join(', ')}; join${p.join?.credits != null ? ` (${p.join.credits} credits)` : ''}: ${p.join?.how || 'a hard cut'}.` : ''}${p.windowNote ? `\n${p.windowNote}` : ''}${p.instruction ? `\nInstruction: ${p.instruction}` : ''}${p.consent ? `\n${p.consent}` : ''}`;
20220
20267
  if (!p.queued) return { content: [{ type: 'text', text: `QUOTE (nothing rendered, nothing charged): about ${p.credits} credits.\n${head}\nTell the user the price; after they okay it, call recast_hook again with the same inputs and confirm:true.` }], structuredContent: { plan: p, credits: p.credits, queued: false } };
20221
20268
  return { content: [{ type: 'text', text: `Recast queued as job ${p.job?.id} (about ${p.credits} credits).\n${head}\nCall get_job until it reports done; it has no file until then. ${p.next || ''}` }], structuredContent: { plan: p, job: p.job, credits: p.credits, queued: true } };
20222
20269
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.345",
3
+ "version": "0.1.355",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 864 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",