@hubfluencer/mcp 0.9.0 → 0.10.0

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/README.md CHANGED
@@ -68,7 +68,7 @@ claude mcp add hubfluencer --env HUBFLUENCER_API_TOKEN=YOUR_TOKEN -- npx -y @hub
68
68
 
69
69
  ## Tools
70
70
 
71
- 92 tools, grouped by workflow. Every tool prices its action in its own `description` — costs
71
+ 93 tools, grouped by workflow. Every tool prices its action in its own `description` — costs
72
72
  below are the ground truth. **"$0" means it spends no video credits**; planning/AI-helper tools
73
73
  instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_assists`: 1 credit
74
74
  → +10). Reads need the `video:read` scope; writes need `video:generate`. Nothing here needs
@@ -80,12 +80,12 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
80
80
  |---|---|---|
81
81
  | **`make_video`** | **One shot: prompt → finished MP4** (create → **price** → start → poll → download). `kind:"auto"` picks a multi-scene editor ad for ad/promo/story briefs, a short for simple ones (reported as `kind_inferred`). Pass `dry_run:true` to preview the cost without spending, or `max_credits` to cap it. | short **15**, editor ~**28** (`dry_run`/`max_credits` gate it) |
82
82
  | `create_short` | Create a short draft (12s vertical: two 6s AI segments + title overlay + music; 14s when it ends on a poster or brand-lockup end card). Set `headline` (≈4 words, ≤40 chars), `subheadline`, `music_vibe`, and an explicit `visual_language` (`kinetic_creator` is a strong default) — and **always `cta_text`** (a short without a CTA doesn't convert; blank `cta_text` → the server renders a neutral localized ask, e.g. "Learn more" — a truly CTA-less short is not currently supported). End-card fields: `closing_claim` (≤80), `brand_name` (≤40), `end_card` (`"auto"`\|`"none"`). Other opt-in conversion graphics: `offer_text`/`badge_text`/`star_rating` — only what you can substantiate. | **0** |
83
- | `update_short` | Patch an existing short draft — brief (`product_prompt`), copy, styling, end-card fields (`closing_claim`/`brand_name`/`poster_includes_lockup`/`end_card`), or the opt-in conversion graphics; only the fields you pass change. Clear one with `""` (`[]` for `text_beats`, `0` for `star_rating`). Apply text/style/CTA/end-card edits to a generated short free with `rerender_short`; footage/music edits need `generate_short`. | **0** |
83
+ | `update_short` | Patch an existing short draft — brief (`product_prompt`), copy, styling, end-card fields (`closing_claim`/`brand_name`/`poster_includes_lockup`/`end_card`), logo overlay placement (`short_logo_position`/`short_logo_treatment`), or the opt-in conversion graphics; only the fields you pass change. Clear one with `""` (`[]` for `text_beats`, `0` for `star_rating`). Apply text/style/CTA/end-card/logo edits to a generated short free with `rerender_short`; footage/music edits need `generate_short`. | **0** |
84
84
  | `generate_short` | Render an existing short — re-rolls the footage + music (fresh AI generation). If headline/subheadline/beats are all blank the server auto-writes the copy first (`skip_auto_text:true` for a deliberately bare clip). Safe to re-call (in-flight = reported, no double charge; a failed short can re-generate). | **15** |
85
- | `rerender_short` | **FREE re-render** — applies the short's current text/style/CTA/end-card state over the already-paid footage + music. Use after `update_short` for copy/style/CTA tweaks; footage or music changes need `generate_short` instead. Needs one completed generation first (422 `short_not_ready` otherwise); changed footage/music inputs return 422 `short_paid_regeneration_required`. A failed free re-render leaves the delivered video intact — retry `rerender_short`, it stays free. | **0** |
85
+ | `rerender_short` | **FREE re-render** — applies the short's current text/style/CTA/end-card/logo state over the already-paid footage + music. Use after `update_short` for copy/style/CTA tweaks, or after a logo change (`set_short_logo`, or `short_logo_position`/`short_logo_treatment`); footage or music changes need `generate_short` instead. Needs one completed generation first (422 `short_not_ready` otherwise); changed footage/music inputs return 422 `short_paid_regeneration_required`. A failed free re-render leaves the delivered video intact — retry `rerender_short`, it stays free. | **0** |
86
86
  | `generate_short_text` | AI-draft editable headline / subheadline / caption beats for a short. | 1 assist |
87
87
  | `create_editor_ad` | Create an editor project, attach optional local product/logo/closing assets, and return the live autopilot quote. Omit `max_credits` to stop free; provide an approved cap to launch. For a mascot/hero product that must remain identical with no humans, pass `cast_mode:"product_only"` plus `product_image_path`. | **0** until capped launch |
88
- | `start_autopilot` | Quote autopilot on an **existing** editor draft when called with only `slug`; launch only with an explicit `max_credits` that covers the estimate and an adequate balance. | **0** quote; credits on capped launch |
88
+ | `start_autopilot` | Quote autopilot on an **existing** editor draft when called with only `slug`; launch only with an explicit `max_credits` that covers the estimate and an adequate balance. On a completed project, pass `restart:true` to quote and build a fresh creative run; omit it to resume pending edits. | **0** quote; credits on capped launch |
89
89
  | `create_slider` | Create an image-carousel draft: one prompt → N still slides + a ready-to-post caption + hashtags. `mode` `creative` (story) or `ad_driven` (facts). | **0** |
90
90
  | `generate_slider` | Render a carousel (copy → AI backgrounds → composite). Poll `get_slider` until `completed`. | **1 / slide** (3–10) |
91
91
  | `get_slider` | Read a carousel's status and, when completed, the per-slide image URLs + caption + hashtags. | **0** |
@@ -133,6 +133,7 @@ images at **8 MiB (8,388,608 bytes)**; closing/short/catalog images at **20 MiB
133
133
  | `set_closing_image` | Set the ~2s end-card image (JPEG/PNG, ≤20 MiB; upload a file, or reuse the product image with `from_product:true`). |
134
134
  | `set_logo` | Upload a brand logo overlay (JPEG/PNG, ≤1 MiB; editor only; optional treatment/position/duration). |
135
135
  | `set_short_product` | Attach a product image (JPEG/PNG, ≤20 MiB) to a **short**. |
136
+ | `set_short_logo` | Attach an exact deterministic logo overlay (JPEG/PNG, ≤5 MiB) to a **short**; choose its corner and `throughout`/`end_card`/`none` (hide) treatment. The logo is never sent to the video model. |
136
137
  | `set_short_poster` | Set a short's end-card poster (JPEG/PNG, ≤20 MiB; extends the render 12s → 14s). Pass `poster_includes_lockup:true` if the poster already carries brand name/CTA/badges — suppresses the overlaid claim/CTA so they don't collide. |
137
138
 
138
139
  ### Status & delivery
@@ -233,7 +234,7 @@ edit are **0 credits** — you only pay to generate/render.
233
234
  |---|---|
234
235
  | Any draft create / edit / reorder | 0 |
235
236
  | Short render | **15** |
236
- | Short re-render (`rerender_short` — text/style/CTA/end-card over existing footage) | **0** |
237
+ | Short re-render (`rerender_short` — text/style/CTA/end-card/logo over existing footage) | **0** |
237
238
  | Editor AI scene (`generate_segment`) | **5** (4 each in a batch of ≥3) |
238
239
  | Editor scene re-roll (`regenerate_segment`, new version of a completed scene) | **4** |
239
240
  | Editor voice | **3** |
@@ -259,8 +260,9 @@ If it returns `terminal:false`, the render is still going — call `wait_for_com
259
260
  `create_editor_ad` (free configure + quote) → obtain spend approval → `start_autopilot {slug, max_credits}` → `wait_for_completion {kind:"editor"}` → `download_result`.
260
261
 
261
262
  **Iterate a short for free (never re-roll):** the first `generate_short` (15 credits) buys the footage +
