@hubfluencer/mcp 0.9.1 → 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/src/index.ts CHANGED
@@ -121,6 +121,7 @@ import {
121
121
  MAX_CLOSING_IMAGE_BYTES,
122
122
  MAX_LOGO_BYTES,
123
123
  MAX_PRODUCT_IMAGE_BYTES,
124
+ MAX_SHORT_LOGO_BYTES,
124
125
  type PreparedUploadFile,
125
126
  preparedUploadIdentity,
126
127
  prepareImageUpload,
@@ -614,12 +615,12 @@ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
614
615
  8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
615
616
 
616
617
  CREDITS (spent server-side; each tool prices before charging — see its description)
617
- - Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
618
+ - Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card/logo edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
618
619
  - Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video supports dry_run/max_credits; create_editor_ad/start_autopilot require max_credits before launching.
619
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.
620
621
 
621
622
  WAITING & RESUMING
622
- - 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.
623
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.
624
625
 
625
626
  LOCAL FILES (sandboxed)
@@ -682,10 +683,9 @@ async function pollSegmentToTerminal(
682
683
  slug: string,
683
684
  segmentId: string | number,
684
685
  extra: ToolExtra,
685
- budgetMs: number,
686
+ deadline: number,
686
687
  intervalMs: number,
687
688
  ): Promise<{ status: string; error: string | null; timed_out: boolean }> {
688
- const deadline = Date.now() + budgetMs;
689
689
  const sid = String(segmentId);
690
690
  const read = async (): Promise<{ status: string; error: string | null }> => {
691
691
  // Bound each poll GET's retries by the loop deadline (see fetchStatus).
@@ -1134,6 +1134,7 @@ registerTool(
1134
1134
  : `/editor/${slug}/autopilot/cost`;
1135
1135
  let estimated_credits: number | null = null;
1136
1136
  let available_credits: number | null = null;
1137
+ let authorizedCredits: number | undefined;
1137
1138
  try {
1138
1139
  const cost = asRecord(
1139
1140
  asRecord(await client.get<{ data: unknown }>(costPath)).data,
@@ -1237,6 +1238,7 @@ registerTool(
1237
1238
  "Editor autopilot launch requires max_credits or a live quote",
1238
1239
  );
1239
1240
  }
1241
+ authorizedCredits = approvedCap;
1240
1242
  await client.post(
1241
1243
  `/editor/${slug}/autopilot`,
1242
1244
  // Use the caller's explicit cap when given, otherwise the live
@@ -1271,12 +1273,50 @@ registerTool(
1271
1273
  .saved_to;
1272
1274
  }
1273
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
+
1274
1314
  const result = {
1275
1315
  ...status,
1276
1316
  kind_inferred: requestedKind === undefined,
1277
1317
  estimated_credits,
1278
1318
  available_credits,
1279
- charged: true,
1319
+ ...billing,
1280
1320
  saved_to,
1281
1321
  timed_out: !status.terminal,
1282
1322
  };
@@ -1296,13 +1336,15 @@ registerTool(
1296
1336
  note: status.ready
1297
1337
  ? "Done. Presigned ~24h URL — download promptly."
1298
1338
  : status.terminal
1299
- ? // A terminal-but-not-ready make_video is a failed/cancelled run.
1300
- // Point at a restart: re-running make_video now genuinely restarts
1301
- // (the start key folds the failure state, so it no longer replays the
1302
- // dead 202), or resume the pipeline directly. Fix the cause (often
1303
- // credits) first.
1304
- `Terminal (${status.stage}). ${status.error ?? ""}`.trim() +
1305
- ` 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}.`
1306
1348
  : `Still rendering — call wait_for_completion(${resumeArgs}).`,
1307
1349
  },
1308
1350
  links,
@@ -1436,7 +1478,7 @@ registerTool(
1436
1478
  "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " +
1437
1479
  "segments + an on-screen title overlay + music; 14s when it ends on a poster or brand-lockup end card). " +
1438
1480
  "Returns the slug, costs 0 credits. Follow with generate_short to render. Iteration is FREE after that " +
1439
- "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card " +
1481
+ "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card/logo " +
1440
1482
  "tweak re-renders over them for 0 credits (update_short → rerender_short). " +
1441
1483
  "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " +
1442
1484
  "title), and pick a music_vibe plus visual_language that fit the brand. " +
@@ -1447,8 +1489,8 @@ registerTool(
1447
1489
  "default). If headline/subheadline/text_beats are left blank, the server auto-writes the headline + " +
1448
1490
  "caption beats at generate time (pass skip_auto_text:true to generate_short for a deliberately bare clip). " +
1449
1491
  "To brand it further, attach a product image " +
1450
- "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
1451
- "overlay that's an editor-only feature; use create_editor_ad for logo branding.)",
1492
+ "(set_short_product), a deterministic brand logo (set_short_logo), or an end-card poster " +
1493
+ "(set_short_poster) before generate_short.",
1452
1494
  inputSchema: {
1453
1495
  product_prompt: z
1454
1496
  .string()
@@ -1736,9 +1778,10 @@ registerTool(
1736
1778
  title: "Update a short draft (0 credits)",
1737
1779
  description:
1738
1780
  "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " +
1739
- "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card fields " +
1781
+ "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card/logo fields " +
1740
1782
  "(headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, font, " +
1741
- "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card) on an " +
1783
+ "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card, short_logo_position, " +
1784
+ "short_logo_treatment) on an " +
1742
1785
  "already-generated short? " +
1743
1786
  "rerender_short applies them for 0 credits. Footage/music-affecting edits (product_prompt, product " +
1744
1787
  "image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need " +
@@ -1922,6 +1965,20 @@ registerTool(
1922
1965
  "Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " +
1923
1966
  "claim/CTA so they don't collide (default false).",
1924
1967
  ),
1968
+ short_logo_position: z
1969
+ .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
1970
+ .optional()
1971
+ .describe(
1972
+ "Corner for the deterministic brand-logo overlay (set the image with set_short_logo). Moving it is " +
1973
+ "a FREE re-render — logo fields never cost credits.",
1974
+ ),
1975
+ short_logo_treatment: z
1976
+ .enum(["throughout", "end_card", "none"])
1977
+ .optional()
1978
+ .describe(
1979
+ 'When the brand logo shows: "throughout" the footage, only on the "end_card", or "none" to hide it. ' +
1980
+ "FREE re-render.",
1981
+ ),
1925
1982
  brand_profile_id: z
1926
1983
  .number()
1927
1984
  .int()
@@ -1967,6 +2024,12 @@ registerTool(
1967
2024
  brand_name?: string;
1968
2025
  end_card?: string;
1969
2026
  poster_includes_lockup?: boolean;
2027
+ short_logo_position?:
2028
+ | "top-left"
2029
+ | "top-right"
2030
+ | "bottom-left"
2031
+ | "bottom-right";
2032
+ short_logo_treatment?: "throughout" | "end_card" | "none";
1970
2033
  brand_profile_id?: number;
1971
2034
  },
1972
2035
  client,
@@ -2013,6 +2076,13 @@ registerTool(
2013
2076
  if (args.end_card !== undefined) body.end_card = args.end_card;
2014
2077
  if (args.poster_includes_lockup !== undefined)
2015
2078
  body.poster_includes_lockup = args.poster_includes_lockup;
2079
+ // Logo overlay placement — the API maps these body keys straight onto the
2080
+ // factory (short_logo_position/short_logo_treatment). They're excluded from
2081
+ // the paid-inputs snapshot, so a corner/treatment change is a FREE re-render.
2082
+ if (args.short_logo_position !== undefined)
2083
+ body.short_logo_position = args.short_logo_position;
2084
+ if (args.short_logo_treatment !== undefined)
2085
+ body.short_logo_treatment = args.short_logo_treatment;
2016
2086
  if (args.brand_profile_id !== undefined)
2017
2087
  body.brand_profile_id = args.brand_profile_id;
2018
2088
 
@@ -2024,7 +2094,7 @@ registerTool(
2024
2094
  return ok({
2025
2095
  ...asRecord(data),
2026
2096
  next:
2027
- "Saved. Only text/style/CTA/end-card changed on an already-generated short? " +
2097
+ "Saved. Only text/style/CTA/end-card/logo changed on an already-generated short? " +
2028
2098
  `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` +
2029
2099
  "(prompt, product image, format, visual_language, theme, music_*, language) need " +
2030
2100
  "generate_short (15 credits).",
@@ -2464,8 +2534,9 @@ registerTool(
2464
2534
  {
2465
2535
  title: "Re-render a short (FREE — 0 credits)",
2466
2536
  description:
2467
- "FREE re-render: applies the short's current text/style/CTA/end-card state over the already-paid " +
2468
- "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks. Footage or music " +
2537
+ "FREE re-render: applies the short's current text/style/CTA/end-card/logo state over the already-paid " +
2538
+ "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks, or after a logo change " +
2539
+ "(set_short_logo, or short_logo_position/short_logo_treatment via update_short). Footage or music " +
2469
2540
  "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " +
2470
2541
  "end_card) can shift the video between 12s and 14s; when the pinned music bed is shorter than the " +
2471
2542
  "export it crossfades into a repeated continuation, then fades at the export end, without regeneration. " +
@@ -3030,7 +3101,7 @@ registerTool(
3030
3101
  logo_path: z
3031
3102
  .string()
3032
3103
  .optional()
3033
- .describe("Local logo (.jpg/.jpeg/.png, ≤1 MiB) to overlay."),
3104
+ .describe("Local logo (.jpg/.jpeg/.png, ≤5 MiB) to overlay."),
3034
3105
  logo_treatment: z
3035
3106
  .enum(["intro", "outro", "both", "none"])
3036
3107
  .optional()
@@ -3288,7 +3359,10 @@ registerTool(
3288
3359
  kind: "editor",
3289
3360
  status,
3290
3361
  ...quote,
3291
- charged: true,
3362
+ launched: true,
3363
+ authorized_credits: args.max_credits,
3364
+ credits_spent: 0,
3365
+ charged: false,
3292
3366
  next: "wait_for_completion",
3293
3367
  });
3294
3368
  } finally {
@@ -3308,7 +3382,9 @@ registerTool(
3308
3382
  "explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " +
3309
3383
  "the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " +
3310
3384
  "if a run is already in progress for this slug the server returns already_running — it never double-starts " +
3311
- "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 " +
3312
3388
  'wait_for_completion({ slug, kind: "editor" }).',
3313
3389
  inputSchema: {
3314
3390
  slug: z.string().describe("Editor project slug"),
@@ -3425,13 +3501,79 @@ registerTool(
3425
3501
  kind: "editor",
3426
3502
  status,
3427
3503
  ...quote,
3428
- charged: true,
3504
+ launched: true,
3505
+ authorized_credits: args.max_credits,
3506
+ credits_spent: 0,
3507
+ charged: false,
3429
3508
  next: "wait_for_completion",
3430
3509
  });
3431
3510
  },
3432
3511
  ),
3433
3512
  );
3434
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
+
3435
3577
  // ── AI assists (free daily quota) ───────────────────────────────────────────
3436
3578
 
3437
3579
  registerTool(
@@ -3630,21 +3772,81 @@ registerTool(
3630
3772
  ),
3631
3773
  );
3632
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
+
3633
3826
  registerTool(
3634
3827
  "get_editor",
3635
3828
  {
3636
- title: "Get full editor state (review step)",
3829
+ title: "Get editor state (review step)",
3637
3830
  description:
3638
- "Returns the full editor project state: scenario_prompt, segments[] (with prompts + status), " +
3639
- "narration_script/status, music, latest_render, segments_count, ai_assist_quota. This is the REVIEW " +
3640
- "step call it after generating the scenario/segments to inspect and decide what to edit.",
3641
- 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
+ },
3642
3836
  annotations: { title: "Get editor", ...RO },
3643
3837
  },
3644
- tool(async (args: { slug: string }, client) => {
3645
- const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
3646
- return ok(asRecord(res).data);
3647
- }),
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
+ ),
3648
3850
  );
3649
3851
 
3650
3852
  registerTool(
@@ -4093,20 +4295,30 @@ registerTool(
4093
4295
  },
4094
4296
  },
4095
4297
  tool(async (args: { slug: string }, client, extra) => {
4096
- 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
+ );
4097
4307
  const data = asRecord(asRecord(res).data);
4098
4308
  const segments = Array.isArray(data.segments)
4099
4309
  ? (data.segments as Record<string, unknown>[])
4100
4310
  : [];
4101
- const pending = segments.filter((s) => {
4102
- const st = (s.status as string) ?? (s.generation_status as string);
4103
- return (
4104
- st === undefined ||
4105
- st === "pending" ||
4106
- st === "draft" ||
4107
- 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),
4108
4321
  );
4109
- });
4110
4322
 
4111
4323
  // Overall wall-clock budget: cap the whole call at the same 280s ceiling the
4112
4324
  // other wait loops use (under a typical 300s client timeout). Without it a
@@ -4114,20 +4326,23 @@ registerTool(
4114
4326
  // hours) in a single call. When the budget runs out mid-batch we stop and
4115
4327
  // return the standard resume contract — call again and it naturally continues
4116
4328
  // with whatever scenes are still pending (completed ones are skipped).
4117
- const overallDeadline = Date.now() + 280_000;
4118
- // Each scene's own poll budget stays generous (a Veo scene can take minutes)
4119
- // but is clamped to the time left overall so one scene can't overrun 280s.
4120
- 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;
4121
4336
 
4122
4337
  const generated: Array<string | number> = [];
4123
4338
  let n = 0;
4124
- for (const seg of pending) {
4339
+ for (const seg of unfinished) {
4125
4340
  const id = seg.id;
4126
4341
  if (id === undefined) continue;
4127
4342
  const sid = id as string | number;
4128
4343
  // Stop before submitting another scene once the overall budget is spent, so
4129
4344
  // the call returns instead of charging + waiting past the deadline.
4130
- const remainingMs = overallDeadline - Date.now();
4345
+ let remainingMs = overallDeadline - Date.now();
4131
4346
  if (remainingMs <= 0) {
4132
4347
  return ok({
4133
4348
  slug: args.slug,
@@ -4139,22 +4354,45 @@ registerTool(
4139
4354
  await reportProgress(
4140
4355
  extra,
4141
4356
  ++n,
4142
- `generating segment ${String(sid)} (${n}/${pending.length})`,
4143
- pending.length,
4357
+ `generating segment ${String(sid)} (${n}/${unfinished.length})`,
4358
+ unfinished.length,
4144
4359
  );
4145
- try {
4146
- await client.post(
4147
- `/editor/${args.slug}/segments/${String(sid)}/generate`,
4148
- undefined,
4149
- undefined,
4150
- );
4151
- } 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) {
4152
4390
  return ok({
4153
4391
  slug: args.slug,
4154
4392
  generated,
4155
4393
  stopped_at: sid,
4156
- error: errMessage(e),
4157
- 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.",
4158
4396
  });
4159
4397
  }
4160
4398
  // Gate on THIS segment finishing before starting the next, so the next
@@ -4166,7 +4404,7 @@ registerTool(
4166
4404
  args.slug,
4167
4405
  sid,
4168
4406
  extra,
4169
- Math.min(PER_SEGMENT_MAX_MS, remainingMs),
4407
+ overallDeadline,
4170
4408
  15000,
4171
4409
  );
4172
4410
  if (seg_result.status === "failed") {
@@ -4187,7 +4425,45 @@ registerTool(
4187
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.",
4188
4426
  });
4189
4427
  }
4190
- 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
+ });
4191
4467
  }
4192
4468
  return ok({
4193
4469
  slug: args.slug,
@@ -4988,7 +5264,7 @@ registerTool(
4988
5264
  {
4989
5265
  title: "Set a brand logo overlay (0 credits)",
4990
5266
  description:
4991
- "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 " +
4992
5268
  "transparency), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. Optional placement controls: treatment, " +
4993
5269
  "position, duration_seconds.",
4994
5270
  inputSchema: {
@@ -5043,11 +5319,11 @@ registerTool(
5043
5319
  ),
5044
5320
  );
5045
5321
 
5046
- // ── Shorts: branding images (product + end-card poster) ──────────────────────
5322
+ // ── Shorts: branding images (product + logo + end-card poster) ───────────────
5047
5323
  //
5048
5324
  // The editor branding tools above hit /editor/...; these hit /shorts/... A short
5049
- // has no logo overlay (editor-only), but it CAN feature a product image woven
5050
- // into the footage and a closing end-card poster. Both are 0 credits.
5325
+ // Product images are woven into AI footage; logos are deterministic Forge
5326
+ // overlays; posters are closing cards. All are 0 credits.
5051
5327
 
5052
5328
  registerTool(
5053
5329
  "set_short_product",
@@ -5096,6 +5372,57 @@ registerTool(
5096
5372
  ),
5097
5373
  );
5098
5374
 
5375
+ registerTool(
5376
+ "set_short_logo",
5377
+ {
5378
+ title: "Set a Short brand logo overlay (0 credits)",
5379
+ description:
5380
+ "Uploads an exact brand logo for deterministic compositing over a Short. The image is never sent to the " +
5381
+ "AI video provider, so lettering is preserved. PNG/JPEG, ≤5 MiB, 0 credits. Choose a corner and whether " +
5382
+ "it appears throughout the footage or only on the end card.",
5383
+ inputSchema: {
5384
+ slug: z.string().describe("Short slug"),
5385
+ file_path: z.string().describe("Local logo image (.png/.jpg/.jpeg)"),
5386
+ position: z
5387
+ .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
5388
+ .optional()
5389
+ .describe("Logo corner (default top-right)"),
5390
+ treatment: z
5391
+ .enum(["throughout", "end_card", "none"])
5392
+ .optional()
5393
+ .describe("When the logo appears (default throughout)"),
5394
+ },
5395
+ annotations: { title: "Set Short logo", ...WRITE, idempotentHint: false },
5396
+ },
5397
+ tool(
5398
+ async (
5399
+ args: {
5400
+ slug: string;
5401
+ file_path: string;
5402
+ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
5403
+ treatment?: "throughout" | "end_card" | "none";
5404
+ },
5405
+ client,
5406
+ ) => {
5407
+ const { s3_key } = await uploadImageFile(
5408
+ client,
5409
+ `/shorts/${args.slug}/logo/presign`,
5410
+ args.file_path,
5411
+ MAX_SHORT_LOGO_BYTES,
5412
+ );
5413
+ const res = await client.post<{ data: unknown }>(
5414
+ `/shorts/${args.slug}/logo/confirm`,
5415
+ {
5416
+ s3_key,
5417
+ position: args.position ?? "top-right",
5418
+ treatment: args.treatment ?? "throughout",
5419
+ },
5420
+ );
5421
+ return ok(asRecord(res).data ?? res);
5422
+ },
5423
+ ),
5424
+ );
5425
+
5099
5426
  registerTool(
5100
5427
  "set_short_poster",
5101
5428
  {
@@ -5105,7 +5432,7 @@ registerTool(
5105
5432
  "render from 12s to 14s). Accepted: " +
5106
5433
  IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
5107
5434
  " (≤20 MiB = 20,971,520 bytes), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " +
5108
- "again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
5435
+ "again replaces it. Use set_short_logo for an exact, non-generative brand overlay.",
5109
5436
  inputSchema: {
5110
5437
  slug: z.string().describe("Short slug (from create_short)"),
5111
5438
  file_path: z.string().describe("Local poster image (.jpg/.jpeg/.png)"),