hermoso 0.1.384 → 0.1.385

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
@@ -454,6 +454,8 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
454
454
  export async function apiSSE(p, body = {}) {
455
455
  const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
456
456
  if (!res.ok) { let e; try { e = (await res.json()).error; } catch {} throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
457
+ // A server that answered with plain JSON (an older revision) is read as JSON, never as an empty stream.
458
+ if (!/text\/event-stream/i.test(res.headers.get('content-type') || '')) { const j = await res.json(); return j && Object.prototype.hasOwnProperty.call(j, 'data') ? j.data : j; }
457
459
  const reader = res.body.getReader(); const dec = new TextDecoder();
458
460
  let buf = '', done = null, error = null; const progress = [];
459
461
  for (;;) {
@@ -125,7 +125,7 @@ export function plannerBrief(product, { brief = '', angles = [] } = {}) {
125
125
  ].filter(Boolean).join('\n');
126
126
  }
127
127
 
128
- // One render's prompt: the planner's visual brief, then the exact words, then the logo rule. The words are QUOTED so
128
+ // One render's prompt: the planner's visual brief, then the exact words, then (with no logo on file) the no-logo rule. The words are QUOTED so
129
129
  // the model prints them as given; a static whose headline is misspelled is not finished.
130
130
  export function staticAdPrompt(v, { logo = 'server', productPhoto = false } = {}) {
131
131
  const x = v && typeof v === 'object' ? v : {};
@@ -137,9 +137,10 @@ export function staticAdPrompt(v, { logo = 'server', productPhoto = false } = {}
137
137
  ].filter(Boolean);
138
138
  if (words.length) lines.push(`On-image text, spelled exactly as given and nothing else: ${words.join(', ')}. Bold ad typography with a clear hierarchy, crisp and correctly spelled. A finished, ready-to-run static ad.`);
139
139
  if (productPhoto) lines.push('The first reference image is the REAL product: keep its shape, colours and label exactly.');
140
- if (logo === 'attached') lines.push("The LAST reference image is the brand's REAL logo. Place exactly that logo, unchanged, small and clean in the layout. Never redraw or re-letter it.");
141
- else if (logo === 'server') lines.push("Place the brand's real logo small and clean in the layout.");
142
- else lines.push('Do not draw or invent any logo or wordmark.');
140
+ // THE LOGO IS LAID ON, NEVER PAINTED (2026-10-06): 'overlay' (and the old 'attached' / 'server') add nothing here. The
141
+ // render goes with brandLogo:true, and the image route tells the model to keep a corner clear and lays the brand's real
142
+ // logo file over the finished ad (lib/logo-intent.mjs). The model is never handed the logo to re-draw.
143
+ if (!['overlay', 'attached', 'server'].includes(logo)) lines.push('Do not draw or invent any logo or wordmark.');
143
144
  return lines.filter(Boolean).join('\n\n');
144
145
  }
145
146
 
package/mcp/tools.mjs CHANGED
@@ -5788,7 +5788,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5788
5788
  }, wrap(async (a) => {
5789
5789
  if (a.redo && a.redo.id) {
5790
5790
  const r = a.redo;
5791
- const d = await apiPost('/api/schedule/refill/redo', { id: r.id, feedback: r.feedback || '', part: r.change || 'both', remember: r.remember !== false, confirm: r.confirm === true });
5791
+ const d = await (r.confirm === true ? apiSSE : apiPost)('/api/schedule/refill/redo', { id: r.id, feedback: r.feedback || '', part: r.change || 'both', remember: r.remember !== false, confirm: r.confirm === true }); // streamed when it renders: past the edge's 100s limit (2026-10-06)
5792
5792
  if (d.needsConfirm) return ok(`${d.summary}\nAsk the user before spending; call run_post_refill again with redo.confirm:true only on a yes.`, d);
5793
5793
  return ok([d.summary || '', d.draft ? `The new version (${d.draft.id}, ${d.draft.kind}, ${d.draft.at}):\n ${String(d.draft.message || '').replace(/\n+/g, ' / ').slice(0, 300)}\n ${d.draft.media || ''}\nIt is still a draft: approve it with run_post_refill approve.` : ''].filter(Boolean).join('\n'), d);
5794
5794
  }
@@ -5802,12 +5802,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
5802
5802
  d.discarded ? `Discarded ${d.discarded} draft(s).` : '',
5803
5803
  d.edited ? `Edited ${d.edited} draft(s).` : '',
5804
5804
  (d.editErrors || []).length ? `Edits refused: ${d.editErrors.map(x => `${x.draftId} — ${x.error}`).join(' · ')}` : '',
5805
+ (d.editNotes || []).length ? d.editNotes.map(x => `${x.draftId}: ${x.note}`).join('\n') : '',
5805
5806
  (d.unknown || []).length ? `No such draft: ${d.unknown.join(', ')}` : '',
5806
5807
  (d.learned || []).length ? `Saved for future batches: ${d.learned.map((x) => `"${x}"`).join(', ')}.` : '',
5807
5808
  `${d.remaining} draft(s) still waiting for approval.`,
5808
5809
  ].filter(Boolean).join('\n'), d);
5809
5810
  }