262
- music; after that, every text/style/CTA/end-card tweak re-renders over them for **0 credits** —
263
- `update_short` (new hook/CTA/style) → `rerender_short` → compare → repeat → keep the winner. Only
263
+ music; after that, every text/style/CTA/end-card/logo tweak re-renders over them for **0 credits** —
264
+ `update_short` (new hook/CTA/style) → `rerender_short` → compare → repeat → keep the winner. Logo
265
+ placement (`set_short_logo`, or `short_logo_position`/`short_logo_treatment`) is free too. Only
264
266
  footage/music-affecting edits (`product_prompt`, product image, `creative_format`, `visual_language`,
265
267
  `theme`, `music_vibe`, `music_instruments`, `language`) need another `generate_short`. Edits that
266
268
  add/remove the end card (poster, `end_card`) can shift the video between 12s and 14s; the pinned music
package/dist/index.js CHANGED
@@ -29498,6 +29498,16 @@ function normalizeStatus(kind, slug, data) {
29498
29498
  video_url: videoUrl,
29499
29499
  error: d.error_message ?? null
29500
29500
  };
29501
+ if (typeof d.failed_stage === "string")
29502
+ status.failed_stage = d.failed_stage;
29503
+ if (typeof d.failure_code === "string")
29504
+ status.failure_code = d.failure_code;
29505
+ if (d.failure_details && typeof d.failure_details === "object")
29506
+ status.failure_details = d.failure_details;
29507
+ if (typeof d.failure_source === "string")
29508
+ status.failure_source = d.failure_source;
29509
+ if (typeof d.failure_attempt === "number")
29510
+ status.failure_attempt = d.failure_attempt;
29501
29511
  if (typeof d.latest_render_free === "boolean") {
29502
29512
  status.latest_render_free = d.latest_render_free;
29503
29513
  }
