ima2-gen 3.5.2 → 3.5.3

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.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/bin/commands/video.js +10 -9
  3. package/bin/lib/videoMcp.js +8 -4
  4. package/config.js +25 -8
  5. package/docs/CLI.md +2 -2
  6. package/docs/migration/runtime-test-inventory.md +3 -1
  7. package/lib/errors/providerMap.js +1 -0
  8. package/lib/grokImageAdapter.js +4 -1
  9. package/lib/grokImageCore.js +4 -1
  10. package/lib/grokVideoAdapter.js +153 -37
  11. package/lib/grokVideoDownload.js +1 -1
  12. package/lib/grokVideoPlannerPrompt.js +46 -0
  13. package/lib/grokVideoShared.js +14 -3
  14. package/lib/videoClientTimeouts.js +17 -0
  15. package/package.json +2 -2
  16. package/routes/video.js +2 -0
  17. package/ui/dist/.vite/manifest.json +31 -31
  18. package/ui/dist/assets/{AgentWorkspace-IQkTyjJH.js → AgentWorkspace-BZuw_82p.js} +1 -1
  19. package/ui/dist/assets/{AssetGenWorkspace-C8m2ZPqQ.js → AssetGenWorkspace-D-p57I-0.js} +2 -2
  20. package/ui/dist/assets/{AssetsWorkspace-HFowWrn5.js → AssetsWorkspace-BlVr5Qjq.js} +1 -1
  21. package/ui/dist/assets/{CardNewsWorkspace-BakVkPHn.js → CardNewsWorkspace-BPYWHZqh.js} +1 -1
  22. package/ui/dist/assets/{GenerationRequestLogPanel-CYY42FV8.js → GenerationRequestLogPanel-CJYn7vCN.js} +1 -1
  23. package/ui/dist/assets/{HomeWorkspace-B_CfJ8NM.js → HomeWorkspace-DbWuQI7B.js} +1 -1
  24. package/ui/dist/assets/{KeyingPanel-D1HFpVPP.js → KeyingPanel-hK4oc51k.js} +1 -1
  25. package/ui/dist/assets/{NodeCanvas-CmhGjMDf.js → NodeCanvas-CO9FzAfn.js} +1 -1
  26. package/ui/dist/assets/{PromptBuilderPanel-D9UwkVEV.js → PromptBuilderPanel-DEPrkXcU.js} +1 -1
  27. package/ui/dist/assets/{PromptImportDialog-CFT_1oGB.js → PromptImportDialog-CGxyjU6L.js} +2 -2
  28. package/ui/dist/assets/{PromptImportDiscoverySection-DzVY9HM4.js → PromptImportDiscoverySection-C5PY1YV3.js} +1 -1
  29. package/ui/dist/assets/{PromptImportFolderSection-8AcoiI0c.js → PromptImportFolderSection-DWsWg6nw.js} +1 -1
  30. package/ui/dist/assets/{PromptLibraryPanel-DDraaZxD.js → PromptLibraryPanel-BPiKLU-z.js} +2 -2
  31. package/ui/dist/assets/{SettingsWorkspace-N7JHafTr.js → SettingsWorkspace-D1gLUIPQ.js} +1 -1
  32. package/ui/dist/assets/{SpriteRecipeWorkspace-d0VeLHp7.js → SpriteRecipeWorkspace-vlnp398m.js} +1 -1
  33. package/ui/dist/assets/{index-DJimPauj.js → index-DogXlrkz.js} +3 -3
  34. package/ui/dist/assets/{index-BOSjAXB6.js → index-znBY56O9.js} +3 -3
  35. package/ui/dist/assets/{pptxgen.es-B4z3xJiJ.js → pptxgen.es-C5xOqzMQ.js} +1 -1
  36. package/ui/dist/assets/{useAgentDialogFocus-BqrYA0we.js → useAgentDialogFocus-C1ZNYO-9.js} +1 -1
  37. package/ui/dist/index.html +1 -1
package/README.md CHANGED
@@ -314,7 +314,9 @@ environment variables > ~/.ima2/config.json > built-in defaults
314
314
  | `IMA2_GROK_PROXY_PORT` | `18645` | Port for the bundled progrok proxy |
315
315
  | `IMA2_NO_GROK_PROXY` | — | Set `1` to disable automatic progrok startup |
316
316
  | `IMA2_GROK_PLANNER_MODEL` | `grok-4.5` | Grok search/planner model (also configurable via settings UI or `--planner-model` CLI flag) |
317
- | `IMA2_GROK_PLANNER_TIMEOUT_MS` | `60000` | Timeout for Grok search and planner calls |
317
+ | `IMA2_GROK_PLANNER_TIMEOUT_MS` | `900000` | Timeout for the Grok planner call |
318
+ | `IMA2_GROK_SEARCH_TIMEOUT_MS` | `300000` | Timeout for the Grok web-search brief (degrades instead of failing) |
319
+ | `IMA2_GROK_VIDEO_PLAN_TOTAL_TIMEOUT_MS` | `1500000` | Ceiling on the whole video planning phase (clamped above search + planner) |
318
320
  | `IMA2_GROK_IMAGE_MODEL_DEFAULT` | `grok-imagine-image-quality` | Default final Grok image model |
319
321
  | `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
320
322
  | `IMA2_GROK_GENERATION_TIMEOUT_MS` | `120000` | Timeout for the final Grok Images API call |
@@ -6,6 +6,7 @@ import { writeFile, mkdir } from "node:fs/promises";
6
6
  import { basename, dirname } from "node:path";
7
7
  import { runVideoGenerate } from "../lib/videoMcp.js";
8
8
  import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_15_PREVIEW_ALIAS, GROK_VIDEO_MODEL_BASE, validateVideoResolutionForRequest, } from "../../lib/imageModels.js";
9
+ import { VIDEO_CLIENT_TIMEOUT_SEC } from "../../lib/videoClientTimeouts.js";
9
10
  const VALID_RESOLUTIONS = new Set(["480p", "720p", "1080p"]);
10
11
  const VALID_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "auto"]);
11
12
  const VALID_MODELS = new Set([GROK_VIDEO_MODEL_BASE, GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_15_PREVIEW_ALIAS]);
@@ -40,7 +41,7 @@ async function readJsonResponse(res, label) {
40
41
  }
41
42
  }
42
43
  function parseTimeoutSeconds(seconds) {
43
- const sec = parseIntegerFlag(seconds, 600, "--timeout");
44
+ const sec = parseIntegerFlag(seconds, VIDEO_CLIENT_TIMEOUT_SEC, "--timeout");
44
45
  if (sec < 1)
45
46
  die(2, "--timeout must be at least 1");
46
47
  return sec;
@@ -82,7 +83,7 @@ const SPEC = {
82
83
  out: { short: "o", type: "string" },
83
84
  "out-dir": { short: "d", type: "string" },
84
85
  json: { type: "boolean" },
85
- timeout: { type: "string", default: "600" },
86
+ timeout: { type: "string", default: String(VIDEO_CLIENT_TIMEOUT_SEC) },
86
87
  server: { type: "string" },
87
88
  session: { type: "string" },
88
89
  help: { short: "h", type: "boolean" },
@@ -126,7 +127,7 @@ const HELP = `
126
127
  -o, --out <file> Output file path
