hermoso 0.1.420 → 0.1.422

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/client.mjs CHANGED
@@ -500,6 +500,22 @@ export async function submitJob(type, input, { label = '' } = {}) {
500
500
  return apiPost('/api/jobs', { type, input, label }); // → publicJob {id, status, ...}
501
501
  }
502
502
  export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
503
+ // ONE READ THAT A ROLLOVER CANNOT TURN INTO "IT DOES NOT EXIST" (2026-10-19). get_job is a single read, and its 404
504
+ // answer tells the caller to STOP POLLING — so a 404 from a revision that has not yet seen a job (see pollMissVerdict)
505
+ // ended the follow-up for good. A 404 is re-read for a short window before it is reported as absent; any other answer
506
+ // returns at once, and an id that really does not exist costs only that window. null = absent after the window.
507
+ export const JOB_READ_GRACE_MS = 12_000;
508
+ export async function getJobSettled(id, { graceMs = JOB_READ_GRACE_MS, intervalMs = 3000, getJobFn = getJob } = {}) {
509
+ const until = Date.now() + Math.max(0, graceMs);
510
+ for (;;) {
511
+ try { return await getJobFn(id); }
512
+ catch (e) {
513
+ if (Number(e?.status) !== 404) throw e;
514
+ if (Date.now() + intervalMs > until) return null;
515
+ await new Promise((r) => setTimeout(r, intervalMs));
516
+ }
517
+ }
518
+ }
503
519
  export function jobResult(job) { const r = job?.result; return r && Object.prototype.hasOwnProperty.call(r, 'data') ? r.data : r; }
504
520
 
505
521
  // HOW LONG A RENDER TOOL HOLDS ITS CALLER, in ms. `cap` is the transport's own maximum (45s hosted, 10min local) and