@@ -29558,7 +29568,7 @@ function normalizeStatus(kind, slug, data) {
29558
29568
  const renderFresh = latest.is_stale === false;
29559
29569
  const stale = renderStale || staleSegments.length > 0 || audioStale;
29560
29570
  const renderActive = renderStatus === "pending" || renderStatus === "rendering" || renderStatus === "processing";
29561
- const scenarioActive = d.scenario_status === "generating" || d.scenario_apply_status === "applying";
29571
+ const scenarioActive = d.scenario_status === "generating" || d.scenario_apply_status === "pending" || d.scenario_apply_status === "applying";
29562
29572
  const narrationActive = d.narration_status === "generating";
29563
29573
  const scenarioFailed = d.scenario_status === "failed";
29564
29574
  const scenarioApplyFailed = d.scenario_apply_status === "failed";
@@ -29589,13 +29599,16 @@ function normalizeStatus(kind, slug, data) {
29589
29599
  ].find((value) => typeof value === "string" && value.trim().length > 0);
29590
29600
  const audioActive = currentAudio.status === "pending" || currentAudio.status === "processing" || currentMusic.status === "pending" || currentMusic.status === "processing" || newerAudioVersions.some(versionActive) || newerMusicVersions.some(versionActive);
29591
29601
  const workActive = autopilotActive || batchActive || renderActive || scenarioActive || narrationActive || audioActive || activeSegments.length > 0;
29592
- const ready = renderStatus === "completed" && Boolean(videoUrl) && renderFresh && !stale && !workActive && failedSegments.length === 0 && !audioFailed && !scenarioFailed && !scenarioApplyFailed && !narrationFailed && !batchFailed;
29602
+ const deliveredArtifactSupersedesOrchestrationFailure = renderStatus === "completed" && Boolean(videoUrl) && renderFresh && !stale && !workActive && failedSegments.length === 0 && !audioFailed;
29603
+ const ready = deliveredArtifactSupersedesOrchestrationFailure;
29593
29604
  const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
29594
29605
  const renderFailed = renderStatus === "failed";
29595
- const failed = autopilotFailed || renderFailed || scenarioApplyFailed || scenarioFailed || narrationFailed || batchFailed || failedSegments.length > 0 || audioFailed;
29606
+ const failed = autopilotFailed && !deliveredArtifactSupersedesOrchestrationFailure || renderFailed || scenarioApplyFailed && !deliveredArtifactSupersedesOrchestrationFailure || scenarioFailed && !deliveredArtifactSupersedesOrchestrationFailure || narrationFailed && !deliveredArtifactSupersedesOrchestrationFailure || batchFailed && !deliveredArtifactSupersedesOrchestrationFailure || failedSegments.length > 0 || audioFailed;
29596
29607
  const terminal = ready || failed || batchInsufficientCredits || !workActive;
29597
29608
  let stage = autopilot;
29598
- if (autopilot === "failed")
29609
+ if (ready)
29610
+ stage = "completed";
29611
+ else if (autopilot === "failed")
29599
29612
  stage = "failed";
29600
29613
  else if (autopilot === "cancelled")
29601
29614
  stage = "cancelled";
@@ -29629,8 +29642,6 @@ function normalizeStatus(kind, slug, data) {
29629
29642
  stage = "audio_processing";
29630
29643
  else if (!workActive && stale)
29631
29644
  stage = "editing_required";
29632
- else if (ready)
29633
- stage = "completed";
29634
29645
  const segmentError = failedSegments.map((segment) => segment.error_message).find((value) => typeof value === "string" && value.length > 0);
29635
29646
  return {
29636
29647
  kind,
@@ -29639,7 +29650,7 @@ function normalizeStatus(kind, slug, data) {
29639
29650
  terminal,
29640
29651
  ready,
29641
29652
  video_url: videoUrl,
29642
- error: d.autopilot_error_message ?? latest.error_message ?? d.scenario_apply_error_message ?? d.scenario_error_message ?? d.narration_error_message ?? d.batch_generation_error_message ?? segmentError ?? audioError ?? (batchInsufficientCredits ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume." : null),
29653
+ error: ready ? null : d.autopilot_error_message ?? latest.error_message ?? d.scenario_apply_error_message ?? d.scenario_error_message ?? d.narration_error_message ?? d.batch_generation_error_message ?? segmentError ?? audioError ?? (batchInsufficientCredits ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume." : null),
29643
29654
  stale,
29644
29655
  active_segment_ids: activeSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string"),
29645
29656
  stale_segment_ids: staleSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string")
@@ -30232,7 +30243,7 @@ function readStoredCredentials() {
30232
30243
  // package.json
30233
30244
  var package_default = {
30234
30245
  name: "@hubfluencer/mcp",
30235
- version: "0.9.0",
30246
+ version: "0.10.0",
30236
30247
  description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30237
30248
  license: "MIT",
30238
30249
  author: "Monocursive <contact@monocursive.com>",
@@ -30545,6 +30556,11 @@ var getStatusOutput = exports_external.object({
30545
30556
  stale: exports_external.boolean().optional(),
30546
30557
  active_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
30547
30558
  stale_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
30559
+ failed_stage: exports_external.string().optional().describe("Short only: exact API failure phase behind a terminal failure."),
30560
+ failure_code: exports_external.string().optional().describe("Short only: stable machine-readable failure classification."),
30561
+ failure_details: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Short only: structured provider/QC details for recovery."),
30562
+ failure_source: exports_external.string().optional().describe("Short only: worker/recovery layer that finalized the failure."),
30563
+ failure_attempt: exports_external.number().optional().describe("Short only: paid generation attempt number that failed."),
30548
30564
  latest_render_free: exports_external.boolean().optional(),
30549
30565
  last_free_rerender_failed: exports_external.boolean().optional()
30550
30566
  }).passthrough();
@@ -30559,6 +30575,11 @@ var waitForCompletionOutput = exports_external.object({
30559
30575
  stale: exports_external.boolean().optional(),
30560
30576
  active_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
30561
30577
  stale_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
30578
+ failed_stage: exports_external.string().optional().describe("Short only: exact API failure phase behind a terminal failure."),
30579
+ failure_code: exports_external.string().optional().describe("Short only: stable machine-readable failure classification."),
30580
+ failure_details: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Short only: structured provider/QC details for recovery."),
30581
+ failure_source: exports_external.string().optional().describe("Short only: worker/recovery layer that finalized the failure."),
30582
+ failure_attempt: exports_external.number().optional().describe("Short only: paid generation attempt number that failed."),
30562
30583
  latest_render_free: exports_external.boolean().optional(),
30563
30584
  last_free_rerender_failed: exports_external.boolean().optional(),
30564
30585
  saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
@@ -31102,6 +31123,7 @@ var MAX_VIDEO_BYTES = 500000000;
31102
31123
  var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
31103
31124
  var MAX_PRODUCT_IMAGE_BYTES = 8 * 1024 * 1024;
31104
31125
  var MAX_LOGO_BYTES = 1 * 1024 * 1024;
31126
+ var MAX_SHORT_LOGO_BYTES = 5 * 1024 * 1024;
31105
31127
  var MAX_CLOSING_IMAGE_BYTES = 20 * 1024 * 1024;
31106
31128
  var MULTIPART_THRESHOLD = 50 * 1024 * 1024;
31107
31129
  var MAX_CATALOG_VIDEO_DURATION_SECONDS = 60;
@@ -31531,6 +31553,8 @@ async function editorAutopilotQuote(client, slug, overrides = {}) {
31531
31553
  params.set("product_subject", overrides.product_subject);
31532
31554
  if (overrides.product_prompt)
31533
31555
  params.set("product_prompt", overrides.product_prompt);
31556
+ if (overrides.restart)
31557
+ params.set("restart", "true");
31534
31558
  const query = params.toString();
31535
31559
  const response = await client.get(`/editor/${slug}/autopilot/cost${query ? `?${query}` : ""}`);
31536
31560
  const cost = asRecord(asRecord(response).data);
@@ -31553,6 +31577,26 @@ async function editorAutopilotQuote(client, slug, overrides = {}) {
31553
31577
  };
31554
31578
  }
31555
31579
  }
31580
+ async function editorChargeReceipt(client, slug, action, listPriceCredits) {
31581
+ try {
31582
+ const response = await client.get(`/editor/${slug}`);
31583
+ const editor = asRecord(asRecord(response).data);
31584
+ return {
31585
+ action,
31586
+ list_price_credits: listPriceCredits,
31587
+ project_spend: editor.project_spend ?? null,
31588
+ approved_cap: editor.autopilot_max_credits ?? null,
31589
+ approved_run_spend: editor.autopilot_credits_spent ?? null
31590
+ };
31591
+ } catch {
31592
+ return {
31593
+ action,
31594
+ list_price_credits: listPriceCredits,
31595
+ project_spend: null,
31596
+ enrichment_unavailable: true
31597
+ };
31598
+ }
31599
+ }
31556
31600
  function isRecompositeInProgressConflict(e) {
31557
31601
  const err = e;
31558
31602
  if (err?.status !== 409)
@@ -31772,7 +31816,7 @@ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
31772
31816
  8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
31773
31817
 
31774
31818
  CREDITS (spent server-side; each tool prices before charging — see its description)
31775
- - Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
31819
+ - Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card/logo edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
31776
31820
  - Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video supports dry_run/max_credits; create_editor_ad/start_autopilot require max_credits before launching.
31777
31821
  - AI ASSISTS (all generate_*/enhance/suggest/plan/hook tools) draw a FREE daily quota (20/day), NOT credits. On 429, set auto_unlock:true to spend 1 credit for +10 assists (retried once), or write the copy yourself and use the set_* tools.
31778
31822
 
@@ -32065,7 +32109,7 @@ registerTool("list_projects", {
32065
32109
  }));
32066
32110
  registerTool("create_short", {
32067
32111
  title: "Create a short (draft)",
32068
- description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when it ends on a poster or brand-lockup end card). " + "Returns the slug, costs 0 credits. Follow with generate_short to render. Iteration is FREE after that " + "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card " + "tweak re-renders over them for 0 credits (update_short → rerender_short). " + "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " + "title), and pick a music_vibe plus visual_language that fit the brand. " + "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " + "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " + "CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " + "default). If headline/subheadline/text_beats are left blank, the server auto-writes the headline + " + "caption beats at generate time (pass skip_auto_text:true to generate_short for a deliberately bare clip). " + "To brand it further, attach a product image " + "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " + "overlay that's an editor-only feature; use create_editor_ad for logo branding.)",
32112
+ description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when it ends on a poster or brand-lockup end card). " + "Returns the slug, costs 0 credits. Follow with generate_short to render. Iteration is FREE after that " + "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card/logo " + "tweak re-renders over them for 0 credits (update_short → rerender_short). " + "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " + "title), and pick a music_vibe plus visual_language that fit the brand. " + "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " + "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " + "CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " + "default). If headline/subheadline/text_beats are left blank, the server auto-writes the headline + " + "caption beats at generate time (pass skip_auto_text:true to generate_short for a deliberately bare clip). " + "To brand it further, attach a product image " + "(set_short_product), a deterministic brand logo (set_short_logo), or an end-card poster " + "(set_short_poster) before generate_short.",
32069
32113
  inputSchema: {
32070
32114
  product_prompt: exports_external.string().min(10).describe("What the short should be about"),
32071
32115
  language: exports_external.enum(LANGUAGES).optional().describe('Language code, e.g. "en" (default). Bare ISO-639-1 code only — no region/script subtag like "en-US".'),
@@ -32162,7 +32206,7 @@ registerTool("create_short", {
32162
32206
  }));
32163
32207
  registerTool("update_short", {
32164
32208
  title: "Update a short draft (0 credits)",
32165
- description: "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " + "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card fields " + "(headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, font, " + "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card) on an " + "already-generated short? " + "rerender_short applies them for 0 credits. Footage/music-affecting edits (product_prompt, product " + "image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need " + "generate_short (15 credits) again. Use this to edit the brief, copy, " + "styling, or the optional conversion graphics after create_short. " + "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " + "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " + "CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " + "default). " + "CLEARING a field: the conversion graphics (offer_text, cta_text, badge_text, star_rating) and " + 'text_beats are OPT-IN and OFF by default — pass "" (or 0 for star_rating, [] for text_beats) to REMOVE ' + "one that was set. The four conversion graphics are user-supplied only and never written by the AI copy " + "assist; text_beats (like headline/subheadline) IS rewritten by generate_short_text, so hand-crafted " + "beats don't survive a later assist call. " + "404 for an unknown/foreign slug.",
32209
+ description: "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " + "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card/logo fields " + "(headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, font, " + "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card, short_logo_position, " + "short_logo_treatment) on an " + "already-generated short? " + "rerender_short applies them for 0 credits. Footage/music-affecting edits (product_prompt, product " + "image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need " + "generate_short (15 credits) again. Use this to edit the brief, copy, " + "styling, or the optional conversion graphics after create_short. " + "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " + "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " + "CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " + "default). " + "CLEARING a field: the conversion graphics (offer_text, cta_text, badge_text, star_rating) and " + 'text_beats are OPT-IN and OFF by default — pass "" (or 0 for star_rating, [] for text_beats) to REMOVE ' + "one that was set. The four conversion graphics are user-supplied only and never written by the AI copy " + "assist; text_beats (like headline/subheadline) IS rewritten by generate_short_text, so hand-crafted " + "beats don't survive a later assist call. " + "404 for an unknown/foreign slug.",
32166
32210
  inputSchema: {
32167
32211
  slug: exports_external.string().describe("Short slug (from create_short)"),
32168
32212
  product_prompt: exports_external.string().min(10).optional().describe("Rewrite the brief (what the short is about, ≥10 chars); drives the next generate_short."),
@@ -32198,6 +32242,8 @@ registerTool("update_short", {
32198
32242
  brand_name: exports_external.string().max(40).optional().describe("Brand/product display name (≤40 chars) shown big on the closing brand-lockup slate when no " + `poster is set. Server falls back to your brand profile's name when unset. Pass "" to remove it.`),
32199
32243
  end_card: exports_external.enum(["auto", "none"]).optional().describe('"auto" (default): end on the poster, or on a brand-lockup slate (brand_name + claim + CTA pill) ' + 'when no poster is set — a 14s render. "none": no end card at all (12s, for deliberate ' + "loop-style shorts)."),
32200
32244
  poster_includes_lockup: exports_external.boolean().optional().describe("Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " + "claim/CTA so they don't collide (default false)."),
32245
+ short_logo_position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe("Corner for the deterministic brand-logo overlay (set the image with set_short_logo). Moving it is " + "a FREE re-render — logo fields never cost credits."),
32246
+ short_logo_treatment: exports_external.enum(["throughout", "end_card", "none"]).optional().describe('When the brand logo shows: "throughout" the footage, only on the "end_card", or "none" to hide it. ' + "FREE re-render."),
32201
32247
  brand_profile_id: exports_external.number().int().optional().describe("Seed brand defaults — colors/font/CTA — from a saved brand profile (id from " + "list_brand_profiles). Best set at create_short time (that path also freezes a brand snapshot); " + "on update it sets the linked profile. Must be one of your own profiles.")
32202
32248
  },
32203
32249
  annotations: {
@@ -32255,13 +32301,17 @@ registerTool("update_short", {
32255
32301
  body.end_card = args.end_card;
32256
32302
  if (args.poster_includes_lockup !== undefined)
32257
32303
  body.poster_includes_lockup = args.poster_includes_lockup;
32304
+ if (args.short_logo_position !== undefined)
32305
+ body.short_logo_position = args.short_logo_position;
32306
+ if (args.short_logo_treatment !== undefined)
32307
+ body.short_logo_treatment = args.short_logo_treatment;
32258
32308
  if (args.brand_profile_id !== undefined)
32259
32309
  body.brand_profile_id = args.brand_profile_id;
32260
32310
  const res = await client.patch(`/shorts/${args.slug}`, body);
32261
32311
  const data = asRecord(res).data ?? res;
32262
32312
  return ok({
32263
32313
  ...asRecord(data),
32264
- next: "Saved. Only text/style/CTA/end-card changed on an already-generated short? " + `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` + "(prompt, product image, format, visual_language, theme, music_*, language) need " + "generate_short (15 credits)."
32314
+ next: "Saved. Only text/style/CTA/end-card/logo changed on an already-generated short? " + `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` + "(prompt, product image, format, visual_language, theme, music_*, language) need " + "generate_short (15 credits)."
32265
32315
  });
32266
32316
  }));
32267
32317
  async function getTracking(client, slug, deadline) {
@@ -32430,7 +32480,7 @@ registerTool("generate_short", {
32430
32480
  }));
32431
32481
  registerTool("rerender_short", {
32432
32482
  title: "Re-render a short (FREE — 0 credits)",
32433
- description: "FREE re-render: applies the short's current text/style/CTA/end-card state over the already-paid " + "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks. Footage or music " + "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " + "end_card) can shift the video between 12s and 14s; when the pinned music bed is shorter than the " + "export it crossfades into a repeated continuation, then fades at the export end, without regeneration. " + "Requires a completed generation to re-render over " + "(422 short_not_ready otherwise — run generate_short once first); while any generation/render is in " + "flight it reports in-progress (409) rather than duplicating. Then poll with get_status or " + "wait_for_completion (kind=short). A failed free re-render leaves the delivered video intact — retry " + "rerender_short, it stays free (the status fields latest_render_free / last_free_rerender_failed " + "report this when the server sends them).",
32483
+ description: "FREE re-render: applies the short's current text/style/CTA/end-card/logo state over the already-paid " + "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks, or after a logo change " + "(set_short_logo, or short_logo_position/short_logo_treatment via update_short). Footage or music " + "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " + "end_card) can shift the video between 12s and 14s; when the pinned music bed is shorter than the " + "export it crossfades into a repeated continuation, then fades at the export end, without regeneration. " + "Requires a completed generation to re-render over " + "(422 short_not_ready otherwise — run generate_short once first); while any generation/render is in " + "flight it reports in-progress (409) rather than duplicating. Then poll with get_status or " + "wait_for_completion (kind=short). A failed free re-render leaves the delivered video intact — retry " + "rerender_short, it stays free (the status fields latest_render_free / last_free_rerender_failed " + "report this when the server sends them).",
32434
32484
  inputSchema: {
32435
32485
  slug: exports_external.string().describe("Short slug (from create_short)")
32436
32486
  },
@@ -32805,13 +32855,14 @@ registerTool("create_editor_ad", {
32805
32855
  }));
32806
32856
  registerTool("start_autopilot", {
32807
32857
  title: "Start autopilot on an existing editor draft",
32808
- description: "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " + "EXISTING editor project/draft. Without max_credits it returns the live quote and charges nothing. Supply an " + "explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " + "the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " + "if a run is already in progress for this slug the server returns already_running — it never double-starts " + "or double-charges. Once finished, failed, or cancelled, calling again relaunches it (resume after a top-up, raising your spend cap, or a mid-pipeline failure). Then poll with " + 'wait_for_completion({ slug, kind: "editor" }).',
32858
+ description: "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " + "EXISTING editor project/draft. Without max_credits it returns the live quote and charges nothing. Supply an " + "explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " + "the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " + "if a run is already in progress for this slug the server returns already_running — it never double-starts " + "or double-charges. Failed or cancelled runs resume normally. For a completed project, set restart:true to quote and build a fresh creative run; omitting it only resumes pending edits. Then poll with " + 'wait_for_completion({ slug, kind: "editor" }).',
32809
32859
  inputSchema: {
32810
32860
  slug: exports_external.string().describe("Editor project slug"),
32811
32861
  language: exports_external.enum(LANGUAGES).optional().describe('Optional language override applied before launch. Bare ISO-639-1 code only, e.g. "fr" — no ' + "region/script subtag. Drives scenario, narration, voice, and captions."),
32812
32862
  product_subject: exports_external.string().max(300).optional().describe("Optional locked product / main subject applied before launch so the AI cannot invent an " + "unrelated product."),
32813
32863
  product_prompt: exports_external.string().optional().describe("Optional freeform brief to set/replace before launch."),
32814
- max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to quote only; no credits are charged.")
32864
+ max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to quote only; no credits are charged."),
32865
+ restart: exports_external.boolean().optional().describe("Set true only on a completed project to quote/build a fresh creative run. The delivered timeline stays available until replacement apply.")
32815
32866
  },
32816
32867
  outputSchema: createEditorOutput,
32817
32868
  annotations: { title: "Start autopilot", ...WRITE, idempotentHint: false }
@@ -32830,6 +32881,8 @@ registerTool("start_autopilot", {
32830
32881
  overrides.product_subject = args.product_subject;
32831
32882
  if (args.product_prompt !== undefined)
32832
32883
  overrides.product_prompt = args.product_prompt;
32884
+ if (args.restart)
32885
+ overrides.restart = true;
32833
32886
  const quote = await editorAutopilotQuote(client, slug, overrides);
32834
32887
  const overCap = args.max_credits !== undefined && quote.estimated_credits !== null && quote.estimated_credits > args.max_credits;
32835
32888
  if (args.max_credits === undefined || quote.estimated_credits === null || overCap || !quote.affordable) {
@@ -33110,7 +33163,10 @@ registerTool("regenerate_segment", {
33110
33163
  }
33111
33164
  }, tool(async (args, client) => {
33112
33165
  const res = await client.post(`/editor/${args.slug}/segments/${String(args.segment_id)}/regenerate`, args.prompt !== undefined ? { prompt: args.prompt } : undefined, undefined);
33113
- return ok(asRecord(res).data ?? res);
33166
+ return ok({
33167
+ ...asRecord(asRecord(res).data ?? res),
33168
+ charge_receipt: await editorChargeReceipt(client, args.slug, "scene_regeneration", 4)
33169
+ });
33114
33170
  }));
33115
33171
  registerTool("generate_all_segments", {
33116
33172
  title: "Generate all pending segments (sequential)",
@@ -33227,7 +33283,10 @@ registerTool("generate_voice", {
33227
33283
  annotations: { title: "Generate voice", ...WRITE, idempotentHint: true }
33228
33284
  }, tool(async (args, client) => {
33229
33285
  const res = await client.post(`/editor/${args.slug}/generate-voice`, { voice_id: args.voice_id }, await voiceKey(client, args.slug, args.voice_id));
33230
- return ok(asRecord(res).data ?? res);
33286
+ return ok({
33287
+ ...asRecord(asRecord(res).data ?? res),
33288
+ charge_receipt: await editorChargeReceipt(client, args.slug, "voice_generation", 3)
33289
+ });
33231
33290
  }));
33232
33291
  registerTool("generate_music", {
33233
33292
  title: "Generate the background music (5 credits)",
@@ -33254,11 +33313,14 @@ registerTool("generate_music", {
33254
33313
  if (args.instruments !== undefined)
33255
33314
  body.instruments = args.instruments;
33256
33315
  const res = await client.post(`/editor/${args.slug}/generate-music`, body, undefined);
33257
- return ok(asRecord(res).data ?? res);
33316
+ return ok({
33317
+ ...asRecord(asRecord(res).data ?? res),
33318
+ charge_receipt: await editorChargeReceipt(client, args.slug, "music_generation", 5)
33319
+ });
33258
33320
  }));
33259
33321
  registerTool("render", {
33260
33322
  title: "Render the final editor video (0 credits)",
33261
- description: "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits (any still-ungenerated " + "segments are auto-charged at generation cost first). Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " + 'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' + "STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
33323
+ description: "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits (any still-ungenerated " + "segments are auto-charged at generation cost first). Render NEVER generates or charges for voice/music; a new " + "music charge comes only from generate_music or an already-running autopilot child job. Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " + 'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' + "STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
33262
33324
  inputSchema: { slug: exports_external.string().describe("Editor project slug") },
33263
33325
  annotations: { title: "Render", ...WRITE, idempotentHint: false }
33264
33326
  }, tool(async (args, client) => {
@@ -33598,9 +33660,28 @@ registerTool("set_short_product", {
33598
33660
  const res = await client.post(`/shorts/${args.slug}/product/confirm`, { s3_key, product_description: args.description });
33599
33661
  return ok(asRecord(res).data ?? res);
33600
33662
  }));
33663
+ registerTool("set_short_logo", {
33664
+ title: "Set a Short brand logo overlay (0 credits)",
33665
+ description: "Uploads an exact brand logo for deterministic compositing over a Short. The image is never sent to the " + "AI video provider, so lettering is preserved. PNG/JPEG, ≤5 MiB, 0 credits. Choose a corner and whether " + "it appears throughout the footage or only on the end card.",
33666
+ inputSchema: {
33667
+ slug: exports_external.string().describe("Short slug"),
33668
+ file_path: exports_external.string().describe("Local logo image (.png/.jpg/.jpeg)"),
33669
+ position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe("Logo corner (default top-right)"),
33670
+ treatment: exports_external.enum(["throughout", "end_card", "none"]).optional().describe("When the logo appears (default throughout)")
33671
+ },
33672
+ annotations: { title: "Set Short logo", ...WRITE, idempotentHint: false }
33673
+ }, tool(async (args, client) => {
33674
+ const { s3_key } = await uploadImageFile(client, `/shorts/${args.slug}/logo/presign`, args.file_path, MAX_SHORT_LOGO_BYTES);
33675
+ const res = await client.post(`/shorts/${args.slug}/logo/confirm`, {
33676
+ s3_key,
33677
+ position: args.position ?? "top-right",
33678
+ treatment: args.treatment ?? "throughout"
33679
+ });
33680
+ return ok(asRecord(res).data ?? res);
33681
+ }));
33601
33682
  registerTool("set_short_poster", {
33602
33683
  title: "Set a short's end-card poster image (0 credits)",
33603
- description: "Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " + "render from 12s to 14s). Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20 MiB = 20,971,520 bytes), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " + "again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
33684
+ description: "Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " + "render from 12s to 14s). Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20 MiB = 20,971,520 bytes), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " + "again replaces it. Use set_short_logo for an exact, non-generative brand overlay.",
33604
33685
  inputSchema: {
33605
33686
  slug: exports_external.string().describe("Short slug (from create_short)"),
33606
33687
  file_path: exports_external.string().describe("Local poster image (.jpg/.jpeg/.png)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
5
5
  "license": "MIT",
6
6
  "author": "Monocursive <contact@monocursive.com>",
package/src/core.ts CHANGED
@@ -265,6 +265,16 @@ export interface NormalizedStatus {
265
265
  ready: boolean;
266
266
  video_url: string | null;
267
267
  error: string | null;
268
+ /** Short only: exact API failure phase instead of a collapsed terminal state. */
269
+ failed_stage?: string;
270
+ /** Short only: stable machine-readable terminal failure classification. */
271
+ failure_code?: string;
272
+ /** Short only: structured provider/QC details for recovery decisions. */
273
+ failure_details?: Record<string, unknown>;
274
+ /** Short only: worker/recovery layer that finalized the failed attempt. */
275
+ failure_source?: string;
276
+ /** Short only: paid generation attempt number that failed. */
277
+ failure_attempt?: number;
268
278
  /** Editor only: the newest delivered render no longer matches the live project. */
269
279
  stale?: boolean;
270
280
  /** Editor only: current scene ids that are still generating. */
@@ -341,6 +351,16 @@ export function normalizeStatus(
341
351
  video_url: videoUrl,
342
352
  error: (d.error_message as string) ?? null,
343
353
  };
354
+ if (typeof d.failed_stage === "string")
355
+ status.failed_stage = d.failed_stage;
356
+ if (typeof d.failure_code === "string")
357
+ status.failure_code = d.failure_code;
358
+ if (d.failure_details && typeof d.failure_details === "object")
359
+ status.failure_details = d.failure_details as Record<string, unknown>;
360
+ if (typeof d.failure_source === "string")
361
+ status.failure_source = d.failure_source;
362
+ if (typeof d.failure_attempt === "number")
363
+ status.failure_attempt = d.failure_attempt;
344
364
  // Additive free-re-render bookkeeping (2026-07 API): surfaced only when
345
365
  // the server actually sends the booleans, so older payloads normalize
346
366
  // byte-identically. A failed free re-render does NOT flip stage to
@@ -477,10 +497,11 @@ export function normalizeStatus(
477
497
  renderStatus === "processing";
478
498
  // Scenario text generation / scenario apply run as async workers too
479
499
  // (scenario_status: none|generating|ready|failed; scenario_apply_status:
480
- // idle|applying|failed) — count them as active so a wait right after
500
+ // idle|pending|applying|failed) — count them as active so a wait right after
481
501
  // generate_scenario/apply_scenario doesn't bail out as an "idle" draft.
482
502
  const scenarioActive =
483
503
  d.scenario_status === "generating" ||
504
+ d.scenario_apply_status === "pending" ||
484
505
  d.scenario_apply_status === "applying";
485
506
  // Narration generation is a distinct async worker that runs before an
486
507
  // AudioVersion exists. Without this gate, a project with an older completed
@@ -553,27 +574,27 @@ export function normalizeStatus(
553
574
  narrationActive ||
554
575
  audioActive ||
555
576
  activeSegments.length > 0;
556
- const ready =
577
+ // Orchestration failures are historical once a repaired timeline has a fresh,
578
+ // completed MP4. Keeping them terminal would permanently block download_result
579
+ // after a successful granular recovery.
580
+ const deliveredArtifactSupersedesOrchestrationFailure =
557
581
  renderStatus === "completed" &&
558
582
  Boolean(videoUrl) &&
559
583
  renderFresh &&
560
584
  !stale &&
561
585
  !workActive &&
562
586
  failedSegments.length === 0 &&
563
- !audioFailed &&
564
- !scenarioFailed &&
565
- !scenarioApplyFailed &&
566
- !narrationFailed &&
567
- !batchFailed;
587
+ !audioFailed;
588
+ const ready = deliveredArtifactSupersedesOrchestrationFailure;
568
589
  const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
569
590
  const renderFailed = renderStatus === "failed";
570
591
  const failed =
571
- autopilotFailed ||
592
+ (autopilotFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
572
593
  renderFailed ||
573
- scenarioApplyFailed ||
574
- scenarioFailed ||
575
- narrationFailed ||
576
- batchFailed ||
594
+ (scenarioApplyFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
595
+ (scenarioFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
596
+ (narrationFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
597
+ (batchFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
577
598
  failedSegments.length > 0 ||
578
599
  audioFailed;
579
600
  // Terminal = control should return to the agent because nothing will progress
@@ -589,7 +610,8 @@ export function normalizeStatus(
589
610
  // Failure always wins over an unrelated active flag. Workers can leave an
590
611
  // outer autopilot/batch state active after a child stage has failed; reporting
591
612
  // the active stage would make agents poll forever instead of taking action.
592
- if (autopilot === "failed") stage = "failed";
613
+ if (ready) stage = "completed";
614
+ else if (autopilot === "failed") stage = "failed";
593
615
  else if (autopilot === "cancelled") stage = "cancelled";
594
616
  else if (renderFailed) stage = "render_failed";
595
617
  else if (scenarioApplyFailed) stage = "scenario_apply_failed";
@@ -606,7 +628,6 @@ export function normalizeStatus(
606
628
  else if (narrationActive) stage = "narration_processing";
607
629
  else if (audioActive) stage = "audio_processing";
608
630
  else if (!workActive && stale) stage = "editing_required";
609
- else if (ready) stage = "completed";
610
631
  const segmentError = failedSegments
611
632
  .map((segment) => segment.error_message)
612
633
  .find(
@@ -619,18 +640,19 @@ export function normalizeStatus(
619
640
  terminal,
620
641
  ready,
621
642
  video_url: videoUrl,
622
- error:
623
- (d.autopilot_error_message as string) ??
624
- (latest.error_message as string) ??
625
- (d.scenario_apply_error_message as string) ??
626
- (d.scenario_error_message as string) ??
627
- (d.narration_error_message as string) ??
628
- (d.batch_generation_error_message as string) ??
629
- segmentError ??
630
- audioError ??
631
- (batchInsufficientCredits
632
- ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume."
633
- : null),
643
+ error: ready
644
+ ? null
645
+ : ((d.autopilot_error_message as string) ??
646
+ (latest.error_message as string) ??
647
+ (d.scenario_apply_error_message as string) ??
648
+ (d.scenario_error_message as string) ??
649
+ (d.narration_error_message as string) ??
650
+ (d.batch_generation_error_message as string) ??
651
+ segmentError ??
652
+ audioError ??
653
+ (batchInsufficientCredits
654
+ ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume."
655
+ : null)),
634
656
  stale,
635
657
  active_segment_ids: activeSegments
636
658
  .map((segment) => segment.id)
package/src/index.ts CHANGED
@@ -121,6 +121,7 @@ import {
121
121
  MAX_CLOSING_IMAGE_BYTES,
122
122
  MAX_LOGO_BYTES,
123
123
  MAX_PRODUCT_IMAGE_BYTES,
124
+ MAX_SHORT_LOGO_BYTES,
124
125
  type PreparedUploadFile,
125
126
  preparedUploadIdentity,
126
127
  prepareImageUpload,
@@ -160,6 +161,7 @@ async function editorAutopilotQuote(
160
161
  language?: string;
161
162
  product_subject?: string;
162
163
  product_prompt?: string;
164
+ restart?: boolean;
163
165
  } = {},
164
166
  ): Promise<{
165
167
  estimated_credits: number | null;
@@ -173,6 +175,7 @@ async function editorAutopilotQuote(
173
175
  params.set("product_subject", overrides.product_subject);
174
176
  if (overrides.product_prompt)
175
177
  params.set("product_prompt", overrides.product_prompt);
178
+ if (overrides.restart) params.set("restart", "true");
176
179
  const query = params.toString();
177
180
  const response = await client.get<{ data: unknown }>(
178
181
  `/editor/${slug}/autopilot/cost${query ? `?${query}` : ""}`,
@@ -215,6 +218,34 @@ async function editorAutopilotQuote(
215
218
  }
216
219
  }
217
220
 
221
+ async function editorChargeReceipt(
222
+ client: HubfluencerClient,
223
+ slug: string,
224
+ action: "scene_regeneration" | "voice_generation" | "music_generation",
225
+ listPriceCredits: number,
226
+ ): Promise<Record<string, unknown>> {
227
+ try {
228
+ const response = await client.get<{ data: unknown }>(`/editor/${slug}`);
229
+ const editor = asRecord(asRecord(response).data);
230
+ return {
231
+ action,
232
+ list_price_credits: listPriceCredits,
233
+ project_spend: editor.project_spend ?? null,
234
+ approved_cap: editor.autopilot_max_credits ?? null,
235
+ approved_run_spend: editor.autopilot_credits_spent ?? null,
236
+ };
237
+ } catch {
238
+ // The paid mutation already succeeded. Never make a receipt-enrichment GET
239
+ // look like a failed charge that an agent should retry.
240
+ return {
241
+ action,
242
+ list_price_credits: listPriceCredits,
243
+ project_spend: null,
244
+ enrichment_unavailable: true,
245
+ };
246
+ }
247
+ }
248
+
218
249
  function isRecompositeInProgressConflict(e: unknown): boolean {
219
250
  const err = e as Partial<HubfluencerError>;
220
251
  if (err?.status !== 409) return false;
@@ -584,7 +615,7 @@ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
584
615
  8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
585
616
 
586
617
  CREDITS (spent server-side; each tool prices before charging — see its description)
587
- - Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
618
+ - Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card/logo edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
588
619
  - Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video supports dry_run/max_credits; create_editor_ad/start_autopilot require max_credits before launching.
589
620
  - AI ASSISTS (all generate_*/enhance/suggest/plan/hook tools) draw a FREE daily quota (20/day), NOT credits. On 429, set auto_unlock:true to spend 1 credit for +10 assists (retried once), or write the copy yourself and use the set_* tools.
590
621
 
@@ -1406,7 +1437,7 @@ registerTool(
1406
1437
  "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " +
1407
1438
  "segments + an on-screen title overlay + music; 14s when it ends on a poster or brand-lockup end card). " +
1408
1439
  "Returns the slug, costs 0 credits. Follow with generate_short to render. Iteration is FREE after that " +
1409
- "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card " +
1440
+ "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card/logo " +
1410
1441
  "tweak re-renders over them for 0 credits (update_short → rerender_short). " +
1411
1442
  "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " +
1412
1443
  "title), and pick a music_vibe plus visual_language that fit the brand. " +
@@ -1417,8 +1448,8 @@ registerTool(
1417
1448
  "default). If headline/subheadline/text_beats are left blank, the server auto-writes the headline + " +
1418
1449
  "caption beats at generate time (pass skip_auto_text:true to generate_short for a deliberately bare clip). " +
1419
1450
  "To brand it further, attach a product image " +
1420
- "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
1421
- "overlay that's an editor-only feature; use create_editor_ad for logo branding.)",
1451
+ "(set_short_product), a deterministic brand logo (set_short_logo), or an end-card poster " +
1452
+ "(set_short_poster) before generate_short.",
1422
1453
  inputSchema: {
1423
1454
  product_prompt: z
1424
1455
  .string()
@@ -1706,9 +1737,10 @@ registerTool(
1706
1737
  title: "Update a short draft (0 credits)",
1707
1738
  description:
1708
1739
  "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " +
1709
- "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card fields " +
1740
+ "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card/logo fields " +
1710
1741
  "(headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, font, " +
1711
- "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card) on an " +
1742
+ "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card, short_logo_position, " +
1743
+ "short_logo_treatment) on an " +
1712
1744
  "already-generated short? " +
1713
1745
  "rerender_short applies them for 0 credits. Footage/music-affecting edits (product_prompt, product " +
1714
1746
  "image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need " +
@@ -1892,6 +1924,20 @@ registerTool(
1892
1924
  "Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " +
1893
1925
  "claim/CTA so they don't collide (default false).",
1894
1926
  ),
1927
+ short_logo_position: z
1928
+ .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
1929
+ .optional()
1930
+ .describe(
1931
+ "Corner for the deterministic brand-logo overlay (set the image with set_short_logo). Moving it is " +
1932
+ "a FREE re-render — logo fields never cost credits.",
1933
+ ),
1934
+ short_logo_treatment: z
1935
+ .enum(["throughout", "end_card", "none"])
1936
+ .optional()
1937
+ .describe(
1938
+ 'When the brand logo shows: "throughout" the footage, only on the "end_card", or "none" to hide it. ' +
1939
+ "FREE re-render.",
1940
+ ),
1895
1941
  brand_profile_id: z
1896
1942
  .number()
1897
1943
  .int()
@@ -1937,6 +1983,12 @@ registerTool(
1937
1983
  brand_name?: string;
1938
1984
  end_card?: string;
1939
1985
  poster_includes_lockup?: boolean;
1986
+ short_logo_position?:
1987
+ | "top-left"
1988
+ | "top-right"
1989
+ | "bottom-left"
1990
+ | "bottom-right";
1991
+ short_logo_treatment?: "throughout" | "end_card" | "none";
1940
1992
  brand_profile_id?: number;
1941
1993
  },
1942
1994
  client,
@@ -1983,6 +2035,13 @@ registerTool(
1983
2035
  if (args.end_card !== undefined) body.end_card = args.end_card;
1984
2036
  if (args.poster_includes_lockup !== undefined)
1985
2037
  body.poster_includes_lockup = args.poster_includes_lockup;
2038
+ // Logo overlay placement — the API maps these body keys straight onto the
2039
+ // factory (short_logo_position/short_logo_treatment). They're excluded from
2040
+ // the paid-inputs snapshot, so a corner/treatment change is a FREE re-render.
2041
+ if (args.short_logo_position !== undefined)
2042
+ body.short_logo_position = args.short_logo_position;
2043
+ if (args.short_logo_treatment !== undefined)
2044
+ body.short_logo_treatment = args.short_logo_treatment;
1986
2045
  if (args.brand_profile_id !== undefined)
1987
2046
  body.brand_profile_id = args.brand_profile_id;
1988
2047
 
@@ -1994,7 +2053,7 @@ registerTool(
1994
2053
  return ok({
1995
2054
  ...asRecord(data),
1996
2055
  next:
1997
- "Saved. Only text/style/CTA/end-card changed on an already-generated short? " +
2056
+ "Saved. Only text/style/CTA/end-card/logo changed on an already-generated short? " +
1998
2057
  `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` +
1999
2058
  "(prompt, product image, format, visual_language, theme, music_*, language) need " +
2000
2059
  "generate_short (15 credits).",
@@ -2434,8 +2493,9 @@ registerTool(
2434
2493
  {
2435
2494
  title: "Re-render a short (FREE — 0 credits)",
2436
2495
  description:
2437
- "FREE re-render: applies the short's current text/style/CTA/end-card state over the already-paid " +
2438
- "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks. Footage or music " +
2496
+ "FREE re-render: applies the short's current text/style/CTA/end-card/logo state over the already-paid " +
2497
+ "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks, or after a logo change " +
2498
+ "(set_short_logo, or short_logo_position/short_logo_treatment via update_short). Footage or music " +
2439
2499
  "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " +
2440
2500
  "end_card) can shift the video between 12s and 14s; when the pinned music bed is shorter than the " +
2441
2501
  "export it crossfades into a repeated continuation, then fades at the export end, without regeneration. " +
@@ -3278,7 +3338,7 @@ registerTool(
3278
3338
  "explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " +
3279
3339
  "the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " +
3280
3340
  "if a run is already in progress for this slug the server returns already_running — it never double-starts " +
3281
- "or double-charges. Once finished, failed, or cancelled, calling again relaunches it (resume after a top-up, raising your spend cap, or a mid-pipeline failure). Then poll with " +
3341
+ "or double-charges. Failed or cancelled runs resume normally. For a completed project, set restart:true to quote and build a fresh creative run; omitting it only resumes pending edits. Then poll with " +
3282
3342
  'wait_for_completion({ slug, kind: "editor" }).',
3283
3343
  inputSchema: {
3284
3344
  slug: z.string().describe("Editor project slug"),
@@ -3309,6 +3369,12 @@ registerTool(
3309
3369
  .describe(
3310
3370
  "Explicit spend cap. Omit to quote only; no credits are charged.",
3311
3371
  ),
3372
+ restart: z
3373
+ .boolean()
3374
+ .optional()
3375
+ .describe(
3376
+ "Set true only on a completed project to quote/build a fresh creative run. The delivered timeline stays available until replacement apply.",
3377
+ ),
3312
3378
  },
3313
3379
  outputSchema: createEditorOutput,
3314
3380
  annotations: { title: "Start autopilot", ...WRITE, idempotentHint: false },
@@ -3321,6 +3387,7 @@ registerTool(
3321
3387
  product_subject?: string;
3322
3388
  product_prompt?: string;
3323
3389
  max_credits?: number;
3390
+ restart?: boolean;
3324
3391
  },
3325
3392
  client,
3326
3393
  ) => {
@@ -3334,12 +3401,14 @@ registerTool(
3334
3401
  language?: string;
3335
3402
  product_subject?: string;
3336
3403
  product_prompt?: string;
3404
+ restart?: boolean;
3337
3405
  } = {};
3338
3406
  if (args.language !== undefined) overrides.language = args.language;
3339
3407
  if (args.product_subject !== undefined)
3340
3408
  overrides.product_subject = args.product_subject;
3341
3409
  if (args.product_prompt !== undefined)
3342
3410
  overrides.product_prompt = args.product_prompt;
3411
+ if (args.restart) overrides.restart = true;
3343
3412
 
3344
3413
  const quote = await editorAutopilotQuote(client, slug, overrides);
3345
3414
  const overCap =
@@ -4021,7 +4090,15 @@ registerTool(
4021
4090
  args.prompt !== undefined ? { prompt: args.prompt } : undefined,
4022
4091
  undefined,
4023
4092
  );
4024
- return ok(asRecord(res).data ?? res);
4093
+ return ok({
4094
+ ...asRecord(asRecord(res).data ?? res),
4095
+ charge_receipt: await editorChargeReceipt(
4096
+ client,
4097
+ args.slug,
4098
+ "scene_regeneration",
4099
+ 4,
4100
+ ),
4101
+ });
4025
4102
  },
4026
4103
  ),
4027
4104
  );
@@ -4230,7 +4307,15 @@ registerTool(
4230
4307
  { voice_id: args.voice_id },
4231
4308
  await voiceKey(client, args.slug, args.voice_id),
4232
4309
  );
4233
- return ok(asRecord(res).data ?? res);
4310
+ return ok({
4311
+ ...asRecord(asRecord(res).data ?? res),
4312
+ charge_receipt: await editorChargeReceipt(
4313
+ client,
4314
+ args.slug,
4315
+ "voice_generation",
4316
+ 3,
4317
+ ),
4318
+ });
4234
4319
  }),
4235
4320
  );
4236
4321
 
@@ -4278,7 +4363,15 @@ registerTool(
4278
4363
  body,
4279
4364
  undefined,
4280
4365
  );
4281
- return ok(asRecord(res).data ?? res);
4366
+ return ok({
4367
+ ...asRecord(asRecord(res).data ?? res),
4368
+ charge_receipt: await editorChargeReceipt(
4369
+ client,
4370
+ args.slug,
4371
+ "music_generation",
4372
+ 5,
4373
+ ),
4374
+ });
4282
4375
  },
4283
4376
  ),
4284
4377
  );
@@ -4289,7 +4382,8 @@ registerTool(
4289
4382
  title: "Render the final editor video (0 credits)",
4290
4383
  description:
4291
4384
  "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits (any still-ungenerated " +
4292
- "segments are auto-charged at generation cost first). Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " +
4385
+ "segments are auto-charged at generation cost first). Render NEVER generates or charges for voice/music; a new " +
4386
+ "music charge comes only from generate_music or an already-running autopilot child job. Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " +
4293
4387
  'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' +
4294
4388
  "STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
4295
4389
  inputSchema: { slug: z.string().describe("Editor project slug") },
@@ -4979,11 +5073,11 @@ registerTool(
4979
5073
  ),
4980
5074
  );
4981
5075
 
4982
- // ── Shorts: branding images (product + end-card poster) ──────────────────────
5076
+ // ── Shorts: branding images (product + logo + end-card poster) ───────────────
4983
5077
  //
4984
5078
  // The editor branding tools above hit /editor/...; these hit /shorts/... A short
4985
- // has no logo overlay (editor-only), but it CAN feature a product image woven
4986
- // into the footage and a closing end-card poster. Both are 0 credits.
5079
+ // Product images are woven into AI footage; logos are deterministic Forge
5080
+ // overlays; posters are closing cards. All are 0 credits.
4987
5081
 
4988
5082
  registerTool(
4989
5083
  "set_short_product",
@@ -5032,6 +5126,57 @@ registerTool(
5032
5126
  ),
5033
5127
  );
5034
5128
 
5129
+ registerTool(
5130
+ "set_short_logo",
5131
+ {
5132
+ title: "Set a Short brand logo overlay (0 credits)",
5133
+ description:
5134
+ "Uploads an exact brand logo for deterministic compositing over a Short. The image is never sent to the " +
5135
+ "AI video provider, so lettering is preserved. PNG/JPEG, ≤5 MiB, 0 credits. Choose a corner and whether " +
5136
+ "it appears throughout the footage or only on the end card.",
5137
+ inputSchema: {
5138
+ slug: z.string().describe("Short slug"),
5139
+ file_path: z.string().describe("Local logo image (.png/.jpg/.jpeg)"),
5140
+ position: z
5141
+ .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
5142
+ .optional()
5143
+ .describe("Logo corner (default top-right)"),
5144
+ treatment: z
5145
+ .enum(["throughout", "end_card", "none"])
5146
+ .optional()
5147
+ .describe("When the logo appears (default throughout)"),
5148
+ },
5149
+ annotations: { title: "Set Short logo", ...WRITE, idempotentHint: false },
5150
+ },
5151
+ tool(
5152
+ async (
5153
+ args: {
5154
+ slug: string;
5155
+ file_path: string;
5156
+ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
5157
+ treatment?: "throughout" | "end_card" | "none";
5158
+ },
5159
+ client,
5160
+ ) => {
5161
+ const { s3_key } = await uploadImageFile(
5162
+ client,
5163
+ `/shorts/${args.slug}/logo/presign`,
5164
+ args.file_path,
5165
+ MAX_SHORT_LOGO_BYTES,
5166
+ );
5167
+ const res = await client.post<{ data: unknown }>(
5168
+ `/shorts/${args.slug}/logo/confirm`,
5169
+ {
5170
+ s3_key,
5171
+ position: args.position ?? "top-right",
5172
+ treatment: args.treatment ?? "throughout",
5173
+ },
5174
+ );
5175
+ return ok(asRecord(res).data ?? res);
5176
+ },
5177
+ ),
5178
+ );
5179
+
5035
5180
  registerTool(
5036
5181
  "set_short_poster",
5037
5182
  {
@@ -5041,7 +5186,7 @@ registerTool(
5041
5186
  "render from 12s to 14s). Accepted: " +
5042
5187
  IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
5043
5188
  " (≤20 MiB = 20,971,520 bytes), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " +
5044
- "again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
5189
+ "again replaces it. Use set_short_logo for an exact, non-generative brand overlay.",
5045
5190
  inputSchema: {
5046
5191
  slug: z.string().describe("Short slug (from create_short)"),
5047
5192
  file_path: z.string().describe("Local poster image (.jpg/.jpeg/.png)"),
@@ -65,8 +65,32 @@ export const getStatusOutput = z
65
65
  stale: z.boolean().optional(),
66
66
  active_segment_ids: z.array(z.union([z.number(), z.string()])).optional(),
67
67
  stale_segment_ids: z.array(z.union([z.number(), z.string()])).optional(),
68
- // Short only, additive (normalizeStatus forwards them gated on typeof
69
- // boolean, so the precise type can never reject a payload).
68
+ // Short only, additive (normalizeStatus forwards each gated on its typeof,
69
+ // so the precise type can never reject a payload).
70
+ failed_stage: z
71
+ .string()
72
+ .optional()
73
+ .describe(
74
+ "Short only: exact API failure phase behind a terminal failure.",
75
+ ),
76
+ failure_code: z
77
+ .string()
78
+ .optional()
79
+ .describe("Short only: stable machine-readable failure classification."),
80
+ failure_details: z
81
+ .record(z.string(), z.unknown())
82
+ .optional()
83
+ .describe("Short only: structured provider/QC details for recovery."),
84
+ failure_source: z
85
+ .string()
86
+ .optional()
87
+ .describe(
88
+ "Short only: worker/recovery layer that finalized the failure.",
89
+ ),
90
+ failure_attempt: z
91
+ .number()
92
+ .optional()
93
+ .describe("Short only: paid generation attempt number that failed."),
70
94
  latest_render_free: z.boolean().optional(),
71
95
  last_free_rerender_failed: z.boolean().optional(),
72
96
  })
@@ -85,6 +109,31 @@ export const waitForCompletionOutput = z
85
109
  stale: z.boolean().optional(),
86
110
  active_segment_ids: z.array(z.union([z.number(), z.string()])).optional(),
87
111
  stale_segment_ids: z.array(z.union([z.number(), z.string()])).optional(),
112
+ // Short only, additive — mirrors getStatusOutput (see there).
113
+ failed_stage: z
114
+ .string()
115
+ .optional()
116
+ .describe(
117
+ "Short only: exact API failure phase behind a terminal failure.",
118
+ ),
119
+ failure_code: z
120
+ .string()
121
+ .optional()
122
+ .describe("Short only: stable machine-readable failure classification."),
123
+ failure_details: z
124
+ .record(z.string(), z.unknown())
125
+ .optional()
126
+ .describe("Short only: structured provider/QC details for recovery."),
127
+ failure_source: z
128
+ .string()
129
+ .optional()
130
+ .describe(
131
+ "Short only: worker/recovery layer that finalized the failure.",
132
+ ),
133
+ failure_attempt: z
134
+ .number()
135
+ .optional()
136
+ .describe("Short only: paid generation attempt number that failed."),
88
137
  latest_render_free: z.boolean().optional(),
89
138
  last_free_rerender_failed: z.boolean().optional(),
90
139
  saved_to: z.union([z.string(), z.null()]).optional(),
package/src/uploads.ts CHANGED
@@ -64,6 +64,7 @@ const MAX_VIDEO_BYTES = 500_000_000; // EditorUpload.@max_upload_bytes
64
64
  const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
65
65
  export const MAX_PRODUCT_IMAGE_BYTES = 8 * 1024 * 1024;
66
66
  export const MAX_LOGO_BYTES = 1 * 1024 * 1024; // Editor.Logo.@max_logo_bytes
67
+ export const MAX_SHORT_LOGO_BYTES = 5 * 1024 * 1024;
67
68
  export const MAX_CLOSING_IMAGE_BYTES = 20 * 1024 * 1024;
68
69
  const MULTIPART_THRESHOLD = 50 * 1024 * 1024; // @multipart_min_size — at/above this, use multipart
69
70
  const MAX_CATALOG_VIDEO_DURATION_SECONDS = 60; // CatalogAsset video validator