ima2-gen 1.1.18 → 1.1.20

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 (54) hide show
  1. package/README.md +11 -2
  2. package/bin/commands/grok.js +19 -21
  3. package/bin/commands/grok.ts +20 -21
  4. package/bin/commands/video.js +4 -0
  5. package/bin/commands/video.ts +3 -0
  6. package/docs/README.ja.md +2 -2
  7. package/docs/README.ko.md +15 -3
  8. package/docs/README.zh-CN.md +2 -2
  9. package/docs/migration/runtime-test-inventory.md +2 -1
  10. package/lib/agentGenerationPlanner.js +37 -1
  11. package/lib/agentGenerationPlanner.ts +45 -1
  12. package/lib/agentRuntime.js +107 -1
  13. package/lib/agentRuntime.ts +121 -1
  14. package/lib/agentTypes.js +1 -0
  15. package/lib/agentTypes.ts +2 -1
  16. package/lib/assetLifecycle.js +12 -8
  17. package/lib/assetLifecycle.ts +12 -8
  18. package/lib/capabilities.js +1 -1
  19. package/lib/capabilities.ts +1 -1
  20. package/lib/grokVideoAdapter.js +30 -2
  21. package/lib/grokVideoAdapter.ts +36 -2
  22. package/lib/historyList.js +1 -0
  23. package/lib/historyList.ts +1 -0
  24. package/lib/videoSeriesChain.js +24 -0
  25. package/lib/videoSeriesChain.ts +29 -0
  26. package/node_modules/progrok/README.md +300 -22
  27. package/node_modules/progrok/dist/index.js +558 -173
  28. package/node_modules/progrok/dist/index.js.map +1 -1
  29. package/node_modules/progrok/package.json +3 -3
  30. package/node_modules/progrok/skills/progrok/SKILL.md +145 -109
  31. package/package.json +2 -2
  32. package/routes/video.js +10 -1
  33. package/routes/video.ts +11 -1
  34. package/skills/ima2/SKILL.md +65 -0
  35. package/ui/dist/.vite/manifest.json +12 -12
  36. package/ui/dist/assets/AgentWorkspace-DS8uvoLI.js +3 -0
  37. package/ui/dist/assets/{CardNewsWorkspace-DmqCMnIx.js → CardNewsWorkspace-CYxMsE67.js} +1 -1
  38. package/ui/dist/assets/NodeCanvas-DccIc347.js +7 -0
  39. package/ui/dist/assets/{PromptBuilderPanel-CoWjqQZS.js → PromptBuilderPanel-BvxxwSJp.js} +2 -2
  40. package/ui/dist/assets/{PromptImportDialog-C2zGZkyK.js → PromptImportDialog-u1_BFDRd.js} +2 -2
  41. package/ui/dist/assets/{PromptImportDiscoverySection-N0ZxHLYs.js → PromptImportDiscoverySection-C5uvkVSz.js} +1 -1
  42. package/ui/dist/assets/{PromptImportFolderSection-BC3dCASZ.js → PromptImportFolderSection-D3E_O1SD.js} +1 -1
  43. package/ui/dist/assets/{PromptLibraryPanel-CcVliYnF.js → PromptLibraryPanel-4gyf9CB9.js} +2 -2
  44. package/ui/dist/assets/{SettingsWorkspace-CiB4ux7E.js → SettingsWorkspace-F3eNu3mJ.js} +1 -1
  45. package/ui/dist/assets/index-B6tcw_UF.css +1 -0
  46. package/ui/dist/assets/index-DYOh6gQD.js +32 -0
  47. package/ui/dist/assets/{index-C93CfR9P.js → index-DoKtXbod.js} +1 -1
  48. package/ui/dist/index.html +2 -2
  49. package/vendor/progrok-0.1.1.tgz +0 -0
  50. package/ui/dist/assets/AgentWorkspace-BTuPjlDH.js +0 -3
  51. package/ui/dist/assets/NodeCanvas-jr9WXfNm.js +0 -7
  52. package/ui/dist/assets/index-CIhB_ia7.css +0 -1
  53. package/ui/dist/assets/index-uBEJn5jz.js +0 -32
  54. package/vendor/progrok-0.1.0.tgz +0 -0