@@ -519,9 +535,17 @@ export function jobWaitMs(requested, cap) {
519
535
  // 404 (and a 502/503/504, which is what a rollover looks like from outside) is retried; past it a 404 is final and is
520
536
  // said in words, never as the bare route error. PURE, so tools/job-readthrough-check.mjs runs it.
521
537
  export const JOB_MISS_GRACE_MS = 30_000;
522
- export function pollMissVerdict(status, { startedAt, now = Date.now(), deadline = Infinity, graceMs = JOB_MISS_GRACE_MS } = {}) {
538
+ // THE GRACE RUNS FROM THE FIRST MISS, NOT FROM THE SUBMIT (2026-10-19). A rollover can land at ANY point in a 1-3 minute
539
+ // render, not only in its first seconds: job_mvfhwfft5wijd was submitted at 16:55:21 to revision 02505, the poll at
540
+ // 16:56:09 reached 02506 (booted 16:55:48), which had not yet seen it and answered 404, and because the grace had been
541
+ // counted from the submit (48s earlier) that first 404 was FINAL: the caller read "no record of job" about a render
542
+ // that answered 200 nine seconds later and finished. `missSince` is when the current run of 404s began (pollJob resets
543
+ // it on any answer), so a miss is final only after it has lasted the whole grace; a caller with no `missSince` keeps
544
+ // the old reading (startedAt). The deadline still bounds every wait.
545
+ export function pollMissVerdict(status, { startedAt, missSince = null, now = Date.now(), deadline = Infinity, graceMs = JOB_MISS_GRACE_MS } = {}) {
523
546
  const st = Number(status);
524
- if (st === 404) return (now - startedAt < graceMs && now < deadline) ? 'retry' : 'final';
547
+ const since = Number.isFinite(+missSince) && missSince != null ? +missSince : startedAt;
548
+ if (st === 404) return (now - since < graceMs && now < deadline) ? 'retry' : 'final';
525
549
  // a throttle is not news about the render: a poll that was turned away waits and asks again (2026-10-04)
526
550
  if ((st === 429 || st === 502 || st === 503 || st === 504) && now < deadline) return 'retry';
527
551
  return 'throw';
@@ -531,14 +555,16 @@ export function jobMissMessage(id) {
531
555
  + 'If it was just submitted, the server that took it was replaced before it wrote the job down, so it cannot be followed from here. A render lost that way is not charged: the credits held for it are released automatically. '
532
556
  + 'Call list_jobs to see this workspace\'s recent jobs. Do not re-run the render on the strength of this message alone.';
533
557
  }
534
- export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick, getJobFn = getJob } = {}) {
558
+ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick, getJobFn = getJob, graceMs = JOB_MISS_GRACE_MS } = {}) {
535
559
  const startedAt = Date.now();
536
560
  const deadline = startedAt + timeoutMs;
561
+ let missSince = null; // when the current run of 404s began (see pollMissVerdict); any answer resets it
537
562
  for (;;) {
538
563
  let job;
539
- try { job = await getJobFn(id); }
564
+ try { job = await getJobFn(id); missSince = null; }
540
565
  catch (e) {
541
- const v = pollMissVerdict(e?.status, { startedAt, now: Date.now(), deadline });
566
+ if (Number(e?.status) === 404 && missSince == null) missSince = Date.now();
567
+ const v = pollMissVerdict(e?.status, { startedAt, missSince, now: Date.now(), deadline, graceMs });
542
568
  if (v === 'final') throw Object.assign(new Error(jobMissMessage(id)), { status: 404, _viaApi: true, _jobMissing: true });
543
569
  if (v === 'throw') throw e;
544
570
  await new Promise(r => setTimeout(r, Math.min(intervalMs, Math.max(50, deadline - Date.now()))));
package/mcp/tools.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
6
6
  import { z } from 'zod';
7
7
  import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
8
- import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT, brandScope } from './client.mjs';
8
+ import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, getJobSettled, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT, brandScope } from './client.mjs';
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -18243,9 +18243,38 @@ function buildTools(rawServer, opts = {}, sink = null) {
18243
18243
 
18244
18244
  // ---------- planning (LLM, 0 SC credits) ----------
18245
18245
  server.group('create');
18246
+ // THE USER'S OWN PICTURES CUT INTO THE VIDEO (2026-10-19, parity with the web Studio's create_ad `inserts`). Each
18247
+ // image may be an https URL (an upload_file URL included), a Library id from list_library (L0, L1… newest first), or a
18248
+ // local path on the CLI (sent as bytes, stored once by the server). An id or path that resolves to nothing is refused
18249
+ // by name, free, before anything is planned or rendered.
18250
+ const INSERTS_Z = z.array(z.object({
18251
+ image: z.string().describe('the picture: an https URL, an upload_file URL, a Library id from list_library (L0, L1…), or a local file path on the CLI'),
18252
+ start: z.number().describe('second of the finished video it appears at'),
18253
+ end: z.number().describe('second it ends at (at least half a second after start)'),
18254
+ })).max(8);
18255
+ const insertsResolved = async (list) => {
18256
+ if (!Array.isArray(list)) return undefined;
18257
+ let lib = null; const out = [];
18258
+ for (const x of list.slice(0, 8)) {
18259
+ if (!x || typeof x !== 'object') continue;
18260
+ const raw = String(x.image || '').trim();
18261
+ let image = raw;
18262
+ const lid = /^L(\d{1,3})$/i.exec(raw);
18263
+ if (lid) {
18264
+ if (!lib) { const all = await readStore('heist.assets.v1').catch(() => null); lib = Array.isArray(all) ? all.filter((a) => a && a.url) : []; }
18265
+ const a = lib[+lid[1]];
18266
+ if (!a || (a.kind && a.kind !== 'image')) throw Object.assign(new Error(`${raw} is not an image in this workspace's Library (list_library numbers them L0, L1… newest first). Nothing was planned, rendered or charged.`), { status: 400, _userInput: true });
18267
+ image = String(a.url);
18268
+ } else image = await toRef(raw);
18269
+ if (!image) throw Object.assign(new Error(`The picture "${raw.slice(0, 80)}" could not be read: pass an https URL, an upload_file URL or a Library id (L0…). Nothing was planned, rendered or charged.`), { status: 400, _userInput: true });
18270
+ out.push({ image: /^\/generated\//.test(image) ? abs(image) : image, start: +x.start, end: +x.end });
18271
+ }
18272
+ return out;
18273
+ };
18274
+ const insertsLine = (ins, verb = 'are laid') => (Array.isArray(ins) && ins.length ? `\nCut-ins: ${ins.length === 1 ? 'the user\'s picture' : `${ins.length} of the user's pictures`} ${ins.length === 1 ? verb.replace(/^are/, 'is') : verb} full-screen at ${ins.map((x) => `${x.start}–${x.end}s`).join(', ')}.` : '');
18246
18275
  server.registerTool('plan_ad', {
18247
18276
  title: 'Plan an ad concept',
18248
- description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. Spends credits.',
18277
+ description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. THE USER’S EXPLICIT LENGTH IS SOVEREIGN: when they name a duration ("a 30 second ad", "make it 45s"), pass it as durationSeconds — the board is then AUTHORED to that length (its scenes sum to it) and render_ad renders it as one clip or stitched acts accordingly. Leaving it out lets the planner pick its own default, which is how an explicit ask silently becomes a 15s spot. The user’s OWN screenshots or pictures cut into the video full-screen go in `inserts`: the board keeps those seconds free of any screen, and render_ad lays the real pictures in. Spends credits.',
18249
18278
  inputSchema: {
18250
18279
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
18251
18280
  product: z.string().describe('what to advertise + any angle/offer the user specified'),
@@ -18258,9 +18287,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
18258
18287
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
18259
18288
  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."),
18260
18289
  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."),
18290
+ inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's OWN pictures (app or report screenshots, a web page, a chart) shown FULL-SCREEN over the video at these seconds while its voice carries on underneath. The plan keeps the shot under each window on camera with no screen in it (the real picture covers it, never a drawn copy), the pictures are never used as references for the video itself, and they ride the returned creative as `inserts` so render_ad lays them in right after the render, in the same job (about 2 credits more). Each picture goes over the seconds that talk about what it shows; at most 8, never two at once. A picture the planned length has no room for is left out and the reply says so."),
18261
18291
  },
18262
18292
  outputSchema: {
18263
18293
  format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
18294
+ inserts: z.array(z.any()).optional().describe("the user's pictures render_ad will lay in full-screen: [{image, start, end}], fitted to the planned length"),
18295
+ inserts_note: z.string().optional().describe('set when a picture was left out because the planned length has no room for it'),
18264
18296
  concept: z.string().optional().describe('the one-line creative concept'),
18265
18297
  format_note: z.string().optional().describe("the plan's own read-back when it chose an IMAGE on an open format because the balance could not cover the default video — states the video's real credit price"),
18266
18298
  budget_pick: z.any().optional().describe('{format, reason, videoCredits, balance, seconds} when format_note is set'),
@@ -18278,7 +18310,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18278
18310
  presence_note: z.string().optional(),
18279
18311
  },
18280
18312
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
18281
- }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent }) => {
18313
+ }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language, durationSeconds, hook, setting, draft, talent, inserts }) => {
18282
18314
  // LENGTH SOVEREIGNTY over MCP (found live 2026-07-31: a 40-second brief came back as render_plan.duration_seconds
18283
18315
  // 15, structure single_clip, scenes summing to 15 — the 40 was silently dropped because this tool declared no
18284
18316
  // duration at all). /api/create has honored `durationSeconds` all along (it becomes the planner's "Target video
@@ -18298,7 +18330,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
18298
18330
  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 {}
18299
18331
  }
18300
18332
  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
18301
- 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 } : {}) });
18333
+ const _ins = await insertsResolved(inserts); // the user's own pictures, laid over the video after it renders (resolved before anything is billed)
18334
+ const d = await apiPost('/api/create', { brand: brandObj, product, format: _draft ? 'video' : (_ins && _ins.length && format === 'auto' ? '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 } : {}), ...(_ins && _ins.length ? { inserts: _ins } : {}) });
18302
18335
  const c = d.creative || d;
18303
18336
  // EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
18304
18337
  // grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
@@ -18323,7 +18356,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18323
18356
  + (_askedLen && _askedLen !== _len ? ` — you asked for ${_askedLen}s, which is outside the supported 4–180s range, so it was clamped to ${_len}s` : '')
18324
18357
  + (_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` : '');
18325
18358
  }
18326
- 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}${c.for_fun ? '\nJust for fun: a plain picture of what they asked for, with no headline, button or ad copy. Render it as it is and do not call it an ad.' : `\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'}.`;
18359
+ 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}${c.for_fun ? '\nJust for fun: a plain picture of what they asked for, with no headline, button or ad copy. Render it as it is and do not call it an ad.' : `\nHeadline: ${c.copy?.[0]?.headline || ''}`}${insertsLine(c.inserts, 'are laid in by render_ad')}${c.inserts_note ? `\n${c.inserts_note}` : ''}${Array.isArray(inserts) && inserts.length && !(Array.isArray(c.inserts) && c.inserts.length) && !c.inserts_note ? (c.format === 'video' ? '\n⚠ None of the pictures could be kept on this plan; render_ad will not lay any in.' : '\nThe pictures were not used: this plan is an image, and cut-ins go into a video.') : ''}\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'}.`;
18327
18360
  return ok(text, c);
18328
18361
  }));
