ima2-gen 3.7.2 → 3.8.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.
Files changed (35) hide show
  1. package/bin/commands/video.js +12 -4
  2. package/bin/lib/videoMcp.js +14 -7
  3. package/docs/migration/runtime-test-inventory.md +6 -1
  4. package/lib/agentGenerationPlanner.js +3 -0
  5. package/lib/agentImageVideoGen.js +36 -8
  6. package/lib/agentPlannerModel.js +2 -1
  7. package/lib/agentRuntime.js +3 -0
  8. package/lib/capabilities.js +40 -0
  9. package/lib/errors/providerMap.js +2 -0
  10. package/lib/grokVideoAdapter.js +36 -7
  11. package/lib/imageModels.js +16 -7
  12. package/lib/videoGenerationRequest.js +1 -0
  13. package/package.json +2 -2
  14. package/routes/video.js +37 -4
  15. package/ui/dist/.vite/manifest.json +31 -31
  16. package/ui/dist/assets/{AgentWorkspace-JQ0K_ndE.js → AgentWorkspace-DAjUdAsK.js} +1 -1
  17. package/ui/dist/assets/{AssetGenWorkspace-B2d-aiIb.js → AssetGenWorkspace-CpAkJhms.js} +2 -2
  18. package/ui/dist/assets/{AssetsWorkspace-Di0BYZZW.js → AssetsWorkspace-Bh1OzpUk.js} +1 -1
  19. package/ui/dist/assets/{CardNewsWorkspace-F3LrEj0b.js → CardNewsWorkspace-DSqExDy-.js} +1 -1
  20. package/ui/dist/assets/{GenerationRequestLogPanel-CpyvTE6t.js → GenerationRequestLogPanel-B9n86Nqn.js} +1 -1
  21. package/ui/dist/assets/{HomeWorkspace-KplbVQT9.js → HomeWorkspace-Dr0j6d1O.js} +1 -1
  22. package/ui/dist/assets/{KeyingPanel-BY_xV317.js → KeyingPanel-2-wMdwa8.js} +1 -1
  23. package/ui/dist/assets/{NodeCanvas-scDxt0LA.js → NodeCanvas-DS3-x-hx.js} +1 -1
  24. package/ui/dist/assets/{PromptBuilderPanel-DeSY9dl-.js → PromptBuilderPanel-CvXqQl0o.js} +1 -1
  25. package/ui/dist/assets/{PromptImportDialog-BTP0imZl.js → PromptImportDialog-CBw_qPiD.js} +2 -2
  26. package/ui/dist/assets/{PromptImportDiscoverySection-BBThhJZM.js → PromptImportDiscoverySection-B1BY5Ntk.js} +1 -1
  27. package/ui/dist/assets/{PromptImportFolderSection-6sXWWJoK.js → PromptImportFolderSection-CPwnX0PB.js} +1 -1
  28. package/ui/dist/assets/{PromptLibraryPanel-BYTG-mBa.js → PromptLibraryPanel-kXL1oFA8.js} +2 -2
  29. package/ui/dist/assets/{SettingsWorkspace-chsddbrB.js → SettingsWorkspace-Cf7rRTV3.js} +1 -1
  30. package/ui/dist/assets/{SpriteRecipeWorkspace-HgacgO0x.js → SpriteRecipeWorkspace-_WcHPSd4.js} +1 -1
  31. package/ui/dist/assets/{index-BqFlxfzT.js → index-BKW7WOYj.js} +18 -18
  32. package/ui/dist/assets/{index-VXkP0z69.js → index-BTHxkxxS.js} +3 -3
  33. package/ui/dist/assets/{pptxgen.es-DX8PxAzM.js → pptxgen.es-BOlreaat.js} +1 -1
  34. package/ui/dist/assets/{useAgentDialogFocus-DYjA7lCJ.js → useAgentDialogFocus-CxsC04Sp.js} +1 -1
  35. package/ui/dist/index.html +1 -1
@@ -78,6 +78,7 @@ const SPEC = {
78
78
  start: { type: "string" },
79
79
  end: { type: "string" },
80
80
  ref: { type: "string", repeatable: true },
81
+ voice: { type: "string", repeatable: true },
81
82
  "video-ref": { type: "string" },
82
83
  character: { type: "string" },
83
84
  out: { short: "o", type: "string" },
@@ -121,6 +122,9 @@ const HELP = `
121
122
  --start <generated-filename> MCP start frame
122
123
  --end <generated-filename> MCP end frame; requires --start
123
124
  --ref <file|@last|file:tag> Grok image input or MCP tagged image reference
125
+ --voice <voice-id> Preset voice for the subject (grok-imagine-video-1.5,
126
+ repeatable, max 3). e.g. eve, leo, rex, luna, atlas.
127
+ An unknown id returns the full list of valid voices
124
128
  Repeatable: Grok max 7, MCP max 3
125
129
  --video-ref <generated-filename> MCP V2V/restyle reference video
126
130
  --character <element-id|name> MCP lanes only: character binding element
@@ -136,9 +140,9 @@ const HELP = `
136
140
  --duration <2..10> Extension duration only. Default: 6
137
141
 
138
142
  Grok modes (auto-detected from --ref count):
139
- 0 refs → text-to-video
140
- 1 ref image-to-video
141
- 2-7 refs reference-to-video (max 10s duration)
143
+ 0 refs → text-to-video
144
+ 1-7 refs reference-to-video (max 720p). References guide the subject without
145
+ locking the first frame; use a node/continuity chain for a first frame.
142
146
 
143
147
  Examples:
144
148
  ima2 video "a cat playing piano"
@@ -214,8 +218,12 @@ async function videoEditCmd(argv) {
214
218
  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" } } };
215
219
  const args = parseArgs(argv, spec);
216
220
  rejectUnknownFlags(args);
221
+ // Limits: 8.7s input is the owner's own measurement (docs/grok-video-i2v-research.md,
222
+ // 2026-05-30) and appears in no xAI doc — keep it. The 1.5 rejection and the
223
+ // ignored duration/resolution were live-probed against api.x.ai on 2026-08-20.
224
+ // Details + full probe table: skills/ima2/SKILL.md "Provenance of the limits above".
217
225
  if (args.help) {
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`);
226
+ 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 (1.5 rejects edits). Input: mp4, max 8.7s.\n Output inherits the source video's duration and resolution (max 720p).\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`);
219
227
  return;
220
228
  }
221
229
  const prompt = args.positional.join(" ");
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { config } from "../../config.js";
4
- import { deriveVideoMode, GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_15_PREVIEW_ALIAS, validateVideoResolutionForRequest, } from "../../lib/imageModels.js";
4
+ import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_15_PREVIEW_ALIAS, validateVideoResolutionForRequest, } from "../../lib/imageModels.js";
5
5
  import { VIDEO_CLIENT_TIMEOUT_MS } from "../../lib/videoClientTimeouts.js";
