hermoso 0.1.426 → 0.1.429

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/mcp/tools.mjs +22 -14
  2. package/package.json +1 -1
package/mcp/tools.mjs CHANGED
@@ -4364,7 +4364,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4364
4364
  const _rmLine = _rm ? `\nRemake engines (recast_motion, restyle_video, edit_video engine): auto picks, for a clip with no real-looking person, ${Object.entries(_rm.defaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}; with a person in the clip, ${Object.entries(_rm.faceDefaults || {}).map(([k, v]) => `${k} → ${v}`).join(', ')}${_rm.personClipLibraryDefaults ? ` (a person clip our face library can carry, i.e. one of our renders or faceRoute on a paid plan: ${Object.entries(_rm.personClipLibraryDefaults).map(([k, v]) => `${k} → ${v}`).join(', ')})` : ''}. seedance ${(_rm.seconds || []).join('-')}s, up to ${_rm.imagesMax} pictures, ${Object.entries(_rm.seedanceCredits || {}).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanCredits ? `. wan clips up to ${_rm.wanMaxSourceSeconds}s, ${Object.entries(_rm.wanCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}${_rm.wanLabel ? ` (${_rm.wanLabel})` : ''}` : ''}${_rm.wanPrimeCredits ? `. wan-prime (Wan 3.0 Prime by name) ${Object.entries(_rm.wanPrimeCredits).map(([res, m]) => `${res} ${Object.entries(m).map(([s, c]) => `${s}s ${c}cr`).join(' / ')}`).join('; ')}` : ''}` : '';
4365
4365
  const _je = d.jobEngines; // the certified engine per job (watched side-by-side tests, 2026-10-19)
4366
4366
  const _jeLine = _je && _je.defaults ? `\nDefault engine per job (watched side-by-side tests): ${Object.entries(_je.defaults).map(([k, v]) => `${k} → ${v}`).join(', ')}; a talking ad is one take of up to ${_je.talkingMaxSeconds}s. A model the user picks by name is kept; never switch it.` : '';
4367
- const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}${_extLine ? `\nExtend a clip (generate_video extend:true + refVideo): ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\nPRICES: every credit figure above is an estimate. ${PRICE_ESTIMATE_LINE}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`; return ok(text + connLine, d);
4367
+ const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\n${_lenLine}\nExtend a clip (generate_video extend:true + refVideo): by default it cuts to a NEW SHOT of what happens next (new angle, same scene, any i2v model, default ${d.jobEngines?.defaults?.extend || 'certified'})${_extLine ? `; extendMode:'continue' keeps the SAME take instead, on: ${_extLine}` : ''}${_shotLine ? `\nMulti-shot clips (generate_video shots): ${_shotLine}` : ''}${_rsLine ? `\nRestyle looks (restyle_video style): ${_rsLine}` : ''}${_rmLine}${_jeLine}\n${_pickLine}\n${_resLine}${_motionLine}${_avLine}\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\nPRICES: every credit figure above is an estimate. ${PRICE_ESTIMATE_LINE}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`; return ok(text + connLine, d);
4368
4368
  }));
4369
4369
 
4370
4370
  server.registerTool('hermoso_credits', {
@@ -17996,20 +17996,22 @@ function buildTools(rawServer, opts = {}, sink = null) {
17996
17996
 
17997
17997
  server.registerTool('list_drive_files', {
17998
17998
  title: 'List Google Drive files',
17999
- description: 'List the Google Drive files & folders Hermoso can reach — the ones it created, plus any the user handed over with the Google file picker in the app (the drive.file scope exposes nothing else, never their entire Drive). This is how you find the id of a file the user picked. Filter by query (name contains …), folderId (contents of a folder), or onlyFolders:true. Paginate with pageToken. Read-only. A FOLDER: picking a folder hands over the folder alone, not its files, so a folder can list empty or partly; the reply then carries `pickUrl`, an app link that opens the Google Drive picker inside that folder where the user selects every file at once (Cmd+A / Ctrl+A). Give the user that link, then list again.',
17999
+ description: 'List the Google Drive files & folders Hermoso can reach: the ones it created plus any the user picked with the Google file picker in the app (drive.file, never their whole Drive). This is how you find a picked file\'s id. Filter by query (name contains …), folderId, or onlyFolders:true; paginate with pageToken. Read-only. A FOLDER: picking a folder hands over the folder alone, not its files, so a folder can list empty or partly; the reply then carries `pickUrl`, an app link that opens the picker inside that folder (Cmd+A / Ctrl+A selects every file and subfolder). Give the user that exact link, then list again. recursive:true walks every subfolder Hermoso can see (each row has its `path`) and reports picked files whose folder it cannot see.',
18000
18000
  inputSchema: {
18001
18001
  query: z.string().optional().describe('only files whose name contains this'),
18002
18002
  folderId: z.string().optional().describe('list the contents of this folder id'),
18003
+ recursive: z.boolean().optional().describe('with folderId: the whole tree'),
18003
18004
  onlyFolders: z.boolean().optional().describe('list folders only'),
18004
18005
  pageSize: z.number().optional().describe('rows per page (1–200, default 50)'),
18005
18006
  pageToken: z.string().optional().describe('cursor from a previous call'),
18006
18007
  includeTrashed: z.boolean().optional().describe('include trashed files (default false)'),
18007
18008
  },
18008
- outputSchema: { files: z.array(z.any()).optional(), cursor: z.string().nullable().optional(), note: z.string().optional(), pickUrl: z.string().optional(), folderVisible: z.boolean().nullable().optional() },
18009
+ outputSchema: { files: z.array(z.any()).optional(), cursor: z.string().nullable().optional(), note: z.string().optional(), pickUrl: z.string().optional(), folderVisible: z.boolean().nullable().optional(), hiddenParents: z.array(z.any()).optional() },
18009
18010
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
18010
18011
  }, wrap(async (a) => {
18011
18012
  const d = await apiGet('/api/drive/files', a);
18012
- const lines = (d.files || []).map(f => `• ${f.name} (${f.id})${f.mimeType && f.mimeType.includes('folder') ? ' [folder]' : ''}${f.webViewLink ? ` — ${f.webViewLink}` : ''}`);
18013
+ const lines = (d.files || []).map(f => `• ${f.path || ''}${f.name} (${f.id})${f.mimeType && f.mimeType.includes('folder') ? ' [folder]' : ''}${f.webViewLink ? ` — ${f.webViewLink}` : ''}`)
18014
+ .concat((d.hiddenParents || []).map(g => `• in folder ${g.folderId} (not visible to Hermoso): ${g.count} file(s), e.g. ${(g.names || []).join(', ')}`));
18013
18015
  // A folder listing carries the server's note (why it is empty or partial, and the link that fixes it): print it, never a bare "(none)".
18014
18016
  return ok(`${(d.files || []).length} item(s):\n${lines.join('\n') || '(none)'}${d.note ? `\n${d.note}${d.pickUrl && !String(d.note).includes(d.pickUrl) ? ` Link: ${d.pickUrl}` : ''}` : ''}`, d);
18015
18017
  }));
@@ -18402,10 +18404,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
18402
18404
  // image may be an https URL (an upload_file URL included), a Library id from list_library (L0, L1… newest first), or a
18403
18405
  // local path on the CLI (sent as bytes, stored once by the server). An id or path that resolves to nothing is refused
18404
18406
  // by name, free, before anything is planned or rendered.
18407
+ // HOW IT LOOKS (2026-10-19): a screen sits in a rounded card over the footage, which keeps playing around it (look
18408
+ // 'card', the default; 'full' covers the frame, only when the user asked); a photo of a person, product or place (kind
18409
+ // 'photo') is never laid over the film. Never in the first second or the last 1.5 s; 2 s each at most (lib/insert-windows.mjs).
18405
18410
  const INSERTS_Z = z.array(z.object({
18406
18411
  image: z.string().describe('an https or upload_file URL, a Library id (L0, L1…) or a local path on the CLI'),
18407
- start: z.number().describe('second it appears'),
18408
- end: z.number().describe('second it ends (≥0.5 s after start)'),
18412
+ start: z.number().describe('second it appears (never before 1 s: the film opens on moving footage)'),
18413
+ end: z.number().describe('second it ends: at most 2 s after start (2.5 s full-screen), never in the last 1.5 s of the film'),
18414
+ kind: z.enum(['screen', 'photo']).optional().describe("screen (default): an app, a page, a dashboard, a document, a review, a chart. photo: a person, a product or a place, which is NOT laid over the video (a product photo goes in as the brand's product photo, a person to star as a saved creator)"),
18415
+ look: z.enum(['card', 'full']).optional().describe("card (default): a rounded card over the upper middle of the footage, which keeps playing. full: covers the whole frame, ONLY when the user asked for full-screen"),
18409
18416
  })).max(8);
18410
18417
  const insertsResolved = async (list) => {
18411
18418
  if (!Array.isArray(list)) return undefined;
@@ -18422,11 +18429,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
18422
18429
  image = String(a.url);
18423
18430
  } else image = await toRef(raw);
18424
18431
  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 });
18425
- out.push({ image: /^\/generated\//.test(image) ? abs(image) : image, start: +x.start, end: +x.end });
18432
+ out.push({ image: /^\/generated\//.test(image) ? abs(image) : image, start: +x.start, end: +x.end, ...(x.kind === 'photo' ? { kind: 'photo' } : {}), ...(x.look === 'full' ? { look: 'full' } : {}) });
18426
18433
  }
18427
18434
  return out;
18428
18435
  };
18429
- 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(', ')}.` : '');
18436
+ 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} at ${ins.map((x) => `${x.start}–${x.end}s${x.look === 'full' ? ' (full-screen)' : ''}`).join(', ')}${ins.some((x) => x.look !== 'full') ? ', as a card over the footage, which keeps playing' : ''}.` : '');
18430
18437
  server.registerTool('plan_ad', {
18431
18438
  title: 'Plan an ad concept',
18432
18439
  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.',
@@ -18442,12 +18449,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
18442
18449
  language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
18443
18450
  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."),
18444
18451
  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."),
18445
- inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's own pictures (screenshots, a page, a chart) shown FULL-SCREEN at these seconds, the voice running underneath. The plan keeps those shots free of screens, never uses the pictures as references, and returns them as creative.inserts for render_ad to lay in (about 2 credits). At most 8, never two at once; one the planned length has no room for is left out and the reply says so."),
18452
+ inserts: INSERTS_Z.optional().describe("VIDEO ONLY: the user's own SCREENS (an app, a page, a dashboard, a document, a review, a chart) shown at these seconds as a rounded card over the upper middle of the footage, which keeps playing around it (look 'full' covers the frame, only when the user asked), the voice running underneath. Fewer and shorter: about one per 4 s of film, 1.2-2 s each, on the line that talks about it; never in the first second or the last 1.5 s. The plan frames those shots for the card, keeps them free of screens, and returns the pictures as creative.inserts for render_ad to lay in (about 2 credits). A photo of a person, product or place (kind 'photo') is not laid in: pass a product photo as the brand's product photo and a person as a saved creator. One that does not fit is left out and the reply says so."),
18446
18453
  },
18447
18454
  outputSchema: {
18448
18455
  format: z.string().optional().describe("the resolved creative format — 'image' or 'video'"),
18449
- 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"),
18450
- inserts_note: z.string().optional().describe('set when a picture was left out because the planned length has no room for it'),
18456
+ inserts: z.array(z.any()).optional().describe("the user's pictures render_ad will lay in: [{image, start, end, look}] (look card = over the playing footage, full = full-screen), fitted to the planned length"),
18457
+ inserts_note: z.string().optional().describe('set when a picture was left out (the planned length has no room for it, or it was a photo, which is never laid over the video)'),
18451
18458
  concept: z.string().optional().describe('the one-line creative concept'),
18452
18459
  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"),
18453
18460
  budget_pick: z.any().optional().describe('{format, reason, videoCredits, balance, seconds} when format_note is set'),
@@ -18866,7 +18873,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
18866
18873
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
18867
18874
  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'),
18868
18875
  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'),
18869
- inserts: INSERTS_Z.optional().describe("the user's own pictures laid FULL-SCREEN over the finished video at these seconds, sound running underneath (about 2 credits). Omit to use plan_ad's creative.inserts; [] renders without them. plan_ad with inserts writes the board around them."),
18876
+ inserts: INSERTS_Z.optional().describe("the user's own screens laid over the finished video at these seconds, as a card over the playing footage by default (look 'full' covers the frame, only when asked), sound running underneath (about 2 credits). The film always opens and ends on moving footage. Omit to use plan_ad's creative.inserts; [] renders without them. plan_ad with inserts writes the board around them."),
18870
18877
  },
18871
18878
  outputSchema: {
18872
18879
  ...JOB_OUT,
@@ -19337,7 +19344,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
19337
19344
  endImage: z.string().optional().describe('local path or URL of the LAST frame: the clip travels from refImage (required with it) to this image. Only models with endFrame true in hermoso_capabilities take it; any other named model is refused by name, nothing charged.'),
19338
19345
  loop: z.boolean().optional().describe('true = a seamless loop whose last frame flows back into its first. Only models with loop true in hermoso_capabilities; needs refImage and cannot be combined with endImage.'),
19339
19346
  shots: z.array(z.object({ prompt: z.string().describe('what happens in this shot'), seconds: z.number().int().min(1).max(15).describe('this shot’s length in whole seconds') })).optional().describe('MULTI-SHOT: one clip cut into these shots, in order; the seconds add up to a length the model renders. Replaces `prompt`. Only models with multiShot true in hermoso_capabilities; a shot prompt over that model’s shotPromptMaxChars (Kling 3.0: 512) is refused free, never cut.'),
19340
- extend: z.boolean().optional().describe('true = EXTEND refVideo: the same clip continues per your prompt for durationSeconds more (each model’s extend.minSeconds..maxSeconds in hermoso_capabilities), delivered as ONE clip, source then continuation. Omit model for the certified default.'),
19347
+ extend: z.boolean().optional().describe('true = EXTEND refVideo by durationSeconds (default 5) with what your prompt says happens next, as ONE clip. By default it CUTS to a NEW SHOT: new angle, same scene, room sound carried, no music at the cut. A named model (any i2v) renders it.'),
19348
+ extendMode: z.enum(['cut', 'continue']).optional().describe("with extend: 'continue' = the SAME take past its last frame, only when asked seamless (models with extend in hermoso_capabilities)."),
19341
19349
  interactionId: z.string().optional().describe('with extend:true on an Omni model: the interactionId returned by an earlier render on that model — continues it from its own stored context instead of re-uploading refVideo.'),
19342
19350
  durationSeconds: z.number().optional().describe('length of THIS ONE clip in seconds: one of the CHOSEN model’s listed durations in hermoso_capabilities (they differ per model, 4–8s on the short ones up to 30s). One continuous generation, so a longer ask than the model’s longest clip is REFUSED, nothing charged (never quietly truncated). Past ~15s NAME a long-clip model in `model`. For a longer spot use plan_ad with durationSeconds then render_ad, which stitches acts (on a 15s model, 40s = 15+15+10).'),
19343
19351
  aspectRatio: z.string().optional().describe("default '9:16'"),
@@ -19562,7 +19570,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
19562
19570
  }
19563
19571
  const seamLine = seamsText(res); // a timeline / join's measured seams + intro budget ('' for any other video)
19564
19572
  // a render that laid the user's pictures in (render_ad inserts): say they are in it, or that they could not be
19565
- 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}` : '');
19573
+ 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 at ${(res.inserts.windows || []).map((x) => `${x.start}–${x.end}s${x.look === 'full' ? ' (full-screen)' : ''}`).join(', ')}.` : (res?.inserts?.failed && res.note ? `\nTELL THE USER: ${res.note}` : '');
19566
19574
  if (j.status === 'done' && res?.video) return okVideo(wireText + seamLine + insLine, { ...j, url }); // resumed video → same inline poster as a direct return
19567
19575
  if (j.status === 'done' && res?.image) { const img = await imageBlock(url); return { content: [{ type: 'text', text: wireText }, ...(img ? [img] : [])], structuredContent: { ...j, url } }; }
19568
19576
  return ok(wireText, { ...j, url });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.426",
3
+ "version": "0.1.429",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "AI marketing agent and AI ad generator, run from your own AI agent. 922 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",