127
128
  -d, --out-dir <dir> Output directory
128
129
  --json Print JSON result to stdout
129
- --timeout <sec> Default: 600
130
+ --timeout <sec> Default: 5400
130
131
  --server <url> Override server URL
131
132
  --session <id> Session ID
132
133
 
@@ -210,11 +211,11 @@ async function runVideoGenerateRequest(serverBase, body, timeout, silent) {
210
211
  }
211
212
  // --- Subcommands ---
212
213
  async function videoEditCmd(argv) {
213
- const spec = { flags: { video: { type: "string" }, out: { short: "o", type: "string" }, output: { type: "string" }, json: { type: "boolean" }, timeout: { type: "string", default: "600" }, server: { type: "string" }, help: { short: "h", type: "boolean" } } };
214
+ const spec = { flags: { video: { type: "string" }, out: { short: "o", type: "string" }, output: { type: "string" }, json: { type: "boolean" }, timeout: { type: "string", default: String(VIDEO_CLIENT_TIMEOUT_SEC) }, server: { type: "string" }, help: { short: "h", type: "boolean" } } };
214
215
  const args = parseArgs(argv, spec);
215
216
  rejectUnknownFlags(args);
216
217
  if (args.help) {
217
- out(` ima2 video edit <prompt> --video <url|file_id|generated-file>\n\n Edit existing video with text prompt (real V2V).\n Model: grok-imagine-video only. Input: mp4, max 8.7s.\n\n Options:\n --video <value> Source video HTTPS URL, xAI file_id, data URL, or generated filename (required)\n -o, --out <file> Download edited video to file\n --output <file> Alias for --out\n --json Print JSON result\n --timeout <sec> Default: 600\n --server <url> Override server URL`);
218
+ out(` ima2 video edit <prompt> --video <url|file_id|generated-file>\n\n Edit existing video with text prompt (real V2V).\n Model: grok-imagine-video only. Input: mp4, max 8.7s.\n\n Options:\n --video <value> Source video HTTPS URL, xAI file_id, data URL, or generated filename (required)\n -o, --out <file> Download edited video to file\n --output <file> Alias for --out\n --json Print JSON result\n --timeout <sec> Default: 5400\n --server <url> Override server URL`);
218
219
  return;
219
220
  }
220
221
  const prompt = args.positional.join(" ");
@@ -246,11 +247,11 @@ async function videoEditCmd(argv) {
246
247
  }
247
248
  }
248
249
  async function videoExtendCmd(argv) {
249
- const spec = { flags: { video: { type: "string" }, duration: { type: "string", default: "6" }, out: { short: "o", type: "string" }, output: { type: "string" }, json: { type: "boolean" }, timeout: { type: "string", default: "600" }, server: { type: "string" }, help: { short: "h", type: "boolean" } } };
250
+ const spec = { flags: { video: { type: "string" }, duration: { type: "string", default: "6" }, out: { short: "o", type: "string" }, output: { type: "string" }, json: { type: "boolean" }, timeout: { type: "string", default: String(VIDEO_CLIENT_TIMEOUT_SEC) }, server: { type: "string" }, help: { short: "h", type: "boolean" } } };
250
251
  const args = parseArgs(argv, spec);
251
252
  rejectUnknownFlags(args);
252
253
  if (args.help) {
253
- out(` ima2 video extend <prompt> --video <url|file_id|generated-file> [--duration 6]\n\n Extend video from its last frame.\n Model: grok-imagine-video only. Extension: 2-10s.\n\n Options:\n --video <value> Source video HTTPS URL, xAI file_id, data URL, or generated filename (required)\n --duration <2-10> Extension duration (default: 6)\n -o, --out <file> Download extended video to file\n --output <file> Alias for --out\n --json Print JSON result\n --timeout <sec> Default: 600\n --server <url> Override server URL`);
254
+ out(` ima2 video extend <prompt> --video <url|file_id|generated-file> [--duration 6]\n\n Extend video from its last frame.\n Model: grok-imagine-video only. Extension: 2-10s.\n\n Options:\n --video <value> Source video HTTPS URL, xAI file_id, data URL, or generated filename (required)\n --duration <2-10> Extension duration (default: 6)\n -o, --out <file> Download extended video to file\n --output <file> Alias for --out\n --json Print JSON result\n --timeout <sec> Default: 5400\n --server <url> Override server URL`);
254
255
  return;
255
256
  }
256
257
  const prompt = args.positional.join(" ");
@@ -297,7 +298,7 @@ async function videoContinueCmd(argv) {
297
298
  out: { short: "o", type: "string" },
298
299
  output: { type: "string" },
299
300
  json: { type: "boolean" },
300
- timeout: { type: "string", default: "600" },
301
+ timeout: { type: "string", default: String(VIDEO_CLIENT_TIMEOUT_SEC) },
301
302
  server: { type: "string" },
302
303
  help: { short: "h", type: "boolean" },
303
304
  },
@@ -305,7 +306,7 @@ async function videoContinueCmd(argv) {
305
306
  const args = parseArgs(argv, spec);
306
307
  rejectUnknownFlags(args);
307
308
  if (args.help) {
308
- out(` ima2 video continue <prompt> --video <generated-file>\n\n Generate a new clip from a generated video's last frame and carry branch-local revisedPrompt lineage.\n\n Prompt must describe visual flow, motion, sound/music/no-music, dialogue/no-dialogue, ending frame, and how the selected duration should feel naturally filled.\n\n Options:\n --video <file> Generated .mp4 filename (required)\n --duration <1..15> Default: 5. Prompt motion should naturally fill this length\n --resolution <480p|720p|1080p> Default: 720p. 1080p requires --model grok-imagine-video-1.5\n --aspect-ratio <ratio|auto> Default: auto\n --model <name> grok-imagine-video, grok-imagine-video-1.5 (preview alias accepted)\n -o, --out <file> Download continued video to file\n --output <file> Alias for --out\n --json Print JSON result\n --timeout <sec> Default: 600\n --server <url> Override server URL`);
309
+ out(` ima2 video continue <prompt> --video <generated-file>\n\n Generate a new clip from a generated video's last frame and carry branch-local revisedPrompt lineage.\n\n Prompt must describe visual flow, motion, sound/music/no-music, dialogue/no-dialogue, ending frame, and how the selected duration should feel naturally filled.\n\n Options:\n --video <file> Generated .mp4 filename (required)\n --duration <1..15> Default: 5. Prompt motion should naturally fill this length\n --resolution <480p|720p|1080p> Default: 720p. 1080p requires --model grok-imagine-video-1.5\n --aspect-ratio <ratio|auto> Default: auto\n --model <name> grok-imagine-video, grok-imagine-video-1.5 (preview alias accepted)\n -o, --out <file> Download continued video to file\n --output <file> Alias for --out\n --json Print JSON result\n --timeout <sec> Default: 5400\n --server <url> Override server URL`);
309
310
  return;
310
311
  }
311
312
  const prompt = args.positional.join(" ");
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { config } from "../../config.js";
4
4
  import { deriveVideoMode, GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_15_PREVIEW_ALIAS, validateVideoResolutionForRequest, } from "../../lib/imageModels.js";
5
+ import { VIDEO_CLIENT_TIMEOUT_MS } from "../../lib/videoClientTimeouts.js";
5
6
  import { wasFlagPassed } from "./argsExplicit.js";
6
7
  import { resolveHistoryReference, resolveServer, request } from "./client.js";
7
8
  import { loadCliDefaults } from "./config-store.js";
@@ -13,7 +14,10 @@ import { createCliRequestId } from "./recover-output.js";
13
14
  import { streamSse } from "./sse.js";
14
15
  const VALID_RESOLUTIONS = new Set(["480p", "720p", "1080p"]);
15
16
  const VALID_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "auto"]);
