@hubfluencer/mcp 0.10.0 → 0.11.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
- 93 tools, grouped by workflow. Every tool prices its action in its own `description` — costs
71
+ 95 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
@@ -86,6 +86,8 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
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
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
+ | `retry_editor_batch` | Recover `batch_retry_required` by atomically re-queueing the failed scene and resuming its prepaid batch. If the parent Autopilot run is terminal, quote/relaunch it separately afterward. | **0 additional** |
90
+ | `cancel_editor_batch` | Cancel an awaiting/running/paused editor batch. Destructive after scene dispatch: prepaid credits are not refunded (an awaiting-preview batch is refunded server-side). | **0 additional** |
89
91
  | `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
92
  | `generate_slider` | Render a carousel (copy → AI backgrounds → composite). Poll `get_slider` until `completed`. | **1 / slide** (3–10) |
91
93
  | `get_slider` | Read a carousel's status and, when completed, the per-slide image URLs + caption + hashtags. | **0** |
@@ -98,7 +100,7 @@ instead draw a **free daily quota of 20 AI assists** (top up with `unlock_ai_ass
98
100
  | Tool | What it does | Cost |
99
101
  |---|---|---|
100
102
  | `create_editor_draft` | Editor project, **no** autopilot. Accepts `creative_format` / `visual_language` / `theme` / `cast_mode` (`product_only` needs `set_product` before generation; `presenter_led` needs a configured presenter). | **0** |
101
- | `get_editor` | Full project state (scenario, segments, narration, music, latest render, quota) the review step. | **0** |
103
+ | `get_editor` | Full project state by default for compatibility; pass `response_format:"concise"` for a smaller review projection. | **0** |
102
104
  | `set_scenario` / `generate_scenario` | Write your own scenario (free) **or** AI-draft it. `generate_scenario` can also persist `creative_format` / `visual_language` / `theme`. | free / 1 assist |
103
105
  | `apply_scenario` | Compile the **ready** scenario into per-scene prompts on the timeline (`segments_count` 0 or 3..10; omitted uses the generated scenario count, then 5; AI scenes replaced, uploads preserved). Polls until applied (~2 min budget). | **0** |
104
106
  | `set_scene_count` | Grow/shrink to N scenes (1–20); deletes only trailing un-generated scenes. Each AI scene is a fixed 8s. | **0** |
@@ -120,7 +122,7 @@ Reads are confined to `HUBFLUENCER_INPUT_DIR`. All $0.
120
122
 
121
123
  Size units are exact: videos are capped at **500 MB decimal (500,000,000 bytes)**; product
122
124
  images at **8 MiB (8,388,608 bytes)**; closing/short/catalog images at **20 MiB
123
- (20,971,520 bytes)**; logos at **1 MiB (1,048,576 bytes)**. Editor video multipart starts at
125
+ (20,971,520 bytes)**; logos at **5 MiB (5,242,880 bytes)**. Editor video multipart starts at
124
126
  **50 MiB (52,428,800 bytes)**.
125
127
 
126
128
  | Tool | What it does |
@@ -131,7 +133,7 @@ images at **8 MiB (8,388,608 bytes)**; closing/short/catalog images at **20 MiB
131
133
  | `set_product` | Upload an editor product image (JPEG/PNG, ≤8 MiB) and attach it as the project product. |
132
134
  | `set_product_placement` | Set where the product appears on pending AI scenes: `throughout` or `end`. |
133
135
  | `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
- | `set_logo` | Upload a brand logo overlay (JPEG/PNG, ≤1 MiB; editor only; optional treatment/position/duration). |
136
+ | `set_logo` | Upload a brand logo overlay (JPEG/PNG, ≤5 MiB; editor only; optional treatment/position/duration). |
135
137
  | `set_short_product` | Attach a product image (JPEG/PNG, ≤20 MiB) to a **short**. |
136
138
  | `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. |
