@hubfluencer/mcp 0.10.0 → 0.12.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/src/index.ts CHANGED
@@ -171,9 +171,9 @@ async function editorAutopilotQuote(
171
171
  try {
172
172
  const params = new URLSearchParams();
173
173
  if (overrides.language) params.set("language", overrides.language);
174
- if (overrides.product_subject)
174
+ if (overrides.product_subject !== undefined)
175
175
  params.set("product_subject", overrides.product_subject);
176
- if (overrides.product_prompt)
176
+ if (overrides.product_prompt !== undefined)
177
177
  params.set("product_prompt", overrides.product_prompt);
178
178
  if (overrides.restart) params.set("restart", "true");
179
179
  const query = params.toString();
@@ -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,
@@ -2979,12 +3020,12 @@ registerTool(
2979
3020
  "Without max_credits it STOPS after the free draft/configuration and returns the live estimate. It starts " +
2980
3021
  "the paid pipeline only when max_credits is supplied and covers the estimate. Then poll with get_status / " +
2981
3022
  "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " +
2982
- "Supplying assets here is safer than adding them after autopilot because the scenario and scenes are grounded " +
2983
- "in the real product from the start. (Editor ads carry their copy in-scene/narration, not as a " +
2984
- "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.) " +
2985
- "For a mascot or hero product that must stay visually identical with no human presenter, set " +
2986
- "cast_mode:'product_only' together with product_image_path. Presenter-led casting still belongs on " +
2987
- "create_editor_draft because it requires a separately configured presenter.",
3023
+ "A product image is analyzed into semantic project context and sent as a product-identity reference to every " +
3024
+ "generated Editor scene. The exact photo is also linked as the closing image unless a separate closing image " +
3025
+ "was authored; generated fine label text can still vary. Scene prompts are the complete content contract: describe any people, " +
3026
+ "products, visual continuity, absences, and composition directly in the brief. (Editor ads carry their copy " +
3027
+ "in-scene/narration, not as a title overlay; for an on-screen title/subtitle use a short with " +
3028
+ "headline/subheadline instead.)",
2988
3029
  inputSchema: {
2989
3030
  product_prompt: z
2990
3031
  .string()
@@ -3044,14 +3085,14 @@ registerTool(
3044
3085
  .string()
3045
3086
  .optional()
3046
3087
  .describe(
3047
- "Local product image (.jpg/.jpeg/.png, ≤8 MiB) to attach before autopilot.",
3088
+ "Local product image (.jpg/.jpeg/.png, ≤8 MiB) used as the product-identity reference for every generated Editor scene and linked as the exact closing image unless one was already authored.",
3048
3089
  ),
3049
3090
  product_description: z
3050
3091
  .string()
3051
3092
  .max(500)
3052
3093
  .optional()
3053
3094
  .describe(
3054
- "Exact product/character description used with product_image_path.",
3095
+ "Product description used to ground the scenario and narration when product_image_path is supplied.",
3055
3096
  ),
3056
3097
  closing_image_path: z
3057
3098
  .string()
@@ -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()
@@ -3081,12 +3122,6 @@ registerTool(
3081
3122
  .describe(
3082
3123
  "Logo bumper duration in seconds, 0.5–3 (default 1.5). Requires logo_path.",
3083
3124
  ),
3084
- cast_mode: z
3085
- .literal("product_only")
3086
- .optional()
3087
- .describe(
3088
- "Lock every AI scene to the supplied product/mascot reference with no people. Requires product_image_path.",
3089
- ),
3090
3125
  max_credits: z
3091
3126
  .number()
3092
3127
  .int()
@@ -3122,7 +3157,6 @@ registerTool(
3122
3157
  | "bottom-left"
3123
3158
  | "bottom-right";
3124
3159
  logo_duration_seconds?: number;
3125
- cast_mode?: "product_only";
3126
3160
  max_credits?: number;
3127
3161
  },
3128
3162
  client,
@@ -3136,11 +3170,6 @@ registerTool(
3136
3170
  if (langErr) return fail(langErr);
3137
3171
  const promptErr = validateProductPrompt(args.product_prompt);
3138
3172
  if (promptErr) return fail(promptErr);
3139
- if (args.cast_mode === "product_only" && !args.product_image_path) {
3140
- return fail(
3141
- "cast_mode product_only requires product_image_path so identity can be locked before generation.",
3142
- );
3143
- }
3144
3173
  // Fail fast on settings that would otherwise be silently dropped: the
3145
3174
  // logo PATCH is gated on logo_path and the product_description is only
3146
3175
  // sent with product_image_path, so a logo_* / product_description passed
@@ -3200,7 +3229,6 @@ registerTool(
3200
3229
  theme: args.theme,
3201
3230
  voice_id: args.voice_id,
3202
3231
  export_aspect_ratio: args.export_aspect_ratio,
3203
- cast_mode: args.cast_mode,
3204
3232
  },
3205
3233
  // Fold every create-affecting param into the key so two calls that
3206
3234
  // differ only by style/theme/voice/aspect get distinct drafts.
@@ -3318,7 +3346,10 @@ registerTool(
3318
3346
  kind: "editor",
3319
3347
  status,
3320
3348
  ...quote,
3321
- charged: true,
3349
+ launched: true,
3350
+ authorized_credits: args.max_credits,
3351
+ credits_spent: 0,
3352
+ charged: false,
3322
3353
  next: "wait_for_completion",
3323
3354
  });
3324
3355
  } finally {
@@ -3338,7 +3369,9 @@ registerTool(
3338
3369
  "explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " +
3339
3370
  "the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " +
3340
3371
  "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 " +
3372
+ "or double-charges. Failed or cancelled runs resume normally, except when status is batch_retry_required: " +
3373
+ "call retry_editor_batch (then wait for that prepaid batch) or cancel_editor_batch first. For a completed project, " +
3374
+ "set restart:true to quote and build a fresh creative run; omitting it only resumes pending edits. Then poll with " +
3342
3375
  'wait_for_completion({ slug, kind: "editor" }).',
3343
3376
  inputSchema: {
3344
3377
  slug: z.string().describe("Editor project slug"),
@@ -3355,12 +3388,14 @@ registerTool(
3355
3388
  .optional()
3356
3389
  .describe(
3357
3390
  "Optional locked product / main subject applied before launch so the AI cannot invent an " +
3358
- "unrelated product.",
3391
+ "unrelated product. Omit to keep the stored subject; send blank to clear it.",
3359
3392
  ),
3360
3393
  product_prompt: z
3361
3394
  .string()
3362
3395
  .optional()
3363
- .describe("Optional freeform brief to set/replace before launch."),
3396
+ .describe(
3397
+ "Optional freeform brief to set/replace before launch. Omit to keep the stored brief; send blank to clear it.",
3398
+ ),
3364
3399
  max_credits: z
3365
3400
  .number()
3366
3401
  .int()
@@ -3455,13 +3490,132 @@ registerTool(
3455
3490
  kind: "editor",
3456
3491
  status,
3457
3492
  ...quote,
3458
- charged: true,
3493
+ launched: true,
3494
+ authorized_credits: args.max_credits,
3495
+ credits_spent: 0,
3496
+ charged: false,
3459
3497
  next: "wait_for_completion",
3460
3498
  });
3461
3499
  },
3462
3500
  ),
3463
3501
  );
3464
3502
 
3503
+ registerTool(
3504
+ "retry_editor_batch",
3505
+ {
3506
+ title: "Retry a paused editor batch",
3507
+ description:
3508
+ "Recovers an editor whose normalized status is batch_retry_required by atomically re-queueing the failed " +
3509
+ "scene and resuming its prepaid batch. This spends 0 additional credits: the batch generation was already " +
3510
+ "paid. It does not authorize or restart a failed parent Autopilot run; after retrying, wait for the batch " +
3511
+ "to settle. If the parent is then terminal, call start_autopilot without max_credits for a fresh quote and " +
3512
+ "relaunch only with an approved cap. A batch parked on " +
3513
+ "insufficient_credits needs a top-up/scope change instead. Scope: video:generate.",
3514
+ inputSchema: { slug: z.string().describe("Editor project slug") },
3515
+ annotations: {
3516
+ title: "Retry editor batch",
3517
+ ...WRITE,
3518
+ idempotentHint: false,
3519
+ },
3520
+ },
3521
+ tool(async (args: { slug: string }, client) => {
3522
+ const command = await observedEditorBatchCommand(client, args.slug, [
3523
+ "paused_for_retry",
3524
+ ]);
3525
+ const res = await client.post<{ data: unknown }>(
3526
+ `/video-factories/${args.slug}/retry-batch-segment`,
3527
+ command,
3528
+ );
3529
+ return ok({
3530
+ ...asRecord(asRecord(res).data ?? res),
3531
+ slug: args.slug,
3532
+ charged: false,
3533
+ 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.",
3534
+ next: "wait_for_completion",
3535
+ });
3536
+ }),
3537
+ );
3538
+
3539
+ registerTool(
3540
+ "cancel_editor_batch",
3541
+ {
3542
+ title: "Cancel an editor batch",
3543
+ description:
3544
+ "Cancels an editor batch only while it is preparing or paused_for_retry so the project can be edited or " +
3545
+ "Autopilot can be started again. Preparing cancellation releases the uncharged preflight. Cancelling a paused " +
3546
+ "batch is destructive: dispatched scene work is stopped and its prepaid credits are not refunded. Spends " +
3547
+ "0 additional credits. Scope: video:generate.",
3548
+ inputSchema: { slug: z.string().describe("Editor project slug") },
3549
+ annotations: {
3550
+ title: "Cancel editor batch",
3551
+ readOnlyHint: false,
3552
+ destructiveHint: true,
3553
+ openWorldHint: true,
3554
+ idempotentHint: false,
3555
+ },
3556
+ },
3557
+ tool(async (args: { slug: string }, client) => {
3558
+ const command = await observedEditorBatchCommand(client, args.slug, [
3559
+ "preparing",
3560
+ "paused_for_retry",
3561
+ ]);
3562
+ const res = await client.post<{ data: unknown }>(
3563
+ `/video-factories/${args.slug}/cancel-batch`,
3564
+ command,
3565
+ );
3566
+ return ok({
3567
+ ...asRecord(asRecord(res).data ?? res),
3568
+ slug: args.slug,
3569
+ charged: false,
3570
+ next: "get_status",
3571
+ });
3572
+ }),
3573
+ );
3574
+
3575
+ type EditorBatchCommandStatus = "preparing" | "paused_for_retry";
3576
+
3577
+ /**
3578
+ * Reads the exact Editor batch ownership token immediately before issuing a
3579
+ * recovery command. The API validates both fields under the factory row lock,
3580
+ * so a replacement run or phase transition between this GET and the POST is a
3581
+ * safe `batch_command_stale` conflict rather than acting on newer work.
3582
+ */
3583
+ async function observedEditorBatchCommand(
3584
+ client: HubfluencerClient,
3585
+ slug: string,
3586
+ allowedStatuses: readonly EditorBatchCommandStatus[],
3587
+ ): Promise<{
3588
+ expected_batch_run_id: string | null;
3589
+ expected_batch_status: EditorBatchCommandStatus;
3590
+ }> {
3591
+ const response = await client.get<{ data: unknown }>(`/editor/${slug}`);
3592
+ const state = asRecord(asRecord(response).data);
3593
+ const status = state.batch_generation_status;
3594
+ if (
3595
+ typeof status !== "string" ||
3596
+ !allowedStatuses.includes(status as EditorBatchCommandStatus)
3597
+ ) {
3598
+ throw new Error(
3599
+ `Editor batch is not in an allowed phase (${allowedStatuses.join(" or ")}); refresh status before retrying.`,
3600
+ );
3601
+ }
3602
+
3603
+ const runId = state.batch_generation_run_id;
3604
+ if (
3605
+ runId !== null &&
3606
+ (typeof runId !== "string" || runId.trim().length === 0)
3607
+ ) {
3608
+ throw new Error(
3609
+ "Editor state did not include a valid batch_generation_run_id; refresh status before retrying.",
3610
+ );
3611
+ }
3612
+
3613
+ return {
3614
+ expected_batch_run_id: runId,
3615
+ expected_batch_status: status as EditorBatchCommandStatus,
3616
+ };
3617
+ }
3618
+
3465
3619
  // ── AI assists (free daily quota) ───────────────────────────────────────────
3466
3620
 
3467
3621
  registerTool(
@@ -3564,17 +3718,6 @@ registerTool(
3564
3718
  .describe(
3565
3719
  'Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
3566
3720
  ),
3567
- cast_mode: z
3568
- .enum(["product_only", "presenter_led"])
3569
- .optional()
3570
- .describe(
3571
- "Who/what anchors every AI scene. product_only = a product-led ad with NO people; requires a " +
3572
- "product image (set_product) before generation and generates every scene from persistent product " +
3573
- "reference images — the best product fidelity. presenter_led = one locked presenter appears in " +
3574
- "every scene; requires a presenter to be configured first (in the app / REST presenter endpoints " +
3575
- "— no MCP tool yet), else generation fails with presenter_required. Omit for the default auto " +
3576
- "behavior.",
3577
- ),
3578
3721
  voice_id: z
3579
3722
  .string()
3580
3723
  .regex(/^[A-Za-z0-9_-]+$/)
@@ -3611,7 +3754,6 @@ registerTool(
3611
3754
  creative_format?: string;
3612
3755
  visual_language?: string;
3613
3756
  theme?: string;
3614
- cast_mode?: string;
3615
3757
  voice_id?: string;
3616
3758
  export_aspect_ratio?: string;
3617
3759
  project_intent?: string;
@@ -3625,7 +3767,6 @@ registerTool(
3625
3767
  creative_format: args.creative_format,
3626
3768
  visual_language: args.visual_language,
3627
3769
  theme: args.theme,
3628
- cast_mode: args.cast_mode,
3629
3770
  voice_id: args.voice_id,
3630
3771
  export_aspect_ratio: args.export_aspect_ratio,
3631
3772
  project_intent: args.project_intent,
@@ -3648,7 +3789,6 @@ registerTool(
3648
3789
  args.theme ?? "",
3649
3790
  args.voice_id ?? "",
3650
3791
  args.export_aspect_ratio ?? "",
3651
- args.cast_mode ?? "",
3652
3792
  ),
3653
3793
  );
3654
3794
  return ok({
@@ -3660,21 +3800,81 @@ registerTool(
3660
3800
  ),
3661
3801
  );
3662
3802
 
3803
+ function conciseEditorState(value: unknown): Record<string, unknown> {
3804
+ const data = asRecord(value);
3805
+ const keys = [
3806
+ "slug",
3807
+ "status",
3808
+ "autopilot_status",
3809
+ "autopilot_current_step",
3810
+ "autopilot_error_step",
3811
+ "autopilot_error_code",
3812
+ "autopilot_error_message",
3813
+ "autopilot_max_credits",
3814
+ "autopilot_credits_spent",
3815
+ "scenario_status",
3816
+ "scenario_apply_status",
3817
+ "scenario_prompt",
3818
+ "batch_generation_status",
3819
+ "batch_generation_run_id",
3820
+ "auto_render_after_batch",
3821
+ "narration_status",
3822
+ "narration_script",
3823
+ "narration_stale",
3824
+ "voice_stale",
3825
+ "music_stale",
3826
+ "segments_count",
3827
+ "current_audio",
3828
+ "current_music",
3829
+ "latest_render",
3830
+ "project_spend",
3831
+ "ai_assist_quota",
3832
+ ] as const;
3833
+ const result: Record<string, unknown> = {};
3834
+ for (const key of keys) {
3835
+ if (key in data) result[key] = data[key];
3836
+ }
3837
+ result.segments = Array.isArray(data.segments)
3838
+ ? (data.segments as Record<string, unknown>[]).map((segment) => ({
3839
+ id: segment.id,
3840
+ position: segment.position,
3841
+ prompt: segment.prompt,
3842
+ status: segment.status,
3843
+ source_type: segment.source_type,
3844
+ use_previous_frame: segment.use_previous_frame,
3845
+ video_stale: segment.video_stale,
3846
+ error_code: segment.error_code,
3847
+ error_message: segment.error_message,
3848
+ narration_text: segment.narration_text,
3849
+ }))
3850
+ : [];
3851
+ return result;
3852
+ }
3853
+
3663
3854
  registerTool(
3664
3855
  "get_editor",
3665
3856
  {
3666
- title: "Get full editor state (review step)",
3857
+ title: "Get editor state (review step)",
3667
3858
  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") },
3859
+ "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_status, current_audio, current_music, latest_render, batch_generation_status, spend, and quota.",
3860
+ inputSchema: {
3861
+ slug: z.string().describe("Editor project slug"),
3862
+ response_format: z.enum(["concise", "full"]).default("full"),
3863
+ },
3672
3864
  annotations: { title: "Get editor", ...RO },
3673
3865
  },
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
- }),
3866
+ tool(
3867
+ async (
3868
+ args: { slug: string; response_format: "concise" | "full" },
3869
+ client,
3870
+ ) => {
3871
+ const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
3872
+ const data = asRecord(res).data;
3873
+ return ok(
3874
+ args.response_format === "full" ? data : conciseEditorState(data),
3875
+ );
3876
+ },
3877
+ ),
3678
3878
  );
3679
3879
 
3680
3880
  registerTool(
@@ -3726,16 +3926,16 @@ registerTool(
3726
3926
  .optional()
3727
3927
  .describe("How many scenes (3–10, server default 5)"),
3728
3928
  creative_format: z
3729
- .enum(CREATIVE_FORMATS)
3929
+ .union([z.enum(CREATIVE_FORMATS), z.literal("")])
3730
3930
  .optional()
3731
3931
  .describe(
3732
3932
  "Persist the narrative arc / segment structure before generating. Omit to leave it unchanged; pass an empty string to clear.",
3733
3933
  ),
3734
3934
  visual_language: z
3735
- .enum(VISUAL_LANGUAGES)
3935
+ .union([z.enum(VISUAL_LANGUAGES), z.literal("")])
3736
3936
  .optional()
3737
3937
  .describe(
3738
- "Persist the visual style / render look before generating. When set it drives the look and a theme of 'none' is ignored.",
3938
+ "Persist the visual style / render look before generating. Omit to leave it unchanged; pass an empty string to clear. When set it drives the look and a theme of 'none' is ignored.",
3739
3939
  ),
3740
3940
  theme: z
3741
3941
  .string()
@@ -4123,20 +4323,30 @@ registerTool(
4123
4323
  },
4124
4324
  },
4125
4325
  tool(async (args: { slug: string }, client, extra) => {
4126
- const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
4326
+ // One absolute deadline owns the entire read/submit/poll/reconcile path.
4327
+ // Start it before the initial retrying GET so no preparatory request sits
4328
+ // outside the advertised ~280s tool budget.
4329
+ const overallDeadline = Date.now() + 280_000;
4330
+ const res = await client.get<{ data: unknown }>(
4331
+ `/editor/${args.slug}`,
4332
+ undefined,
4333
+ overallDeadline,
4334
+ );
4127
4335
  const data = asRecord(asRecord(res).data);
4128
4336
  const segments = Array.isArray(data.segments)
4129
4337
  ? (data.segments as Record<string, unknown>[])
4130
4338
  : [];
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"
4339
+ const unfinished = segments
4340
+ .filter((s) => {
4341
+ if (s.source_type === "upload") return false;
4342
+ const st = (s.status as string) ?? (s.generation_status as string);
4343
+ return st !== "completed";
4344
+ })
4345
+ .sort(
4346
+ (a, b) =>
4347
+ Number(a.position ?? Number.MAX_SAFE_INTEGER) -
4348
+ Number(b.position ?? Number.MAX_SAFE_INTEGER),
4138
4349
  );
4139
- });
4140
4350
 
4141
4351
  // Overall wall-clock budget: cap the whole call at the same 280s ceiling the
4142
4352
  // other wait loops use (under a typical 300s client timeout). Without it a
@@ -4144,20 +4354,23 @@ registerTool(
4144
4354
  // hours) in a single call. When the budget runs out mid-batch we stop and
4145
4355
  // return the standard resume contract — call again and it naturally continues
4146
4356
  // 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;
4357
+ // Every scene poll receives this same absolute deadline, so time already spent
4358
+ // on earlier reads, submissions, and scenes is never granted again.
4359
+ // Reserve a full non-idempotent POST timeout plus one final status read before
4360
+ // starting another paid scene. POSTs deliberately aren't aborted/retried, so
4361
+ // beginning one in the last seconds of the budget would make a client timeout
4362
+ // ambiguous after the server may already have accepted the charge.
4363
+ const SUBMIT_HEADROOM_MS = 61_000;
4151
4364
 
4152
4365
  const generated: Array<string | number> = [];
4153
4366
  let n = 0;
4154
- for (const seg of pending) {
4367
+ for (const seg of unfinished) {
4155
4368
  const id = seg.id;
4156
4369
  if (id === undefined) continue;
4157
4370
  const sid = id as string | number;
4158
4371
  // Stop before submitting another scene once the overall budget is spent, so
4159
4372
  // the call returns instead of charging + waiting past the deadline.
4160
- const remainingMs = overallDeadline - Date.now();
4373
+ let remainingMs = overallDeadline - Date.now();
4161
4374
  if (remainingMs <= 0) {
4162
4375
  return ok({
4163
4376
  slug: args.slug,
@@ -4169,22 +4382,45 @@ registerTool(
4169
4382
  await reportProgress(
4170
4383
  extra,
4171
4384
  ++n,
4172
- `generating segment ${String(sid)} (${n}/${pending.length})`,
4173
- pending.length,
4385
+ `generating segment ${String(sid)} (${n}/${unfinished.length})`,
4386
+ unfinished.length,
4174
4387
  );
4175
- try {
4176
- await client.post(
4177
- `/editor/${args.slug}/segments/${String(sid)}/generate`,
4178
- undefined,
4179
- undefined,
4180
- );
4181
- } catch (e) {
4388
+ const initialStatus =
4389
+ (seg.status as string) ?? (seg.generation_status as string);
4390
+ if (initialStatus !== "processing") {
4391
+ remainingMs = overallDeadline - Date.now();
4392
+ if (remainingMs < SUBMIT_HEADROOM_MS) {
4393
+ return ok({
4394
+ slug: args.slug,
4395
+ generated,
4396
+ timed_out: true,
4397
+ note: "Wall-clock budget is too low to safely start another paid scene. Re-run generate_all_segments to continue; completed scenes are skipped.",
4398
+ });
4399
+ }
4400
+ try {
4401
+ await client.post(
4402
+ `/editor/${args.slug}/segments/${String(sid)}/generate`,
4403
+ undefined,
4404
+ undefined,
4405
+ );
4406
+ } catch (e) {
4407
+ return ok({
4408
+ slug: args.slug,
4409
+ generated,
4410
+ stopped_at: sid,
4411
+ error: errMessage(e),
4412
+ note: "Stopped on first error — fix the cause (often credits) and re-run; completed scenes are skipped, failed/pending are retried.",
4413
+ });
4414
+ }
4415
+ }
4416
+ remainingMs = overallDeadline - Date.now();
4417
+ if (remainingMs <= 0) {
4182
4418
  return ok({
4183
4419
  slug: args.slug,
4184
4420
  generated,
4185
4421
  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.",
4422
+ timed_out: true,
4423
+ 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
4424
  });
4189
4425
  }
4190
4426
  // Gate on THIS segment finishing before starting the next, so the next
@@ -4196,7 +4432,7 @@ registerTool(
4196
4432
  args.slug,
4197
4433
  sid,
4198
4434
  extra,
4199
- Math.min(PER_SEGMENT_MAX_MS, remainingMs),
4435
+ overallDeadline,
4200
4436
  15000,
4201
4437
  );
4202
4438
  if (seg_result.status === "failed") {
@@ -4217,7 +4453,45 @@ registerTool(
4217
4453
  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
4454
  });
4219
4455
  }
4220
- generated.push(sid);
4456
+ if (initialStatus !== "processing") generated.push(sid);
4457
+ }
4458
+
4459
+ // Reconcile against a fresh authoritative snapshot. A scene may have been
4460
+ // processing when this call started, or a completion webhook may still be
4461
+ // landing; never claim "all generated" while any AI scene is unfinished.
4462
+ if (overallDeadline - Date.now() < 1_000) {
4463
+ return ok({
4464
+ slug: args.slug,
4465
+ generated,
4466
+ timed_out: true,
4467
+ note: "Wall-clock budget reached before the final reconciliation read. Re-run generate_all_segments to confirm completion; completed scenes are skipped.",
4468
+ });
4469
+ }
4470
+ const finalRes = await client.get<{ data: unknown }>(
4471
+ `/editor/${args.slug}`,
4472
+ undefined,
4473
+ overallDeadline,
4474
+ );
4475
+ const finalData = asRecord(asRecord(finalRes).data);
4476
+ const finalSegments = Array.isArray(finalData.segments)
4477
+ ? (finalData.segments as Record<string, unknown>[])
4478
+ : [];
4479
+ const stillUnfinished = finalSegments.filter((segment) => {
4480
+ if (segment.source_type === "upload") return false;
4481
+ const status =
4482
+ (segment.status as string) ?? (segment.generation_status as string);
4483
+ return status !== "completed";
4484
+ });
4485
+ if (stillUnfinished.length > 0) {
4486
+ return ok({
4487
+ slug: args.slug,
4488
+ generated,
4489
+ timed_out: true,
4490
+ in_flight_segment_ids: stillUnfinished
4491
+ .map((segment) => segment.id)
4492
+ .filter((id) => id !== undefined),
4493
+ note: "Some scenes are still processing. Re-run generate_all_segments later; it will wait for in-flight scenes and generate only unfinished ones.",
4494
+ });
4221
4495
  }
4222
4496
  return ok({
4223
4497
  slug: args.slug,
@@ -4233,15 +4507,15 @@ registerTool(
4233
4507
  {
4234
4508
  title: "Set the narration script",
4235
4509
  description:
4236
- "Writes your own narration script (1–12000 chars) instead of generating one. Free — no credits, no AI " +
4510
+ "Writes your own narration script (1–10000 chars) instead of generating one. Free — no credits, no AI " +
4237
4511
  "assist. Use this in place of generate_narration when you want to control the voiceover text. Changing the narration after generating voice/music marks them stale — regenerate (generate_voice / generate_music) before render.",
4238
4512
  inputSchema: {
4239
4513
  slug: z.string().describe("Editor project slug"),
4240
4514
  script: z
4241
4515
  .string()
4242
4516
  .min(1)
4243
- .max(12000)
4244
- .describe("Narration / voiceover text (1–12000 chars)"),
4517
+ .max(10000)
4518
+ .describe("Narration / voiceover text (1–10000 chars)"),
4245
4519
  },
4246
4520
  annotations: { title: "Set narration", ...WRITE, idempotentHint: true },
4247
4521
  },
@@ -4258,7 +4532,7 @@ registerTool(
4258
4532
  {
4259
4533
  title: "Generate the narration (AI assist)",
4260
4534
  description:
4261
- "Generates the narration script with AI from the timeline. CONSUMES 1 AI ASSIST (free daily quota) — " +
4535
+ "Generates one coherent narration script with AI from the complete timeline. Every current scene must be completed with a known positive duration, and at least one scene must not keep original upload audio. CONSUMES 1 AI ASSIST (free daily quota) only when a fresh job is queued; an exact retry is deduplicated without consuming another assist — " +
4262
4536
  "it is free of CREDITS but NOT free of quota. On 429 set auto_unlock:true (1 credit → +10 assists, " +
4263
4537
  "retried once) or write the script yourself with set_narration_script.",
4264
4538
  inputSchema: {
@@ -4290,7 +4564,7 @@ registerTool(
4290
4564
  title: "Generate the voiceover (3 credits)",
4291
4565
  description:
4292
4566
  "Synthesizes the voiceover from the narration script. Costs 3 credits. Safe to retry; re-call after changing the narration or voice to regenerate." +
4293
- "voice_id is REQUIRED — pick one from list_voices (≤64 chars, [A-Za-z0-9_-] only). 402 fails cleanly. Editing the scenario/narration/timeline after this marks the voice stale, and render will 422 (editor_voice_stale) until you re-run generate_voice.",
4567
+ " All current scenes must be completed with known durations first. voice_id is REQUIRED — pick one from list_voices (≤64 chars, [A-Za-z0-9_-] only). 402 fails cleanly. Editing the scenario/narration/timeline after this marks the voice stale, and render will 422 (editor_voice_stale) until you re-run generate_voice.",
4294
4568
  inputSchema: {
4295
4569
  slug: z.string().describe("Editor project slug"),
4296
4570
  voice_id: z
@@ -4529,9 +4803,10 @@ registerTool(
4529
4803
  {
4530
4804
  title: "List renders for an editor project",
4531
4805
  description:
4532
- "Lists every render version for an editor project: {id, version, status, video_url, duration_seconds, " +
4533
- "error_message, inserted_at}. Use it to find a FAILED render to retry, or to recover a finished video_url " +
4534
- "(presigned, ~24h). Read-only.",
4806
+ "Lists every render version for an editor project: {id, version, status, video_url, thumbnail_url, " +
4807
+ "duration_seconds, error_message, retryable, combination_hash, is_stale, inserted_at}. Use it to find a " +
4808
+ "FAILED render to retry, or to recover a finished video_url (presigned, ~24h). status is pending | " +
4809
+ "processing | completed | failed; is_stale null means unknown, not current. Read-only.",
4535
4810
  inputSchema: { slug: z.string().describe("Editor project slug") },
4536
4811
  annotations: { title: "List renders", ...RO },
4537
4812
  },
@@ -4883,9 +5158,9 @@ registerTool(
4883
5158
  description:
4884
5159
  "Uploads a local product image and attaches it to the project as the product. Accepted: " +
4885
5160
  IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
4886
- " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. The FIRST product added " +
4887
- "defaults every pending AI scene to feature it 'throughout' (change with set_product_placement). Optional " +
4888
- "description (≤500 chars) guides how it's woven into scenes.",
5161
+ " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Its pixels guide product identity " +
5162
+ "in every generated Editor scene and its confirmed description grounds scenario and narration. The exact photo " +
5163
+ "is linked as the closing image unless a separate closing image was authored; generated fine label text can still vary.",
4889
5164
  inputSchema: {
4890
5165
  slug: z.string().describe("Editor project slug"),
4891
5166
  file_path: z
@@ -4919,33 +5194,6 @@ registerTool(
4919
5194
  ),
4920
5195
  );
4921
5196
 
4922
- registerTool(
4923
- "set_product_placement",
4924
- {
4925
- title: "Set default product placement on pending scenes (0 credits)",
4926
- description:
4927
- "Sets where the product appears across pending (never-generated) AI scenes. mode is 'throughout' (every scene) " +
4928
- "or 'end' (closing scene only; requires a first+last-frame capable model). 0 credits. Add a product first with " +
4929
- "set_product. (There is no 'none' — to remove the product, that's a separate action.)",
4930
- inputSchema: {
4931
- slug: z.string().describe("Editor project slug"),
4932
- mode: z.enum(["throughout", "end"]).describe("'throughout' or 'end'"),
4933
- },
4934
- annotations: {
4935
- title: "Set product placement",
4936
- ...WRITE,
4937
- idempotentHint: true,
4938
- },
4939
- },
4940
- tool(async (args: { slug: string; mode: "throughout" | "end" }, client) => {
4941
- const res = await client.post<{ data: unknown }>(
4942
- `/editor/${args.slug}/product/apply-default-placement`,
4943
- { placement: args.mode },
4944
- );
4945
- return ok(asRecord(res).data ?? res);
4946
- }),
4947
- );
4948
-
4949
5197
  registerTool(
4950
5198
  "set_closing_image",
4951
5199
  {
@@ -5018,7 +5266,7 @@ registerTool(
5018
5266
  {
5019
5267
  title: "Set a brand logo overlay (0 credits)",
5020
5268
  description:
5021
- "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤1 MiB; PNG keeps " +
5269
+ "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤5 MiB; PNG keeps " +
5022
5270
  "transparency), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. Optional placement controls: treatment, " +
5023
5271
  "position, duration_seconds.",
5024
5272
  inputSchema: {