hermoso 0.1.525 → 0.1.526
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/tools.mjs +70 -26
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -3411,7 +3411,7 @@ export const APP_WIDGET_RENAMES = Object.freeze({ remake_video_for_brand: 'plan_
|
|
|
3411
3411
|
export const APP_WIDGET_ALIAS_OF = Object.freeze({ plan_video_remake: 'clone_video' });
|
|
3412
3412
|
const APP_WIDGET_TITLES = Object.freeze({ plan_video_remake: 'Plan a video remake for your brand' });
|
|
3413
3413
|
const APP_WIDGET_DESCRIPTIONS = Object.freeze({
|
|
3414
|
-
plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, YouTube
|
|
3414
|
+
plan_video_remake: "Plans a new video ad for this brand modelled on a video the user links to: a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). It reads the video first (frames across the clip plus a transcript of the voiceover, on-screen text and cut map), then writes a storyboard that keeps its hook, structure, cuts and pacing while using this brand's product, cast, setting and words, not the original's words, face or brand. The plan matches the original's length (up to 60s) unless durationSeconds is given. It renders nothing: render_ad turns the returned plan into the video. Uses credits for the plan plus about 2 credits to read the link. The reply says what was read; when a platform does not hand over the footage, the plan rests on the captions and thumbnail only. For a local file, use upload_file first and pass the URL.",
|
|
3415
3415
|
});
|
|
3416
3416
|
// OPENAI'S FIRST SCAN OF THE TWO NEW APPS (2026-11-06) gave stated reasons on tools ChatGPT had never listed before. On a
|
|
3417
3417
|
// ChatGPT app (widget host) only: the hint OpenAI read as wrong is corrected, a sentence that compares us with other
|
|
@@ -3595,7 +3595,7 @@ export function stripOutputSchema(def) {
|
|
|
3595
3595
|
export const WIDGET_HOST_DESCRIPTIONS = Object.freeze({
|
|
3596
3596
|
search_console_performance: "Google Search Console's performance report for the brand's site: the search queries people used, with impressions, clicks, click-through rate and average position, by query, page, country, device, search appearance, date or hour. startDate and endDate are required, as YYYY-MM-DD (Pacific time). Rows are the top ones Google returns; anonymised queries are withheld, so per-query clicks will not add up to the site total; the default 'final' data lags 2 to 3 days and 'all' adds fresh partial data. Up to 16 months of history. Read-only, 0 credits.",
|
|
3597
3597
|
list_linkedin_events: "The events a LinkedIn company Page organizes: name, link, start and end times, online or in person, visibility and state. Only Pages shared with this profile can be read. It needs LinkedIn's Events permission; if a connection made before that permission was enabled is refused, the user reconnects LinkedIn once. Read-only, 0 credits. Needs LinkedIn connected.",
|
|
3598
|
-
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook
|
|
3598
|
+
edit_timeline: "Render one edited video from a timeline the caller describes: segments placed on an output timeline (later segments are drawn on top, and overlapping segments form the transition), each with keyframed position, scale, rotation, speed and opacity, plus optional HTML overlays and masks. Sources are a videoUrl, a Library or render URL, a direct file, or a public TikTok, Reel, Facebook or X post link (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).); in and out are source seconds. Audio per segment: gain, mute, and J-cut or L-cut timing. video_frames shows exact frames for free, to find a cut point and to check the result. Costs the flat post_edit price (about 2 credits); a 'generate' segment, which renders a new transition shot with a video model, costs more and is used only when the user asks for one.",
|
|
3599
3599
|
list_openai_ads_feeds: "The product feeds on this ChatGPT Ads account: id, name, countries, currencies and the campaigns using each. A product-feed campaign advertises the products in one of these feeds. Read-only, 0 credits. Needs ChatGPT Ads connected.",
|
|
3600
3600
|
create_openai_ads_feed: "Create an empty feed on ChatGPT Ads, with a name and the countries it serves (ISO codes). By default it is a product feed, and products are added afterwards with update_openai_ads_feed_products. inventoryType 'hotel' asks for a hotel property feed instead, which OpenAI accepts only for advertisers in its hotel beta and refuses for everyone else. Needs ChatGPT Ads connected.",
|
|
3601
3601
|
update_reddit_ads_ad_group: "Edit an existing Reddit ad group: its name, budget, goal type, bid, schedule dates or targeting. Budget and bid are amounts in the ad account's currency. Targeting is updated per key: each targeting key sent replaces that whole list, keys left out stay as they are, and an empty list clears a key. It does not start or pause delivery (set_reddit_ads_status does). The saved ad group is read back from Reddit. Needs Reddit Ads connected.",
|
|
@@ -7268,8 +7268,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7268
7268
|
description: z.string().optional().describe('YOUTUBE: the video DESCRIPTION, max 5000 characters, carrying the links, the CTA and what YouTube search reads. Omit it and the caption is used.'),
|
|
7269
7269
|
tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
|
|
7270
7270
|
thumbnailUrl: z.string().optional().describe('YOUTUBE: the custom thumbnail, a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a frame of the video is used; "auto" keeps YouTube’s pick.'),
|
|
7271
|
-
paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure)'),
|
|
7272
|
-
brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle)'),
|
|
7271
|
+
paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure). Carried to the upload when the post fires.'),
|
|
7272
|
+
brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle). Carried to the upload when the post fires.'),
|
|
7273
|
+
// THE REST OF A YOUTUBE UPLOAD (2026-11-07) — everything post_to_youtube takes now rides the queue, so a video can be scheduled through
|
|
7274
|
+
// OUR scheduler instead of uploaded early with YouTube's publishAt.
|
|
7275
|
+
categoryId: z.string().optional().describe('YOUTUBE: NUMERIC category id (28 Science & Technology, 27 Education, 24 Entertainment, 22 People & Blogs…; list_youtube_categories names them). Checked against YouTube’s assignable list when you schedule. Default 22.'),
|
|
7276
|
+
madeForKids: z.boolean().optional().describe('YOUTUBE: declare the video MADE FOR KIDS (COPPA). Default false.'),
|
|
7277
|
+
notifySubscribers: z.boolean().optional().describe('YOUTUBE: notify subscribers when it goes live. Omit to follow the privacy (public notifies, unlisted/private do not); true announces anyway, false stays quiet even when public.'),
|
|
7278
|
+
captionTrack: z.object({ url: z.string().optional(), text: z.string().optional(), language: z.string(), name: z.string().optional() }).optional().describe('YOUTUBE: a subtitle track (SRT or WebVTT) uploaded right after the video goes up. Pass url (a Hermoso-hosted file — upload_file returns one) OR text (the file body), plus language (BCP-47, required). If the caption upload fails the video is still posted and the result row carries the exact manage_youtube_caption call to retry just the track.'),
|
|
7273
7279
|
// THREADS' OWN POST OPTIONS — schedulable since 2026-08-17. `threadsPublish` has accepted all five since the
|
|
7274
7280
|
// connector-breadth sweep and `threadsPublishExtras` validates them; only the QUEUE could not carry them, so
|
|
7275
7281
|
// they worked when you posted now and vanished when you scheduled.
|
|
@@ -7494,6 +7500,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
7494
7500
|
thumbnailUrl: z.string().optional().describe('YOUTUBE: replace the custom thumbnail; "" goes back to a frame of the video, "auto" to YouTube’s pick.'),
|
|
7495
7501
|
paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure) — false removes it'),
|
|
7496
7502
|
brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle); "" removes it'),
|
|
7503
|
+
categoryId: z.string().optional().describe('YOUTUBE: NUMERIC category id; "" goes back to the default (22).'),
|
|
7504
|
+
madeForKids: z.boolean().optional().describe('YOUTUBE: made-for-kids declaration.'),
|
|
7505
|
+
notifySubscribers: z.boolean().optional().describe('YOUTUBE: notify subscribers when it goes live.'),
|
|
7506
|
+
captionTrack: z.object({ url: z.string().optional(), text: z.string().optional(), language: z.string(), name: z.string().optional() }).optional().describe('YOUTUBE: replace the queued caption track (url OR text, plus language).'),
|
|
7497
7507
|
// The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
|
|
7498
7508
|
// and reschedule_post is how an agent corrects a queued post it got wrong.
|
|
7499
7509
|
replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
|
|
@@ -8653,6 +8663,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8653
8663
|
tags: z.array(z.string()).optional().describe('up to 30 tags'),
|
|
8654
8664
|
thumbnailUrl: z.string().optional().describe('the custom thumbnail: a Hermoso-hosted image (make_thumbnail, or upload_file for the user’s own). Omit it and a representative frame of the video is set, free; "auto" keeps YouTube’s pick. Custom thumbnails need a verified channel.'),
|
|
8655
8665
|
privacy: z.enum(['private', 'unlisted', 'public']).optional().describe('default public (live + searchable on the channel); unlisted = link-only (the ad-ready setting, or to add YouTube music in Studio first — only when the user asks); private = eyes-only (cannot run as an ad)'),
|
|
8666
|
+
madeForKids: z.boolean().optional().describe('declare the video MADE FOR KIDS (COPPA) — default false; YouTube then turns off comments and personalised ads'),
|
|
8656
8667
|
categoryId: z.string().optional().describe('YouTube category id, NUMERIC — default "22" (People & Blogs), which is wrong for most ads. 1 Film & Animation · 2 Autos & Vehicles · 10 Music · 15 Pets & Animals · 17 Sports · 19 Travel & Events · 20 Gaming · 22 People & Blogs · 23 Comedy · 24 Entertainment · 25 News & Politics · 26 Howto & Style · 27 Education · 28 Science & Technology · 29 Nonprofits & Activism. The assignable set is region-specific, so the value is forwarded as given and YouTube has the last word.'),
|
|
8657
8668
|
publishAt: z.string().optional().describe('SCHEDULE the publish — ISO 8601, e.g. "2026-09-01T15:00:00Z", and it must be in the future. YouTube only allows this on a PRIVATE video and makes it PUBLIC at that moment, so pass privacy:"private" (or leave privacy unset) — asking for a scheduled "unlisted" or "public" post is refused rather than half-honoured.'),
|
|
8658
8669
|
notifySubscribers: z.boolean().optional().describe('THE DEFAULT FOLLOWS PRIVACY. privacy:"public" NOTIFIES the channel\'s subscribers — that is YouTube\'s own default and normally what someone publishing publicly wants. privacy:"unlisted" and "private" do NOT: the video is not on the channel, so announcing it is nonsense, and a blast to somebody\'s whole subscriber list cannot be undone. A scheduled publish (publishAt) is PRIVATE at upload, so it does not notify either — pass true to announce one. An explicit value ALWAYS wins in both directions: true announces an unlisted/private upload, false publishes publicly and quietly. The reply reports which way it went and why.'),
|
|
@@ -9004,7 +9015,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9004
9015
|
server.registerTool('manage_youtube_caption', {
|
|
9005
9016
|
title: 'Upload, replace or delete a caption track',
|
|
9006
9017
|
description: 'Attach a real subtitle track to one of the connected channel’s videos, replace one, or delete one. Pass the caption file BODY itself as `text` (SRT or WebVTT), not a URL. `language` is required and may not be guessed — YouTube uses it to decide which viewers see the track. A track YouTube generated itself (trackKind ASR) is refused by name: YouTube owns it and it cannot be edited or deleted through any API; to replace it, upload your own track in the same language and YouTube prefers yours. Delete is confirm-gated — viewers with captions on lose them immediately and YouTube stops indexing the video by that text, though it can be re-uploaded if you still have the file. 0 credits. Needs a connected YouTube channel.',
|
|
9007
|
-
inputSchema: { action: z.enum(['upload', 'update', 'delete']).describe('upload | update | delete'), videoId: z.string().optional().describe('required for upload; strongly recommended for update and delete so the track can be resolved and read back'), captionId: z.string().optional().describe('required for update and delete (from list_youtube_captions)'), text: z.string().optional().describe('the caption file body — SRT or WebVTT. Required for upload.'), language: z.string().optional().describe('BCP-47 code such as en, es, pt-BR. Required for upload.'), name: z.string().optional().describe('a label shown in YouTube’s caption picker'), draft: z.boolean().optional().describe('true keeps the track unpublished while you review it'), confirm: z.boolean().optional().describe('required to delete') },
|
|
9018
|
+
inputSchema: { action: z.enum(['upload', 'update', 'delete']).describe('upload | update | delete'), videoId: z.string().optional().describe('required for upload; strongly recommended for update and delete so the track can be resolved and read back'), captionId: z.string().optional().describe('required for update and delete (from list_youtube_captions)'), text: z.string().optional().describe('the caption file body — SRT or WebVTT. Required for upload unless you pass url.'), url: z.string().optional().describe('upload only: a Hermoso-hosted caption file (upload_file returns one) instead of pasting the body as text'), language: z.string().optional().describe('BCP-47 code such as en, es, pt-BR. Required for upload.'), name: z.string().optional().describe('a label shown in YouTube’s caption picker'), draft: z.boolean().optional().describe('true keeps the track unpublished while you review it'), confirm: z.boolean().optional().describe('required to delete') },
|
|
9008
9019
|
outputSchema: { ok: z.boolean().optional(), action: z.string().optional(), videoId: z.string().nullable().optional(), captionId: z.string().optional(), language: z.string().optional(), name: z.string().optional(), status: z.string().optional(), isDraft: z.boolean().optional(), deleted: z.boolean().optional(), verified: z.boolean().nullable().optional(), note: z.string().optional() },
|
|
9009
9020
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9010
9021
|
}, wrap(async (a) => { const d = await apiPost('/api/youtube/caption', a); return ok(d.note, d); }));
|
|
@@ -22900,7 +22911,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22900
22911
|
};
|
|
22901
22912
|
server.registerTool('plan_ad', {
|
|
22902
22913
|
title: 'Plan an ad concept',
|
|
22903
|
-
description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. When the user names a length ("a 30 second ad", "make it 45s"), pass it as durationSeconds: the storyboard is written to that length and render_ad renders it as one clip or as joined clips. Without it the planner chooses a length that suits the format, so a length the user asked for belongs in durationSeconds rather than only in the brief. The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. Uses credits for the planning step.',
|
|
22914
|
+
description: 'Plan an ad: turns a brand and a product or brief into an ad concept, with copy variants (headline, primary text, call to action) and either an image prompt or a timed video storyboard, plus the recipe and the model ids to render with. It renders nothing; pass its output to render_ad, generate_image or generate_video. When the user names a length ("a 30 second ad", "make it 45s"), pass it as durationSeconds: the storyboard is written to that length and render_ad renders it as one clip or as joined clips. Without it the planner chooses a length that suits the format, so a length the user asked for belongs in durationSeconds rather than only in the brief. The user\u2019s own screenshots or pictures to show inside the video go in `inserts`. The brand\u2019s SAVED product photo (get_brand / list_product_photos) is used automatically; never ask the user to upload it again. Uses credits for the planning step.',
|
|
22904
22915
|
inputSchema: {
|
|
22905
22916
|
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'),
|
|
22906
22917
|
product: z.string().describe('what to advertise + any angle/offer the user specified'),
|
|
@@ -22909,7 +22920,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22909
22920
|
hook: z.string().optional().describe('force the VISUAL scroll-stop mechanic the opening beat is built on — a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "macro_asmr"). Omit to let the planner pick. A hook that cannot be delivered in this brief is DROPPED with the reason rather than rendered wrongly — an on-screen-text hook on an authentic/UGC ad is the one that bites, because that register carries zero on-screen text.'),
|
|
22910
22921
|
setting: z.string().optional().describe('force the WHERE — a setting id from list_hooks (e.g. "kitchen", "gym", or a surreal one like "volcano_rim" / "airplane_wing", which are played 100% straight and never acknowledged). Omit for a neutral setting.'),
|
|
22911
22922
|
recipe: z.string().optional().describe('a recipe id from hermoso_capabilities to force an archetype'),
|
|
22912
|
-
reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post
|
|
22923
|
+
reference: z.string().optional().describe('a reference to clone: an ad-library link (Facebook Ad Library, LinkedIn Ad Library, Google Ads Transparency — its real copy/advertiser are fetched) OR a VIDEO link — a TikTok, Instagram Reel, Facebook video, X post or a direct video file (not YouTube: YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file).) — which is WATCHED first (frames + voiceover/on-screen-text transcript) so the concept keeps its hook, structure and pacing. To remake one video for this brand at its own length, clone_video is the direct tool'),
|
|
22913
22924
|
language: z.string().optional().describe('output language for the ad copy (e.g. Spanish) — default English'),
|
|
22914
22925
|
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."),
|
|
22915
22926
|
concepts: z.array(z.string()).max(4).optional().describe('several variants: one line per concept (a different angle each); built exactly'),
|
|
@@ -23001,9 +23012,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23001
23012
|
// Streamed (apiSSE), because watching + planning can outlast a 100s proxy limit on the non-streaming path.
|
|
23002
23013
|
server.registerTool('clone_video', {
|
|
23003
23014
|
title: 'Clone a video for your brand',
|
|
23004
|
-
description: 'Remake a video you like FOR THIS BRAND from its link — a TikTok, Instagram Reel, Facebook video or reel, X post, YouTube
|
|
23015
|
+
description: 'Remake a video you like FOR THIS BRAND from its link — a TikTok, Instagram Reel, Facebook video or reel, X post, or a direct video file URL. YouTube does not let us download videos, so a YouTube link is refused up front and nothing is charged; the user downloads it themselves and uploads the file (upload_file). Hermoso WATCHES it first (frames across the whole clip plus a transcript of the voiceover, on-screen text and cut map), then plans a storyboard that keeps its hook device, structure, jump cuts and pacing while swapping in THIS brand\'s product, cast, setting and words — never the original\'s words, face or brand. The new ad MATCHES THE ORIGINAL\'S LENGTH (capped at 60s) unless durationSeconds is given. Renders nothing: pass the returned creative to render_ad to make the video. Costs the plan plus about 2 credits to read the link. The reply says exactly what was watched, and when a platform will not hand over the footage (YouTube sometimes refuses servers) it says the plan rests on the captions and thumbnail only. For a local file, upload_file it first and pass the URL.',
|
|
23005
23016
|
inputSchema: {
|
|
23006
|
-
url: z.string().describe('the video to clone — a TikTok / Instagram Reel / Facebook / X
|
|
23017
|
+
url: z.string().describe('the video to clone — a TikTok / Instagram Reel / Facebook / X link, or a direct https video file URL (not YouTube, which cannot be downloaded); ' + MEDIA_PARAM_NOTE),
|
|
23007
23018
|
product: z.string().optional().describe('what the new ad sells, plus any angle or offer; omit to use the saved brand\'s product'),
|
|
23008
23019
|
changes: z.string().optional().describe('what to change or keep from the original, in the user\'s words (e.g. "same hook but in a gym", "keep the jump cut, older creator")'),
|
|
23009
23020
|
brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace\'s saved brand (see get_brand)'),
|
|
@@ -23023,7 +23034,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23023
23034
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
23024
23035
|
}, wrap(async ({ url, product, changes, brand, durationSeconds, language }) => {
|
|
23025
23036
|
const link = String(url || '').trim();
|
|
23026
|
-
if (!/^https?:\/\//i.test(link)) return ok('clone_video needs an https:// link to the video (a TikTok, Reel, Facebook
|
|
23037
|
+
if (!/^https?:\/\//i.test(link)) return ok('clone_video needs an https:// link to the video (a TikTok, Reel, Facebook or X post, or a direct file; YouTube links cannot be downloaded, so for a YouTube video the user downloads it and you upload_file it). For a file on this machine, call upload_file first and pass the URL it returns. Nothing was charged.');
|
|
23027
23038
|
const _askedLen = +durationSeconds > 0 ? Math.round(+durationSeconds) : 0;
|
|
23028
23039
|
const _len = _askedLen ? clampAdSeconds(_askedLen) : 0;
|
|
23029
23040
|
let brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
|
|
@@ -23084,7 +23095,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23084
23095
|
server.group('create');
|
|
23085
23096
|
server.registerTool('generate_image', {
|
|
23086
23097
|
title: 'Generate ad image',
|
|
23087
|
-
description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product. It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched. AN ANIMATED GIF OR AN SVG: format "gif" (a short looping animation, frames + frameMs) or "svg" (an editable vector) on gpt-image-2.6 / gpt-image-2.6-flare, from the prompt alone (no refImages). RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Seconds. Spends credits.',
|
|
23098
|
+
description: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product. It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. The saved brand\u2019s product photo is used automatically (see get_brand / list_product_photos), so never ask the user to upload it again. refImages (paths or URLs) composite a real product into the scene. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. Product photos also pick the ENGINE (the compositing model), so a named `model` is honoured only when no references ride; raw:true or useBrand:false keeps it. model = a catalog id from hermoso_capabilities (omit for the default). A PRODUCT IN A PERSON’S HANDS, or a garment on them: name an id marked `needsRefs` with a `refsMax`; person photo first, then up to three product/garment photos. It EDITS THE PHOTOGRAPH: the person is RE-POSED (stance and hands change; face, clothing, setting and light are kept), so it is the wrong tool when the rest must stay untouched. AN ANIMATED GIF OR AN SVG: format "gif" (a short looping animation, frames + frameMs) or "svg" (an editable vector) on gpt-image-2.6 / gpt-image-2.6-flare, from the prompt alone (no refImages). RAW MODEL ACCESS: ' + RAW_TOOL_NOTE + ' Seconds. Spends credits.',
|
|
23088
23099
|
inputSchema: {
|
|
23089
23100
|
prompt: z.string().optional().describe('REQUIRED on every model EXCEPT the pose rows below. the full image prompt — subject, composition, lighting, and any on-image ad text. ON A POSE MODEL (product-in-hand / try-on) THIS IS EXTRA DIRECTION AND IT IS OPTIONAL — leave it out and the pose is built for you. If you do write one, DESCRIBE THE POSE ("she holds the bottle upright in her right hand at chest height, label to camera"); do NOT phrase it as a swap ("replace the mug with the bottle"), which is REFUSED for free, because the product then comes out the size of whatever it replaced — a 30ml bottle rendered mug-sized in testing.'),
|
|
23090
23101
|
refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in. ON THE POSE MODELS — any row hermoso_capabilities marks `needsRefs` with a `refsMax`, such as putting your product in someone’s hands or a virtual try-on — THE ORDER IS THE CONTRACT AND IT IS NOT A COMPOSITE: refImages[0] is the PERSON photo, and the rest (up to `refsMax` minus one) are the product or garment photos. Reversed, you get the product wearing the person. A 4th product is dropped and the reply says so. ' + LIKENESS_TERMS),
|
|
@@ -23432,7 +23443,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23432
23443
|
server.group('create');
|
|
23433
23444
|
server.registerTool('render_ad', {
|
|
23434
23445
|
title: 'Render ad video',
|
|
23435
|
-
description: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product. It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). UGC and cinematic plans render from a painted STORYBOARD (a cinematic spot paints a location anchor first, and a hero when someone is on camera); a product-only spot renders from the real product photo, and board:true or foundations:\'choose\' adds an identity sheet, a moodboard and a board. A plan that fits one clip of the render model renders as one continuous pass; a longer one as STITCHED ACTS (the fewest balanced clips), never time-compressed. Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
|
|
23446
|
+
description: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product. It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). UGC and cinematic plans render from a painted STORYBOARD (a cinematic spot paints a location anchor first, and a hero when someone is on camera); a product-only spot renders from the real product photo, and board:true or foundations:\'choose\' adds an identity sheet, a moodboard and a board. A plan that fits one clip of the render model renders as one continuous pass; a longer one as STITCHED ACTS (the fewest balanced clips), never time-compressed. Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. The brand\u2019s saved product photo (get_brand / list_product_photos) is the real product in the shots, with no upload needed. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
|
|
23436
23447
|
inputSchema: {
|
|
23437
23448
|
creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
|
|
23438
23449
|
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 (casting skips the portrait we would otherwise generate). 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),
|
|
@@ -23759,7 +23770,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23759
23770
|
title: 'Compose an edit (timeline)',
|
|
23760
23771
|
description: "Compose ANY edit or transition yourself; there is no preset list. Segments on an output timeline (later ones drawn on top; overlapping ones ARE the transition), each animated by keyframes, plus your own HTML overlays and masks, compiled server-side into one render. Whip pan, zoom through, push, spin, speed ramp, slow motion, freeze frame, reverse, flash, J-cut / L-cut, circle or shape wipe, split screen, picture in picture: all composed from the fields below. Local render, the flat post_edit price (~2 credits, overlays included); a `generate` segment (a paid generated transition shot) only when the user explicitly asks for one. "
|
|
23761
23772
|
+ "LOOK FIRST AND AFTER: video_frames(url, start, end, fps) shows exact frames free, so find the moment (the second the egg tips) before you cut, and check the seam of what you rendered before you call it done. "
|
|
23762
|
-
+ "SOURCES: src is 'this' (videoUrl) or any Library / render URL, direct file or PUBLIC TikTok / Reel / Facebook / X
|
|
23773
|
+
+ "SOURCES: src is 'this' (videoUrl) or any Library / render URL, direct file or PUBLIC TikTok / Reel / Facebook / X post link (a viral hook's link works as is; a YouTube link is refused, YouTube does not let us download videos, so upload the file instead). in/out are SOURCE seconds; out:'payoff' cuts just before the hook's payoff, found from its motion. "
|
|
23763
23774
|
+ "AUDIO: audio.tail:'payoff' carries the hook's OWN payoff sound across the cut (the egg's real splat over our clip); tail seconds = an L-cut; lead = a J-cut; gain dB or keys; mute. "
|
|
23764
23775
|
+ "A VIRAL HOOK + THEIR PRODUCT: start from the hook. A clip matched to an unrelated hook never reads as one video, so write the clip AFTER the hook for it: a linking script + shot brief (the first line answers the hook, e.g. 'still waiting for the egg to land... anyway, come check out our restaurant'; what to film so it follows on; 5-15 s; then the pitch). The user records it, or you generate it (render_ad / generate_video, cost quoted first, on their OK); then join here: the hook with out:'payoff' and audio.tail:'payoff', then their clip. Match an existing unrelated clip only if they insist. A {generate:{prompt, seconds 3-8}} segment (a generated transition-only shot, paid, postEditTimeline) is never suggested; build it only when they explicitly ask for one. "
|
|
23765
23776
|
+ "LINK FIRST: an effect alone never connects two unrelated clips; the link comes from what is in the frames. (1) Match cut, the default: video_frames (with its MOTION readout) on the hook's last second and across the other clip; pick the out-point AND the in-point (in: seconds, not always 0) where a motion direction, a screen position or size, a shape, a surface, a gesture or a gaze carries across, then ride the effect on that shared motion. (2) Its host names the hook in the first line. If the two share nothing, say so and offer the follow clip made for the hook. "
|
|
@@ -23894,9 +23905,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23894
23905
|
// leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
|
|
23895
23906
|
server.registerTool('clip_video', {
|
|
23896
23907
|
title: 'Clip a long video',
|
|
23897
|
-
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a
|
|
23908
|
+
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: one cheap vision call per clip (billed as its own event) picks a SINGLE crop offset held for the whole clip, so a speaker sitting camera-left is not cropped out and the framing never drifts inside a clip; with nothing to discard or no single subject it stays dead centre — read `reframedToSubject` and each clip's `reframeWhy` back rather than assuming either way. ACCEPTS: a Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED watch link (NOT YouTube: YouTube does not let us download videos, so for a YouTube video the user downloads it and uploads the file), a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source ~15s to ~600MB; only the first ~40 minutes is analysed (truncated:true says so). Cost: a ~7-credit hold settled to the exact transcription + encode cost, plus the clip-selection model's tokens as their own small event. RETURNS clips[] — each its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode. SUBTITLES ARE BURNED IN BY DEFAULT (slim white CAPS, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL — cues follow the transcript's per-sentence timestamps, split by character count; never promise frame-accurate sync. captionsBurned counts the clips that really carry a burned track and captionNote says why any are bare.",
|
|
23898
23909
|
inputSchema: {
|
|
23899
|
-
video: z.string().describe('the long video to clip — a
|
|
23910
|
+
video: z.string().describe('the long video to clip — a Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL; ' + MEDIA_PARAM_NOTE),
|
|
23900
23911
|
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
23901
23912
|
aspectRatio: z.string().optional().describe("clip shape: '9:16' (default), '1:1', '16:9', any 'W:H', or 'keep' for the source framing"),
|
|
23902
23913
|
captions: z.boolean().optional().describe('burn subtitles into every clip. DEFAULT TRUE — a clip cut from a podcast or a talk is watched on mute, and the words are the product. Set false for clean footage. A clip whose window carries no readable speech is delivered bare rather than captioned with a guess, and the result says which.'),
|
|
@@ -23926,7 +23937,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23926
23937
|
// upload or someone else's video had no way to get them. The look is the same textStyle vocabulary render_ad speaks.
|
|
23927
23938
|
server.registerTool('add_subtitles', {
|
|
23928
23939
|
title: 'Add subtitles to a video',
|
|
23929
|
-
description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Only when asked, textStyle 'highlight' (or a highlightColor) lights the word being said, timed word by word. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a
|
|
23940
|
+
description: "Burn subtitles into ANY video and get the .srt too; nothing is cut or re-rendered. Set textStyle only when the user describes a look (default: white sentence case, thin outline, bottom). Timed per sentence, or auto:'words' for phrases on their spoken words. Only when asked, textStyle 'highlight' (or a highlightColor) lights the word being said, timed word by word. Or pass cues to burn your own lines exactly, untranscribed (reel-style phrases, *stage directions*). burn:false returns only the .srt. Takes a video file or URL, a Vimeo-style page, or a TikTok / Reel / X post link (not YouTube: YouTube does not let us download videos, so upload the file). No speech is refused and refunded. Runs in the background and lands in the Library.",
|
|
23930
23941
|
inputSchema: {
|
|
23931
23942
|
video: z.string().describe('the video to subtitle; ' + MEDIA_PARAM_NOTE),
|
|
23932
23943
|
textStyle: TEXT_STYLE_Z.optional().describe('the look: words, a preset or fields; omit for the default'),
|
|
@@ -24077,7 +24088,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
24077
24088
|
};
|
|
24078
24089
|
server.registerTool('make_series', {
|
|
24079
24090
|
title: 'Make a series of faceless short videos (Content Factory)',
|
|
24080
|
-
description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel /
|
|
24091
|
+
description: 'CONTENT FACTORY: turn ONE topic into a SERIES of faceless short videos (narration over pictures, no presenter), optionally in the STYLE of a trending reference video. Give `topic` ("make a video about…"), `episodes` (1-10, default 1) or your own `episodeTitles`, `durationSeconds` (20-120 per episode, default 30) and optionally `reference` (a TikTok / Reel / Facebook / X link or a video file URL whose look, pacing and opening hook every episode copies, with plain subtitles only if the reference burns its own; never its words or characters). Find a trending reference with search_tiktok / search_instagram / search_youtube when the user wants one. THREE STEPS, ALWAYS IN THIS ORDER: (1) call with no `confirm` for a FREE quote of the whole series (per episode and total); (2) optionally `planOnly:true` to see the episode titles and the style card first (a small planning charge); (3) `confirm:true` (pass the `plan` from step 2 to reuse it) queues every episode and returns their job ids; poll get_job. To make MORE of a series later, pass its returned plan as `moreOf`: same style, no episode repeated, the reference not watched again. Each finished episode can be scheduled with this tool again: pass `jobIds` plus `schedule` {channels, at, everyHours, timezone} and it books one post per finished episode, spaced `everyHours` apart (default 24). Captions follow the reference (on if it burns words on screen) unless `captions` says otherwise.',
|
|
24081
24092
|
inputSchema: {
|
|
24082
24093
|
topic: z.string().optional().describe('what the series is about, in the user\'s words'),
|
|
24083
24094
|
episodes: z.number().optional().describe('how many episodes, 1-10 (default 1)'),
|
|
@@ -26491,8 +26502,29 @@ function memoryNoteVerdict(text) {
|
|
|
26491
26502
|
// so the text says so before it says what the create tools do with the profile — the same note the web Studio reads.
|
|
26492
26503
|
const who = d?.personaNote ? `\n${d.personaNote}` : '';
|
|
26493
26504
|
const _pt = d?.profileType ? ` · ${d.profileType} profile` : '';
|
|
26505
|
+
// WHAT THE BRAND HOLDS, IN WORDS (2026-11-06): the product photos, logo, palette and product names were in
|
|
26506
|
+
// structuredContent only, and an agent reading the TEXT told a user the brand had no product photo while one was saved.
|
|
26507
|
+
const _brandFacts = (b) => {
|
|
26508
|
+
if (!b || typeof b !== 'object') return '';
|
|
26509
|
+
const _s = (x) => (typeof x === 'string' ? x.trim() : '');
|
|
26510
|
+
const imgs = [...new Set((Array.isArray(b.productImages) ? b.productImages : []).map((x) => _s(typeof x === 'string' ? x : x?.url)).filter(Boolean))];
|
|
26511
|
+
const hero = _s(b.product) || imgs[0] || '';
|
|
26512
|
+
const titles = new Map((Array.isArray(b.productTitles) ? b.productTitles : []).filter((x) => x && x.image).map((x) => [x.image, _s(x.title)]));
|
|
26513
|
+
const all = hero && !imgs.includes(hero) ? [hero, ...imgs] : imgs;
|
|
26514
|
+
const lines = [];
|
|
26515
|
+
if (all.length) {
|
|
26516
|
+
const ordered = [hero, ...all.filter((u) => u !== hero)].filter(Boolean);
|
|
26517
|
+
lines.push(`Product photos: ${ordered.length} saved (never ask the user to upload them again; plan_ad / render_ad / generate_image ground on the primary one automatically). Primary (hero): ${abs(ordered[0])}${titles.get(ordered[0]) ? ' (' + titles.get(ordered[0]) + ')' : ''}.` + (ordered.length > 1 ? ' Others: ' + ordered.slice(1, 8).map((u) => abs(u) + (titles.get(u) ? ' (' + titles.get(u) + ')' : '')).join('; ') + (ordered.length > 8 ? ` (+${ordered.length - 8} more, list_product_photos)` : '') + '.' : ''));
|
|
26518
|
+
} else lines.push('Product photos: none saved (set_product_image or draft_brand with confirmed:true adds them).');
|
|
26519
|
+
if (_s(b.logo)) lines.push(`Logo: ${abs(_s(b.logo))}`);
|
|
26520
|
+
const pal = (Array.isArray(b.palette) ? b.palette : []).map((c) => _s(typeof c === 'string' ? c : c?.hex || c?.color)).filter(Boolean);
|
|
26521
|
+
if (pal.length) lines.push(`Palette: ${pal.slice(0, 8).join(', ')}`);
|
|
26522
|
+
const prods = (Array.isArray(b.products) ? b.products : []).map((x) => _s(typeof x === 'string' ? x : x?.title || x?.name)).filter(Boolean);
|
|
26523
|
+
if (prods.length) lines.push(`Products: ${prods.slice(0, 12).join(', ')}`);
|
|
26524
|
+
return lines.length ? '\n' + lines.join('\n') : '';
|
|
26525
|
+
};
|
|
26494
26526
|
const text = d?.hasBrand
|
|
26495
|
-
? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''}${_pt} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.${who}`
|
|
26527
|
+
? `Saved brand: ${d.brand.name || d.brand.domain}${d.brand.category ? ' · ' + d.brand.category : ''}${_pt} · ${d.memoryCount} learned memory notes. plan_ad / plan_variations / create use it automatically when you omit brand.${_brandFacts(d.brand)}${who}`
|
|
26496
26528
|
: (d?.brand && d?.profileType === 'explore')
|
|
26497
26529
|
? `The profile “${d.brand.name || d.brand.domain}” is saved but set to just exploring, so nothing you make uses it. Make what they ask with no brand; switch it back with update_brand profileType (website, social or describe) only if THEY ask for on-brand work.${who}`
|
|
26498
26530
|
: (d?.persona === 'explorer'
|
|
@@ -26618,13 +26650,14 @@ function memoryNoteVerdict(text) {
|
|
|
26618
26650
|
|
|
26619
26651
|
server.registerTool('list_library', {
|
|
26620
26652
|
title: 'List library',
|
|
26621
|
-
description: "Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), 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.",
|
|
26653
|
+
description: "Browse this workspace's Library — every image/video generated in the Studio, and uploads (source:'upload'), 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), a human TITLE (the ad's headline or concept, the prompt's first words, or the upload's name), kind, model, and age. When the user names an ad by its headline (\"the Afternoon fix ad\"), pass `query` to find it by title. Free, read-only.",
|
|
26622
26654
|
inputSchema: {
|
|
26623
26655
|
kind: z.enum(['image', 'video', 'document', 'all']).optional().describe("filter by asset kind (default 'all')"),
|
|
26624
26656
|
uploads: z.boolean().optional(),
|
|
26657
|
+
query: z.string().optional().describe('only assets whose title contains this text (case-insensitive), e.g. an ad headline the user quoted'),
|
|
26625
26658
|
limit: z.number().optional().describe('max assets to return (default 20, max 60)'),
|
|
26626
26659
|
},
|
|
26627
|
-
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() },
|
|
26660
|
+
outputSchema: { assets: z.array(z.object({ id: z.string().optional(), url: z.string(), title: z.string().optional(), kind: z.string().optional(), model: z.string().optional(), ageHours: z.number().optional() })).optional() },
|
|
26628
26661
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
26629
26662
|
}, wrap(async (a) => {
|
|
26630
26663
|
let list = await readStore('heist.assets.v1'); // via /api/store/bootstrap — there is no GET /api/store/:key route
|
|
@@ -26633,10 +26666,21 @@ function memoryNoteVerdict(text) {
|
|
|
26633
26666
|
const lim = Math.min(60, Math.max(1, +a.limit || 20));
|
|
26634
26667
|
// id: L0, L1… over the whole Library newest first (a kind filter does not renumber), the id plan_ad / render_ad `inserts` take
|
|
26635
26668
|
const up = a.uploads === true;
|
|
26636
|
-
|
|
26637
|
-
|
|
26669
|
+
// A HUMAN TITLE ON EVERY ROW (2026-11-06): a user quotes an ad by its headline and the Library had only ids and urls.
|
|
26670
|
+
// Newer rows carry `title`; older ones are named from their linked creation (headline, concept, title) or their concept.
|
|
26671
|
+
let _cr = []; try { const c = await readStore('heist.creations.v1'); if (Array.isArray(c)) _cr = c; } catch {}
|
|
26672
|
+
const _crById = new Map(_cr.filter((c) => c && c.id).map((c) => [c.id, c]));
|
|
26673
|
+
const _clean = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
26674
|
+
const titleOf = (x) => {
|
|
26675
|
+
const c = x.creationId ? _crById.get(x.creationId) : null;
|
|
26676
|
+
return _clean(x.title) || (x.source === 'upload' ? _clean(x.name) : '') || _clean(c?.title) || _clean(c?.creative?.headline) || _clean(c?.creative?.concept) || _clean(x.concept) || _clean(c?.product) || '';
|
|
26677
|
+
};
|
|
26678
|
+
const q = String(a.query || '').trim().toLowerCase();
|
|
26679
|
+
const assets = list.filter(x => x && x.url).map((x, i) => ({ x, id: 'L' + i, title: titleOf(x) })).filter(({ x, title }) => (!kind || x.kind === kind) && (!up || x.source === 'upload') && (!q || (title + ' ' + _clean(x.concept) + ' ' + _clean(x.name)).toLowerCase().includes(q))).slice(0, lim)
|
|
26680
|
+
.map(({ x, id, title }) => ({ id, url: abs(x.url), ...(title ? { title } : {}), kind: x.kind || '', model: x.model || '', ...(x.source === 'upload' ? { source: 'upload', ...(x.name ? { name: String(x.name).slice(0, 120) } : {}) } : {}), ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
|
|
26681
|
+
if (!assets.length && q) return ok(`No Library asset has a title containing “${a.query}”. Call list_library without query to see every title.`, { assets: [] });
|
|
26638
26682
|
if (!assets.length) return ok(up ? 'Nothing uploaded to this workspace\'s Library yet — upload_file keeps an image, video or PDF there.' : 'The Library is empty for this workspace — render something or upload_file a file first.', { assets: [] });
|
|
26639
|
-
return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. [${x.kind || '?'}${x.source === 'upload' ? ' · your upload' + (x.name ? ' “' + x.name + '”' : '') : x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
|
|
26683
|
+
return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x) => ` ${x.id}. ${x.title ? '“' + x.title + '” ' : ''}[${x.kind || '?'}${x.source === 'upload' ? ' · your upload' + (x.name ? ' “' + x.name + '”' : '') : x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
|
|
26640
26684
|
}));
|
|
26641
26685
|
|
|
26642
26686
|
// DELETE FROM THE LIBRARY (2026-10-23). A customer was told it could not be done and sent to support. The server
|
|
@@ -27082,9 +27126,9 @@ function memoryNoteVerdict(text) {
|
|
|
27082
27126
|
// motion and the ORIGINAL AUDIO untouched. The plan is one server call; every variant is a `multiply` job (opening-frame keyframe edit → motion transfer driven by the source, so length, cut, motion and audio are the original's).
|
|
27083
27127
|
server.registerTool('multiply_ad', {
|
|
27084
27128
|
title: 'Multiply an ad',
|
|
27085
|
-
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were (that is what made the ad work), and any burned-in captions are removed. Pass the source video URL (a previous render, a job result, list_library, or the top performer from post_performance / meta_insights). HOW: the source video itself DRIVES each variant (motion transfer from one image-edited opening frame), so every variant comes back the SAME LENGTH as the source with the same cut, the same performance and the original audio — only the person, outfit, set and props change. Sources up to 30 seconds work as they are; longer ones are refused for free with the way out (trim it first: post_edit with ops [{op:'trim', start:0, end:30}] — clip_video is the AI highlight clipper, not a trim). Returns the plan and ONE JOB PER VARIANT — call get_job on each until it reports done; do not describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. Your own pictures: images takes up to 9 of the user's product, people, clothes or places, and change names them @Image1, @Image2 in order ('she holds the bottle in @Image1'); the saved brand's product photo is read as a reference to its real look (useBrand:false leaves it out). A TikTok, Reel, Facebook
|
|
27129
|
+
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were (that is what made the ad work), and any burned-in captions are removed. Pass the source video URL (a previous render, a job result, list_library, or the top performer from post_performance / meta_insights). HOW: the source video itself DRIVES each variant (motion transfer from one image-edited opening frame), so every variant comes back the SAME LENGTH as the source with the same cut, the same performance and the original audio — only the person, outfit, set and props change. Sources up to 30 seconds work as they are; longer ones are refused for free with the way out (trim it first: post_edit with ops [{op:'trim', start:0, end:30}] — clip_video is the AI highlight clipper, not a trim). Returns the plan and ONE JOB PER VARIANT — call get_job on each until it reports done; do not describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. Your own pictures: images takes up to 9 of the user's product, people, clothes or places, and change names them @Image1, @Image2 in order ('she holds the bottle in @Image1'); the saved brand's product photo is read as a reference to its real look (useBrand:false leaves it out). A TikTok, Reel, Facebook or X post link works as the source (not YouTube: upload the file): one paid lookup reads it into a file. quality: 1080p (default) or 720p; quoteOnly prices both. Regions: pass regions:['Berlin','Tokyo'] to restyle variants per market; translation is a separate, explicit step — dub_video on a finished variant. `change` is what should change, in the user's own words ('older women', 'winter streets', 'swap the mug for our bottle'): every variant applies it and the variants still differ around it; a part of it about the voice, words, language, music, length or captions cannot change here, and the reply says so and names the tool that can.",
|
|
27086
27130
|
inputSchema: {
|
|
27087
|
-
video: z.string().describe('the source video: a file URL, or a TikTok, Reel, Facebook
|
|
27131
|
+
video: z.string().describe('the source video: a file URL, or a TikTok, Reel, Facebook or X post link (not YouTube)'),
|
|
27088
27132
|
images: z.array(z.union([z.string(), z.object({ url: z.string(), role: z.string().optional().describe('what it is, in your words, e.g. "our founder"') })])).optional().describe('up to 9 pictures to put in the variants, in the order @Image1, @Image2 name them: URLs or local paths'),
|
|
27089
27133
|
quality: z.enum(['1080p', '720p']).optional().describe('1080p is the default; 720p costs less, and hands holding a product are less reliable at 720p'),
|
|
27090
27134
|
useBrand: z.boolean().optional().describe('false = send no brand product photo (for a video that is not this workspace brand)'),
|
|
@@ -27246,7 +27290,7 @@ function memoryNoteVerdict(text) {
|
|
|
27246
27290
|
// the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
|
|
27247
27291
|
server.registerTool('recast_hook', {
|
|
27248
27292
|
title: 'Recast a viral hook with your subject',
|
|
27249
|
-
description: "RECAST A VIRAL HOOK with the brand's own subject (the egg rolling off the table becomes your product; your host does the viral move), then join it into your clip. voice/language: a new voice, or language lip-synced, for one person swapped in. hook: a public post link (TikTok, Reel, Facebook, X
|
|
27293
|
+
description: "RECAST A VIRAL HOOK with the brand's own subject (the egg rolling off the table becomes your product; your host does the viral move), then join it into your clip. voice/language: a new voice, or language lip-synced, for one person swapped in. hook: a public post link (TikTok, Reel, Facebook, X; one lookup; not YouTube, which cannot be downloaded) or a video file. Subject: creator (saved name or id), image (a product or person; a real person's photo records consent), or neither for the brand's first product photo. A DUO OR GROUP (2-4 people): subjects [{creator or image, position: 'left'|'right'|'center' or your own words, refs?, outfit?}], each bound to its place in the frame, keeping that performer's movement and timing on the original sound. LOCATION AND OUTFITS, same pass: location (a place photo or words) moves the scene; outfit (a photo or words) re-dresses the subject, subjects[].outfit each person; with no subject named nobody is swapped and only those change (useBrand:true also swaps in the brand product). Pictures are references, 4 at most; more go in words. Person for person: Seedance 2.5 (720p) if the hook is our render or faceRoute is set, else Wan 3.0 (≤15s; Kling 4.0 Edit past). FULL LENGTH: past its one pass (30 s) the window is recast in passes, each continuing from the previous one's frame and crossfaded across the cut, under the original sound: one video up to 60 s, quoted as passes plus join; the reply names the cut. mode 'auto' is always 'swap' (a region edit: camera, set, light, timing, text and sound kept; a multi-shot hook goes shot by shot); 'motion' only if asked (your person moves in their own photo's setting). swap: your own words for what changes. start/end pick the window (3 s minimum; default: a group or a location/outfit change takes the whole clip, one subject the first 10 s). PAID, QUOTED FIRST: no confirm returns the plan and credits; confirm:true once the user okays it. Then get_job until done, and post_edit with videoUrl = the result and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
|
|
27250
27294
|
inputSchema: {
|
|
27251
27295
|
hook: z.string().describe('the viral hook: a post link or a video URL'),
|
|
27252
27296
|
creator: z.string().optional().describe("a saved creator's name or id (list_creators). NONE SAVED: cast a preset by exact name (list_creators), or pass image instead: a portrait of an AI person that generate_image painted from words (no reference photo) swaps in on every plan, nothing to save. A described lookalike of the user is fine on every plan; a photo of anyone is a real face (paid plan)"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.526",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1178 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 10 posts a month and every paid plan posts without a limit. 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, WordPress, Discord, Slack, Whop 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",
|