137
139
  | `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. |
@@ -251,14 +253,18 @@ edit are **0 credits** — you only pay to generate/render.
251
253
 
252
254
  **One shot (recommended):**
253
255
  `make_video({ prompt: "an ad for…", save_path: "ad.mp4" })` → returns `{ ready, video_url, saved_to, estimated_credits, available_credits }`.
254
- It prices the job and only charges if it's affordable (and within `max_credits` if you set one); otherwise it returns
255
- `charged:false` with the estimate and a free draft slug. Preview first with `make_video({ prompt, dry_run:true })`.
256
+ It prices the job and only launches if it's affordable (and within `max_credits` if you set one); otherwise it returns
257
+ `charged:false` with the estimate and a free draft slug. Shorts charge at launch. Editors return the authorized cap and
258
+ observed incremental spend (`authorized_credits`, `credits_spent`) instead of treating authorization as a charge.
259
+ Preview first with `make_video({ prompt, dry_run:true })`.
256
260
  If it returns `terminal:false`, the render is still going — call `wait_for_completion` with the returned slug.
257
261
 
258
262
  **Granular (control/recovery):**
259
263
  `create_short` → optional `generate_hook_variations` (test hook angles free) / `generate_short_text` → `generate_short` → `wait_for_completion {kind:"short"}` → `download_result`, or
260
264
  `create_editor_ad` (free configure + quote) → obtain spend approval → `start_autopilot {slug, max_credits}` → `wait_for_completion {kind:"editor"}` → `download_result`.
261
265
 
266
+ If waiting returns `stage:"batch_retry_required"`, call `retry_editor_batch` to continue the prepaid failed scene, or `cancel_editor_batch` to abandon the batch. Resolve that child batch before calling `start_autopilot` again; a retry does not itself authorize a new parent Autopilot run.
267
+
262
268
  **Iterate a short for free (never re-roll):** the first `generate_short` (15 credits) buys the footage +
263
269
  music; after that, every text/style/CTA/end-card/logo tweak re-renders over them for **0 credits** —
264
270
  `update_short` (new hook/CTA/style) → `rerender_short` → compare → repeat → keep the winner. Logo
package/dist/index.js CHANGED
@@ -29554,7 +29554,8 @@ function normalizeStatus(kind, slug, data) {
29554
29554
  const staleSegments = segments.filter((segment) => segment.video_stale === true && segment.status === "completed");
29555
29555
  const failedSegments = segments.filter((segment) => segment.status === "failed");
29556
29556
  const batchStatus = d.batch_generation_status ?? null;
29557
- const batchActive = batchStatus === "running" || batchStatus === "preparing" || batchStatus === "awaiting_previews" || batchStatus === "paused_for_retry";
29557
+ const batchActive = batchStatus === "running" || batchStatus === "preparing" || batchStatus === "awaiting_previews";
29558
+ const batchRetryRequired = batchStatus === "paused_for_retry";
29558
29559
  const batchInsufficientCredits = batchStatus === "paused_insufficient_credits";
29559
29560
  const batchFailed = batchStatus === "failed";
29560
29561
  const autopilotActive = [
@@ -29598,33 +29599,42 @@ function normalizeStatus(kind, slug, data) {
29598
29599
  currentMusic.error_message
29599
29600
  ].find((value) => typeof value === "string" && value.trim().length > 0);
29600
29601
  const audioActive = currentAudio.status === "pending" || currentAudio.status === "processing" || currentMusic.status === "pending" || currentMusic.status === "processing" || newerAudioVersions.some(versionActive) || newerMusicVersions.some(versionActive);
29601
- const workActive = autopilotActive || batchActive || renderActive || scenarioActive || narrationActive || audioActive || activeSegments.length > 0;
29602
+ const childWorkActive = batchActive || renderActive || scenarioActive || narrationActive || audioActive || activeSegments.length > 0;
29603
+ const autopilotStartedAt = typeof d.autopilot_started_at === "string" ? Date.parse(d.autopilot_started_at) : Number.NaN;
29604
+ const now = Date.now();
29605
+ const autopilotResumeGraceActive = autopilotActive && Number.isFinite(autopilotStartedAt) && autopilotStartedAt <= now && now - autopilotStartedAt <= 120000;
29606
+ const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
29607
+ const batchRecoveryActive = autopilotFailed && batchActive;
29608
+ const childFailureActionable = !batchRecoveryActive && (!autopilotActive || !childWorkActive && !autopilotResumeGraceActive);
29609
+ const workActive = autopilotActive || childWorkActive;
29602
29610
  const deliveredArtifactSupersedesOrchestrationFailure = renderStatus === "completed" && Boolean(videoUrl) && renderFresh && !stale && !workActive && failedSegments.length === 0 && !audioFailed;
29603
29611
  const ready = deliveredArtifactSupersedesOrchestrationFailure;
29604
- const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
29605
- const renderFailed = renderStatus === "failed";
29606
- const failed = autopilotFailed && !deliveredArtifactSupersedesOrchestrationFailure || renderFailed || scenarioApplyFailed && !deliveredArtifactSupersedesOrchestrationFailure || scenarioFailed && !deliveredArtifactSupersedesOrchestrationFailure || narrationFailed && !deliveredArtifactSupersedesOrchestrationFailure || batchFailed && !deliveredArtifactSupersedesOrchestrationFailure || failedSegments.length > 0 || audioFailed;
29607
- const terminal = ready || failed || batchInsufficientCredits || !workActive;
29612
+ const parentFailureActionable = autopilotFailed && !batchRecoveryActive;
29613
+ const renderFailed = renderStatus === "failed" && childFailureActionable;
29614
+ const failed = parentFailureActionable && !deliveredArtifactSupersedesOrchestrationFailure || renderFailed || childFailureActionable && scenarioApplyFailed && !deliveredArtifactSupersedesOrchestrationFailure || childFailureActionable && scenarioFailed && !deliveredArtifactSupersedesOrchestrationFailure || childFailureActionable && narrationFailed && !deliveredArtifactSupersedesOrchestrationFailure || childFailureActionable && batchFailed && !deliveredArtifactSupersedesOrchestrationFailure || childFailureActionable && failedSegments.length > 0 || childFailureActionable && audioFailed;
29615
+ const terminal = ready || failed || batchRetryRequired || batchInsufficientCredits || !workActive;
29608
29616
  let stage = autopilot;
29609
29617
  if (ready)
29610
29618
  stage = "completed";
29611
- else if (autopilot === "failed")
29619
+ else if (batchRetryRequired)
29620
+ stage = "batch_retry_required";
29621
+ else if (parentFailureActionable && autopilot === "failed")
29612
29622
  stage = "failed";
29613
- else if (autopilot === "cancelled")
29623
+ else if (parentFailureActionable && autopilot === "cancelled")
29614
29624
  stage = "cancelled";
29615
29625
  else if (renderFailed)
29616
29626
  stage = "render_failed";
29617
- else if (scenarioApplyFailed)
29627
+ else if (childFailureActionable && scenarioApplyFailed)
29618
29628
  stage = "scenario_apply_failed";
29619
- else if (scenarioFailed)
29629
+ else if (childFailureActionable && scenarioFailed)
29620
29630
  stage = "scenario_failed";
29621
- else if (narrationFailed)
29631
+ else if (childFailureActionable && narrationFailed)
29622
29632
  stage = "narration_failed";
29623
- else if (batchFailed)
29633
+ else if (childFailureActionable && batchFailed)
29624
29634
  stage = "batch_failed";
29625
- else if (failedSegments.length > 0)
29635
+ else if (childFailureActionable && failedSegments.length > 0)
29626
29636
  stage = "segment_failed";
29627
- else if (audioFailed)
29637
+ else if (childFailureActionable && audioFailed)
29628
29638
  stage = "audio_failed";
29629
29639
  else if (batchInsufficientCredits)
29630
29640
  stage = "insufficient_credits";
@@ -29643,6 +29653,7 @@ function normalizeStatus(kind, slug, data) {
29643
29653
  else if (!workActive && stale)
29644
29654
  stage = "editing_required";
29645
29655
  const segmentError = failedSegments.map((segment) => segment.error_message).find((value) => typeof value === "string" && value.length > 0);
29656
+ const actionableChildError = childFailureActionable ? latest.error_message ?? d.scenario_apply_error_message ?? d.scenario_error_message ?? d.narration_error_message ?? d.batch_generation_error_message ?? segmentError ?? audioError : null;
29646
29657
  return {
29647
29658
  kind,
29648
29659
  slug,
@@ -29650,7 +29661,7 @@ function normalizeStatus(kind, slug, data) {
29650
29661
  terminal,
29651
29662
  ready,
29652
29663
  video_url: videoUrl,
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),
29664
+ error: ready ? null : batchRetryRequired ? "Batch generation is paused after a failed scene. Call retry_editor_batch or cancel_editor_batch before continuing." : (parentFailureActionable ? d.autopilot_error_message : null) ?? actionableChildError ?? (batchInsufficientCredits ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume." : null),
29654
29665
  stale,
29655
29666
  active_segment_ids: activeSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string"),
29656
29667
  stale_segment_ids: staleSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string")
@@ -30243,7 +30254,7 @@ function readStoredCredentials() {
30243
30254
  // package.json
30244
30255
  var package_default = {
30245
30256
  name: "@hubfluencer/mcp",
30246
- version: "0.10.0",
30257
+ version: "0.11.0",
30247
30258
  description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30248
30259
  license: "MIT",
30249
30260
  author: "Monocursive <contact@monocursive.com>",
@@ -30646,6 +30657,9 @@ var createEditorOutput = exports_external.object({
30646
30657
  estimated_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
30647
30658
  available_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
30648
30659
  affordable: exports_external.boolean().optional(),
30660
+ launched: exports_external.boolean().optional(),
30661
+ authorized_credits: exports_external.number().optional(),
30662
+ credits_spent: exports_external.number().optional(),
30649
30663
  charged: exports_external.boolean().optional(),
30650
30664
  note: exports_external.string().optional(),
30651
30665
  next: exports_external.string().optional()
@@ -30662,6 +30676,9 @@ var makeVideoOutput = exports_external.object({
30662
30676
  estimated_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
30663
30677
  available_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
30664
30678
  affordable: exports_external.boolean().optional(),
30679
+ launched: exports_external.boolean().optional(),
30680
+ authorized_credits: exports_external.number().optional(),
30681
+ credits_spent: exports_external.number().optional(),
30665
30682
  charged: exports_external.boolean().optional(),
30666
30683
  saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30667
30684
  timed_out: exports_external.boolean().optional(),
@@ -31122,7 +31139,7 @@ var ALLOW_LOOPBACK_FETCH = /^http:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)/.test(p
31122
31139
  var MAX_VIDEO_BYTES = 500000000;
31123
31140
  var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
31124
31141
  var MAX_PRODUCT_IMAGE_BYTES = 8 * 1024 * 1024;
31125
- var MAX_LOGO_BYTES = 1 * 1024 * 1024;
31142
+ var MAX_LOGO_BYTES = 5 * 1024 * 1024;
31126
31143
  var MAX_SHORT_LOGO_BYTES = 5 * 1024 * 1024;
31127
31144
  var MAX_CLOSING_IMAGE_BYTES = 20 * 1024 * 1024;
31128
31145
  var MULTIPART_THRESHOLD = 50 * 1024 * 1024;
@@ -31821,7 +31838,7 @@ CREDITS (spent server-side; each tool prices before charging — see its descrip
31821
31838
  - 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.
31822
31839
 
31823
31840
  WAITING & RESUMING
31824
- - Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout. A terminal result is not always ready: an editor can go terminal in a needs-action stage (editing_required = stale after edits, regenerate the flagged scenes/audio; insufficient_credits = batch parked, top up / reduce scope; idle = draft with nothing generating, start generation first) — act on those instead of re-polling.
31841
+ - Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout. A terminal result is not always ready: an editor can go terminal in a needs-action stage (editing_required = stale after edits, regenerate the flagged scenes/audio; batch_retry_required = call retry_editor_batch or cancel_editor_batch before starting Autopilot again; insufficient_credits = batch parked, top up / reduce scope; idle = draft with nothing generating, start generation first) — act on those instead of re-polling.
31825
31842
  - Creates and charged actions are idempotency-keyed: a transport retry is safe; a genuine re-run after a terminal FAILURE restarts rather than replaying the dead attempt.
31826
31843
 
31827
31844
  LOCAL FILES (sandboxed)
@@ -31841,8 +31858,7 @@ async function pollToTerminal(client, kind, slug, extra, budgetMs, intervalMs) {
31841
31858
  }
31842
31859
  return status;
31843
31860
  }
31844
- async function pollSegmentToTerminal(client, slug, segmentId, extra, budgetMs, intervalMs) {
31845
- const deadline = Date.now() + budgetMs;
31861
+ async function pollSegmentToTerminal(client, slug, segmentId, extra, deadline, intervalMs) {
31846
31862
  const sid = String(segmentId);
31847
31863
  const read = async () => {
31848
31864
  const res = await client.get(`/editor/${slug}`, undefined, deadline);
@@ -31991,6 +32007,7 @@ registerTool("make_video", {
31991
32007
  const costPath = kind === "short" ? `/shorts/${slug}/cost` : `/editor/${slug}/autopilot/cost`;
31992
32008
  let estimated_credits = null;
31993
32009
  let available_credits = null;
32010
+ let authorizedCredits;
31994
32011
  try {
31995
32012
  const cost = asRecord(asRecord(await client.get(costPath)).data);
31996
32013
  estimated_credits = typeof cost.total === "number" ? cost.total : null;
@@ -32025,6 +32042,7 @@ registerTool("make_video", {
32025
32042
  if (approvedCap === null) {
32026
32043
  throw new Error("Editor autopilot launch requires max_credits or a live quote");
32027
32044
  }
32045
+ authorizedCredits = approvedCap;
32028
32046
  await client.post(`/editor/${slug}/autopilot`, { max_credits: approvedCap }, makeVideoAutopilotKey(slug, args, startState));
32029
32047
  }
32030
32048
  await reportProgress(extra, 0, `started ${kind} ${slug}`);
@@ -32033,12 +32051,29 @@ registerTool("make_video", {
32033
32051
  if (status.ready && status.video_url && args.save_path) {
32034
32052
  saved_to = (await downloadTo(status.video_url, args.save_path)).saved_to;
32035
32053
  }
32054
+ let editorCreditsSpent;
32055
+ if (kind === "editor") {
32056
+ try {
32057
+ const current = asRecord(asRecord(await client.get(`/editor/${slug}`, undefined, Date.now() + 5000)).data);
32058
+ if (typeof current.autopilot_credits_spent === "number") {
32059
+ editorCreditsSpent = current.autopilot_credits_spent;
32060
+ }
32061
+ } catch {}
32062
+ }
32063
+ const billing = kind === "short" ? { charged: true } : {
32064
+ launched: true,
32065
+ authorized_credits: authorizedCredits,
32066
+ ...editorCreditsSpent === undefined ? {} : {
32067
+ credits_spent: editorCreditsSpent,
32068
+ charged: editorCreditsSpent > 0
32069
+ }
32070
+ };
32036
32071
  const result = {
32037
32072
  ...status,
32038
32073
  kind_inferred: requestedKind === undefined,
32039
32074
  estimated_credits,
32040
32075
  available_credits,
32041
- charged: true,
32076
+ ...billing,
32042
32077
  saved_to,
32043
32078
  timed_out: !status.terminal
32044
32079
  };
@@ -32046,7 +32081,7 @@ registerTool("make_video", {
32046
32081
  const links = status.ready && status.video_url ? [mp4Link(status.video_url, slug)] : [];
32047
32082
  return ok({
32048
32083
  ...result,
32049
- note: status.ready ? "Done. Presigned ~24h URL — download promptly." : status.terminal ? `Terminal (${status.stage}). ${status.error ?? ""}`.trim() + ` To restart, fix the cause (often credits) then re-run make_video, or resume with ${resume}.` : `Still rendering — call wait_for_completion(${resumeArgs}).`
32084
+ note: status.ready ? "Done. Presigned ~24h URL — download promptly." : status.terminal ? status.stage === "batch_retry_required" ? `Terminal (${status.stage}). ${status.error ?? ""} Resolve the paused batch before restarting Autopilot.`.trim() : `Terminal (${status.stage}). ${status.error ?? ""}`.trim() + ` To restart, fix the cause (often credits) then re-run make_video, or resume with ${resume}.` : `Still rendering — call wait_for_completion(${resumeArgs}).`
32050
32085
  }, links);
32051
32086
  }));
32052
32087
  registerTool("get_credits", {
@@ -32735,7 +32770,7 @@ registerTool("create_editor_ad", {
32735
32770
  product_image_path: exports_external.string().optional().describe("Local product image (.jpg/.jpeg/.png, ≤8 MiB) to attach before autopilot."),
32736
32771
  product_description: exports_external.string().max(500).optional().describe("Exact product/character description used with product_image_path."),
32737
32772
  closing_image_path: exports_external.string().optional().describe("Local closing-card image (.jpg/.jpeg/.png, ≤20 MiB)."),
32738
- logo_path: exports_external.string().optional().describe("Local logo (.jpg/.jpeg/.png, ≤1 MiB) to overlay."),
32773
+ logo_path: exports_external.string().optional().describe("Local logo (.jpg/.jpeg/.png, ≤5 MiB) to overlay."),
32739
32774
  logo_treatment: exports_external.enum(["intro", "outro", "both", "none"]).optional().describe('When the logo bumper plays: "intro", "outro", "both" (default), or "none". Requires logo_path.'),
32740
32775
  logo_position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe('Logo corner: "top-left", "top-right" (default), "bottom-left", or "bottom-right". Requires logo_path.'),
32741
32776
  logo_duration_seconds: exports_external.number().min(0.5).max(3).optional().describe("Logo bumper duration in seconds, 0.5–3 (default 1.5). Requires logo_path."),
@@ -32846,7 +32881,10 @@ registerTool("create_editor_ad", {
32846
32881
  kind: "editor",
32847
32882
  status,
32848
32883
  ...quote,
32849
- charged: true,
32884
+ launched: true,
32885
+ authorized_credits: args.max_credits,
32886
+ credits_spent: 0,
32887
+ charged: false,
32850
32888
  next: "wait_for_completion"
32851
32889
  });
32852
32890
  } finally {
@@ -32855,7 +32893,7 @@ registerTool("create_editor_ad", {
32855
32893
  }));
32856
32894
  registerTool("start_autopilot", {
32857
32895
  title: "Start autopilot on an existing editor draft",
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" }).',
32896
+ 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, except when status is batch_retry_required: " + "call retry_editor_batch (then wait for that prepaid batch) or cancel_editor_batch first. 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" }).',
32859
32897
  inputSchema: {
32860
32898
  slug: exports_external.string().describe("Editor project slug"),
32861
32899
  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."),
@@ -32901,10 +32939,52 @@ registerTool("start_autopilot", {
32901
32939
  kind: "editor",
32902
32940
  status,
32903
32941
  ...quote,
32904
- charged: true,
32942
+ launched: true,
32943
+ authorized_credits: args.max_credits,
32944
+ credits_spent: 0,
32945
+ charged: false,
32905
32946
  next: "wait_for_completion"
32906
32947
  });
32907
32948
  }));
32949
+ registerTool("retry_editor_batch", {
32950
+ title: "Retry a paused editor batch",
32951
+ description: "Recovers an editor whose normalized status is batch_retry_required by atomically re-queueing the failed " + "scene and resuming its prepaid batch. This spends 0 additional credits: the batch generation was already " + "paid. It does not authorize or restart a failed parent Autopilot run; after retrying, wait for the batch " + "to settle. If the parent is then terminal, call start_autopilot without max_credits for a fresh quote and " + "relaunch only with an approved cap. A batch parked on " + "insufficient_credits needs a top-up/scope change instead. Scope: video:generate.",
32952
+ inputSchema: { slug: exports_external.string().describe("Editor project slug") },
32953
+ annotations: {
32954
+ title: "Retry editor batch",
32955
+ ...WRITE,
32956
+ idempotentHint: false
32957
+ }
32958
+ }, tool(async (args, client) => {
32959
+ const res = await client.post(`/video-factories/${args.slug}/retry-batch-segment`);
32960
+ return ok({
32961
+ ...asRecord(asRecord(res).data ?? res),
32962
+ slug: args.slug,
32963
+ charged: false,
32964
+ note: "The failed scene retry is queued and the prepaid batch is running. Wait for it to settle; if the parent Autopilot run is then terminal, quote start_autopilot again and relaunch only with an approved max_credits cap.",
32965
+ next: "wait_for_completion"
32966
+ });
32967
+ }));
32968
+ registerTool("cancel_editor_batch", {
32969
+ title: "Cancel an editor batch",
32970
+ description: "Cancels an editor batch in awaiting_previews, running, or paused_for_retry so the project can be edited or " + "Autopilot can be started again. Cancellation is destructive: dispatched scene work is stopped and its prepaid " + "credits are not refunded; only an awaiting_previews batch (before dispatch) is refunded by the server. Spends " + "0 additional credits. Scope: video:generate.",
32971
+ inputSchema: { slug: exports_external.string().describe("Editor project slug") },
32972
+ annotations: {
32973
+ title: "Cancel editor batch",
32974
+ readOnlyHint: false,
32975
+ destructiveHint: true,
32976
+ openWorldHint: true,
32977
+ idempotentHint: false
32978
+ }
32979
+ }, tool(async (args, client) => {
32980
+ const res = await client.post(`/video-factories/${args.slug}/cancel-batch`);
32981
+ return ok({
32982
+ ...asRecord(asRecord(res).data ?? res),
32983
+ slug: args.slug,
32984
+ charged: false,
32985
+ next: "get_status"
32986
+ });
32987
+ }));
32908
32988
  registerTool("get_ai_assists", {
32909
32989
  title: "Get AI assist quota",
32910
32990
  description: "Returns the free daily AI-assist quota {used, limit, bonus, remaining, resets_at, unlock_cost, " + "unlock_batch_size}. AI assists power helper calls (generate_scenario, generate_narration, " + "enhance_prompt, suggest_next_scene, suggest_music_prompt). Default limit is 20/day. When remaining " + "hits 0 those helpers return 429 — either unlock_ai_assists (1 credit → +10) or write the content " + "yourself with set_scenario / set_narration_script / set_segment_prompt (those are free, no quota).",
@@ -32964,14 +33044,67 @@ registerTool("create_editor_draft", {
32964
33044
  next: "generate_scenario | set_scenario, then get_editor to review, then apply_scenario"
32965
33045
  });
32966
33046
  }));
33047
+ function conciseEditorState(value) {
33048
+ const data = asRecord(value);
33049
+ const keys = [
33050
+ "slug",
33051
+ "status",
33052
+ "autopilot_status",
33053
+ "autopilot_current_step",
33054
+ "autopilot_error_step",
33055
+ "autopilot_error_code",
33056
+ "autopilot_error_message",
33057
+ "autopilot_max_credits",
33058
+ "autopilot_credits_spent",
33059
+ "scenario_status",
33060
+ "scenario_apply_status",
33061
+ "scenario_prompt",
33062
+ "batch_generation_status",
33063
+ "narration_status",
33064
+ "narration_script",
33065
+ "narration_stale",
33066
+ "voice_stale",
33067
+ "music_stale",
33068
+ "segments_count",
33069
+ "current_audio",
33070
+ "current_music",
33071
+ "latest_render",
33072
+ "project_spend",
33073
+ "ai_assist_quota"
33074
+ ];
33075
+ const result = {};
33076
+ for (const key of keys) {
33077
+ if (key in data)
33078
+ result[key] = data[key];
33079
+ }
33080
+ result.segments = Array.isArray(data.segments) ? data.segments.map((segment) => ({
33081
+ id: segment.id,
33082
+ position: segment.position,
33083
+ prompt: segment.prompt,
33084
+ status: segment.status,
33085
+ source_type: segment.source_type,
33086
+ use_previous_frame: segment.use_previous_frame,
33087
+ protagonist_presence: segment.protagonist_presence,
33088
+ cast_mode_snapshot: segment.cast_mode_snapshot,
33089
+ video_stale: segment.video_stale,
33090
+ error_code: segment.error_code,
33091
+ error_message: segment.error_message,
33092
+ narration_text: segment.narration_text
33093
+ })) : [];
33094
+ return result;
33095
+ }
32967
33096
  registerTool("get_editor", {
32968
- title: "Get full editor state (review step)",
32969
- description: "Returns the full editor project state: scenario_prompt, segments[] (with prompts + status), " + "narration_script/status, music, latest_render, segments_count, ai_assist_quota. This is the REVIEW " + "step call it after generating the scenario/segments to inspect and decide what to edit.",
32970
- inputSchema: { slug: exports_external.string().describe("Editor project slug") },
33097
+ title: "Get editor state (review step)",
33098
+ description: "Returns the full editor state by default for backward compatibility. Pass response_format:'concise' for a smaller review projection containing orchestration, scenario, scene prompts/status, narration, audio, render, spend, and quota.",
33099
+ inputSchema: {
33100
+ slug: exports_external.string().describe("Editor project slug"),
33101
+ response_format: exports_external.enum(["concise", "full"]).default("full")
33102
+ },
32971
33103
  annotations: { title: "Get editor", ...RO }
32972
33104
  }, tool(async (args, client) => {
32973
33105
  const res = await client.get(`/editor/${args.slug}`);
32974
- return ok(asRecord(res).data);
33106
+ const data = asRecord(res).data;
33107
+ return ok(args.response_format === "full" ? data : conciseEditorState(data));
32975
33108
  }));
32976
33109
  registerTool("set_scenario", {
32977
33110
  title: "Set the editor scenario",
@@ -33178,23 +33311,25 @@ registerTool("generate_all_segments", {
33178
33311
  idempotentHint: false
33179
33312
  }
33180
33313
  }, tool(async (args, client, extra) => {
33181
- const res = await client.get(`/editor/${args.slug}`);
33314
+ const overallDeadline = Date.now() + 280000;
33315
+ const res = await client.get(`/editor/${args.slug}`, undefined, overallDeadline);
33182
33316
  const data = asRecord(asRecord(res).data);
33183
33317
  const segments = Array.isArray(data.segments) ? data.segments : [];
33184
- const pending = segments.filter((s) => {
33318
+ const unfinished = segments.filter((s) => {
33319
+ if (s.source_type === "upload")
33320
+ return false;
33185
33321
  const st = s.status ?? s.generation_status;
33186
- return st === undefined || st === "pending" || st === "draft" || st === "failed";
33187
- });
33188
- const overallDeadline = Date.now() + 280000;
33189
- const PER_SEGMENT_MAX_MS = 6 * 60 * 1000;
33322
+ return st !== "completed";
33323
+ }).sort((a, b) => Number(a.position ?? Number.MAX_SAFE_INTEGER) - Number(b.position ?? Number.MAX_SAFE_INTEGER));
33324
+ const SUBMIT_HEADROOM_MS = 61000;
33190
33325
  const generated = [];
33191
33326
  let n = 0;
33192
- for (const seg of pending) {
33327
+ for (const seg of unfinished) {
33193
33328
  const id = seg.id;
33194
33329
  if (id === undefined)
33195
33330
  continue;
33196
33331
  const sid = id;
33197
- const remainingMs = overallDeadline - Date.now();
33332
+ let remainingMs = overallDeadline - Date.now();
33198
33333
  if (remainingMs <= 0) {
33199
33334
  return ok({
33200
33335
  slug: args.slug,
@@ -33203,19 +33338,41 @@ registerTool("generate_all_segments", {
33203
33338
  note: "Wall-clock budget reached — stopped before the next scene. Re-run generate_all_segments to continue (it retries failed/pending scenes and skips completed ones)."
33204
33339
  });
33205
33340
  }
33206
- await reportProgress(extra, ++n, `generating segment ${String(sid)} (${n}/${pending.length})`, pending.length);
33207
- try {
33208
- await client.post(`/editor/${args.slug}/segments/${String(sid)}/generate`, undefined, undefined);
33209
- } catch (e) {
33341
+ await reportProgress(extra, ++n, `generating segment ${String(sid)} (${n}/${unfinished.length})`, unfinished.length);
33342
+ const initialStatus = seg.status ?? seg.generation_status;
33343
+ if (initialStatus !== "processing") {
33344
+ remainingMs = overallDeadline - Date.now();
33345
+ if (remainingMs < SUBMIT_HEADROOM_MS) {
33346
+ return ok({
33347
+ slug: args.slug,
33348
+ generated,
33349
+ timed_out: true,
33350
+ note: "Wall-clock budget is too low to safely start another paid scene. Re-run generate_all_segments to continue; completed scenes are skipped."
33351
+ });
33352
+ }
33353
+ try {
33354
+ await client.post(`/editor/${args.slug}/segments/${String(sid)}/generate`, undefined, undefined);
33355
+ } catch (e) {
33356
+ return ok({
33357
+ slug: args.slug,
33358
+ generated,
33359
+ stopped_at: sid,
33360
+ error: errMessage(e),
33361
+ note: "Stopped on first error — fix the cause (often credits) and re-run; completed scenes are skipped, failed/pending are retried."
33362
+ });
33363
+ }
33364
+ }
33365
+ remainingMs = overallDeadline - Date.now();
33366
+ if (remainingMs <= 0) {
33210
33367
  return ok({
33211
33368
  slug: args.slug,
33212
33369
  generated,
33213
33370
  stopped_at: sid,
33214
- error: errMessage(e),
33215
- note: "Stopped on first error fix the cause (often credits) and re-run; completed scenes are skipped, failed/pending are retried."
33371
+ timed_out: true,
33372
+ note: "The scene was submitted, but the wall-clock budget ended before its status could be checked. Re-run generate_all_segments later to continue safely."
33216
33373
  });
33217
33374
  }
33218
- const seg_result = await pollSegmentToTerminal(client, args.slug, sid, extra, Math.min(PER_SEGMENT_MAX_MS, remainingMs), 15000);
33375
+ const seg_result = await pollSegmentToTerminal(client, args.slug, sid, extra, overallDeadline, 15000);
33219
33376
  if (seg_result.status === "failed") {
33220
33377
  return ok({
33221
33378
  slug: args.slug,
@@ -33234,7 +33391,34 @@ registerTool("generate_all_segments", {
33234
33391
  note: "A scene is still rendering after the poll budget. Re-run generate_all_segments later to continue (it retries failed/pending, skips completed), or watch it with get_editor."
33235
33392
  });
33236
33393
  }
33237
- generated.push(sid);
33394
+ if (initialStatus !== "processing")
33395
+ generated.push(sid);
33396
+ }
33397
+ if (overallDeadline - Date.now() < 1000) {
33398
+ return ok({
33399
+ slug: args.slug,
33400
+ generated,
33401
+ timed_out: true,
33402
+ note: "Wall-clock budget reached before the final reconciliation read. Re-run generate_all_segments to confirm completion; completed scenes are skipped."
33403
+ });
33404
+ }
33405
+ const finalRes = await client.get(`/editor/${args.slug}`, undefined, overallDeadline);
33406
+ const finalData = asRecord(asRecord(finalRes).data);
33407
+ const finalSegments = Array.isArray(finalData.segments) ? finalData.segments : [];
33408
+ const stillUnfinished = finalSegments.filter((segment) => {
33409
+ if (segment.source_type === "upload")
33410
+ return false;
33411
+ const status = segment.status ?? segment.generation_status;
33412
+ return status !== "completed";
33413
+ });
33414
+ if (stillUnfinished.length > 0) {
33415
+ return ok({
33416
+ slug: args.slug,
33417
+ generated,
33418
+ timed_out: true,
33419
+ in_flight_segment_ids: stillUnfinished.map((segment) => segment.id).filter((id) => id !== undefined),
33420
+ note: "Some scenes are still processing. Re-run generate_all_segments later; it will wait for in-flight scenes and generate only unfinished ones."
33421
+ });
33238
33422
  }
33239
33423
  return ok({
33240
33424
  slug: args.slug,
@@ -33616,7 +33800,7 @@ registerTool("set_closing_image", {
33616
33800
  }));
33617
33801
  registerTool("set_logo", {
33618
33802
  title: "Set a brand logo overlay (0 credits)",
33619
- description: "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤1 MiB; PNG keeps " + "transparency), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. Optional placement controls: treatment, " + "position, duration_seconds.",
33803
+ description: "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤5 MiB; PNG keeps " + "transparency), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. Optional placement controls: treatment, " + "position, duration_seconds.",
33620
33804
  inputSchema: {
33621
33805
  slug: exports_external.string().describe("Editor project slug"),
33622
33806
  file_path: exports_external.string().describe("Local logo image (.png/.jpg/.jpeg)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.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
@@ -457,15 +457,14 @@ export function normalizeStatus(
457
457
  );
458
458
  const batchStatus = (d.batch_generation_status as string) ?? null;
459
459
  // The batch enum's ACTIVE states (video_factory.ex): preparing (spinning up),
460
- // awaiting_previews (previews generating), running, and paused_for_retry
461
- // (auto-resuming). "queued" is not a real value and was dropped; "preparing"
462
- // and "awaiting_previews" were missing, so an in-flight batch briefly looked
463
- // idle and could be reported terminal mid-run.
460
+ // awaiting_previews (previews generating), and running. "queued" is not a real
461
+ // value and was dropped. paused_for_retry is deliberately NOT active: no worker
462
+ // owns it until the user explicitly retries or cancels the paused batch.
464
463
  const batchActive =
465
464
  batchStatus === "running" ||
466
465
  batchStatus === "preparing" ||
467
- batchStatus === "awaiting_previews" ||
468
- batchStatus === "paused_for_retry";
466
+ batchStatus === "awaiting_previews";
467
+ const batchRetryRequired = batchStatus === "paused_for_retry";
469
468
  // A parked batch that ran out of credits: it cannot progress without the user
470
469
  // topping up or reducing scope, so it is a needs-user-action TERMINAL state
471
470
  // (not active work to wait on).
@@ -566,14 +565,39 @@ export function normalizeStatus(
566
565
  currentMusic.status === "processing" ||
567
566
  newerAudioVersions.some(versionActive) ||
568
567
  newerMusicVersions.some(versionActive);
569
- const workActive =
570
- autopilotActive ||
568
+ const childWorkActive =
571
569
  batchActive ||
572
570
  renderActive ||
573
571
  scenarioActive ||
574
572
  narrationActive ||
575
573
  audioActive ||
576
574
  activeSegments.length > 0;
575
+ // A resumed Autopilot briefly exposes the previous attempt's failed child
576
+ // states before its new worker reaches and clears them. Suppress that history
577
+ // only while real child work is active or for a short, timestamped hand-off
578
+ // window. A parent stuck "running" with no worker must eventually surface the
579
+ // child failure instead of making wait_for_completion poll forever.
580
+ const autopilotStartedAt =
581
+ typeof d.autopilot_started_at === "string"
582
+ ? Date.parse(d.autopilot_started_at)
583
+ : Number.NaN;
584
+ const now = Date.now();
585
+ const autopilotResumeGraceActive =
586
+ autopilotActive &&
587
+ Number.isFinite(autopilotStartedAt) &&
588
+ autopilotStartedAt <= now &&
589
+ now - autopilotStartedAt <= 120_000;
590
+ const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
591
+ // Retrying a paused batch deliberately leaves the failed parent Autopilot
592
+ // marker in place while the already-paid child batch finishes. In that one
593
+ // state combination the parent/old scene failures are history, not a reason
594
+ // to return terminal immediately. Once the batch settles, the parent failure
595
+ // becomes actionable again and the agent can quote/relaunch Autopilot.
596
+ const batchRecoveryActive = autopilotFailed && batchActive;
597
+ const childFailureActionable =
598
+ !batchRecoveryActive &&
599
+ (!autopilotActive || (!childWorkActive && !autopilotResumeGraceActive));
600
+ const workActive = autopilotActive || childWorkActive;
577
601
  // Orchestration failures are historical once a repaired timeline has a fresh,
578
602
  // completed MP4. Keeping them terminal would permanently block download_result
579
603
  // after a successful granular recovery.
@@ -586,17 +610,26 @@ export function normalizeStatus(
586
610
  failedSegments.length === 0 &&
587
611
  !audioFailed;
588
612
  const ready = deliveredArtifactSupersedesOrchestrationFailure;
589
- const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
590
- const renderFailed = renderStatus === "failed";
613
+ const parentFailureActionable = autopilotFailed && !batchRecoveryActive;
614
+ const renderFailed = renderStatus === "failed" && childFailureActionable;
591
615
  const failed =
592
- (autopilotFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
616
+ (parentFailureActionable &&
617
+ !deliveredArtifactSupersedesOrchestrationFailure) ||
593
618
  renderFailed ||
594
- (scenarioApplyFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
595
- (scenarioFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
596
- (narrationFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
597
- (batchFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
598
- failedSegments.length > 0 ||
599
- audioFailed;
619
+ (childFailureActionable &&
620
+ scenarioApplyFailed &&
621
+ !deliveredArtifactSupersedesOrchestrationFailure) ||
622
+ (childFailureActionable &&
623
+ scenarioFailed &&
624
+ !deliveredArtifactSupersedesOrchestrationFailure) ||
625
+ (childFailureActionable &&
626
+ narrationFailed &&
627
+ !deliveredArtifactSupersedesOrchestrationFailure) ||
628
+ (childFailureActionable &&
629
+ batchFailed &&
630
+ !deliveredArtifactSupersedesOrchestrationFailure) ||
631
+ (childFailureActionable && failedSegments.length > 0) ||
632
+ (childFailureActionable && audioFailed);
600
633
  // Terminal = control should return to the agent because nothing will progress
601
634
  // without another tool call. With every async job type folded into workActive
602
635
  // above (autopilot, batch, segments, scenario, narration, audio, render), NO
@@ -605,21 +638,35 @@ export function normalizeStatus(
605
638
  // a new tool call can move it, so wait_for_completion must return instead of
606
639
  // polling its full budget. (ready is subsumed by !workActive but kept for
607
640
  // clarity; explicit failure states are terminal even while cleanup work spins.)
608
- const terminal = ready || failed || batchInsufficientCredits || !workActive;
641
+ const terminal =
642
+ ready ||
643
+ failed ||
644
+ batchRetryRequired ||
645
+ batchInsufficientCredits ||
646
+ !workActive;
609
647
  let stage = autopilot;
610
648
  // Failure always wins over an unrelated active flag. Workers can leave an
611
649
  // outer autopilot/batch state active after a child stage has failed; reporting
612
650
  // the active stage would make agents poll forever instead of taking action.
613
651
  if (ready) stage = "completed";
614
- else if (autopilot === "failed") stage = "failed";
615
- else if (autopilot === "cancelled") stage = "cancelled";
652
+ // A paused child batch is the immediate recovery gate even when its parent
653
+ // Autopilot run has already failed/cancelled. Reporting only the generic
654
+ // parent terminal state hides the one action that must happen before a new
655
+ // Autopilot run is allowed to start.
656
+ else if (batchRetryRequired) stage = "batch_retry_required";
657
+ else if (parentFailureActionable && autopilot === "failed") stage = "failed";
658
+ else if (parentFailureActionable && autopilot === "cancelled")
659
+ stage = "cancelled";
616
660
  else if (renderFailed) stage = "render_failed";
617
- else if (scenarioApplyFailed) stage = "scenario_apply_failed";
618
- else if (scenarioFailed) stage = "scenario_failed";
619
- else if (narrationFailed) stage = "narration_failed";
620
- else if (batchFailed) stage = "batch_failed";
621
- else if (failedSegments.length > 0) stage = "segment_failed";
622
- else if (audioFailed) stage = "audio_failed";
661
+ else if (childFailureActionable && scenarioApplyFailed)
662
+ stage = "scenario_apply_failed";
663
+ else if (childFailureActionable && scenarioFailed) stage = "scenario_failed";
664
+ else if (childFailureActionable && narrationFailed)
665
+ stage = "narration_failed";
666
+ else if (childFailureActionable && batchFailed) stage = "batch_failed";
667
+ else if (childFailureActionable && failedSegments.length > 0)
668
+ stage = "segment_failed";
669
+ else if (childFailureActionable && audioFailed) stage = "audio_failed";
623
670
  else if (batchInsufficientCredits) stage = "insufficient_credits";
624
671
  else if (activeSegments.length > 0) stage = "segment_processing";
625
672
  else if (batchActive) stage = "segment_generation";
@@ -633,6 +680,15 @@ export function normalizeStatus(
633
680
  .find(
634
681
  (value): value is string => typeof value === "string" && value.length > 0,
635
682
  );
683
+ const actionableChildError = childFailureActionable
684
+ ? ((latest.error_message as string) ??
685
+ (d.scenario_apply_error_message as string) ??
686
+ (d.scenario_error_message as string) ??
687
+ (d.narration_error_message as string) ??
688
+ (d.batch_generation_error_message as string) ??
689
+ segmentError ??
690
+ audioError)
691
+ : null;
636
692
  return {
637
693
  kind,
638
694
  slug,
@@ -642,17 +698,15 @@ export function normalizeStatus(
642
698
  video_url: videoUrl,
643
699
  error: ready
644
700
  ? 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)),
701
+ : batchRetryRequired
702
+ ? "Batch generation is paused after a failed scene. Call retry_editor_batch or cancel_editor_batch before continuing."
703
+ : ((parentFailureActionable
704
+ ? (d.autopilot_error_message as string)
705
+ : null) ??
706
+ actionableChildError ??
707
+ (batchInsufficientCredits
708
+ ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume."
709
+ : null)),
656
710
  stale,
657
711
  active_segment_ids: activeSegments
658
712
  .map((segment) => segment.id)
package/src/index.ts CHANGED
@@ -620,7 +620,7 @@ CREDITS (spent server-side; each tool prices before charging — see its descrip
620
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.
621
621
 
622
622
  WAITING & RESUMING
623
- - Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout. A terminal result is not always ready: an editor can go terminal in a needs-action stage (editing_required = stale after edits, regenerate the flagged scenes/audio; insufficient_credits = batch parked, top up / reduce scope; idle = draft with nothing generating, start generation first) — act on those instead of re-polling.
623
+ - Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout. A terminal result is not always ready: an editor can go terminal in a needs-action stage (editing_required = stale after edits, regenerate the flagged scenes/audio; batch_retry_required = call retry_editor_batch or cancel_editor_batch before starting Autopilot again; insufficient_credits = batch parked, top up / reduce scope; idle = draft with nothing generating, start generation first) — act on those instead of re-polling.
624
624
  - Creates and charged actions are idempotency-keyed: a transport retry is safe; a genuine re-run after a terminal FAILURE restarts rather than replaying the dead attempt.
625
625
 
626
626
  LOCAL FILES (sandboxed)
@@ -683,10 +683,9 @@ async function pollSegmentToTerminal(
683
683
  slug: string,
684
684
  segmentId: string | number,
685
685
  extra: ToolExtra,
686
- budgetMs: number,
686
+ deadline: number,
687
687
  intervalMs: number,
688
688
  ): Promise<{ status: string; error: string | null; timed_out: boolean }> {
689
- const deadline = Date.now() + budgetMs;
690
689
  const sid = String(segmentId);
691
690
  const read = async (): Promise<{ status: string; error: string | null }> => {
692
691
  // Bound each poll GET's retries by the loop deadline (see fetchStatus).
@@ -1135,6 +1134,7 @@ registerTool(
1135
1134
  : `/editor/${slug}/autopilot/cost`;
1136
1135
  let estimated_credits: number | null = null;
1137
1136
  let available_credits: number | null = null;
1137
+ let authorizedCredits: number | undefined;
1138
1138
  try {
1139
1139
  const cost = asRecord(
1140
1140
  asRecord(await client.get<{ data: unknown }>(costPath)).data,
@@ -1238,6 +1238,7 @@ registerTool(
1238
1238
  "Editor autopilot launch requires max_credits or a live quote",
1239
1239
  );
1240
1240
  }
1241
+ authorizedCredits = approvedCap;
1241
1242
  await client.post(
1242
1243
  `/editor/${slug}/autopilot`,
1243
1244
  // Use the caller's explicit cap when given, otherwise the live
@@ -1272,12 +1273,50 @@ registerTool(
1272
1273
  .saved_to;
1273
1274
  }
1274
1275
 
1276
+ // Shorts charge atomically when generation starts. Editor autopilot only
1277
+ // authorizes a cap at launch and spends incrementally as child work starts,
1278
+ // so report observed run spend instead of claiming the whole launch charged.
1279
+ // If this receipt-enrichment read fails, omit the charge claim entirely.
1280
+ let editorCreditsSpent: number | undefined;
1281
+ if (kind === "editor") {
1282
+ try {
1283
+ const current = asRecord(
1284
+ asRecord(
1285
+ await client.get<{ data: unknown }>(
1286
+ `/editor/${slug}`,
1287
+ undefined,
1288
+ Date.now() + 5_000,
1289
+ ),
1290
+ ).data,
1291
+ );
1292
+ if (typeof current.autopilot_credits_spent === "number") {
1293
+ editorCreditsSpent = current.autopilot_credits_spent;
1294
+ }
1295
+ } catch {
1296
+ // The launch/poll result remains valid without receipt enrichment.
1297
+ }
1298
+ }
1299
+
1300
+ const billing =
1301
+ kind === "short"
1302
+ ? { charged: true }
1303
+ : {
1304
+ launched: true,
1305
+ authorized_credits: authorizedCredits,
1306
+ ...(editorCreditsSpent === undefined
1307
+ ? {}
1308
+ : {
1309
+ credits_spent: editorCreditsSpent,
1310
+ charged: editorCreditsSpent > 0,
1311
+ }),
1312
+ };
1313
+
1275
1314
  const result = {
1276
1315
  ...status,
1277
1316
  kind_inferred: requestedKind === undefined,
1278
1317
  estimated_credits,
1279
1318
  available_credits,
1280
- charged: true,
1319
+ ...billing,
1281
1320
  saved_to,
1282
1321
  timed_out: !status.terminal,
1283
1322
  };
@@ -1297,13 +1336,15 @@ registerTool(
1297
1336
  note: status.ready
1298
1337
  ? "Done. Presigned ~24h URL — download promptly."
1299
1338
  : status.terminal
1300
- ? // A terminal-but-not-ready make_video is a failed/cancelled run.
1301
- // Point at a restart: re-running make_video now genuinely restarts
1302
- // (the start key folds the failure state, so it no longer replays the
1303
- // dead 202), or resume the pipeline directly. Fix the cause (often
1304
- // credits) first.
1305
- `Terminal (${status.stage}). ${status.error ?? ""}`.trim() +
1306
- ` To restart, fix the cause (often credits) then re-run make_video, or resume with ${resume}.`
1339
+ ? status.stage === "batch_retry_required"
1340
+ ? `Terminal (${status.stage}). ${status.error ?? ""} Resolve the paused batch before restarting Autopilot.`.trim()
1341
+ : // A terminal-but-not-ready make_video is normally a failed/cancelled run.
1342
+ // Point at a restart: re-running make_video now genuinely restarts
1343
+ // (the start key folds the failure state, so it no longer replays the
1344
+ // dead 202), or resume the pipeline directly. Fix the cause (often
1345
+ // credits) first.
1346
+ `Terminal (${status.stage}). ${status.error ?? ""}`.trim() +
1347
+ ` To restart, fix the cause (often credits) then re-run make_video, or resume with ${resume}.`
1307
1348
  : `Still rendering — call wait_for_completion(${resumeArgs}).`,
1308
1349
  },
1309
1350
  links,
@@ -3060,7 +3101,7 @@ registerTool(
3060
3101
  logo_path: z
3061
3102
  .string()
3062
3103
  .optional()
3063
- .describe("Local logo (.jpg/.jpeg/.png, ≤1 MiB) to overlay."),
3104
+ .describe("Local logo (.jpg/.jpeg/.png, ≤5 MiB) to overlay."),
3064
3105
  logo_treatment: z
3065
3106
  .enum(["intro", "outro", "both", "none"])
3066
3107
  .optional()
@@ -3318,7 +3359,10 @@ registerTool(
3318
3359
  kind: "editor",
3319
3360
  status,
3320
3361
  ...quote,
3321
- charged: true,
3362
+ launched: true,
3363
+ authorized_credits: args.max_credits,
3364
+ credits_spent: 0,
3365
+ charged: false,
3322
3366
  next: "wait_for_completion",
3323
3367
  });
3324
3368
  } finally {
@@ -3338,7 +3382,9 @@ registerTool(
3338
3382
  "explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " +
3339
3383
  "the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " +
3340
3384
  "if a run is already in progress for this slug the server returns already_running — it never double-starts " +
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 " +
3385
+ "or double-charges. Failed or cancelled runs resume normally, except when status is batch_retry_required: " +
3386
+ "call retry_editor_batch (then wait for that prepaid batch) or cancel_editor_batch first. For a completed project, " +
3387
+ "set restart:true to quote and build a fresh creative run; omitting it only resumes pending edits. Then poll with " +
3342
3388
  'wait_for_completion({ slug, kind: "editor" }).',
3343
3389
  inputSchema: {
3344
3390
  slug: z.string().describe("Editor project slug"),
@@ -3455,13 +3501,79 @@ registerTool(
3455
3501
  kind: "editor",
3456
3502
  status,
3457
3503
  ...quote,
3458
- charged: true,
3504
+ launched: true,
3505
+ authorized_credits: args.max_credits,
3506
+ credits_spent: 0,
3507
+ charged: false,
3459
3508
  next: "wait_for_completion",
3460
3509
  });
3461
3510
  },
3462
3511
  ),
3463
3512
  );
3464
3513
 
3514
+ registerTool(
3515
+ "retry_editor_batch",
3516
+ {
3517
+ title: "Retry a paused editor batch",
3518
+ description:
3519
+ "Recovers an editor whose normalized status is batch_retry_required by atomically re-queueing the failed " +
3520
+ "scene and resuming its prepaid batch. This spends 0 additional credits: the batch generation was already " +
3521
+ "paid. It does not authorize or restart a failed parent Autopilot run; after retrying, wait for the batch " +
3522
+ "to settle. If the parent is then terminal, call start_autopilot without max_credits for a fresh quote and " +
3523
+ "relaunch only with an approved cap. A batch parked on " +
3524
+ "insufficient_credits needs a top-up/scope change instead. Scope: video:generate.",
3525
+ inputSchema: { slug: z.string().describe("Editor project slug") },
3526
+ annotations: {
3527
+ title: "Retry editor batch",
3528
+ ...WRITE,
3529
+ idempotentHint: false,
3530
+ },
3531
+ },
3532
+ tool(async (args: { slug: string }, client) => {
3533
+ const res = await client.post<{ data: unknown }>(
3534
+ `/video-factories/${args.slug}/retry-batch-segment`,
3535
+ );
3536
+ return ok({
3537
+ ...asRecord(asRecord(res).data ?? res),
3538
+ slug: args.slug,
3539
+ charged: false,
3540
+ note: "The failed scene retry is queued and the prepaid batch is running. Wait for it to settle; if the parent Autopilot run is then terminal, quote start_autopilot again and relaunch only with an approved max_credits cap.",
3541
+ next: "wait_for_completion",
3542
+ });
3543
+ }),
3544
+ );
3545
+
3546
+ registerTool(
3547
+ "cancel_editor_batch",
3548
+ {
3549
+ title: "Cancel an editor batch",
3550
+ description:
3551
+ "Cancels an editor batch in awaiting_previews, running, or paused_for_retry so the project can be edited or " +
3552
+ "Autopilot can be started again. Cancellation is destructive: dispatched scene work is stopped and its prepaid " +
3553
+ "credits are not refunded; only an awaiting_previews batch (before dispatch) is refunded by the server. Spends " +
3554
+ "0 additional credits. Scope: video:generate.",
3555
+ inputSchema: { slug: z.string().describe("Editor project slug") },
3556
+ annotations: {
3557
+ title: "Cancel editor batch",
3558
+ readOnlyHint: false,
3559
+ destructiveHint: true,
3560
+ openWorldHint: true,
3561
+ idempotentHint: false,
3562
+ },
3563
+ },
3564
+ tool(async (args: { slug: string }, client) => {
3565
+ const res = await client.post<{ data: unknown }>(
3566
+ `/video-factories/${args.slug}/cancel-batch`,
3567
+ );
3568
+ return ok({
3569
+ ...asRecord(asRecord(res).data ?? res),
3570
+ slug: args.slug,
3571
+ charged: false,
3572
+ next: "get_status",
3573
+ });
3574
+ }),
3575
+ );
3576
+
3465
3577
  // ── AI assists (free daily quota) ───────────────────────────────────────────
3466
3578
 
3467
3579
  registerTool(
@@ -3660,21 +3772,81 @@ registerTool(
3660
3772
  ),
3661
3773
  );
3662
3774
 
3775
+ function conciseEditorState(value: unknown): Record<string, unknown> {
3776
+ const data = asRecord(value);
3777
+ const keys = [
3778
+ "slug",
3779
+ "status",
3780
+ "autopilot_status",
3781
+ "autopilot_current_step",
3782
+ "autopilot_error_step",
3783
+ "autopilot_error_code",
3784
+ "autopilot_error_message",
3785
+ "autopilot_max_credits",
3786
+ "autopilot_credits_spent",
3787
+ "scenario_status",
3788
+ "scenario_apply_status",
3789
+ "scenario_prompt",
3790
+ "batch_generation_status",
3791
+ "narration_status",
3792
+ "narration_script",
3793
+ "narration_stale",
3794
+ "voice_stale",
3795
+ "music_stale",
3796
+ "segments_count",
3797
+ "current_audio",
3798
+ "current_music",
3799
+ "latest_render",
3800
+ "project_spend",
3801
+ "ai_assist_quota",
3802
+ ] as const;
3803
+ const result: Record<string, unknown> = {};
3804
+ for (const key of keys) {
3805
+ if (key in data) result[key] = data[key];
3806
+ }
3807
+ result.segments = Array.isArray(data.segments)
3808
+ ? (data.segments as Record<string, unknown>[]).map((segment) => ({
3809
+ id: segment.id,
3810
+ position: segment.position,
3811
+ prompt: segment.prompt,
3812
+ status: segment.status,
3813
+ source_type: segment.source_type,
3814
+ use_previous_frame: segment.use_previous_frame,
3815
+ protagonist_presence: segment.protagonist_presence,
3816
+ cast_mode_snapshot: segment.cast_mode_snapshot,
3817
+ video_stale: segment.video_stale,
3818
+ error_code: segment.error_code,
3819
+ error_message: segment.error_message,
3820
+ narration_text: segment.narration_text,
3821
+ }))
3822
+ : [];
3823
+ return result;
3824
+ }
3825
+
3663
3826
  registerTool(
3664
3827
  "get_editor",
3665
3828
  {
3666
- title: "Get full editor state (review step)",
3829
+ title: "Get editor state (review step)",
3667
3830
  description:
3668
- "Returns the full editor project state: scenario_prompt, segments[] (with prompts + status), " +
3669
- "narration_script/status, music, latest_render, segments_count, ai_assist_quota. This is the REVIEW " +
3670
- "step call it after generating the scenario/segments to inspect and decide what to edit.",
3671
- inputSchema: { slug: z.string().describe("Editor project slug") },
3831
+ "Returns the full editor state by default for backward compatibility. Pass response_format:'concise' for a smaller review projection containing orchestration, scenario, scene prompts/status, narration, audio, render, spend, and quota.",
3832
+ inputSchema: {
3833
+ slug: z.string().describe("Editor project slug"),
3834
+ response_format: z.enum(["concise", "full"]).default("full"),
3835
+ },
3672
3836
  annotations: { title: "Get editor", ...RO },
3673
3837
  },
3674
- tool(async (args: { slug: string }, client) => {
3675
- const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
3676
- return ok(asRecord(res).data);
3677
- }),
3838
+ tool(
3839
+ async (
3840
+ args: { slug: string; response_format: "concise" | "full" },
3841
+ client,
3842
+ ) => {
3843
+ const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
3844
+ const data = asRecord(res).data;
3845
+ return ok(
3846
+ args.response_format === "full" ? data : conciseEditorState(data),
3847
+ );
3848
+ },
3849
+ ),
3678
3850
  );
3679
3851
 
3680
3852
  registerTool(
@@ -4123,20 +4295,30 @@ registerTool(
4123
4295
  },
4124
4296
  },
4125
4297
  tool(async (args: { slug: string }, client, extra) => {
4126
- const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
4298
+ // One absolute deadline owns the entire read/submit/poll/reconcile path.
4299
+ // Start it before the initial retrying GET so no preparatory request sits
4300
+ // outside the advertised ~280s tool budget.
4301
+ const overallDeadline = Date.now() + 280_000;
4302
+ const res = await client.get<{ data: unknown }>(
4303
+ `/editor/${args.slug}`,
4304
+ undefined,
4305
+ overallDeadline,
4306
+ );
4127
4307
  const data = asRecord(asRecord(res).data);
4128
4308
  const segments = Array.isArray(data.segments)
4129
4309
  ? (data.segments as Record<string, unknown>[])
4130
4310
  : [];
4131
- const pending = segments.filter((s) => {
4132
- const st = (s.status as string) ?? (s.generation_status as string);
4133
- return (
4134
- st === undefined ||
4135
- st === "pending" ||
4136
- st === "draft" ||
4137
- st === "failed"
4311
+ const unfinished = segments
4312
+ .filter((s) => {
4313
+ if (s.source_type === "upload") return false;
4314
+ const st = (s.status as string) ?? (s.generation_status as string);
4315
+ return st !== "completed";
4316
+ })
4317
+ .sort(
4318
+ (a, b) =>
4319
+ Number(a.position ?? Number.MAX_SAFE_INTEGER) -
4320
+ Number(b.position ?? Number.MAX_SAFE_INTEGER),
4138
4321
  );
4139
- });
4140
4322
 
4141
4323
  // Overall wall-clock budget: cap the whole call at the same 280s ceiling the
4142
4324
  // other wait loops use (under a typical 300s client timeout). Without it a
@@ -4144,20 +4326,23 @@ registerTool(
4144
4326
  // hours) in a single call. When the budget runs out mid-batch we stop and
4145
4327
  // return the standard resume contract — call again and it naturally continues
4146
4328
  // with whatever scenes are still pending (completed ones are skipped).
4147
- const overallDeadline = Date.now() + 280_000;
4148
- // Each scene's own poll budget stays generous (a Veo scene can take minutes)
4149
- // but is clamped to the time left overall so one scene can't overrun 280s.
4150
- const PER_SEGMENT_MAX_MS = 6 * 60 * 1000;
4329
+ // Every scene poll receives this same absolute deadline, so time already spent
4330
+ // on earlier reads, submissions, and scenes is never granted again.
4331
+ // Reserve a full non-idempotent POST timeout plus one final status read before
4332
+ // starting another paid scene. POSTs deliberately aren't aborted/retried, so
4333
+ // beginning one in the last seconds of the budget would make a client timeout
4334
+ // ambiguous after the server may already have accepted the charge.
4335
+ const SUBMIT_HEADROOM_MS = 61_000;
4151
4336
 
4152
4337
  const generated: Array<string | number> = [];
4153
4338
  let n = 0;
4154
- for (const seg of pending) {
4339
+ for (const seg of unfinished) {
4155
4340
  const id = seg.id;
4156
4341
  if (id === undefined) continue;
4157
4342
  const sid = id as string | number;
4158
4343
  // Stop before submitting another scene once the overall budget is spent, so
4159
4344
  // the call returns instead of charging + waiting past the deadline.
4160
- const remainingMs = overallDeadline - Date.now();
4345
+ let remainingMs = overallDeadline - Date.now();
4161
4346
  if (remainingMs <= 0) {
4162
4347
  return ok({
4163
4348
  slug: args.slug,
@@ -4169,22 +4354,45 @@ registerTool(
4169
4354
  await reportProgress(
4170
4355
  extra,
4171
4356
  ++n,
4172
- `generating segment ${String(sid)} (${n}/${pending.length})`,
4173
- pending.length,
4357
+ `generating segment ${String(sid)} (${n}/${unfinished.length})`,
4358
+ unfinished.length,
4174
4359
  );
4175
- try {
4176
- await client.post(
4177
- `/editor/${args.slug}/segments/${String(sid)}/generate`,
4178
- undefined,
4179
- undefined,
4180
- );
4181
- } catch (e) {
4360
+ const initialStatus =
4361
+ (seg.status as string) ?? (seg.generation_status as string);
4362
+ if (initialStatus !== "processing") {
4363
+ remainingMs = overallDeadline - Date.now();
4364
+ if (remainingMs < SUBMIT_HEADROOM_MS) {
4365
+ return ok({
4366
+ slug: args.slug,
4367
+ generated,
4368
+ timed_out: true,
4369
+ note: "Wall-clock budget is too low to safely start another paid scene. Re-run generate_all_segments to continue; completed scenes are skipped.",
4370
+ });
4371
+ }
4372
+ try {
4373
+ await client.post(
4374
+ `/editor/${args.slug}/segments/${String(sid)}/generate`,
4375
+ undefined,
4376
+ undefined,
4377
+ );
4378
+ } catch (e) {
4379
+ return ok({
4380
+ slug: args.slug,
4381
+ generated,
4382
+ stopped_at: sid,
4383
+ error: errMessage(e),
4384
+ note: "Stopped on first error — fix the cause (often credits) and re-run; completed scenes are skipped, failed/pending are retried.",
4385
+ });
4386
+ }
4387
+ }
4388
+ remainingMs = overallDeadline - Date.now();
4389
+ if (remainingMs <= 0) {
4182
4390
  return ok({
4183
4391
  slug: args.slug,
4184
4392
  generated,
4185
4393
  stopped_at: sid,
4186
- error: errMessage(e),
4187
- note: "Stopped on first error fix the cause (often credits) and re-run; completed scenes are skipped, failed/pending are retried.",
4394
+ timed_out: true,
4395
+ note: "The scene was submitted, but the wall-clock budget ended before its status could be checked. Re-run generate_all_segments later to continue safely.",
4188
4396
  });
4189
4397
  }
4190
4398
  // Gate on THIS segment finishing before starting the next, so the next
@@ -4196,7 +4404,7 @@ registerTool(
4196
4404
  args.slug,
4197
4405
  sid,
4198
4406
  extra,
4199
- Math.min(PER_SEGMENT_MAX_MS, remainingMs),
4407
+ overallDeadline,
4200
4408
  15000,
4201
4409
  );
4202
4410
  if (seg_result.status === "failed") {
@@ -4217,7 +4425,45 @@ registerTool(
4217
4425
  note: "A scene is still rendering after the poll budget. Re-run generate_all_segments later to continue (it retries failed/pending, skips completed), or watch it with get_editor.",
4218
4426
  });
4219
4427
  }
4220
- generated.push(sid);
4428
+ if (initialStatus !== "processing") generated.push(sid);
4429
+ }
4430
+
4431
+ // Reconcile against a fresh authoritative snapshot. A scene may have been
4432
+ // processing when this call started, or a completion webhook may still be
4433
+ // landing; never claim "all generated" while any AI scene is unfinished.
4434
+ if (overallDeadline - Date.now() < 1_000) {
4435
+ return ok({
4436
+ slug: args.slug,
4437
+ generated,
4438
+ timed_out: true,
4439
+ note: "Wall-clock budget reached before the final reconciliation read. Re-run generate_all_segments to confirm completion; completed scenes are skipped.",
4440
+ });
4441
+ }
4442
+ const finalRes = await client.get<{ data: unknown }>(
4443
+ `/editor/${args.slug}`,
4444
+ undefined,
4445
+ overallDeadline,
4446
+ );
4447
+ const finalData = asRecord(asRecord(finalRes).data);
4448
+ const finalSegments = Array.isArray(finalData.segments)
4449
+ ? (finalData.segments as Record<string, unknown>[])
4450
+ : [];
4451
+ const stillUnfinished = finalSegments.filter((segment) => {
4452
+ if (segment.source_type === "upload") return false;
4453
+ const status =
4454
+ (segment.status as string) ?? (segment.generation_status as string);
4455
+ return status !== "completed";
4456
+ });
4457
+ if (stillUnfinished.length > 0) {
4458
+ return ok({
4459
+ slug: args.slug,
4460
+ generated,
4461
+ timed_out: true,
4462
+ in_flight_segment_ids: stillUnfinished
4463
+ .map((segment) => segment.id)
4464
+ .filter((id) => id !== undefined),
4465
+ note: "Some scenes are still processing. Re-run generate_all_segments later; it will wait for in-flight scenes and generate only unfinished ones.",
4466
+ });
4221
4467
  }
4222
4468
  return ok({
4223
4469
  slug: args.slug,
@@ -5018,7 +5264,7 @@ registerTool(
5018
5264
  {
5019
5265
  title: "Set a brand logo overlay (0 credits)",
5020
5266
  description:
5021
- "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤1 MiB; PNG keeps " +
5267
+ "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤5 MiB; PNG keeps " +
5022
5268
  "transparency), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. Optional placement controls: treatment, " +
5023
5269
  "position, duration_seconds.",
5024
5270
  inputSchema: {
@@ -249,6 +249,9 @@ export const createEditorOutput = z
249
249
  estimated_credits: z.union([z.number(), z.null()]).optional(),
250
250
  available_credits: z.union([z.number(), z.null()]).optional(),
251
251
  affordable: z.boolean().optional(),
252
+ launched: z.boolean().optional(),
253
+ authorized_credits: z.number().optional(),
254
+ credits_spent: z.number().optional(),
252
255
  charged: z.boolean().optional(),
253
256
  note: z.string().optional(),
254
257
  next: z.string().optional(),
@@ -269,6 +272,9 @@ export const makeVideoOutput = z
269
272
  estimated_credits: z.union([z.number(), z.null()]).optional(),
270
273
  available_credits: z.union([z.number(), z.null()]).optional(),
271
274
  affordable: z.boolean().optional(),
275
+ launched: z.boolean().optional(),
276
+ authorized_credits: z.number().optional(),
277
+ credits_spent: z.number().optional(),
272
278
  charged: z.boolean().optional(),
273
279
  saved_to: z.union([z.string(), z.null()]).optional(),
274
280
  timed_out: z.boolean().optional(),
package/src/uploads.ts CHANGED
@@ -63,7 +63,7 @@ const ALLOW_LOOPBACK_FETCH =
63
63
  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
- export const MAX_LOGO_BYTES = 1 * 1024 * 1024; // Editor.Logo.@max_logo_bytes
66
+ export const MAX_LOGO_BYTES = 5 * 1024 * 1024; // Editor.Logo.@max_logo_bytes
67
67
  export const MAX_SHORT_LOGO_BYTES = 5 * 1024 * 1024;
68
68
  export const MAX_CLOSING_IMAGE_BYTES = 20 * 1024 * 1024;
69
69
  const MULTIPART_THRESHOLD = 50 * 1024 * 1024; // @multipart_min_size — at/above this, use multipart