5810
- const d = await apiPost('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force });
5811
+ const d = await (a.dryRun === false ? apiSSE : apiPost)('/api/schedule/refill/run', { dryRun: a.dryRun === false ? false : true, force: !!a.force }); // a live run renders for minutes: streamed past the edge's 100s limit (2026-10-06)
5811
5812
  const lines = (d.posts || []).map((p, i) => ` ${i + 1}. ${p.at} → ${(p.channels || []).map(c => `${c}[${(p.visibilityByChannel || {})[c] || 'public'}]`).join(', ')}\n ${String(p.message || '').replace(/\n+/g, ' / ').slice(0, 220)}\n new ${p.kind}: ${p.media || `(made when it runs live) ${String(p.asset?.scene || '').slice(0, 160)}`} · ${p.asset?.credits || 0} credits`);
5812
5813
  const q = (d.queued || []).filter(x => x && x.id).length, qFail = (d.queued || []).filter(x => x && x.error);
5813
5814
  return ok([
@@ -17989,24 +17990,26 @@ function buildTools(rawServer, opts = {}, sink = null) {
17989
17990
  model: z.string().optional().describe('image model id from hermoso_capabilities. A model whose `refs.mode` is "edit" there (gpt-image-2.5) takes your refImages on ITS OWN editor, up to its `refs.max`, instead of the default compositor'),
17990
17991
  imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
17991
17992
  fixLabel: z.boolean().optional().describe('default true: when the saved brand\'s product photo rides in this render, the product\'s label on the finished image is READ and compared with the photo, and re-printed from the photo at close range ONLY if it came out wrong (a label that is already right costs only the check, a credit or two; a re-print adds about ten). The reply says whether the label was checked, fixed or left as rendered (`labelPass`). Pass false when the user wants the packaging left exactly as generated: nothing is checked or re-printed.'),
17993
+ logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: the real logo file laid flat (lockup, corner). in_scene: ON something in the scene (cup, shirt, sign, moving or angled), from the file, then checked. auto (default): from the prompt, none unless asked'),
17992
17994
  mask: z.string().optional().describe('MASKED EDIT — change ONE region of an image and keep the rest: a local path or URL of a mask image for refImages[0] (the image being edited). Either convention works and the reply says which it read: TRANSPARENT pixels = change, or, on a mask with no transparency, WHITE = change and black = keep. Any size; it is scaled to the image. The mask GUIDES the edit rather than stencilling it: the new content can blend a little past its edge. Runs on the model hermoso_capabilities marks `refs.mask` (gpt-image-2.5): leave `model` empty or name that one — any other named model is refused, free. Needs refImages; the result keeps the source image\'s own frame, so aspectRatio is not applied.'),
17993
17995
  },
17994
17996
  outputSchema: {
17995
17997
  image: z.string().optional().describe('the served absolute URL of the finished image'),
17996
17998
  model: z.string().optional().describe('the product-facing label of the model that rendered it'),
17997
17999
  productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
18000
+ logoCheck: z.any().optional().describe('after an in_scene logo: {verdict: match|drift|absent|unclear, where, issues[], note}'),
17998
18001
  labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
17999
18002
  },
18000
18003
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
18001
18004
  _meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
18002
- }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel }) => {
18005
+ }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement }) => {
18003
18006
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
18004
18007
  const maskRef = mask ? await toRef(mask) : undefined; // a local mask file travels the same way a local reference does
18005
18008
  // `raw === true` only — a raw render is opt-in and must be stated properly, so a truthy stray value never
18006
18009
  // silently turns off the brand pipeline on an on-brand ad (the same rule lib/raw-passthrough.mjs's predicate uses).
18007
18010
  // A MASKED EDIT IS AN EDIT OF THE CALLER'S OWN IMAGE: the saved brand's product photos must not be hydrated in front
18008
18011
  // of it, so a mask implies useBrand:false (the server also refuses a mask with no refImages, free).
18009
- const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}) }; // fixLabel:false only when stated: the label check is ON by default
18012
+ const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}) }; // fixLabel:false only when stated: the label check is ON by default
18010
18013
  // A CALLER WITH A WAIT BUDGET GETS A QUEUED JOB (2026-09-21, R321). Only `/v1/tools/generate_image?wait=` sets
