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 +31 -5
- package/mcp/tools.mjs +53 -15
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
21276
|
-
|
|
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
|
|
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.
|
|
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",
|