6
6
  import { wasFlagPassed } from "./argsExplicit.js";
7
7
  import { resolveHistoryReference, resolveServer, request } from "./client.js";
@@ -94,9 +94,10 @@ function validateCoreOptions(args, refs, model) {
94
94
  die(2, "--aspect-ratio must be one of: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, auto");
95
95
  if (refs.length > 7)
96
96
  die(2, "max 7 --ref attachments for video");
97
- if (refs.length >= 2 && duration > 10)
98
- die(2, "--duration must be between 1 and 10 when using 2 or more --ref attachments");
99
- const check = validateVideoResolutionForRequest(model, resolution, deriveVideoMode(refs.length), { allowTextCanvasShim: true });
97
+ // `--ref` always fills the reference slot now, so any attachment means
98
+ // reference-to-video the same conclusion the server reaches from the slot.
99
+ const mode = refs.length > 0 ? "reference-to-video" : "text-to-video";
100
+ const check = validateVideoResolutionForRequest(model, resolution, mode, { allowTextCanvasShim: true });
100
101
  if (!("ok" in check))
101
102
  die(2, check.error);
102
103
  return { duration, resolution, aspectRatio };
@@ -129,9 +130,15 @@ function coreBody(args, context, options, references, requestId) {
129
130
  body.sessionId = args.session;
130
131
  if (args.topic)
131
132
  body.topic = args.topic;
132
- if (references.length === 1)
133
- body.sourceImage = references[0];
134
- else if (references.length > 1)
133
+ // Voice ids pass through unvalidated on purpose: xAI owns the roster (and custom
134
+ // voices), and its rejection names every accepted value.
135
+ const voices = (Array.isArray(args.voice) ? args.voice : args.voice ? [args.voice] : []);
136
+ if (voices.length > 0)
137
+ body.referenceAudios = voices.map((v) => String(v));
138
+ // `--ref` is the reference slot at any count; one reference guides the video without
139
+ // locking the first frame. Mapping a single --ref to sourceImage made the CLI disagree
140
+ // with the server contract and with the UI.
141
+ if (references.length > 0)
135
142
  body.referenceImages = references;
136
143
  return body;
137
144
  }
@@ -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: 366 (runtime: 163, contract: 203)
7
+ Total: 371 (runtime: 168, contract: 203)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/agent-mode-auto-planner-contract.test.ts`
@@ -17,6 +17,7 @@ Total: 366 (runtime: 163, contract: 203)
17
17
  - `tests/agent-mode-ux-feedback-contract.test.ts`
18
18
  - `tests/agent-queue-store-contract.test.ts`
19
19
  - `tests/agent-video-intent.test.ts`
20
+ - `tests/agent-video-reference-contract.test.ts`
20
21
  - `tests/agy-artifact-fallback.test.ts`
21
22
  - `tests/agy-cli.test.ts`
22
23
  - `tests/api-cache-policy.test.ts`
@@ -31,6 +32,7 @@ Total: 366 (runtime: 163, contract: 203)
31
32
  - `tests/backend-input-lan-hardening.test.ts`
32
33
  - `tests/background-presets.test.ts`
33
34
  - `tests/billing-source.test.ts`
35
+ - `tests/capabilities-video-modes-contract.test.ts`
34
36
  - `tests/card-news-contract.test.ts`
35
37
  - `tests/card-news-template.test.ts`
36
38
  - `tests/classic-generate-async.test.ts`
@@ -163,7 +165,10 @@ Total: 366 (runtime: 163, contract: 203)
163
165
  - `tests/video-concat.test.ts`
164
166
  - `tests/video-history-item.test.ts`
165
167
  - `tests/video-motion-presets.test.ts`
168
+ - `tests/video-ref2v-duration-contract.test.ts`
169
+ - `tests/video-reference-audio-contract.test.ts`
166
170
  - `tests/video-request-contract.test.ts`
171
+ - `tests/video-single-reference-mode-contract.test.ts`
167
172
  - `tests/videoArtifactPersistence.test.ts`
168
173
  - `tests/videoContinuity.test.ts`
169
174
  - `tests/videoExtendI2v.test.ts`
@@ -235,6 +235,9 @@ export function cleanVideoParams(value) {
235
235
  if (typeof input.aspectRatio === "string" && /^(auto|16:9|9:16|4:3|3:4|3:2|2:3|1:1)$/.test(input.aspectRatio)) {
236
236
  params.aspectRatio = input.aspectRatio;
237
237
  }
238
+ if (input.mode === "text-to-video" || input.mode === "image-to-video" || input.mode === "reference-to-video") {
239
+ params.mode = input.mode;
240
+ }
238
241
  return Object.keys(params).length > 0 ? params : null;
239
242
  }
240
243
  function cleanCommandName(value) {
@@ -16,7 +16,7 @@ import { generateViaAtlasCloud } from "./atlasCloudImageAdapter.js";
16
16
  import { generateViaMinimax } from "./minimaxImageAdapter.js";
17
17
  import { DEFAULT_GROK_PLANNER_MODEL } from "../config.js";
18
18
  import { generateVideoViaGrok } from "./grokVideoAdapter.js";
19
- import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_BASE, resolveGrokQualityModel } from "./imageModels.js";
19
+ import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_BASE, resolveGrokQualityModel, validateVideoResolutionForRequest } from "./imageModels.js";
20
20
  import { parseVideoParams } from "./agentGenerationPlanner.js";
21
21
  import { isVideoGenerationError, normalizeVideoGenerationRequest, } from "./videoGenerationRequest.js";
22
22
  import { appendAgentTurn, getAgentImages, getAgentSession, importAgentImage, } from "./agentStore.js";
@@ -198,17 +198,29 @@ export async function runAgentVideoGeneration(ctx, sessionId, prompt, options =
198
198
  }
199
199
  const requestId = options.requestId ?? `agent_video_${ulid()}`;
200
200
  const startedAt = Date.now();
201
- // Auto I2V: if session has a last image, use it as source
201
+ // Attach the session's current image unless the planner said not to. The image path
202
+ // has always honored this policy; the video path ignored it, so "make something new"
203
+ // still got the previous image welded on as the first frame.
204
+ //
205
+ // The planner also chooses what the image MEANS. A first frame reproduces the source
206
+ // shot; a reference carries the subject into a new one. Chat has no reference tray,
207
+ // so without the planner saying which, reference-to-video was unreachable here.
208
+ // devlog/_plan/260820_grok15_multi_reference_video/020_agent_reference_loss.md
209
+ const requestedMode = options.videoParams?.mode;
210
+ const attachPolicy = options.sourceImagePolicy ?? "auto";
202
211
  let sourceImage;
203
- let mode = "text-to-video";
204
- if (session.lastImageId) {
212
+ let referenceImages;
213
+ if (attachPolicy !== "none" && session.lastImageId) {
205
214
  const images = getAgentImages(sessionId);
206
215
  const lastImage = images.find((img) => img.id === session.lastImageId);
207
216
  if (lastImage?.filename && !lastImage.filename.endsWith(".mp4")) {
208
217
  try {
209
218
  const { loadAssetB64 } = await import("./nodeStore.js");
210
- sourceImage = await loadAssetB64(ctx.rootDir, lastImage.filename, ctx.config.storage.generatedDir);
211
- mode = "image-to-video";
219
+ const b64 = await loadAssetB64(ctx.rootDir, lastImage.filename, ctx.config.storage.generatedDir);
220
+ if (requestedMode === "reference-to-video")
221
+ referenceImages = [b64];
222
+ else
223
+ sourceImage = b64;
212
224
  }
213
225
  catch { /* fallback to T2V */ }
214
226
  }
@@ -219,8 +231,9 @@ export async function runAgentVideoGeneration(ctx, sessionId, prompt, options =
219
231
  // validation cannot drift between "generate from chat" and "generate from the app".
220
232
  const normalized = normalizeVideoGenerationRequest({
221
233
  prompt,
222
- mode,
223
234
  sourceImage,
235
+ ...(referenceImages ? { referenceImages } : {}),
236
+ ...(requestedMode ? { mode: requestedMode } : {}),
224
237
  duration: options.videoParams?.duration ?? parsedParams.duration,
225
238
  resolution: options.videoParams?.resolution ?? parsedParams.resolution,
226
239
  aspectRatio: options.videoParams?.aspectRatio ?? parsedParams.aspectRatio,
@@ -236,11 +249,26 @@ export async function runAgentVideoGeneration(ctx, sessionId, prompt, options =
236
249
  const videoModel = videoParams.resolution === "1080p"
237
250
  ? GROK_VIDEO_MODEL_15
238
251
  : GROK_VIDEO_MODEL_BASE;
252
+ // Reference-to-video has no 1080p (xAI returns 400). Say so instead of quietly
253
+ // downgrading the resolution — silently altering the request is the defect this
254
+ // work-phase exists to remove. Same helper the HTTP route uses, so the two surfaces
255
+ // cannot drift apart again.
256
+ const resolutionCheck = validateVideoResolutionForRequest(videoModel, videoParams.resolution, videoParams.mode, {
257
+ allowTextCanvasShim: true,
258
+ });
259
+ if (!("ok" in resolutionCheck)) {
260
+ throw Object.assign(new Error(resolutionCheck.error), {
261
+ status: resolutionCheck.status,
262
+ code: resolutionCheck.code,
263
+ });
264
+ }
239
265
  options.onProgressStage?.("requesting");
240
266
  const result = await generateVideoViaGrok(prompt, ctx, {
241
267
  model: videoModel,
242
- mode: videoParams.mode === "reference-to-video" ? "text-to-video" : videoParams.mode,
268
+ mode: videoParams.mode,
243
269
  sourceImage,
270
+ ...(videoParams.referenceImages ? { referenceImages: videoParams.referenceImages } : {}),
271
+ ...(videoParams.referenceAudios ? { referenceAudios: videoParams.referenceAudios } : {}),
244
272
  duration: videoParams.duration,
245
273
  resolution: videoParams.resolution,
246
274
  aspectRatio: videoParams.aspectRatio,
@@ -27,7 +27,7 @@ function buildPlannerDeveloperPrompt(hasSourceImage, imageCount) {
27
27
  `- Last image available as image-to-video source: ${hasSourceImage ? "yes" : "no"}`,
28
28
  "",
29
29
  "Decide ONE plan and respond with ONLY a JSON object (no prose, no code fences):",
30
- '{"mode":"single|fanout|video|question|errors","prompts":["..."],"plannedVariants":1,"plannedParallelism":1,"sourceImagePolicy":"none|current|auto","videoParams":{"duration":5,"resolution":"480p","aspectRatio":"auto"},"assistantText":"...","reason":"short reason"}',
30
+ '{"mode":"single|fanout|video|question|errors","prompts":["..."],"plannedVariants":1,"plannedParallelism":1,"sourceImagePolicy":"none|current|auto","videoParams":{"duration":5,"resolution":"480p","aspectRatio":"auto","mode":"text-to-video|image-to-video|reference-to-video"},"assistantText":"...","reason":"short reason"}',
31
31
  "",
32
32
  "Rules:",
33
33
  "- You are a conversational assistant first. Generate media ONLY when the user clearly asks you to create or edit an image/video. Everything else (questions, chat, greetings, feedback, follow-ups) is mode question.",
@@ -37,6 +37,7 @@ function buildPlannerDeveloperPrompt(hasSourceImage, imageCount) {
37
37
  "- sourceImagePolicy for single/fanout: use current only when the user explicitly asks to use/edit/modify/transform/reference the current image, including '이 이미지', '현재 이미지', '방금 그거', '참조', 'reference', 'i2i', 'image-to-image', '유지해서'.",
38
38
  "- sourceImagePolicy for plain image requests with no explicit reference wording is none.",
39
39
  "- mode video: one video via ima2.generate_video. Choose it only when the user asks to CREATE a video. prompts has exactly 1 entry. Extract duration (1-15 s), resolution (480p|720p|1080p), aspectRatio (auto|1:1|16:9|9:16|4:3|3:4|3:2|2:3) from the request into videoParams; omit fields the user did not specify. 1080p uses Grok Video 1.5; prompt-only requests are valid because the server injects a white-canvas I2V shim.",
40
+ "- videoParams.mode decides what the session's current image is FOR. Use image-to-video when the user wants that exact picture animated or continued ('이 이미지를 움직여줘', 'animate this', 'make it move'): it becomes the first frame. Use reference-to-video when the user wants the subject, character, outfit or place carried into a DIFFERENT scene ('이 캐릭터로 다른 장면', 'same person at the beach', 'use this outfit'): it guides the video without locking the opening shot. reference-to-video cannot use 1080p. Omit mode when the user did not imply either.",
40
41
  "- mode question: the user is NOT requesting generation — a question (capabilities, how-to, status), small talk, a greeting, or feedback — e.g. '영상 생성가능하니?', 'can you make videos?', '고마워'. prompts must be []. Write the full answer in assistantText. Mentioning a media word like 'video' or '영상' inside a question does NOT make it a generation request.",
41
42
  "- mode errors: the user is asking why a previous generation failed or about recent errors. prompts must be [].",
42
43
  "- assistantText: REQUIRED for every mode, written in the user's language. For question/errors it is the full reply. For single/fanout/video it is a short natural chat reply telling the user what you are creating (1-2 sentences, no markdown headings).",
@@ -66,6 +66,9 @@ export async function runAgentGenerationPlan(ctx, sessionId, prompt, plan, optio
66
66
  return runAgentVideoGeneration(ctx, sessionId, plan.prompts[0] ?? prompt, {
67
67
  ...options,
68
68
  videoParams: plan.videoParams ?? options.videoParams ?? null,
69
+ // The image path has always forwarded this (see below); the video path did not,
70
+ // so a planner that chose "none" was overruled by whatever image came last.
71
+ sourceImagePolicy: plan.sourceImagePolicy ?? "auto",
69
72
  assistantText: preludeSent ? null : plan.assistantText,
70
73
  requestId: options.requestId ?? `agent_video_${ulid()}`,
71
74
  skipUserTurn: true,
@@ -5,6 +5,7 @@ import { buildCatalog, catalogSummary } from "./contracts/catalog.js";
5
5
  import { loadAllBundledSnapshots } from "./mcp/snapshotStore.js";
6
6
  import { KEY_TO_ENV, WRITABLE_CONFIG_KEYS } from "./configKeys.js";
7
7
  import { DEFAULT_IMAGE_QUALITY, VALID_IMAGE_QUALITIES } from "./oauthNormalize.js";
8
+ import { MAX_REF2V_REFERENCES, MAX_REFERENCE_AUDIOS, MAX_VIDEO_DURATION, MIN_VIDEO_DURATION } from "./imageModels.js";
8
9
  import { deriveProviderIds } from "./providers/derive.js";
9
10
  const VALID_MODES = ["auto", "direct"];
10
11
  const VALID_PROVIDERS = ["auto", ...deriveProviderIds()];
@@ -71,6 +72,45 @@ export function buildIma2Capabilities({ appConfig = runtimeConfigDefault, packag
71
72
  aspectRatios: ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "auto"],
72
73
  durationRange: [1, 15],
73
74
  maxReferences: 7,
75
+ // Flat numbers above describe the widest case, which is not what any single
76
+ // request is allowed to do. A client that reads only those draws controls the
77
+ // server will reject. These per-mode entries come from the same constants the
78
+ // request path enforces, so the advertisement cannot drift from the behavior.
79
+ // Verified against api.x.ai on 2026-08-20:
80
+ // devlog/_plan/260820_grok15_multi_reference_video/000_research.md
81
+ modes: {
82
+ "text-to-video": {
83
+ maxReferences: 0,
84
+ durationRange: [MIN_VIDEO_DURATION, MAX_VIDEO_DURATION],
85
+ resolutions: ["480p", "720p", "1080p"],
86
+ notes: "1080p on grok-imagine-video-1.5 goes through the white-canvas shim.",
87
+ },
88
+ "image-to-video": {
89
+ maxReferences: 1,
90
+ durationRange: [MIN_VIDEO_DURATION, MAX_VIDEO_DURATION],
91
+ resolutions: ["480p", "720p", "1080p"],
92
+ notes: "The source image becomes the first frame.",
93
+ },
94
+ "reference-to-video": {
95
+ maxReferences: MAX_REF2V_REFERENCES,
96
+ durationRange: [MIN_VIDEO_DURATION, MAX_VIDEO_DURATION],
97
+ resolutions: ["480p", "720p"],
98
+ notes: "References guide the subject without locking the first frame. 1080p is rejected upstream.",
99
+ },
100
+ },
101
+ referenceAudio: {
102
+ maxVoices: MAX_REFERENCE_AUDIOS,
103
+ models: ["grok-imagine-video-1.5"],
104
+ // Not the allowlist: xAI owns the roster and accepts custom voice ids too. An
105
+ // unknown id comes back as a 400 naming every voice it will take.
106
+ knownPresets: [
107
+ "ara", "eve", "leo", "rex", "sal", "carina", "zagan", "helix", "orion",
108
+ "luna", "iris", "altair", "zenith", "perseus", "helios", "lux", "kepler",
109
+ "rigel", "cosmo", "celeste", "ursa", "sirius", "lumen", "castor", "naksh",
110
+ "atlas",
111
+ ],
112
+ presetsAreAuthoritative: false,
113
+ },
74
114
  },
75
115
  reasoningEfforts: toArray(appConfig.imageModels.validReasoningEfforts),
76
116
  quality: toArray(VALID_IMAGE_QUALITIES),
@@ -45,6 +45,8 @@ export const PROVIDER_ERROR_MAP = {
45
45
  GROK_VIDEO_EMPTY_RESPONSE: "INTERNAL_STATE_ERROR",
46
46
  GROK_VIDEO_EXPIRED: "MODEL_UNAVAILABLE",
47
47
  GROK_VIDEO_FAILED: "INTERNAL_STATE_ERROR",
48
+ GROK_VIDEO_AUDIO_TOO_MANY: "CAPABILITY_UNSUPPORTED",
49
+ GROK_VIDEO_AUDIO_UNSUPPORTED_MODEL: "CAPABILITY_UNSUPPORTED",
48
50
  GROK_VIDEO_INVALID_MODE: "CAPABILITY_UNSUPPORTED",
49
51
  GROK_VIDEO_MODERATION_BLOCKED: "CONTENT_REJECTED",
50
52
  GROK_VIDEO_POLL_FAILED: "NETWORK_FAILURE",
@@ -5,7 +5,7 @@ import { detectImageMimeFromB64 } from "./refs.js";
5
5
  import { aspectToCanvas, generateWhiteCanvasB64 } from "./grokVideoCanvas.js";
6
6
  import { downloadVideo } from "./grokVideoDownload.js";
7
7
  import { buildGrokVideoPlannerSystemPrompt, composeFallbackVideoPrompt, formatDurationPacingGuidance } from "./grokVideoPlannerPrompt.js";
8
- import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_15_PREVIEW_ALIAS, GROK_VIDEO_MODEL_BASE, MAX_REF2V_REFERENCES, validateVideoResolutionForRequest, } from "./imageModels.js";
8
+ import { GROK_VIDEO_MODEL_15, GROK_VIDEO_MODEL_15_PREVIEW_ALIAS, GROK_VIDEO_MODEL_BASE, MAX_REF2V_REFERENCES, MAX_REFERENCE_AUDIOS, validateVideoResolutionForRequest, } from "./imageModels.js";
9
9
  import { formatVideoContinuityForPlanner } from "./videoContinuity.js";
10
10
  import { DEFAULT_GROK_PLANNER_MODEL } from "../config.js";
11
11
  import { videoConfig, videoEndpoint, withTimeoutSignal, } from "./grokVideoShared.js";
@@ -58,6 +58,12 @@ export function buildGrokVideoPlannerPayload(prompt, opts) {
58
58
  : isI2V
59
59
  ? "This is image-to-video: preserve subject identity and composition unless asked otherwise, and use the source image as the first frame / starting point."
60
60
  : "This is text-to-video: describe motion, camera, and action clearly.";
61
+ // Voices only bind to a speaker if the prompt says where they go, and the planner
62
+ // rewrites the prompt — so it has to know the tag convention.
63
+ const voiceCount = opts.referenceAudios?.length ?? 0;
64
+ const voiceGuidance = voiceCount > 0
65
+ ? `${voiceCount} preset voice${voiceCount > 1 ? "s are" : " is"} attached, referred to as <AUDIO_0>..<AUDIO_${voiceCount - 1}>. Say who speaks with which voice in the prompt (for example "the person from <IMAGE_1> speaks with <AUDIO_0>"); a voice nobody is assigned to will not be used.`
66
+ : "";
61
67
  const lineageText = formatVideoContinuityForPlanner(opts.continuityLineage);
62
68
  const userContent = [
63
69
  {
@@ -68,6 +74,7 @@ export function buildGrokVideoPlannerPayload(prompt, opts) {
68
74
  continuity,
69
75
  lineageText ? `Authoritative continuation context:\n${lineageText}` : "Authoritative continuation context: none.",
70
76
  formatDurationPacingGuidance(opts.duration, opts.mode, opts.resolution),
77
+ ...(voiceGuidance ? [voiceGuidance] : []),
71
78
  opts.searchSummary ? `Mandatory web-search brief:\n${opts.searchSummary}` : "Mandatory web-search brief: unavailable.",
72
79
  ...(opts.backgroundConstraint ? [opts.backgroundConstraint] : []),
73
80
  "Return the generate_video.prompt argument in English only, except for exact visible text the user explicitly requested.",
@@ -200,6 +207,7 @@ export async function planGrokVideo(prompt, ctx, options = {}) {
200
207
  searchSummary,
201
208
  sourceImageUrl: options.sourceImage ? sourceImageUrl(options.sourceImage, options.sourceMime) : undefined,
202
209
  referenceImageUrls,
210
+ ...(options.referenceAudios?.length ? { referenceAudios: options.referenceAudios } : {}),
203
211
  continuityLineage: options.continuityLineage,
204
212
  backgroundConstraint: options.backgroundConstraint,
205
213
  });
@@ -275,13 +283,28 @@ export async function planGrokVideo(prompt, ctx, options = {}) {
275
283
  }
276
284
  export function buildVideoGenerationPayload(plan, opts) {
277
285
  const model = canonicalVideoModel(opts.model);
286
+ const voices = opts.referenceAudios ?? [];
287
+ if (voices.length > 0) {
288
+ // Preset voices exist only on 1.5. Attaching them to the base model earns a 400 from
289
+ // xAI; dropping them earns a video without the voice the user asked for. Say no.
290
+ if (model !== GROK_VIDEO_MODEL_15) {
291
+ throw grokError(`reference audio requires ${GROK_VIDEO_MODEL_15}`, 400, "GROK_VIDEO_AUDIO_UNSUPPORTED_MODEL");
292
+ }
293
+ if (voices.length > MAX_REFERENCE_AUDIOS) {
294
+ throw grokError(`at most ${MAX_REFERENCE_AUDIOS} reference voices`, 400, "GROK_VIDEO_AUDIO_TOO_MANY");
295
+ }
296
+ }
278
297
  if (plan.mode === "image-to-video" && !opts.sourceImageUrl) {
279
298
  throw grokError("image-to-video requires a source image", 400, "GROK_VIDEO_INVALID_MODE");
280
299
  }
281
300
  const refs = opts.referenceImageUrls ?? [];
282
301
  if (plan.mode === "reference-to-video") {
283
- if (refs.length < 2)
284
- throw grokError("reference-to-video requires at least 2 reference images", 400, "GROK_VIDEO_INVALID_MODE");
302
+ // One reference is a legitimate reference-to-video request: it guides the subject
303
+ // without locking the first frame, which is what distinguishes it from
304
+ // image-to-video. Verified against api.x.ai on 2026-08-20 (000_research.md).
305
+ // Zero references is still nonsense — that is text-to-video wearing the wrong name.
306
+ if (refs.length < 1)
307
+ throw grokError("reference-to-video requires at least 1 reference image", 400, "GROK_VIDEO_INVALID_MODE");
285
308
  if (refs.length > MAX_REF2V_REFERENCES)
286
309
  throw grokError(`reference-to-video allows at most ${MAX_REF2V_REFERENCES} reference images`, 400, "GROK_VIDEO_REF_TOO_MANY");
287
310
  if (opts.sourceImageUrl)
@@ -298,6 +321,8 @@ export function buildVideoGenerationPayload(plan, opts) {
298
321
  payload.image = { url: opts.sourceImageUrl };
299
322
  if (plan.mode === "reference-to-video")
300
323
  payload.reference_images = refs.map((url) => ({ url }));
324
+ if (voices.length > 0)
325
+ payload.reference_audios = voices.map((voiceId) => ({ voice_id: voiceId }));
301
326
  return payload;
302
327
  }
303
328
  export async function startVideoRequest(ctx, payload, options) {
@@ -337,6 +362,7 @@ export async function generateVideoViaGrok(prompt, ctx, options = {}) {
337
362
  const model = canonicalVideoModel(options.model || cfg.model);
338
363
  const srcUrl = options.sourceImage ? sourceImageUrl(options.sourceImage, options.sourceMime) : undefined;
339
364
  const refUrls = (options.referenceImages ?? []).map((img) => sourceImageUrl(img, undefined));
365
+ const voices = options.referenceAudios ?? [];
340
366
  options.onEvent?.({ phase: "planning" });
341
367
  const plan = options.plannedPrompt
342
368
  ? {
@@ -356,18 +382,21 @@ export async function generateVideoViaGrok(prompt, ctx, options = {}) {
356
382
  const { width, height } = aspectToCanvas(plan.aspectRatio, plan.resolution);
357
383
  const whiteCanvas = await generateWhiteCanvasB64(width, height);
358
384
  const canvasSrcUrl = `data:image/png;base64,${whiteCanvas}`;
359
- effectivePayload = buildVideoGenerationPayload({ ...plan, mode: "image-to-video", prompt: `[Technical note: the attached image is a blank white canvas used as a technical placeholder for text-to-video generation. It is NOT a meaningful source frame. Ignore it completely and generate a fresh scene from scratch.]\n\n${plan.prompt}` }, { model, sourceImageUrl: canvasSrcUrl, referenceImageUrls: [] });
385
+ effectivePayload = buildVideoGenerationPayload({ ...plan, mode: "image-to-video", prompt: `[Technical note: the attached image is a blank white canvas used as a technical placeholder for text-to-video generation. It is NOT a meaningful source frame. Ignore it completely and generate a fresh scene from scratch.]\n\n${plan.prompt}` }, { model, sourceImageUrl: canvasSrcUrl, referenceImageUrls: [], ...(voices.length ? { referenceAudios: voices } : {}) });
360
386
  logEvent("grok", "video:1.5-t2v-canvas", { requestId: options.requestId, width, height });
361
387
  }
362
388
  else {
363
- effectivePayload = buildVideoGenerationPayload(plan, { model, sourceImageUrl: srcUrl, referenceImageUrls: refUrls });
389
+ effectivePayload = buildVideoGenerationPayload(plan, { model, sourceImageUrl: srcUrl, referenceImageUrls: refUrls, ...(voices.length ? { referenceAudios: voices } : {}) });
364
390
  }
365
391
  try {
366
392
  xaiVideoRequestId = await startVideoRequest(ctx, effectivePayload, options);
367
393
  }
368
394
  catch (e) {
369
- // Fallback: if 1.5-preview still fails, retry with base model
370
- if (model !== GROK_VIDEO_MODEL_BASE && e?.status === 400) {
395
+ // Fallback: if 1.5-preview still fails, retry with base model.
396
+ // Not when voices are attached: the base model rejects reference_audios outright, so
397
+ // the retry would only replace one 400 with a more confusing one. Dropping the voice
398
+ // to make the call succeed would hand back a video missing what was asked for.
399
+ if (model !== GROK_VIDEO_MODEL_BASE && e?.status === 400 && voices.length === 0) {
371
400
  effectiveModel = GROK_VIDEO_MODEL_BASE;
372
401
  const fallbackPayload = buildVideoGenerationPayload(plan, { model: effectiveModel, sourceImageUrl: srcUrl, referenceImageUrls: refUrls });
373
402
  xaiVideoRequestId = await startVideoRequest(ctx, fallbackPayload, options);
@@ -141,10 +141,23 @@ export const VALID_VIDEO_ASPECT_RATIOS = new Set([
141
141
  ]);
142
142
  export const MIN_VIDEO_DURATION = 1;
143
143
  export const MAX_VIDEO_DURATION = 15;
144
- // reference-to-video (xAI): up to 7 reference images, max 10s duration.
144
+ // reference-to-video (xAI): up to 7 reference images (8 -> 400), 1-15s, 720p max.
145
+ // Verified against api.x.ai on 2026-08-20:
146
+ // devlog/_plan/260820_grok15_multi_reference_video/000_research.md
145
147
  export const MAX_REF2V_REFERENCES = 7;
146
- export const MAX_REF2V_DURATION = 10;
147
- // Mode is derived purely from the number of attached reference images.
148
+ // reference_audios: preset voices, grok-imagine-video-1.5 only. 4 -> 400.
149
+ export const MAX_REFERENCE_AUDIOS = 3;
150
+ /**
151
+ * Count-only fallback for callers that have lost track of WHICH slot each image
152
+ * arrived in.
153
+ *
154
+ * A single image is ambiguous on its own: as a first frame it means image-to-video,
155
+ * as a reference it means reference-to-video, and the count cannot tell them apart.
156
+ * This returns the historical default (image-to-video) so old callers keep their
157
+ * behavior — it is a fallback, not a ceiling. Callers that DO know the slot should
158
+ * use `deriveVideoMode` in lib/videoGenerationRequest.ts, which reads intent from
159
+ * the field the caller chose.
160
+ */
148
161
  export function deriveVideoMode(refCount) {
149
162
  if (refCount >= 2)
150
163
  return "reference-to-video";
@@ -152,10 +165,6 @@ export function deriveVideoMode(refCount) {
152
165
  return "image-to-video";
153
166
  return "text-to-video";
154
167
  }
155
- // Clamp duration to the reference-to-video ceiling; other modes keep their value.
156
- export function clampVideoDuration(duration, mode) {
157
- return mode === "reference-to-video" ? Math.min(duration, MAX_REF2V_DURATION) : duration;
158
- }
159
168
  export function isGrokVideoModel(value) {
160
169
  return typeof value === "string" && VALID_GROK_VIDEO_MODELS.has(value);
161
170
  }
@@ -69,6 +69,7 @@ export function normalizeVideoGenerationRequest(input) {
69
69
  ...(input.sourceAssetId ? { sourceAssetId: input.sourceAssetId } : {}),
70
70
  ...(input.referenceImages?.length ? { referenceImages: input.referenceImages } : {}),
71
71
  ...(input.referenceFilenames?.length ? { referenceFilenames: input.referenceFilenames } : {}),
72
+ ...(input.referenceAudios?.length ? { referenceAudios: input.referenceAudios } : {}),
72
73
  ...(input.continueFromVideo ? { continueFromVideo: input.continueFromVideo } : {}),
73
74
  ...(input.topic ? { topic: input.topic } : {}),
74
75
  ...(input.storyboard ? { storyboard: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "3.7.2",
3
+ "version": "3.8.0",
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": "8f72a5b19e9aa58c7ec9ea540e988b3edc8ae4f5"
122
+ "gitHead": "780f6da658d0ae69b5c5a22d946c4742c32a4b9b"
123
123
  }
package/routes/video.js CHANGED
@@ -15,7 +15,7 @@ import { getVideoSeriesChain } from "../lib/videoSeriesChain.js";
15
15
  import { ACTIVE_VIDEO_PROMPT_GUIDANCE, appendVideoContinuityEntry, lineageFromVideoMetadata, normalizeVideoContinuityLineage, readVideoSidecar, requireActiveVideoPrompt, safeGeneratedVideoFilename, } from "../lib/videoContinuity.js";
16
16
  import { extractGeneratedVideoFrameB64 } from "../lib/videoFrameExtract.js";
17
17
  import { errorEnvelopeFields } from "../lib/errors/envelope.js";
18
- import { normalizeGrokVideoModel, normalizeVideoResolution, normalizeVideoAspectRatio, normalizeVideoDuration, deriveVideoMode, clampVideoDuration, MAX_REF2V_REFERENCES, validateVideoResolutionForRequest, } from "../lib/imageModels.js";
18
+ import { normalizeGrokVideoModel, normalizeVideoResolution, normalizeVideoAspectRatio, normalizeVideoDuration, deriveVideoMode, MAX_REF2V_REFERENCES, MAX_REFERENCE_AUDIOS, validateVideoResolutionForRequest, } from "../lib/imageModels.js";
19
19
  import { errInfo } from "../lib/errInfo.js";
20
20
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
21
21
  import { generateVideoThumbnail } from "../lib/videoThumb.js";
@@ -287,7 +287,10 @@ export function registerVideoRoutes(app, ctxRaw) {
287
287
  refInputs.push(...elementResolvedRefs.map((image) => ({ image, source: "composer" })));
288
288
  let resolved;
289
289
  try {
290
- const all = await Promise.all(refInputs.map((r) => resolveSourceImage(ctx, r.image, r.filename)));
290
+ const all = await Promise.all(refInputs.map(async (r) => ({
291
+ ...(await resolveSourceImage(ctx, r.image, r.filename)),
292
+ source: r.source,
293
+ })));
291
294
  resolved = all.filter((r) => Boolean(r.b64));
292
295
  }
293
296
  catch (e) {
@@ -296,8 +299,28 @@ export function registerVideoRoutes(app, ctxRaw) {
296
299
  if (resolved.length > MAX_REF2V_REFERENCES)
297
300
  return fail(400, "GROK_VIDEO_REF_TOO_MANY", `at most ${MAX_REF2V_REFERENCES} reference images`);
298
301
  const incomingProviderUrl = typeof req.body?.providerUrl === "string" && req.body.providerUrl.startsWith("http") ? req.body.providerUrl : null;
299
- const mode = incomingProviderUrl ? "image-to-video" : deriveVideoMode(resolved.length);
300
- const duration = clampVideoDuration(durationCheck.duration, mode);
302
+ // Which slot an image arrived in IS the caller's intent, and a bare count throws
303
+ // that away. A composer reference means "guide the video with this" even when
304
+ // there is only one of them; a node/continuity image means "start from this
305
+ // frame". Deriving from the count alone forced every single composer reference
306
+ // into image-to-video, so the one thing the tray is named for was unreachable.
307
+ // devlog/_plan/260820_grok15_multi_reference_video/030_single_ref_mode_choice.md
308
+ const composerRefCount = resolved.filter((r) => r.source === "composer").length;
309
+ const requestedMode = typeof req.body?.mode === "string" ? req.body.mode : null;
310
+ const derivedMode = composerRefCount > 0
311
+ ? "reference-to-video"
312
+ : deriveVideoMode(resolved.length);
313
+ const mode = incomingProviderUrl
314
+ ? "image-to-video"
315
+ : (requestedMode === "reference-to-video" || requestedMode === "image-to-video" || requestedMode === "text-to-video")
316
+ ? requestedMode
317
+ : derivedMode;
318
+ // An explicit reference-to-video with nothing to reference would ship an empty
319
+ // reference_images array and fail upstream with a less useful message.
320
+ if (mode === "reference-to-video" && resolved.length === 0) {
321
+ return fail(400, "GROK_VIDEO_INVALID_MODE", "reference-to-video requires at least 1 reference image");
322
+ }
323
+ const duration = durationCheck.duration;
301
324
  const resolutionModeCheck = validateVideoResolutionForRequest(modelCheck.model, resolutionCheck.resolution, mode, {
302
325
  allowTextCanvasShim: true,
303
326
  });
@@ -356,6 +379,15 @@ export function registerVideoRoutes(app, ctxRaw) {
356
379
  + (backgroundPreset ? ` ${backgroundPromptSuffix(backgroundPreset, "video")}` : "");
357
380
  const plannerModel = typeof req.body?.plannerModel === "string" ? req.body.plannerModel.trim() : undefined;
358
381
  const directApiKey = provider === "grok-api" ? ctx.xaiApiKey : undefined;
382
+ // Only the shape is checked here. Which voice ids exist is xAI's to answer, and its
383
+ // 400 names every valid voice — a list we would only get wrong, and which cannot
384
+ // include the caller's custom voices anyway.
385
+ const referenceAudios = toArray(req.body?.referenceAudios)
386
+ .map((voice) => (typeof voice === "string" ? voice.trim() : ""))
387
+ .filter((voice) => voice.length > 0);
388
+ if (referenceAudios.length > MAX_REFERENCE_AUDIOS) {
389
+ return fail(400, "GROK_VIDEO_AUDIO_TOO_MANY", `at most ${MAX_REFERENCE_AUDIOS} reference voices`);
390
+ }
359
391
  const result = await generateVideoViaGrok(effectivePrompt, ctx, {
360
392
  model: modelCheck.model,
361
393
  mode,
@@ -364,6 +396,7 @@ export function registerVideoRoutes(app, ctxRaw) {
364
396
  aspectRatio: aspectCheck.aspectRatio,
365
397
  sourceImage: sourceB64,
366
398
  referenceImages,
399
+ ...(referenceAudios.length ? { referenceAudios } : {}),
367
400
  signal: cancelController.signal,
368
401
  requestId,
369
402
  continuityLineage: parentLineage,