16
- const MCP_VIDEO_TIMEOUT_MS = 12 * 60_000 + 120_000 + 30_000;
17
+ // Shared client ceiling (see lib/videoClientTimeouts.ts). Also guards the
18
+ // runway/higgsfield MCP lanes, which end on their own terminal events, so a longer
19
+ // ceiling is harmless there.
20
+ const MCP_VIDEO_TIMEOUT_MS = VIDEO_CLIENT_TIMEOUT_MS;
17
21
  const MCP_LANES = new Set(["runway", "higgsfield"]);
18
22
  function parseInteger(value, fallback, label) {
19
23
  const raw = value === undefined ? String(fallback) : String(value);
@@ -134,7 +138,7 @@ function coreBody(args, context, options, references, requestId) {
134
138
  async function consumeCoreSse(url, body, args, requestId) {
135
139
  const controller = new AbortController();
136
140
  let timedOut = false;
137
- const timeoutMs = parseInteger(args.timeout, 600, "--timeout") * 1000;
141
+ const timeoutMs = parseInteger(args.timeout, MCP_VIDEO_TIMEOUT_MS / 1000, "--timeout") * 1000;
138
142
  const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
139
143
  const onSignal = () => { controller.abort(); process.exit(130); };
140
144
  process.once("SIGINT", onSignal);
@@ -192,7 +196,7 @@ async function runCoreVideo(args, context) {
192
196
  die(1, "server did not return a video filename");
193
197
  const filename = String(done.filename);
194
198
  const target = args.out ? String(args.out) : args["out-dir"] ? join(String(args["out-dir"]), filename) : join(config.storage.generatedDir, filename);
195
- const response = await fetch(`${context.server.base}${done.url || `/generated/${encodeURIComponent(filename)}`}`, { signal: AbortSignal.timeout(parseInteger(args.timeout, 600, "--timeout") * 1000) });
199
+ const response = await fetch(`${context.server.base}${done.url || `/generated/${encodeURIComponent(filename)}`}`, { signal: AbortSignal.timeout(parseInteger(args.timeout, MCP_VIDEO_TIMEOUT_MS / 1000, "--timeout") * 1000) });
196
200
  if (!response.ok)
197
201
  die(1, `failed to download video: HTTP ${response.status}`);
198
202
  await writeBuffer(target, Buffer.from(await response.arrayBuffer()));
@@ -373,7 +377,7 @@ async function runMcpVideo(argv, args, context) {
373
377
  }
374
378
  }
375
379
  export async function runVideoGenerate(argv, args, prompt) {
376
- if (parseInteger(args.timeout, 600, "--timeout") < 1)
380
+ if (parseInteger(args.timeout, MCP_VIDEO_TIMEOUT_MS / 1000, "--timeout") < 1)
377
381
  die(2, "--timeout must be at least 1");
378
382
  const { server, catalog } = await fetchCatalog(args);
379
383
  const target = resolveVideoTarget(args, catalog);
package/config.js CHANGED
@@ -165,7 +165,12 @@ export const config = {
165
165
  nodeHexBytes: pickInt(env.IMA2_NODE_HEX_BYTES, fileCfg.ids?.nodeHexBytes, 5),
166
166
  },
167
167
  inflight: {
168
- ttlMs: pickInt(env.IMA2_INFLIGHT_TTL_MS, fileCfg.inflight?.ttlMs, 10 * 60 * 1000),
168
+ // Must exceed the longest legal request. Grok video can legitimately run ~70 min
169
+ // (planning + start + poll + poll overshoot + download), and purgeStaleJobs() drops the
170
+ // job row without aborting its worker, so a 10-minute TTL used to erase live jobs
171
+ // mid-flight.
172
+ // devlog/_plan/260817_grok_video_planner_timeout/010_timeout_budgets.md
173
+ ttlMs: pickInt(env.IMA2_INFLIGHT_TTL_MS, fileCfg.inflight?.ttlMs, 90 * 60 * 1000),
169
174
  reapMs: pickInt(env.IMA2_INFLIGHT_REAP_MS, fileCfg.inflight?.reapMs, 60 * 1000),
170
175
  terminalTtlMs: pickInt(env.IMA2_INFLIGHT_TERMINAL_TTL_MS, fileCfg.inflight?.terminalTtlMs, 5 * 60 * 1000),
171
176
  },
@@ -200,21 +205,33 @@ export const config = {
200
205
  restartMaxDelayMs: pickInt(env.IMA2_GROK_RESTART_MAX_DELAY_MS, fileCfg.grokProvider?.restartMaxDelayMs, 60_000),
201
206
  restartHealthyMs: pickInt(env.IMA2_GROK_RESTART_HEALTHY_MS, fileCfg.grokProvider?.restartHealthyMs, 60_000),
202
207
  plannerModel: pickStr(env.IMA2_GROK_PLANNER_MODEL, fileCfg.grokProvider?.plannerModel, DEFAULT_GROK_PLANNER_MODEL),
203
- // grok-4.6 web-search planning measured ~72 s against the live proxy and a full
204
- // search+generate round trip measured ~202 s, so the old 60 s budget aborted every
205
- // default-path search. 300 s absorbs slow search days with room to spare.
206
- plannerTimeoutMs: pickInt(env.IMA2_GROK_PLANNER_TIMEOUT_MS, fileCfg.grokProvider?.plannerTimeoutMs, 300_000),
208
+ // Measured 260817: the forced web_search brief takes 70-73 s idle / 44-79 s concurrent,
209
+ // and the planner tool call 9-32 s idle / 28-41 s concurrent. But the reported failure
210
+ // was a planner call that STALLED for its entire 300 s budget while an isolated probe
211
+ // of the same payload answered in 32 s. Budgets are therefore calibrated against the
212
+ // stall, not the probe: 900 s is 3x the observed stall.
213
+ // devlog/_plan/260817_grok_video_planner_timeout/010_timeout_budgets.md
214
+ plannerTimeoutMs: pickInt(env.IMA2_GROK_PLANNER_TIMEOUT_MS, fileCfg.grokProvider?.plannerTimeoutMs, 900_000),
215
+ // The web-search brief gets its own bound: it is an enhancement, not a requirement, so
216
+ // it degrades instead of failing the request (lib/grokVideoAdapter.ts).
217
+ searchTimeoutMs: pickInt(env.IMA2_GROK_SEARCH_TIMEOUT_MS, fileCfg.grokProvider?.searchTimeoutMs, 300_000),
218
+ // Wired ceiling on search + planner combined, so the two stages cannot sum unbounded.
219
+ // It must be STRICTLY greater than searchTimeoutMs + plannerTimeoutMs (invariant enforced
220
+ // by tests): if it equals the sum, a slow search followed by a stalled planner lands both
221
+ // timers together, the phase ceiling wins the race, and the local planner fallback never
222
+ // runs. The margin exists so the planner's own timeout always fires first.
223
+ videoPlanTotalTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_PLAN_TOTAL_TIMEOUT_MS, fileCfg.grokProvider?.videoPlanTotalTimeoutMs, 1_500_000),
207
224
  defaultImageModel: pickStr(env.IMA2_GROK_IMAGE_MODEL_DEFAULT, fileCfg.grokProvider?.defaultImageModel, "grok-imagine-image-2.0"),
208
225
  // grok-imagine-image-2.0 measured 43-97 s per image; 2k/batch requests run longer,
209
226
  // so the image call gets the same generous 300 s ceiling as the planner.
210
227
  generationTimeoutMs: pickInt(env.IMA2_GROK_GENERATION_TIMEOUT_MS, fileCfg.grokProvider?.generationTimeoutMs, 300_000),
211
228
  statusTimeoutMs: pickInt(env.IMA2_GROK_STATUS_TIMEOUT_MS, fileCfg.grokProvider?.statusTimeoutMs, 3000),
212
229
  defaultVideoModel: pickStr(env.IMA2_GROK_VIDEO_MODEL_DEFAULT, fileCfg.grokProvider?.defaultVideoModel, "grok-imagine-video-1.5"),
213
- videoStartTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_START_TIMEOUT_MS, fileCfg.grokProvider?.videoStartTimeoutMs, 150_000),
230
+ videoStartTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_START_TIMEOUT_MS, fileCfg.grokProvider?.videoStartTimeoutMs, 300_000),
214
231
  videoPollIntervalMs: pickInt(env.IMA2_GROK_VIDEO_POLL_INTERVAL_MS, fileCfg.grokProvider?.videoPollIntervalMs, 5_000),
215
232
  videoPollMaxConsecutiveErrors: pickInt(env.IMA2_GROK_VIDEO_POLL_MAX_ERRORS, fileCfg.grokProvider?.videoPollMaxConsecutiveErrors, 5),
216
- videoTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_TIMEOUT_MS, fileCfg.grokProvider?.videoTimeoutMs, 900_000),
217
- videoDownloadTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_DOWNLOAD_TIMEOUT_MS, fileCfg.grokProvider?.videoDownloadTimeoutMs, 120_000),
233
+ videoTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_TIMEOUT_MS, fileCfg.grokProvider?.videoTimeoutMs, 1_800_000),
234
+ videoDownloadTimeoutMs: pickInt(env.IMA2_GROK_VIDEO_DOWNLOAD_TIMEOUT_MS, fileCfg.grokProvider?.videoDownloadTimeoutMs, 300_000),
218
235
  },