18329
18362
 
@@ -18657,7 +18690,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18657
18690
  server.group('create');
18658
18691
  server.registerTool('render_ad', {
18659
18692
  title: 'Render ad video',
18660
- description: 'Use this when the user wants a finished video ad for their brand. 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.',
18693
+ description: 'Use this when the user wants a finished video ad for their brand. 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. The user’s own pictures that plan_ad was given (creative.inserts) are laid in full-screen at their seconds by the same job, or pass `inserts` here. Spends credits.',
18661
18694
  inputSchema: {
18662
18695
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
18663
18696
  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),
@@ -18678,6 +18711,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18678
18711
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
18679
18712
  dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) and the estimated credits the real render reserves, WITHOUT submitting a render — free, nothing charged'),
18680
18713
  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'),
18714
+ inserts: INSERTS_Z.optional().describe("the user's OWN pictures (screenshots, a page, a chart) laid FULL-SCREEN over the finished video at these seconds, its sound running underneath, by the same job right after the render (about 2 credits more). Omit to use the ones plan_ad was given (creative.inserts); [] renders without them. Passing them here when plan_ad never saw them still keeps the shots under them free of screens, but plan_ad with inserts writes the board around them."),
18681
18715
  },
18682
18716
  outputSchema: {
18683
18717
  ...JOB_OUT,
@@ -18699,6 +18733,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18699
18733
  const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
18700
18734
  if (_len) a = { ...a, durationSeconds: _len };
18701
18735
  const _clampNote = (_askedLen && _askedLen !== _len) ? `\n(${_askedLen}s is outside the supported 4–180s range — rendered at ${_len}s.)` : '';
18736
+ if (Array.isArray(a.inserts)) a = { ...a, inserts: await insertsResolved(a.inserts) }; // the user's pictures, resolved before anything is billed
18702
18737
  const { input, jobType, notes, needsProductPhoto, creator, ownRefNotice, quote, brandPhoto } = await apiPost('/api/render/assemble', a); // a passes wholesale — creator/resolution/captions/endCard/music/lockup/ttsVoice ride the body
18703
18738
  // THE CAST IS THE READ-BACK, NEVER THE ASK. `creator` is the row the SERVER resolved out of this workspace's own
18704
18739
  // roster; a half-remembered name that matched nobody, matched two people, or belongs to an unconsented real
@@ -18731,11 +18766,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
18731
18766
  // catalog pick, the cast) were dropped, and get_job cannot say them later: they are not on the job. `brandPhoto`
18732
18767
  // rides structuredContent on both paths, so a surface shows the left-out photo as its own line.
18733
18768
  const _bp = brandPhoto && typeof brandPhoto === 'object' && brandPhoto.note ? brandPhoto : null;
18734
- if (r?.stillRendering) return ok(`${stillMsg(r)}${_bp ? `\nALSO TELL THE USER, in one short line: ${_bp.note}` : ''}${_clampNote}${_castLine}${notes ? `\nNOTES: ${String(notes).trim()}` : ''}`, { ...r, ...(creator ? { creator } : {}), ...(_bp ? { brandPhoto: _bp } : {}), ...(notes ? { notes: String(notes).trim() } : {}) });
18769
+ if (r?.stillRendering) return ok(`${stillMsg(r)}${insertsLine(input?.layInserts, 'are laid in by this same job before it is delivered, so the file get_job returns already has them')}${_bp ? `\nALSO TELL THE USER, in one short line: ${_bp.note}` : ''}${_clampNote}${_castLine}${notes ? `\nNOTES: ${String(notes).trim()}` : ''}`, { ...r, ...(creator ? { creator } : {}), ...(_bp ? { brandPhoto: _bp } : {}), ...(notes ? { notes: String(notes).trim() } : {}) });
18735
18770
  // FOUNDATIONS ONLY (foundations:'choose'): no video was rendered — hand back the pictures to pick from.
18736
18771
  const _fd = renderPayload(r);
18737
18772
  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 }); }
