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.
- package/bin/commands/video.js +12 -4
- package/bin/lib/videoMcp.js +14 -7
- package/docs/migration/runtime-test-inventory.md +6 -1
- package/lib/agentGenerationPlanner.js +3 -0
- package/lib/agentImageVideoGen.js +36 -8
- package/lib/agentPlannerModel.js +2 -1
- package/lib/agentRuntime.js +3 -0
- package/lib/capabilities.js +40 -0
- package/lib/errors/providerMap.js +2 -0
- package/lib/grokVideoAdapter.js +36 -7
- package/lib/imageModels.js +16 -7
- package/lib/videoGenerationRequest.js +1 -0
- package/package.json +2 -2
- package/routes/video.js +37 -4
- package/ui/dist/.vite/manifest.json +31 -31
- package/ui/dist/assets/{AgentWorkspace-JQ0K_ndE.js → AgentWorkspace-DAjUdAsK.js} +1 -1
- package/ui/dist/assets/{AssetGenWorkspace-B2d-aiIb.js → AssetGenWorkspace-CpAkJhms.js} +2 -2
- package/ui/dist/assets/{AssetsWorkspace-Di0BYZZW.js → AssetsWorkspace-Bh1OzpUk.js} +1 -1
- package/ui/dist/assets/{CardNewsWorkspace-F3LrEj0b.js → CardNewsWorkspace-DSqExDy-.js} +1 -1
- package/ui/dist/assets/{GenerationRequestLogPanel-CpyvTE6t.js → GenerationRequestLogPanel-B9n86Nqn.js} +1 -1
- package/ui/dist/assets/{HomeWorkspace-KplbVQT9.js → HomeWorkspace-Dr0j6d1O.js} +1 -1
- package/ui/dist/assets/{KeyingPanel-BY_xV317.js → KeyingPanel-2-wMdwa8.js} +1 -1
- package/ui/dist/assets/{NodeCanvas-scDxt0LA.js → NodeCanvas-DS3-x-hx.js} +1 -1
- package/ui/dist/assets/{PromptBuilderPanel-DeSY9dl-.js → PromptBuilderPanel-CvXqQl0o.js} +1 -1
- package/ui/dist/assets/{PromptImportDialog-BTP0imZl.js → PromptImportDialog-CBw_qPiD.js} +2 -2
- package/ui/dist/assets/{PromptImportDiscoverySection-BBThhJZM.js → PromptImportDiscoverySection-B1BY5Ntk.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-6sXWWJoK.js → PromptImportFolderSection-CPwnX0PB.js} +1 -1
- package/ui/dist/assets/{PromptLibraryPanel-BYTG-mBa.js → PromptLibraryPanel-kXL1oFA8.js} +2 -2
- package/ui/dist/assets/{SettingsWorkspace-chsddbrB.js → SettingsWorkspace-Cf7rRTV3.js} +1 -1
- package/ui/dist/assets/{SpriteRecipeWorkspace-HgacgO0x.js → SpriteRecipeWorkspace-_WcHPSd4.js} +1 -1
- package/ui/dist/assets/{index-BqFlxfzT.js → index-BKW7WOYj.js} +18 -18
- package/ui/dist/assets/{index-VXkP0z69.js → index-BTHxkxxS.js} +3 -3
- package/ui/dist/assets/{pptxgen.es-DX8PxAzM.js → pptxgen.es-BOlreaat.js} +1 -1
- package/ui/dist/assets/{useAgentDialogFocus-DYjA7lCJ.js → useAgentDialogFocus-CxsC04Sp.js} +1 -1
- package/ui/dist/index.html +1 -1
package/bin/commands/video.js
CHANGED
|
@@ -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
|
|
140
|
-
1
|
|
141
|
-
|
|
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(" ");
|
package/bin/lib/videoMcp.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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,
|
package/lib/agentPlannerModel.js
CHANGED
|
@@ -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).",
|
package/lib/agentRuntime.js
CHANGED
|
@@ -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,
|
package/lib/capabilities.js
CHANGED
|
@@ -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",
|
package/lib/grokVideoAdapter.js
CHANGED
|
@@ -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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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);
|
package/lib/imageModels.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
147
|
-
|
|
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.
|
|
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": "
|
|
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,
|
|
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) =>
|
|
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
|
-
|
|
300
|
-
|
|
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,
|