219
236
  // Direct MiniMax image-generation provider (text-to-image / image-to-image).
220
237
  // Region selects the global (.io) or China (.minimaxi.com) OpenAI-compatible
package/docs/CLI.md CHANGED
@@ -162,7 +162,7 @@ Video generate flags:
162
162
  | `--ref <file>` | Attach source/reference image (repeatable, max 7) |
163
163
  | `-o, --out <file>` | Output file path |
164
164
  | `-d, --out-dir <dir>` | Output directory |
165
- | `--timeout <sec>` | Timeout in seconds (default: 600) |
165
+ | `--timeout <sec>` | Timeout in seconds (default: 5400) |
166
166
  | `--session <id>` | Session ID |
167
167
 
168
168
  Blank video prompts are rejected. Prompts should include visual flow, camera or
@@ -181,7 +181,7 @@ Video edit/extend flags:
181
181
  | `--duration <2..10>` | Extension duration only (default: 6) |
182
182
  | `-o, --out <file>` | Download the edited or extended video to a file |
183
183
  | `--json` | Print JSON result |
184
- | `--timeout <sec>` | Timeout in seconds (default: 600) |
184
+ | `--timeout <sec>` | Timeout in seconds (default: 5400) |
185
185
 
186
186
  Video continue flags:
187
187
 