18738
- 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 } : {}), ...(_bp ? { brandPhoto: _bp } : {}) });
18773
+ return okVideo(`Ad video ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]${_fd?.inserts?.laid ? insertsLine(_fd.inserts.windows) : ''}${_fd?.inserts?.failed && _fd.note ? `\nTELL THE USER: ${_fd.note}` : ''}${_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 } : {}), ...(_bp ? { brandPhoto: _bp } : {}) });
18739
18774
  }));
18740
18775
 
18741
18776
 
@@ -19337,7 +19372,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19337
19372
  // `structuredContent` from an error, and `window.openai.callTool` REJECTS on one, so the widget would be back to
19338
19373
  // guessing from prose. The sentence below is unambiguous for the model; `status:'not_found'` is unambiguous for
19339
19374
  // the card; and both say STOP rather than leaving a poller to grind for six minutes.
19340
- const j = await getJob(id).catch((e) => { if (Number(e?.status) === 404) return null; throw e; });
19375
+ const j = await getJobSettled(id); // a 404 is re-read for a few seconds first: a revision taking over mid-render has not yet seen the job (getJobSettled)
19341
19376
  if (!j) return ok(`No job with id "${id}". Nothing is rendering under that id — it does not exist on this workspace (it may have been mistyped, or it may belong to another account or brand). STOP POLLING IT. Do NOT re-run the render on the strength of this: call list_jobs to see this workspace's recent jobs and their real ids.`, { id, status: 'not_found', progress: 0, error: `No job with id "${id}" on this workspace.`, url: null });
19342
19377
  const res = jobResult(j);
19343
19378
  const url = abs(res?.video || res?.image || res?.url);
@@ -19371,7 +19406,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
19371
19406
  return out;
19372
19407
  }