18011
18014
  // `waitMs`; every other caller renders inside the request exactly as before. The server does the same brand-photo
18012
18015
  // handling either way, then queues the ordinary image job; awaitRenderJob waits what the caller allowed and hands
@@ -18147,6 +18150,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18147
18150
  restrainedGrade: z.boolean().optional().describe('true for a calm / premium / muted look instead of the default punchy poster grade'),
18148
18151
  logo: z.string().optional().describe('a brand logo URL or path to place into the composition'),
18149
18152
  logo3d: z.boolean().optional().describe('first turn the flat logo into a volumetric 3D render (one extra billed image), then composite that'),
18153
+ logoPlacement: z.enum(['auto', 'overlay', 'in_scene']).optional().describe('overlay: the real logo file laid flat, exact. in_scene: placed into the composition from the file, then checked. auto (default): in_scene with logo3d, else overlay'),
18150
18154
  split: z.object({ mode: z.enum(['plain', 'before_after', 'versus', 'custom']), panels: z.array(z.string()).optional() }).passthrough().optional().describe('split/panel LAYOUT — only when the user asks for one ("split", "before/after", "versus screen"). "X vs Y" as a SCENE stays one unified frame'),
18151
18155
  reference: z.object({}).passthrough().optional().describe("fields YOU extracted by eye from a reference thumbnail. Extract ALL of: brief (one dense sentence on the concept), subject (pose/action generically, NEVER a specific identity), elements, location, composition, background, split (boolean), split_count, person_count (0-3), emotion (one of the 11 presets or 'other'), emotion_detail (one vivid sentence covering eyes, brows, mouth, head angle). emotion + emotion_detail carry the reference's actual facial performance, which is the single biggest CTR lever on a face; split/split_count reproduce its panel structure. The reference image itself is never sent to the model"),
18152
18156
  tweak: z.object({ kind: z.string(), value: z.string() }).describe('surgical pixel-faithful edit of a FINISHED thumbnail (needs sourceImage): kind emotion / background / background_color / rim_light, or any other kind with the edit in words as value').optional(),
@@ -21275,6 +21279,7 @@ function memoryNoteVerdict(text) {
21275
21279
  brandId: z.string().optional().describe('a profile id/name from list_brands; omit for the active profile (passing it pins it like use_brand)'),
21276
21280
  plan: z.array(z.any()).optional().describe('the `plan` (or `remaining`) a previous call returned: render exactly those ads, no new planning'),
21277
21281
  dryRun: z.boolean().optional().describe('true = plan and quote only, nothing rendered'),
21282
+ logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay (default): the real logo file laid flat on each ad. in_scene: printed on something in the ad, then checked. none. auto: from each visual'),
21278
21283
  },