@@ -4,7 +4,7 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`).
4
4
 
5
5
  _Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._
6
6
 
7
- Total: 360 (runtime: 157, contract: 203)
7
+ Total: 362 (runtime: 159, contract: 203)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/agent-mode-auto-planner-contract.test.ts`
@@ -66,6 +66,8 @@ Total: 360 (runtime: 157, contract: 203)
66
66
  - `tests/grok-size-mapper.test.ts`
67
67
  - `tests/grok-upstream-retry.test.ts`
68
68
  - `tests/grokVideoAdapter.test.ts`
69
+ - `tests/grokVideoPlannerFallback.test.ts`
70
+ - `tests/grokVideoTimeoutBudgets.test.ts`
69
71
  - `tests/history-metadata-fallback.test.ts`
70
72
  - `tests/history-tombstone.test.ts`
71
73
  - `tests/history-video-row.test.ts`
@@ -48,6 +48,7 @@ export const PROVIDER_ERROR_MAP = {
48
48
  GROK_VIDEO_INVALID_MODE: "CAPABILITY_UNSUPPORTED",
49
49
  GROK_VIDEO_MODERATION_BLOCKED: "CONTENT_REJECTED",
50
50
  GROK_VIDEO_POLL_FAILED: "NETWORK_FAILURE",
51
+ GROK_VIDEO_PLAN_TIMEOUT: "PROVIDER_TIMEOUT",
51
52
  GROK_VIDEO_REF_TOO_MANY: "CAPABILITY_UNSUPPORTED",
52
53
  GROK_VIDEO_REQUEST_FAILED: "CAPABILITY_UNSUPPORTED",
53
54
  GROK_VIDEO_TIMEOUT: "PROVIDER_TIMEOUT",
@@ -182,7 +182,10 @@ export async function searchGrokVisualContext(prompt, ctx, options = {}) {
182
182
  const plannerModel = options.plannerModel || planner.model;
183
183
  const payload = buildGrokSearchPayload(prompt, plannerModel);
184
184
  const { url, headers } = getGrokEndpoint(ctx, "/v1/responses", options.directApiKey);
185
- const { combinedSignal, timer } = withTimeoutSignal(options.signal, planner.timeoutMs);
185
+ // The search brief has its own, smaller budget: it runs BEFORE the planner call inside
186
+ // the same user request, so charging it the full planner budget lets one slow stage eat
187
+ // the other's time. See devlog/_plan/260817_grok_video_planner_timeout/010.
188
+ const { combinedSignal, timer } = withTimeoutSignal(options.signal, planner.searchTimeoutMs);
186
189
  logEvent("grok", "search:start", { requestId: options.requestId, plannerModel, promptChars: prompt.length });
187
190
  try {
188
191
  // Safe to replay: search produces no billable artifact, only a research summary.
@@ -39,7 +39,10 @@ export function getPlannerConfig(ctx) {
39
39
  const grokCfg = ctx.config.grokProvider || {};
40
40
  return {
41
41
  model: grokCfg.plannerModel || DEFAULT_GROK_PLANNER_MODEL,
42
- timeoutMs: grokCfg.plannerTimeoutMs || 300_000,
42
+ timeoutMs: grokCfg.plannerTimeoutMs || 900_000,
43
+ // The forced web_search brief is bounded separately: it is an enhancement, so a slow
44
+ // search should cost a less-researched prompt, not the whole generation.
45
+ searchTimeoutMs: grokCfg.searchTimeoutMs || 300_000,
43
46
  };
44
47
  }
45
48
  export function withTimeoutSignal(signal, timeoutMs) {
@@ -1,10 +1,10 @@
1
- import { logEvent } from "./logger.js";
1
+ import { logEvent, logWarn } from "./logger.js";
2
2
  import { grokError, searchGrokVisualContext } from "./grokImageAdapter.js";
3
3
  import { grokFetchWithRetry } from "./grokUpstreamRetry.js";
4
4
  import { detectImageMimeFromB64 } from "./refs.js";
5
5
  import { aspectToCanvas, generateWhiteCanvasB64 } from "./grokVideoCanvas.js";
6
6
  import { downloadVideo } from "./grokVideoDownload.js";
7
- import { buildGrokVideoPlannerSystemPrompt, formatDurationPacingGuidance } from "./grokVideoPlannerPrompt.js";
7
+ import { buildGrokVideoPlannerSystemPrompt, composeFallbackVideoPrompt, formatDurationPacingGuidance } from "./grokVideoPlannerPrompt.js";
8
8
  import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_15_PREVIEW_ALIAS, GROK_VIDEO_MODEL_BASE, MAX_REF2V_REFERENCES, validateVideoResolutionForRequest, } from "./imageModels.js";
9
9
  import { formatVideoContinuityForPlanner } from "./videoContinuity.js";
10
10
  import { DEFAULT_GROK_PLANNER_MODEL } from "../config.js";
@@ -13,6 +13,34 @@ import { pollVideoUntilDone } from "./grokVideoPoll.js";
13
13
  export { downloadVideo } from "./grokVideoDownload.js";
14
14
  export { normalizeVideoPoll } from "./grokVideoShared.js";
15
15
  export { isRecoverablePollError, pollVideoOnce, pollVideoUntilDone } from "./grokVideoPoll.js";
16
+ /**
17
+ * A planner failure is degradable when retrying or waiting could plausibly have produced a
18
+ * prompt: a stall, an upstream 5xx, or a network fault.
19
+ *
20
+ * A 4xx is NOT degradable — the request itself is malformed or unauthorized, and generating
21
+ * a video anyway would spend the user's quota on a request the provider already rejected.
22
+ *
23
+ * Neither is a 200 that carries no usable tool call: the planner ANSWERED and declined or
24
+ * malfunctioned, which is a judgment about the request rather than an availability problem.
25
+ * Billing a video start on the back of a refusal is worse for the user than an honest error.
26
+ */
27
+ export function isDegradablePlannerFailure(err) {
28
+ const message = typeof err?.message === "string" ? err.message : "planner unavailable";
29
+ if (err?.name === "AbortError")
30
+ return { reason: "timeout", message };
31
+ const code = typeof err?.code === "string" ? err.code : "";
32
+ if (code === "GROK_PLANNER_TIMEOUT")
33
+ return { reason: "timeout", message };
34
+ if (code === "GROK_PLANNER_NETWORK_FAILED")
35
+ return { reason: "failed", message };
36
+ const status = typeof err?.status === "number" ? err.status : 0;
37
+ if (code === "GROK_PLANNER_BAD_REQUEST" && status >= 500)
38
+ return { reason: "failed", message };
39
+ // 429 is the same availability class as a stall: the upstream is saturated, not offended.
40
+ if (code === "GROK_PLANNER_BAD_REQUEST" && status === 429)
41
+ return { reason: "failed", message };
42
+ return null;
43
+ }
16
44
  function canonicalVideoModel(model) {
17
45
  return model === GROK_VIDEO_MODEL_15_PREVIEW_ALIAS ? GROK_VIDEO_MODEL_15 : model;
18
46
  }
@@ -117,46 +145,132 @@ export async function planGrokVideo(prompt, ctx, options = {}) {
117
145
  const aspectRatio = options.aspectRatio || "auto";
118
146
  const plannerModel = options.plannerModel || cfg.plannerModel;
119
147
  const model = canonicalVideoModel(options.model || cfg.model);
120
- const search = await searchGrokVisualContext(prompt, ctx, { ...(options.signal ? { signal: options.signal } : {}), ...(options.requestId ? { requestId: options.requestId } : {}), ...(options.directApiKey ? { directApiKey: options.directApiKey } : {}), plannerModel });
121
- const referenceImageUrls = (options.referenceImages ?? []).map((img) => sourceImageUrl(img, undefined));
122
- const payload = buildGrokVideoPlannerPayload(prompt, {
123
- model,
124
- mode,
125
- duration,
126
- resolution,
127
- aspectRatio,
128
- plannerModel,
129
- searchSummary: search.summary,
130
- sourceImageUrl: options.sourceImage ? sourceImageUrl(options.sourceImage, options.sourceMime) : undefined,
131
- referenceImageUrls,
132
- continuityLineage: options.continuityLineage,
133
- backgroundConstraint: options.backgroundConstraint,
134
- });
135
- const { url, headers } = videoEndpoint(ctx, "/v1/chat/completions", options.directApiKey);
136
- const { combinedSignal, timer } = withTimeoutSignal(options.signal, cfg.plannerTimeoutMs);
137
- logEvent("grok", "video:planner:start", { requestId: options.requestId, mode, duration, resolution });
148
+ // One deadline for the whole planning phase. Both stages run under it, so search and
149
+ // planner can never sum to two independent budgets (the shape of the 360 s failure).
150
+ // devlog/_plan/260817_grok_video_planner_timeout/010_timeout_budgets.md
151
+ const phase = withTimeoutSignal(options.signal, cfg.planTotalTimeoutMs);
152
+ const phaseSignal = phase.combinedSignal;
153
+ /** A phase-ceiling abort is fatal; it must never be mistaken for a degradable failure. */
154
+ const phaseExpired = () => phaseSignal.aborted && !options.signal?.aborted;
138
155
  try {
139
- // Safe to replay: the planner produces no billable artifact, only a prompt.
140
- const res = await grokFetchWithRetry(() => fetch(url, { method: "POST", headers, body: JSON.stringify(payload), signal: combinedSignal }), { signal: combinedSignal, label: "video-planner" });
141
- clearTimeout(timer);
142
- if (!res.ok) {
143
- const text = await res.text().catch(() => "");
144
- throw grokError(`Grok video planner failed: ${text || `HTTP ${res.status}`}`, res.status >= 500 ? 502 : res.status, "GROK_PLANNER_BAD_REQUEST");
156
+ // The web-search brief is an enhancement, not a requirement: a slow or failing search
157
+ // used to abort the whole video request (GROK_PLANNER_TIMEOUT / GROK_SEARCH_TIMEOUT).
158
+ // Degrade instead, and keep the planner's own budget intact.
159
+ // devlog/_plan/260817_grok_video_planner_timeout/020_search_resilience.md
160
+ let searchSummary;
161
+ let searchDegraded;
162
+ try {
163
+ // Pass the PHASE signal, never a locally wrapped search timer: searchGrokVisualContext
164
+ // reports any abort of its `options.signal` as a user cancellation, so handing it our
165
+ // own deadline would turn a slow search — the exact case 020 exists to degrade — into
166
+ // GENERATION_CANCELED. The stage bound is applied inside that function via
167
+ // getPlannerConfig().searchTimeoutMs.
168
+ const search = await searchGrokVisualContext(prompt, ctx, { signal: phaseSignal, ...(options.requestId ? { requestId: options.requestId } : {}), ...(options.directApiKey ? { directApiKey: options.directApiKey } : {}), plannerModel });
169
+ searchSummary = search.summary;
145
170
  }
146
- const planPrompt = parseGrokVideoPlanPrompt(await res.json());
147
- logEvent("grok", "video:planner:done", { requestId: options.requestId, mode, promptChars: planPrompt.length });
148
- return { prompt: planPrompt, mode, duration, resolution, aspectRatio, webSearchCalls: 1 };
149
- }
150
- catch (e) {
151
- clearTimeout(timer);
152
- if (e.name === "AbortError") {
171
+ catch (e) { // justified: adapter rejections are untyped; the shape is narrowed here
172
+ // A user cancellation is a real cancellation and must never be swallowed. Neither is
173
+ // the planning-phase ceiling: searchGrokVisualContext reports an inherited abort as its
174
+ // own cancellation, so without this check a ceiling hit would "degrade" and then start
175
+ // the planner after the phase had already expired.
176
+ if (options.signal?.aborted)
177
+ throw e;
178
+ if (phaseExpired())
179
+ throw grokError("Grok video planning timed out", 504, "GROK_VIDEO_PLAN_TIMEOUT");
180
+ if (e?.code === "GENERATION_CANCELED")
181
+ throw e;
182
+ searchDegraded = {
183
+ reason: e?.code === "GROK_SEARCH_TIMEOUT" ? "timeout" : "failed",
184
+ message: typeof e?.message === "string" ? e.message : "web search unavailable",
185
+ };
186
+ logWarn("grok", "video:search:degraded", {
187
+ requestId: options.requestId,
188
+ reason: searchDegraded.reason,
189
+ error: searchDegraded.message,
190
+ });
191
+ }
192
+ const referenceImageUrls = (options.referenceImages ?? []).map((img) => sourceImageUrl(img, undefined));
193
+ const payload = buildGrokVideoPlannerPayload(prompt, {
194
+ model,
195
+ mode,
196
+ duration,
197
+ resolution,
198
+ aspectRatio,
199
+ plannerModel,
200
+ searchSummary,
201
+ sourceImageUrl: options.sourceImage ? sourceImageUrl(options.sourceImage, options.sourceMime) : undefined,
202
+ referenceImageUrls,
203
+ continuityLineage: options.continuityLineage,
204
+ backgroundConstraint: options.backgroundConstraint,
205
+ });
206
+ const { url, headers } = videoEndpoint(ctx, "/v1/chat/completions", options.directApiKey);
207
+ const { combinedSignal, timer } = withTimeoutSignal(phaseSignal, cfg.plannerTimeoutMs);
208
+ logEvent("grok", "video:planner:start", { requestId: options.requestId, mode, duration, resolution });
209
+ try {
210
+ // Safe to replay: the planner produces no billable artifact, only a prompt.
211
+ const res = await grokFetchWithRetry(() => fetch(url, { method: "POST", headers, body: JSON.stringify(payload), signal: combinedSignal }), { signal: combinedSignal, label: "video-planner" });
212
+ clearTimeout(timer);
213
+ if (!res.ok) {
214
+ const text = await res.text().catch(() => "");
215
+ throw grokError(`Grok video planner failed: ${text || `HTTP ${res.status}`}`, res.status >= 500 ? 502 : res.status, "GROK_PLANNER_BAD_REQUEST");
216
+ }
217
+ const planPrompt = parseGrokVideoPlanPrompt(await res.json());
218
+ logEvent("grok", "video:planner:done", { requestId: options.requestId, mode, promptChars: planPrompt.length });
219
+ return {
220
+ prompt: planPrompt,
221
+ mode,
222
+ duration,
223
+ resolution,
224
+ aspectRatio,
225
+ // Honest accounting: no brief means no billable search call was completed.
226
+ webSearchCalls: searchDegraded ? 0 : 1,
227
+ ...(searchDegraded ? { searchDegraded } : {}),
228
+ };
229
+ }
230
+ catch (e) {
231
+ clearTimeout(timer);
232
+ // Real cancellation and the planning-phase ceiling are always fatal.
153
233
  if (options.signal?.aborted)
154
234
  throw grokError("Generation canceled", 499, "GENERATION_CANCELED");
155
- throw grokError("Grok video planner timed out", 504, "GROK_PLANNER_TIMEOUT");
235
+ if (phaseExpired())
236
+ throw grokError("Grok video planning timed out", 504, "GROK_VIDEO_PLAN_TIMEOUT");
237
+ const normalized = e.name === "AbortError"
238
+ ? grokError("Grok video planner timed out", 504, "GROK_PLANNER_TIMEOUT")
239
+ : (e.code && e.status ? e : grokError(`Grok video planner request failed: ${e.message}`, 502, "GROK_PLANNER_NETWORK_FAILED"));
240
+ // A stalled or broken planner must not cost the user their video: the user's own
241
+ // prompt is already usable, so compose one locally and continue. 4xx stays fatal.
242
+ // devlog/_plan/260817_grok_video_planner_timeout/040_planner_fallback.md
243
+ const degradable = isDegradablePlannerFailure(normalized);
244
+ if (!degradable)
245
+ throw normalized;
246
+ const fallbackPrompt = composeFallbackVideoPrompt(prompt, {
247
+ mode,
248
+ duration,
249
+ resolution,
250
+ searchSummary,
251
+ continuityText: formatVideoContinuityForPlanner(options.continuityLineage) || undefined,
252
+ backgroundConstraint: options.backgroundConstraint,
253
+ });
254
+ logWarn("grok", "video:planner:degraded", {
255
+ requestId: options.requestId,
256
+ reason: degradable.reason,
257
+ error: degradable.message,
258
+ promptChars: fallbackPrompt.length,
259
+ });
260
+ return {
261
+ prompt: fallbackPrompt,
262
+ mode,
263
+ duration,
264
+ resolution,
265
+ aspectRatio,
266
+ webSearchCalls: searchDegraded ? 0 : 1,
267
+ ...(searchDegraded ? { searchDegraded } : {}),
268
+ plannerDegraded: degradable,
269
+ };
156
270
  }
157
- if (e.code && e.status)
158
- throw e;
159
- throw grokError(`Grok video planner request failed: ${e.message}`, 502, "GROK_PLANNER_NETWORK_FAILED");
271
+ }
272
+ finally {
273
+ clearTimeout(phase.timer);
160
274
  }
161
275
  }
162
276
  export function buildVideoGenerationPayload(plan, opts) {
@@ -288,5 +402,7 @@ export async function generateVideoViaGrok(prompt, ctx, options = {}) {
288
402
  requestedModel: model,
289
403
  effectiveModel,
290
404
  modelFallback,
405
+ ...(plan.searchDegraded ? { searchDegraded: plan.searchDegraded } : {}),
406
+ ...(plan.plannerDegraded ? { plannerDegraded: plan.plannerDegraded } : {}),
291
407
  };
292
408
  }
@@ -3,7 +3,7 @@ import { grokFetchWithRetry } from "./grokUpstreamRetry.js";
3
3
  const MAX_VIDEO_DOWNLOAD_BYTES = 100 * 1024 * 1024;
4
4
  function downloadTimeoutMs(ctx) {
5
5
  const g = ctx.config.grokProvider || {};
6
- return g.videoDownloadTimeoutMs || 120_000;
6
+ return g.videoDownloadTimeoutMs || 300_000;
7
7
  }
8
8
  function withTimeoutSignal(signal, timeoutMs) {
9
9
  const timeoutController = new AbortController();
@@ -153,3 +153,49 @@ export function buildGrokVideoPlannerSystemPrompt(ctx) {
153
153
  "Call generate_video exactly once. Do not answer with plain text.",
154
154
  ].join("\n");
155
155
  }
156
+ /** Hard cap so a degraded prompt never bloats the generation payload. */
157
+ const FALLBACK_PROMPT_MAX_CHARS = 4000;
158
+ /** The user's own scene text is the one part that must never be cut. */
159
+ const FALLBACK_USER_PROMPT_RESERVE = 2500;
160
+ /**
161
+ * Deterministic, network-free video prompt used when the Grok planner is unavailable
162
+ * (timeout, upstream 5xx/429, or a network fault). A planner that ANSWERS without a tool
163
+ * call is a refusal, not an availability problem, and stays fatal.
164
+ *
165
+ * The user's own prompt is already a legitimate video prompt; this only adds the pacing
166
+ * and mode guidance the planner would have applied, so a stalled planner costs prompt
167
+ * polish instead of the whole generation.
168
+ * devlog/_plan/260817_grok_video_planner_timeout/040_planner_fallback.md
169
+ */
170
+ export function composeFallbackVideoPrompt(userPrompt, opts) {
171
+ // The caller's prompt may already carry a long storyboard preamble. Truncating from the
172
+ // front would keep the boilerplate and drop the user's actual scene, so the user text is
173
+ // reserved first and everything else is fitted around it.
174
+ const base = userPrompt.trim().slice(0, FALLBACK_USER_PROMPT_RESERVE);
175
+ const modeLine = opts.mode === "image-to-video"
176
+ ? "Start from the provided source image as the first frame and preserve the subject's identity and composition while the described motion happens."
177
+ : opts.mode === "reference-to-video"
178
+ ? "Keep the referenced subjects recognizable throughout while the described motion happens."
179
+ : "Describe a fresh scene: establish the setting, then carry the described motion and camera work through the shot.";
180
+ // Continuity and background constraints are load-bearing: without them a continue-from
181
+ // clip can drift off-series, or a background preset can be silently ignored.
182
+ const continuity = opts.continuityText?.trim()
183
+ ? `Continuation context:\n${opts.continuityText.trim().slice(0, 600)}`
184
+ : "";
185
+ const background = opts.backgroundConstraint?.trim() ?? "";
186
+ const brief = opts.searchSummary?.trim()
187
+ ? `Research context: ${opts.searchSummary.trim().slice(0, 600)}`
188
+ : "";
189
+ const composed = [
190
+ base,
191
+ "",
192
+ modeLine,
193
+ continuity,
194
+ background,
195
+ formatDurationPacingGuidance(opts.duration, opts.mode, opts.resolution),
196
+ brief,
197
+ ].filter(Boolean).join("\n");
198
+ return composed.length > FALLBACK_PROMPT_MAX_CHARS
199
+ ? composed.slice(0, FALLBACK_PROMPT_MAX_CHARS).trimEnd()
200
+ : composed;
201
+ }
@@ -12,14 +12,25 @@ export const FAILED_CODE_MAP = {
12
12
  };
13
13
  export function videoConfig(ctx) {
14
14
  const g = ctx.config.grokProvider || {}; // justified: RouteRuntimeContext.config is a loose runtime bag; every Grok adapter reads grokProvider this way
15
+ const plannerTimeoutMs = g.plannerTimeoutMs || 900_000;
16
+ const searchTimeoutMs = g.searchTimeoutMs || 300_000;
17
+ // The planning ceiling must stay STRICTLY greater than the two stages it contains, or a
18
+ // slow search plus a stalled planner lands both timers together and the fatal ceiling
19
+ // preempts the planner fallback. Operator config can violate that, so clamp at runtime
20
+ // rather than trusting the defaults to survive an env override.
21
+ // devlog/_plan/260817_grok_video_planner_timeout/010_timeout_budgets.md
22
+ const configuredPlanTotal = g.videoPlanTotalTimeoutMs || 1_500_000;
23
+ const minimumPlanTotal = searchTimeoutMs + plannerTimeoutMs + 60_000;
15
24
  return {
16
25
  model: g.defaultVideoModel || GROK_FALLBACK_VIDEO_MODEL,
17
- startTimeoutMs: g.videoStartTimeoutMs || 150_000,
26
+ startTimeoutMs: g.videoStartTimeoutMs || 300_000,
18
27
  pollIntervalMs: g.videoPollIntervalMs || 5_000,
19
- totalTimeoutMs: g.videoTimeoutMs || 900_000,
28
+ totalTimeoutMs: g.videoTimeoutMs || 1_800_000,
20
29
  pollMaxConsecutiveErrors: g.videoPollMaxConsecutiveErrors || 5,
21
30
  plannerModel: g.plannerModel || DEFAULT_GROK_PLANNER_MODEL,
22
- plannerTimeoutMs: g.plannerTimeoutMs || 300_000,
31
+ plannerTimeoutMs,
32
+ searchTimeoutMs,
33
+ planTotalTimeoutMs: Math.max(configuredPlanTotal, minimumPlanTotal),
23
34
  };
24
35
  }
25
36
  export function videoEndpoint(ctx, path, directApiKey) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Client-side ceilings for a whole video request, shared by the CLI, the MCP lane, and the
3
+ * budget contract test so none of them can drift apart.
4
+ *
5
+ * The server worst case for one Grok video is
6
+ * 1500 s planning + 300 s start + 1800 s poll + 300 s poll overshoot + 300 s download
7
+ * = 4200 s
8
+ * (the poll loop checks its deadline BEFORE each request, so the final poll can exceed the
9
+ * poll budget by one request timeout). Every client must sit ABOVE that with real slack —
10
+ * an equal ceiling is a race the client can win, abandoning a request the server would
11
+ * have completed.
12
+ *
13
+ * MUST stay a leaf module: no imports, so bin/, ui/, and tests can all read it.
14
+ * devlog/_plan/260817_grok_video_planner_timeout/010_timeout_budgets.md
15
+ */
16
+ export const VIDEO_CLIENT_TIMEOUT_SEC = 5400;
17
+ export const VIDEO_CLIENT_TIMEOUT_MS = VIDEO_CLIENT_TIMEOUT_SEC * 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "3.5.2",
3
+ "version": "3.5.3",
4
4
  "packageManager": "npm@11.18.0",
5
5
  "description": "Local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.",
6
6
  "type": "module",
@@ -119,5 +119,5 @@
119
119
  "tsx": "^4.23.12",
120
120
  "typescript": "^5.9.3"
121
121
  },
122
- "gitHead": "aa5cbf2137e0093fb1c4c050b177285d8f85137e"
122
+ "gitHead": "ff8f95493fc2e2b5130223e6c63f55043d79fcfe"
123
123
  }