19373
19408
  const seamLine = seamsText(res); // a timeline / join's measured seams + intro budget ('' for any other video)
19374
- if (j.status === 'done' && res?.video) return okVideo(wireText + seamLine, { ...j, url }); // resumed video → same inline poster as a direct return
19409
+ // a render that laid the user's pictures in (render_ad inserts): say they are in it, or that they could not be
19410
+ const insLine = res?.inserts?.laid ? `\nCut-ins: ${res.inserts.laid === 1 ? 'the user\'s picture is' : `${res.inserts.laid} of the user's pictures are`} laid in full-screen at ${(res.inserts.windows || []).map((x) => `${x.start}–${x.end}s`).join(', ')}.` : (res?.inserts?.failed && res.note ? `\nTELL THE USER: ${res.note}` : '');
19411
+ if (j.status === 'done' && res?.video) return okVideo(wireText + seamLine + insLine, { ...j, url }); // resumed video → same inline poster as a direct return
19375
19412
  if (j.status === 'done' && res?.image) { const img = await imageBlock(url); return { content: [{ type: 'text', text: wireText }, ...(img ? [img] : [])], structuredContent: { ...j, url } }; }
19376
19413
  return ok(wireText, { ...j, url });
19377
19414
  }));
@@ -21260,22 +21297,23 @@ function memoryNoteVerdict(text) {
21260
21297
 
21261
21298
  server.registerTool('list_library', {
21262
21299
  title: 'List library',
21263
- description: "Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's kind, model, and age. Free, read-only.",
21300
+ description: "Browse this workspace's Library — every image/video generated in the Studio, newest first (the same Library the web app shows). Returns served URLs you can open directly or hand to fetch_asset for a download link, plus each asset's id (L0, L1…, which plan_ad and render_ad `inserts` take), kind, model, and age. Free, read-only.",
21264
21301
  inputSchema: {
21265
21302
  kind: z.enum(['image', 'video', 'all']).optional().describe("filter by asset kind (default 'all')"),
21266
21303
  limit: z.number().optional().describe('max assets to return (default 20, max 60)'),
21267
21304
  },
21268
- outputSchema: { assets: z.array(z.object({ url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
21305
+ outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
21269
21306
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
21270
21307
  }, wrap(async (a) => {
21271
21308
  let list = await readStore('heist.assets.v1'); // via /api/store/bootstrap — there is no GET /api/store/:key route
21272
21309
  if (!Array.isArray(list)) list = [];
21273
21310
  const kind = a.kind && a.kind !== 'all' ? a.kind : null;
21274
21311
  const lim = Math.min(60, Math.max(1, +a.limit || 20));
21275
- const assets = list.filter(x => x && x.url && (!kind || x.kind === kind)).slice(0, lim)
21276
- .map(x => ({ url: abs(x.url), kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
21312
+ // id: L0, L1… over the whole Library newest first (a kind filter does not renumber), the id plan_ad / render_ad `inserts` take
21313
+ const assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i })).filter(({ x }) => !kind || x.kind === kind).slice(0, lim)
21314
+ .map(({ x, id }) => ({ id, url: abs(x.url), kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
21277
21315
  if (!assets.length) return ok('The Library is empty for this workspace — render something first.', { assets: [] });
21278
- return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x, i) => ` ${i + 1}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
21316
+ return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
21279
21317
  }));
21280
21318
 
21281
21319
  server.registerTool('fetch_asset', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.420",
3
+ "version": "0.1.422",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 912 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",