@@ -9,6 +9,8 @@ import { detectImageMimeFromB64 } from "./refs.js";
9
9
  import { resolveProviderOptions } from "./providerOptions.js";
10
10
  import { generateViaResponses } from "./responsesImageAdapter.js";
11
11
  import { generateViaGrok, type GrokReferenceImage } from "./grokImageAdapter.js";
12
+ import { generateVideoViaGrok } from "./grokVideoAdapter.js";
13
+ import { parseVideoParams } from "./agentGenerationPlanner.js";
12
14
  import {
13
15
  appendAgentTurn,
14
16
  buildImageContextManifest,
@@ -95,7 +97,7 @@ export async function runAgentGenerationPlan(
95
97
  const webSearchEnabled = options.provider === "grok" ? true : options.webSearchEnabled ?? session.webSearchEnabled;
96
98
  const enabledTools: AgentToolName[] = webSearchEnabled
97
99
  ? [...AGENT_ALLOWED_TOOLS]
98
- : ["ima2.get_image_context", "ima2.generate_image"];
100
+ : ["ima2.get_image_context", "ima2.generate_image", "ima2.generate_video"];
99
101
  assertAgentAllowedTools(enabledTools);
100
102
  if (behavior.appendUserTurn !== false) {
101
103
  appendAgentTurn({ sessionId, role: "user", text: prompt, status: "complete" });
@@ -111,6 +113,13 @@ export async function runAgentGenerationPlan(
111
113
  });
112
114
  return { assistantTurn, imageIds: [], webFindingIds: [] };
113
115
  }
116
+ if (plan.mode === "video") {
117
+ return runAgentVideoGeneration(ctx, sessionId, prompt, {
118
+ ...options,
119
+ requestId: options.requestId ?? `agent_video_${ulid()}`,
120
+ skipUserTurn: true,
121
+ });
122
+ }
114
123
  const manifest = buildImageContextManifest(sessionId);
115
124
  const contextStartedAt = Date.now();
116
125
  appendAgentTurn({
@@ -397,6 +406,117 @@ async function persistAgentImage(
397
406
  });
398
407
  }
399
408
 
409
+ export async function runAgentVideoGeneration(
410
+ ctx: RuntimeContext,
411
+ sessionId: string,
412
+ prompt: string,
413
+ options: AgentRunOptions & { skipUserTurn?: boolean } = {},
414
+ ) {
415
+ const session = getAgentSession(sessionId);
416
+ if (!session) throw notFound(sessionId);
417
+ if (!options.skipUserTurn) {
418
+ appendAgentTurn({ sessionId, role: "user", text: prompt, status: "complete" });
419
+ }
420
+ const requestId = options.requestId ?? `agent_video_${ulid()}`;
421
+ const startedAt = Date.now();
422
+
423
+ // Auto I2V: if session has a last image, use it as source
424
+ let sourceImage: string | undefined;
425
+ let mode: "text-to-video" | "image-to-video" = "text-to-video";
426
+ if (session.lastImageId) {
427
+ const images = getAgentImages(sessionId);
428
+ const lastImage = images.find((img) => img.id === session.lastImageId);
429
+ if (lastImage?.filename && !lastImage.filename.endsWith(".mp4")) {
430
+ try {
431
+ const { loadAssetB64 } = await import("./nodeStore.js");
432
+ sourceImage = await loadAssetB64(ctx.rootDir, lastImage.filename, ctx.config.storage.generatedDir);
433
+ mode = "image-to-video";
434
+ } catch { /* fallback to T2V */ }
435
+ }
436
+ }
437
+
438
+ const videoParams = parseVideoParams(prompt);
439
+
440
+ const result = await generateVideoViaGrok(prompt, ctx, {
441
+ model: "grok-imagine-video",
442
+ mode,
443
+ sourceImage,
444
+ duration: videoParams.duration ?? 5,
445
+ resolution: videoParams.resolution ?? "480p",
446
+ aspectRatio: (videoParams.aspectRatio ?? "auto") as "auto" | "1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "3:2" | "2:3",
447
+ requestId,
448
+ signal: options.signal ?? undefined,
449
+ });
450
+ const video = await persistAgentVideo(ctx, sessionId, prompt, requestId, result);
451
+ const finishedAt = Date.now();
452
+ const toolCall: AgentToolCallSummary = {
453
+ id: `tc_video_${ulid()}`,
454
+ name: "ima2.generate_video",
455
+ status: "complete",
456
+ startedAt,
457
+ finishedAt,
458
+ durationMs: finishedAt - startedAt,
459
+ requestId,
460
+ inputSummary: prompt,
461
+ outputSummary: `Generated video ${video.filename}.`,
462
+ imageIds: [video.id],
463
+ };
464
+ appendAgentTurn({
465
+ sessionId,
466
+ role: "tool",
467
+ text: "ima2.generate_video",
468
+ imageIds: [video.id],
469
+ status: "complete",
470
+ raw: { toolCalls: [toolCall] },
471
+ });
472
+ const assistantTurn = appendAgentTurn({
473
+ sessionId,
474
+ role: "assistant",
475
+ text: `Generated 1 video artifact. ${result.revisedPrompt}`,
476
+ imageIds: [video.id],
477
+ status: "complete",
478
+ });
479
+ return { assistantTurn, imageIds: [video.id], webFindingIds: [] };
480
+ }
481
+
482
+ async function persistAgentVideo(
483
+ ctx: RuntimeContext,
484
+ sessionId: string,
485
+ prompt: string,
486
+ requestId: string,
487
+ result: { videoBuffer: Buffer; revisedPrompt: string; usage: Record<string, number> | null; webSearchCalls: number },
488
+ ) {
489
+ await mkdir(ctx.config.storage.generatedDir, { recursive: true });
490
+ const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
491
+ const filename = `${Date.now()}_${rand}_agent.mp4`;
492
+ const meta = {
493
+ kind: "agent",
494
+ mediaType: "video",
495
+ requestId,
496
+ sessionId,
497
+ prompt,
498
+ userPrompt: prompt,
499
+ revisedPrompt: result.revisedPrompt,
500
+ provider: "grok",
501
+ model: "grok-imagine-video",
502
+ createdAt: Date.now(),
503
+ usage: result.usage,
504
+ webSearchCalls: result.webSearchCalls,
505
+ };
506
+ await writeFile(join(ctx.config.storage.generatedDir, filename), result.videoBuffer);
507
+ await writeFile(join(ctx.config.storage.generatedDir, `${filename}.json`), JSON.stringify(meta)).catch(() => {});
508
+ invalidateHistoryIndex();
509
+ logEvent("agent", "video_saved", { requestId, sessionId, filename });
510
+ return importAgentImage(sessionId, {
511
+ id: `ai_${ulid()}`,
512
+ filename,
513
+ url: `/generated/${filename}`,
514
+ prompt,
515
+ revisedPrompt: result.revisedPrompt,
516
+ createdAt: Date.now(),
517
+ });
518
+ }
519
+
400
520
  function recordSearchFindings(sessionId: string, prompt: string, count: number, provider: string) {
401
521
  if (!count) return [];
402
522
  const isGrok = provider === "grok";
package/lib/agentTypes.js CHANGED
@@ -2,4 +2,5 @@ export const AGENT_ALLOWED_TOOLS = [
2
2
  "ima2.get_image_context",
3
3
  "ima2.web_search",
4
4
  "ima2.generate_image",
5
+ "ima2.generate_video",
5
6
  ];
package/lib/agentTypes.ts CHANGED
@@ -2,6 +2,7 @@ export const AGENT_ALLOWED_TOOLS = [
2
2
  "ima2.get_image_context",
3
3
  "ima2.web_search",
4
4
  "ima2.generate_image",
5
+ "ima2.generate_video",
5
6
  ] as const;
6
7
 
7
8
  export type AgentToolName = typeof AGENT_ALLOWED_TOOLS[number];
@@ -11,7 +12,7 @@ export type AgentToolCallStatus = "queued" | "running" | "complete" | "error";
11
12
  export type AgentQueueStatus = "queued" | "running" | "succeeded" | "failed" | "canceled";
12
13
  export type AgentSessionRunStatus = "idle" | "queued" | "running" | "error";
13
14
  export type AgentGenerationStrategy = "auto" | "manual";
14
- export type AgentGenerationPlanMode = "single" | "fanout" | "question";
15
+ export type AgentGenerationPlanMode = "single" | "fanout" | "question" | "video";
15
16
  export type AgentGenerationPlanSource = "auto-default" | "auto-request" | "manual-settings" | "slash-command" | "question-command";
16
17
  export type AgentSlashCommandName = "question" | "help" | "variants" | "generate" | "parallelism";
17
18
 
@@ -1,5 +1,5 @@
1
1
  import { getDb } from "./db.js";
2
- import { rename, unlink, access } from "fs/promises";
2
+ import { mkdir, rename, unlink, access } from "fs/promises";
3
3
  import { resolve, sep } from "path";
4
4
  import { moveToSystemTrash } from "./systemTrash.js";
5
5
  import { config } from "../config.js";
@@ -87,21 +87,25 @@ export async function trashAsset(rootDir, filename) {
87
87
  paths.push(sidecar);
88
88
  }
89
89
  catch { }
90
+ let trashMethod = "system";
90
91
  try {
91
92
  await moveToSystemTrash(paths);
92
93
  }
93
- catch (cause) {
94
- const err = new Error("Could not move asset to system trash");
95
- err.status = 500;
96
- err.code = "SYSTEM_TRASH_FAILED";
97
- err.cause = cause;
98
- throw err;
94
+ catch {
95
+ trashMethod = "internal";
96
+ const trashDir = resolve(config.storage.trashDir);
97
+ await mkdir(trashDir, { recursive: true });
98
+ const trashId = `${Date.now()}_${filename}`;
99
+ for (const p of paths) {
100
+ const dest = resolve(trashDir, p.endsWith(".json") ? `${trashId}.json` : trashId);
101
+ await rename(p, dest);
102
+ }
99
103
  }
100
104
  const summary = markNodesAssetMissing(filename);
101
105
  return {
102
106
  ok: true,
103
107
  filename,
104
- trash: "system",
108
+ trash: trashMethod,
105
109
  undoableInApp: false,
106
110
  sessionsTouched: summary.sessionsTouched,
107
111
  nodesTouched: summary.nodesTouched,
@@ -1,5 +1,5 @@
1
1
  import { getDb } from "./db.js";
2
- import { rename, unlink, access } from "fs/promises";
2
+ import { mkdir, rename, unlink, access } from "fs/promises";
3
3
  import { resolve, sep } from "path";
4
4
  import { moveToSystemTrash } from "./systemTrash.js";
5
5
  import { config } from "../config.js";
@@ -84,21 +84,25 @@ export async function trashAsset(rootDir: string, filename: string) {
84
84
  paths.push(sidecar);
85
85
  } catch {}
86
86
 
87
+ let trashMethod: "system" | "internal" = "system";
87
88
  try {
88
89
  await moveToSystemTrash(paths);
89
- } catch (cause) {
90
- const err: any = new Error("Could not move asset to system trash");
91
- err.status = 500;
92
- err.code = "SYSTEM_TRASH_FAILED";
93
- err.cause = cause;
94
- throw err;
90
+ } catch {
91
+ trashMethod = "internal";
92
+ const trashDir = resolve(config.storage.trashDir);
93
+ await mkdir(trashDir, { recursive: true });
94
+ const trashId = `${Date.now()}_${filename}`;
95
+ for (const p of paths) {
96
+ const dest = resolve(trashDir, p.endsWith(".json") ? `${trashId}.json` : trashId);
97
+ await rename(p, dest);
98
+ }
95
99
  }
96
100
 
97
101
  const summary = markNodesAssetMissing(filename);
98
102
  return {
99
103
  ok: true,
100
104
  filename,
101
- trash: "system",
105
+ trash: trashMethod,
102
106
  undoableInApp: false,
103
107
  sessionsTouched: summary.sessionsTouched,
104
108
  nodesTouched: summary.nodesTouched,
@@ -106,7 +106,7 @@ export function buildIma2Capabilities({ appConfig = runtimeConfigDefault, packag
106
106
  i2i: "Use --ref for reference generation, or ima2 edit <file> --prompt \"<text>\" for image edits.",
107
107
  defaults: "Use ima2 defaults set model/reasoning for persistent defaults; request flags remain per-call overrides.",
108
108
  promptBuilder: "Use ima2 prompt build --message \"...\" to refine prompt intent. Use ima2 gen / ima2 multimode to generate images. Workspace profile settings are UI-only.",
109
- video: "Use ima2 video \"<prompt>\" to generate video. Supports --ref for image-to-video and reference-to-video modes.",
109
+ video: "Use ima2 video \"<prompt>\" to generate video. Supports --ref for image-to-video and reference-to-video modes. Use --topic for series continuity across multiple generations.",
110
110
  },
111
111
  };
112
112
  }
@@ -120,7 +120,7 @@ export function buildIma2Capabilities({
120
120
  i2i: "Use --ref for reference generation, or ima2 edit <file> --prompt \"<text>\" for image edits.",
121
121
  defaults: "Use ima2 defaults set model/reasoning for persistent defaults; request flags remain per-call overrides.",
122
122
  promptBuilder: "Use ima2 prompt build --message \"...\" to refine prompt intent. Use ima2 gen / ima2 multimode to generate images. Workspace profile settings are UI-only.",
123
- video: "Use ima2 video \"<prompt>\" to generate video. Supports --ref for image-to-video and reference-to-video modes.",
123
+ video: "Use ima2 video \"<prompt>\" to generate video. Supports --ref for image-to-video and reference-to-video modes. Use --topic for series continuity across multiple generations.",
124
124
  },
125
125
  };
126
126
  }
@@ -45,6 +45,25 @@ function sourceImageUrl(image, mime) {
45
45
  const detected = mime || detectImageMimeFromB64(image) || "image/png";
46
46
  return `data:${detected};base64,${image}`;
47
47
  }
48
+ /** Map aspect ratio + resolution to pixel dimensions for white canvas injection. */
49
+ function aspectToCanvas(aspectRatio, resolution) {
50
+ const base = resolution === "720p" ? 720 : 480;
51
+ const ratios = {
52
+ "16:9": [16, 9], "9:16": [9, 16], "4:3": [4, 3], "3:4": [3, 4],
53
+ "3:2": [3, 2], "2:3": [2, 3], "1:1": [1, 1], "auto": [16, 9],
54
+ };
55
+ const [w, h] = ratios[aspectRatio] || [16, 9];
56
+ if (w >= h)
57
+ return { width: Math.round(base * w / h), height: base };
58
+ return { width: base, height: Math.round(base * h / w) };
59
+ }
60
+ /** Generate a minimal white PNG as base64 (no external deps). */
61
+ function generateWhiteCanvasB64() {
62
+ // Minimal valid 1x1 white PNG, scaled conceptually — xAI will accept any valid PNG
63
+ // For simplicity, use a tiny white PNG (the model doesn't use it as a real frame)
64
+ const PNG_1x1_WHITE = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAHBQKhPX8EPAAAAABJRU5ErkJggg==";
65
+ return PNG_1x1_WHITE;
66
+ }
48
67
  const FAILED_CODE_MAP = {
49
68
  invalid_argument: { code: "GROK_VIDEO_REQUEST_FAILED", status: 400 },
50
69
  permission_denied: { code: "GROK_VIDEO_REQUEST_FAILED", status: 403 },
@@ -383,11 +402,20 @@ export async function generateVideoViaGrok(prompt, ctx, options = {}) {
383
402
  const payload = buildVideoGenerationPayload(plan, { model, sourceImageUrl: srcUrl, referenceImageUrls: refUrls });
384
403
  let xaiVideoRequestId;
385
404
  let effectiveModel = model;
405
+ // grokv1.5 doesn't support T2V — inject a white canvas as source image to use I2V path
406
+ let effectivePayload = payload;
407
+ if (model === "grok-imagine-video-1.5-preview" && !srcUrl && refUrls.length === 0) {
408
+ const { width, height } = aspectToCanvas(plan.aspectRatio, plan.resolution);
409
+ const whiteCanvas = generateWhiteCanvasB64();
410
+ const canvasSrcUrl = `data:image/png;base64,${whiteCanvas}`;
411
+ effectivePayload = buildVideoGenerationPayload({ ...plan, prompt: `${plan.prompt}. This is not a start frame — generate freely as a new video.` }, { model, sourceImageUrl: canvasSrcUrl, referenceImageUrls: [] });
412
+ logEvent("grok", "video:1.5-t2v-canvas", { requestId: options.requestId, width, height });
413
+ }
386
414
  try {
387
- xaiVideoRequestId = await startVideoRequest(ctx, payload, options);
415
+ xaiVideoRequestId = await startVideoRequest(ctx, effectivePayload, options);
388
416
  }
389
417
  catch (e) {
390
- // Fallback: if 1.5-preview fails, retry with base model
418
+ // Fallback: if 1.5-preview still fails, retry with base model
391
419
  if (model !== "grok-imagine-video" && e?.status === 400) {
392
420
  effectiveModel = "grok-imagine-video";
393
421
  const fallbackPayload = buildVideoGenerationPayload(plan, { model: effectiveModel, sourceImageUrl: srcUrl, referenceImageUrls: refUrls });
@@ -124,6 +124,26 @@ function sourceImageUrl(image: string, mime?: string | null): string {
124
124
  return `data:${detected};base64,${image}`;
125
125
  }
126
126
 
127
+ /** Map aspect ratio + resolution to pixel dimensions for white canvas injection. */
128
+ function aspectToCanvas(aspectRatio: string, resolution: string): { width: number; height: number } {
129
+ const base = resolution === "720p" ? 720 : 480;
130
+ const ratios: Record<string, [number, number]> = {
131
+ "16:9": [16, 9], "9:16": [9, 16], "4:3": [4, 3], "3:4": [3, 4],
132
+ "3:2": [3, 2], "2:3": [2, 3], "1:1": [1, 1], "auto": [16, 9],
133
+ };
134
+ const [w, h] = ratios[aspectRatio] || [16, 9];
135
+ if (w >= h) return { width: Math.round(base * w / h), height: base };
136
+ return { width: base, height: Math.round(base * h / w) };
137
+ }
138
+
139
+ /** Generate a minimal white PNG as base64 (no external deps). */
140
+ function generateWhiteCanvasB64(): string {
141
+ // Minimal valid 1x1 white PNG, scaled conceptually — xAI will accept any valid PNG
142
+ // For simplicity, use a tiny white PNG (the model doesn't use it as a real frame)
143
+ const PNG_1x1_WHITE = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/58BAwAHBQKhPX8EPAAAAABJRU5ErkJggg==";
144
+ return PNG_1x1_WHITE;
145
+ }
146
+
127
147
  const FAILED_CODE_MAP: Record<string, { code: string; status: number }> = {
128
148
  invalid_argument: { code: "GROK_VIDEO_REQUEST_FAILED", status: 400 },
129
149
  permission_denied: { code: "GROK_VIDEO_REQUEST_FAILED", status: 403 },
@@ -449,10 +469,24 @@ export async function generateVideoViaGrok(prompt: string, ctx: RouteRuntimeCont
449
469
  const payload = buildVideoGenerationPayload(plan, { model, sourceImageUrl: srcUrl, referenceImageUrls: refUrls });
450
470
  let xaiVideoRequestId: string;
451
471
  let effectiveModel = model;
472
+
473
+ // grokv1.5 doesn't support T2V — inject a white canvas as source image to use I2V path
474
+ let effectivePayload = payload;
475
+ if (model === "grok-imagine-video-1.5-preview" && !srcUrl && refUrls.length === 0) {
476
+ const { width, height } = aspectToCanvas(plan.aspectRatio, plan.resolution);
477
+ const whiteCanvas = generateWhiteCanvasB64();
478
+ const canvasSrcUrl = `data:image/png;base64,${whiteCanvas}`;
479
+ effectivePayload = buildVideoGenerationPayload(
480
+ { ...plan, prompt: `${plan.prompt}. This is not a start frame — generate freely as a new video.` },
481
+ { model, sourceImageUrl: canvasSrcUrl, referenceImageUrls: [] },
482
+ );
483
+ logEvent("grok", "video:1.5-t2v-canvas", { requestId: options.requestId, width, height });
484
+ }
485
+
452
486
  try {
453
- xaiVideoRequestId = await startVideoRequest(ctx, payload, options);
487
+ xaiVideoRequestId = await startVideoRequest(ctx, effectivePayload, options);
454
488
  } catch (e: any) {
455
- // Fallback: if 1.5-preview fails, retry with base model
489
+ // Fallback: if 1.5-preview still fails, retry with base model
456
490
  if (model !== "grok-imagine-video" && e?.status === 400) {
457
491
  effectiveModel = "grok-imagine-video";
458
492
  const fallbackPayload = buildVideoGenerationPayload(plan, { model: effectiveModel, sourceImageUrl: srcUrl, referenceImageUrls: refUrls });
@@ -34,6 +34,7 @@ export async function listHistoryRows(baseDir = config.storage.generatedDir) {
34
34
  url: `/generated/${rel.split("/").map(encodeURIComponent).join("/")}`,
35
35
  mediaType: meta?.mediaType || (/\.mp4$/i.test(name) ? "video" : "image"),
36
36
  video: meta?.video || null,
37
+ videoSeries: meta?.videoSeries || null,
37
38
  createdAt: meta?.createdAt || st?.mtimeMs || 0,
38
39
  prompt: meta?.prompt || null,
39
40
  userPrompt: meta?.userPrompt || meta?.prompt || null,
@@ -36,6 +36,7 @@ export async function listHistoryRows(baseDir = config.storage.generatedDir) {
36
36
  url: `/generated/${rel.split("/").map(encodeURIComponent).join("/")}`,
37
37
  mediaType: meta?.mediaType || (/\.mp4$/i.test(name) ? "video" : "image"),
38
38
  video: meta?.video || null,
39
+ videoSeries: meta?.videoSeries || null,
39
40
  createdAt: meta?.createdAt || st?.mtimeMs || 0,
40
41
  prompt: meta?.prompt || null,
41
42
  userPrompt: meta?.userPrompt || meta?.prompt || null,
@@ -0,0 +1,24 @@
1
+ import { readdir, readFile } from "fs/promises";
2
+ import { join } from "path";
3
+ /**
4
+ * Scan generatedDir for videos with matching topic, return the most recent N revisedPrompts.
5
+ */
6
+ export async function getVideoSeriesChain(generatedDir, topic, limit = 4) {
7
+ if (!topic.trim())
8
+ return [];
9
+ const entries = await readdir(generatedDir).catch(() => []);
10
+ const sidecars = entries.filter((e) => e.endsWith(".mp4.json"));
11
+ const matches = [];
12
+ for (const sidecar of sidecars) {
13
+ try {
14
+ const raw = await readFile(join(generatedDir, sidecar), "utf-8");
15
+ const meta = JSON.parse(raw);
16
+ if (meta.videoSeries?.topic === topic && meta.revisedPrompt) {
17
+ matches.push({ revisedPrompt: meta.revisedPrompt, createdAt: meta.createdAt ?? 0 });
18
+ }
19
+ }
20
+ catch { /* skip unreadable */ }
21
+ }
22
+ matches.sort((a, b) => b.createdAt - a.createdAt);
23
+ return matches.slice(0, limit).reverse().map((m) => m.revisedPrompt);
24
+ }
@@ -0,0 +1,29 @@
1
+ import { readdir, readFile } from "fs/promises";
2
+ import { join } from "path";
3
+
4
+ interface VideoSeriesMeta {
5
+ revisedPrompt?: string;
6
+ createdAt?: number;
7
+ videoSeries?: { topic: string; chainIndex?: number };
8
+ }
9
+
10
+ /**
11
+ * Scan generatedDir for videos with matching topic, return the most recent N revisedPrompts.
12
+ */
13
+ export async function getVideoSeriesChain(generatedDir: string, topic: string, limit = 4): Promise<string[]> {
14
+ if (!topic.trim()) return [];
15
+ const entries = await readdir(generatedDir).catch(() => [] as string[]);
16
+ const sidecars = entries.filter((e) => e.endsWith(".mp4.json"));
17
+ const matches: Array<{ revisedPrompt: string; createdAt: number }> = [];
18
+ for (const sidecar of sidecars) {
19
+ try {
20
+ const raw = await readFile(join(generatedDir, sidecar), "utf-8");
21
+ const meta: VideoSeriesMeta = JSON.parse(raw);
22
+ if (meta.videoSeries?.topic === topic && meta.revisedPrompt) {
23
+ matches.push({ revisedPrompt: meta.revisedPrompt, createdAt: meta.createdAt ?? 0 });
24
+ }
25
+ } catch { /* skip unreadable */ }
26
+ }
27
+ matches.sort((a, b) => b.createdAt - a.createdAt);
28
+ return matches.slice(0, limit).reverse().map((m) => m.revisedPrompt);
29
+ }