21279
21284
  outputSchema: {
21280
21285
  plan: z.array(z.any()).optional().describe('[{product, image, variants:[{angle, headline, supporting, cta, prompt}]}]'),
@@ -21286,7 +21291,7 @@ function memoryNoteVerdict(text) {
21286
21291
  notes: z.array(z.string()).optional(),
21287
21292
  },
21288
21293
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
21289
- }, wrap(async ({ products, count, aspectRatios, brief, angles, language, model, brandId, plan, dryRun }) => {
21294
+ }, wrap(async ({ products, count, aspectRatios, brief, angles, language, model, brandId, plan, dryRun, logoPlacement }) => {
21290
21295
  const brand = await activeBrand(brandId);
21291
21296
  const ratios = normalizeRatios(aspectRatios);
21292
21297
  const notes = [];
@@ -21319,6 +21324,11 @@ function memoryNoteVerdict(text) {
21319
21324
  const { now, later } = splitForQueue(list);
21320
21325
  const heroImg = heroImageOf(brand);
21321
21326
  const logo = typeof brand?.logo === 'string' && /^https?:\/\//i.test(brand.logo) && !/\.svg(\?|#|$)/i.test(brand.logo) ? brand.logo : '';
21327
+ // THE LOGO, PER AD (2026-10-06): overlay by default (a flat lockup of the real file); in_scene puts it on something in
21328
+ // the ad from the real file and checks it; none leaves it off; auto reads each ad's visual. Never handed over as a
21329
+ // plain reference for the model to re-draw.
21330
+ const lmode = !logo || logoPlacement === 'none' ? 'none' : 'overlay';
21331
+ const lbody = lmode === 'none' ? (logo ? { logoPlacement: 'none' } : {}) : { brandLogo: true, logoPlacement: logoPlacement || 'overlay' };
21322
21332
  const jobs = [];
21323
21333
  let stoppedAt = null;
21324
21334
  for (let i = 0; i < now.length; i++) {
@@ -21326,11 +21336,11 @@ function memoryNoteVerdict(text) {
21326
21336
  const isHero = it.image ? it.image === heroImg : true;
21327
21337
  let body;
21328
21338
  if (isHero && (it.image || brand)) {
21329
- body = { prompt: staticAdPrompt(it.variant, { logo: 'server' }), useBrand: true }; // the server attaches the hero photo + the real logo
21339
+ body = { prompt: staticAdPrompt(it.variant, { logo: lmode }), useBrand: true, ...lbody }; // the server attaches the hero photo and puts the real logo in the way lmode says
21330
21340
  } else if (it.image) {
21331
- body = { prompt: staticAdPrompt(it.variant, { logo: logo ? 'attached' : 'none', productPhoto: true }), refImages: [it.image, ...(logo ? [logo] : [])], useBrand: true };
21341
+ body = { prompt: staticAdPrompt(it.variant, { logo: lmode, productPhoto: true }), refImages: [it.image], useBrand: true, ...lbody };
21332
21342
  } else {
21333
- body = logo ? { prompt: staticAdPrompt(it.variant, { logo: 'attached' }), refImages: [logo], useBrand: true } : { prompt: staticAdPrompt(it.variant, { logo: 'none' }), useBrand: false };
21343
+ body = logo ? { prompt: staticAdPrompt(it.variant, { logo: lmode }), useBrand: true, ...lbody } : { prompt: staticAdPrompt(it.variant, { logo: 'none' }), useBrand: false };
21334
21344
  }
21335
21345
  try {
21336
21346
  const q = await apiPost('/api/generate/image', { ...body, aspectRatio: it.aspectRatio, ...(model ? { model } : {}), queue: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.384",
3
+ "version": "0.1.385",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 901 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 and Telegram. 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",