diff --git a/src/components/beatcanvas/beatcanvas-front-layer.tsx b/src/components/beatcanvas/beatcanvas-front-layer.tsx
index 649dd06..6bf495f 100644
--- a/src/components/beatcanvas/beatcanvas-front-layer.tsx
+++ b/src/components/beatcanvas/beatcanvas-front-layer.tsx
@@ -7,7 +7,10 @@ import {
truncatePromptToMaxChars,
} from '@/core/effects/validation';
import { VIDEO_ANALYSIS_MODEL_ID } from '@/core/effects/video-analysis';
-import { findWorkspaceModelOption } from '@/core/effects/workspace-models';
+import {
+ findWorkspaceModelOption,
+ getWorkspaceAspectRatioOptions,
+} from '@/core/effects/workspace-models';
import { cn } from '@/lib/utils';
import { StudioStartHere } from '@/components/studio/studio-start-here';
import {
@@ -356,7 +359,18 @@ export function BeatCanvasFrontLayer() {
? (selectedModel?.supportedDurations ?? [])
: [];
const selectedLanguageOptions = selectedModel?.supportedLanguages ?? [];
- const selectedAspectRatioOptions = selectedModel?.supportedAspectRatios ?? [];
+ const hasImageReferences =
+ activeDraftCard?.referenceCardIds.some(
+ (cardId) => cards[cardId]?.type === 'image'
+ ) ?? false;
+ const selectedAspectRatioOptions = useMemo(
+ () =>
+ getWorkspaceAspectRatioOptions({
+ model: selectedModel,
+ hasImageReferences,
+ }),
+ [hasImageReferences, selectedModel]
+ );
const selectedOutputQualities = selectedModel?.supportedOutputQualities ?? [];
const selectedCharacterOrientationOptions =
selectedModel?.characterOrientationOptions ?? [];
@@ -371,6 +385,17 @@ export function BeatCanvasFrontLayer() {
return;
}
+ if (
+ selectedAspectRatioOptions.length > 0 &&
+ !selectedAspectRatioOptions.includes(activeDraftCard.aspectRatio)
+ ) {
+ onDraftAspectRatioChange(
+ activeDraftCard.id,
+ selectedModel.defaultAspectRatio ?? selectedAspectRatioOptions[0]
+ );
+ return;
+ }
+
const nextAspectRatio = resolveReferenceDrivenDraftAspectRatio({
draftCard: activeDraftCard,
cards,
@@ -392,6 +417,7 @@ export function BeatCanvasFrontLayer() {
editor,
isDraftBusy,
onDraftAspectRatioChange,
+ selectedAspectRatioOptions,
selectedModel,
]);
diff --git a/src/components/pricing/pricing-catalog.json b/src/components/pricing/pricing-catalog.json
index f053921..e1948b5 100644
--- a/src/components/pricing/pricing-catalog.json
+++ b/src/components/pricing/pricing-catalog.json
@@ -1,5 +1,21 @@
{
"groups": [
+ {
+ "model": "Grok Imagine Image 2.0",
+ "category": "image",
+ "specs": [
+ { "id": "grok-imagine-image-2.0-default", "spec": "1 image · generation or edit", "specZh": "1 张图片 · 生成或编辑", "beatapi": 0.03, "competitor": 0.04 }
+ ]
+ },
+ {
+ "model": "Grok Imagine Video 1.5",
+ "category": "video",
+ "specs": [
+ { "id": "grok-imagine-video-1.5-480p", "spec": "8 sec example · 480p", "specZh": "8 秒示例 · 480p", "beatapi": 0.2, "competitor": 0.64, "billingUnit": "second", "beatapiUnitPrice": 0.025, "competitorUnitPrice": 0.08, "exampleQuantity": 8 },
+ { "id": "grok-imagine-video-1.5-720p", "spec": "8 sec example · 720p", "specZh": "8 秒示例 · 720p", "beatapi": 0.4, "competitor": 1.12, "billingUnit": "second", "beatapiUnitPrice": 0.05, "competitorUnitPrice": 0.14, "exampleQuantity": 8 },
+ { "id": "grok-imagine-video-1.5-1080p", "spec": "8 sec example · 1080p · max 1 image", "specZh": "8 秒示例 · 1080p · 最多 1 张图", "beatapi": 0.72, "competitor": 2, "billingUnit": "second", "beatapiUnitPrice": 0.09, "competitorUnitPrice": 0.25, "exampleQuantity": 8 }
+ ]
+ },
{
"model": "MiniMax H3",
"category": "video",
diff --git a/src/components/pricing/pricing-comparison-data.test.ts b/src/components/pricing/pricing-comparison-data.test.ts
index 7e18432..9f10716 100644
--- a/src/components/pricing/pricing-comparison-data.test.ts
+++ b/src/components/pricing/pricing-comparison-data.test.ts
@@ -26,16 +26,45 @@ test('workflow and realtime exclusive cards stay off the public page for now', (
test('catalog keeps one card per model and groups the Seedance family together', () => {
const names = comparisonGroups.map((group) => group.model);
assert.deepEqual(
- names.slice(1, 5),
+ names.slice(3, 7),
['Seedance 2.0', 'Seedance 2.0 Mini', 'Seedance 2.0 Fast', 'Seedance 2.5']
);
assert.equal(new Set(names).size, names.length);
});
test('pricing filters count selectable model families rather than variant cards', () => {
- assert.equal(countPricingModelFamilies(), 11);
- assert.equal(countPricingModelFamilies('video'), 7);
- assert.equal(countPricingModelFamilies('image'), 4);
+ assert.equal(countPricingModelFamilies(), 13);
+ assert.equal(countPricingModelFamilies('video'), 8);
+ assert.equal(countPricingModelFamilies('image'), 5);
+});
+
+test('Grok pricing matches the official BeatAPI list prices', () => {
+ const image = comparisonGroups.find(
+ (group) => group.model === 'Grok Imagine Image 2.0'
+ );
+ const video = comparisonGroups.find(
+ (group) => group.model === 'Grok Imagine Video 1.5'
+ );
+
+ assert.equal(image?.specs[0]?.beatapi, 0.03);
+ assert.equal(image?.specs[0]?.competitor, 0.04);
+ const video480 = video?.specs.find((spec) => spec.id.endsWith('480p'));
+ const video720 = video?.specs.find((spec) => spec.id.endsWith('720p'));
+ const video1080 = video?.specs.find((spec) => spec.id.endsWith('1080p'));
+ assert.equal(video480?.beatapiUnitPrice, 0.025);
+ assert.equal(video480?.exampleQuantity, 8);
+ assert.equal(video480?.beatapi, 0.025 * 8);
+ assert.match(video480?.spec ?? '', /8 sec example/);
+ assert.equal(video720?.beatapiUnitPrice, 0.05);
+ assert.equal(video720?.exampleQuantity, 8);
+ assert.equal(video720?.beatapi, 0.05 * 8);
+ assert.match(video720?.specZh ?? '', /8 秒示例/);
+ assert.equal(video1080?.beatapiUnitPrice, 0.09);
+ assert.equal(video1080?.competitorUnitPrice, 0.25);
+ assert.equal(video1080?.exampleQuantity, 8);
+ assert.equal(video1080?.beatapi, 0.09 * 8);
+ assert.equal(video1080?.competitor, 0.25 * 8);
+ assert.match(video1080?.specZh ?? '', /最多 1 张图/);
});
test('Seedance 2.0 uses the official BeatAPI list prices', () => {
diff --git a/src/components/pricing/pricing-comparison-data.ts b/src/components/pricing/pricing-comparison-data.ts
index 8155443..96e1e94 100644
--- a/src/components/pricing/pricing-comparison-data.ts
+++ b/src/components/pricing/pricing-comparison-data.ts
@@ -14,6 +14,10 @@ export type ComparisonSpec = {
beatapi: number;
competitor?: number;
higgsfieldCredits?: number;
+ billingUnit?: 'second';
+ beatapiUnitPrice?: number;
+ competitorUnitPrice?: number;
+ exampleQuantity?: number;
};
export type ComparisonGroup = {
diff --git a/src/components/pricing/pricing-comparison-table.tsx b/src/components/pricing/pricing-comparison-table.tsx
index f68ff97..6fd6f91 100644
--- a/src/components/pricing/pricing-comparison-table.tsx
+++ b/src/components/pricing/pricing-comparison-table.tsx
@@ -120,12 +120,28 @@ export function PricingModelCard({
{locale === 'zh' ? spec.specZh : spec.spec}
- {formatPrice(spec.beatapi)}
+ {formatPrice(spec.beatapi)}
+ {spec.billingUnit === 'second' &&
+ spec.beatapiUnitPrice !== undefined ? (
+
+ {formatPrice(spec.beatapiUnitPrice)}
+ /s
+
+ ) : null}
|
- {spec.competitor !== undefined
- ? formatCompetitorPrice(spec.competitor)
- : '—'}
+
+ {spec.competitor !== undefined
+ ? formatCompetitorPrice(spec.competitor)
+ : '—'}
+
+ {spec.billingUnit === 'second' &&
+ spec.competitorUnitPrice !== undefined ? (
+
+ {formatCompetitorPrice(spec.competitorUnitPrice)}
+ /s
+
+ ) : null}
|
@@ -153,6 +169,13 @@ export function PricingModelCard({
{formatPrice(spec.beatapi)}
+ {spec.billingUnit === 'second' &&
+ spec.beatapiUnitPrice !== undefined ? (
+
+ {formatPrice(spec.beatapiUnitPrice)}
+ /s
+
+ ) : null}
{spec.competitor !== undefined
? formatCompetitorPrice(spec.competitor)
diff --git a/src/components/studio/studio-composer.tsx b/src/components/studio/studio-composer.tsx
index 8e692f0..4f57d6b 100644
--- a/src/components/studio/studio-composer.tsx
+++ b/src/components/studio/studio-composer.tsx
@@ -10,7 +10,7 @@ import {
Sparkles,
Video,
} from 'lucide-react';
-import { useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import {
composerCardClassName,
@@ -28,6 +28,7 @@ import {
} from '@/core/effects/validation';
import {
findWorkspaceModelOption,
+ getWorkspaceAspectRatioOptions,
type WorkspaceModelOption,
} from '@/core/effects/workspace-models';
import { getModelIconPathByModelId } from '@/core/workspace-lib/model-icons';
@@ -91,7 +92,14 @@ export function StudioComposer({
const selectedDurationOptions =
media === 'video' ? (selectedModel?.supportedDurations ?? []) : [];
const selectedLanguageOptions = selectedModel?.supportedLanguages ?? [];
- const selectedAspectRatioOptions = selectedModel?.supportedAspectRatios ?? [];
+ const selectedAspectRatioOptions = useMemo(
+ () =>
+ getWorkspaceAspectRatioOptions({
+ model: selectedModel,
+ hasImageReferences: referenceUrls.length > 0,
+ }),
+ [referenceUrls.length, selectedModel]
+ );
const selectedOutputQualities = selectedModel?.supportedOutputQualities ?? [];
const selectedCharacterOrientationOptions =
selectedModel?.characterOrientationOptions ?? [];
@@ -148,6 +156,27 @@ export function StudioComposer({
),
}));
+ useEffect(() => {
+ if (
+ isAnalysis ||
+ selectedAspectRatioOptions.length === 0 ||
+ selectedAspectRatioOptions.includes(draft.aspectRatio)
+ ) {
+ return;
+ }
+ onDraftChange({
+ ...draft,
+ aspectRatio:
+ selectedModel?.defaultAspectRatio ?? selectedAspectRatioOptions[0],
+ });
+ }, [
+ draft,
+ isAnalysis,
+ onDraftChange,
+ selectedAspectRatioOptions,
+ selectedModel?.defaultAspectRatio,
+ ]);
+
return (
{
},
}
);
+
+ assert.deepEqual(
+ buildBeatApiTaskRequest({
+ effectType: 1,
+ model: 'grok-imagine-video-1.5',
+ input: {
+ prompt: 'A native 1080p cinematic landscape',
+ aspect_ratio: '16:9',
+ wmDuration: '12s',
+ wmOutputQuality: '1080p',
+ },
+ }).body,
+ {
+ model: 'grok-imagine-video-1.5',
+ prompt: 'A native 1080p cinematic landscape',
+ aspect_ratio: '16:9',
+ duration: 12,
+ resolution: '1080p',
+ }
+ );
});
test('maps standard and deep video analysis to the stable BeatAPI workflow', () => {
@@ -494,6 +514,158 @@ test('maps Veo 3.1 quality tiers and resolution into BeatAPI fields', () => {
);
});
+test('maps Grok Imagine Image 2.0 generation and editing requests', () => {
+ assert.deepEqual(
+ buildBeatApiTaskRequest({
+ effectType: 2,
+ model: 'grok-imagine-image-2.0',
+ input: {
+ prompt: 'Editorial portrait',
+ aspect_ratio: '3:2',
+ },
+ }).body,
+ {
+ model: 'grok-imagine-image-2.0',
+ prompt: 'Editorial portrait',
+ aspect_ratio: '3:2',
+ }
+ );
+
+ const references = Array.from(
+ { length: 5 },
+ (_, index) => `https://example.com/reference-${index + 1}.png`
+ );
+ assert.deepEqual(
+ buildBeatApiTaskRequest({
+ effectType: 2,
+ model: 'grok-imagine-image-2.0',
+ input: {
+ prompt: 'Preserve the source composition',
+ image_urls: references,
+ aspect_ratio: 'auto',
+ },
+ }).body,
+ {
+ model: 'grok-imagine-image-2.0',
+ prompt: 'Preserve the source composition',
+ images: references,
+ aspect_ratio: 'auto',
+ }
+ );
+
+ assert.throws(
+ () =>
+ buildBeatApiTaskRequest({
+ effectType: 2,
+ model: 'grok-imagine-image-2.0',
+ input: { prompt: 'Invalid auto ratio', aspect_ratio: 'auto' },
+ }),
+ /auto aspect ratio requires a reference image/
+ );
+});
+
+test('maps Grok Imagine Video 1.5 text, reference, and first-frame modes', () => {
+ assert.deepEqual(
+ buildBeatApiTaskRequest({
+ effectType: 1,
+ model: 'grok-imagine-video-1.5',
+ input: {
+ prompt: 'A cinematic product reveal',
+ aspect_ratio: '16:9',
+ wmDuration: '8s',
+ wmOutputQuality: '480p',
+ },
+ }).body,
+ {
+ model: 'grok-imagine-video-1.5',
+ prompt: 'A cinematic product reveal',
+ aspect_ratio: '16:9',
+ duration: 8,
+ resolution: '480p',
+ }
+ );
+
+ const references = Array.from(
+ { length: 7 },
+ (_, index) => `https://example.com/grok-reference-${index + 1}.png`
+ );
+ assert.deepEqual(
+ buildBeatApiTaskRequest({
+ effectType: 1,
+ model: 'grok-imagine-video-1.5',
+ input: {
+ prompt: 'Use all references for style and identity',
+ image_urls: references,
+ aspect_ratio: '9:16',
+ wmDuration: '15s',
+ wmOutputQuality: '720p',
+ },
+ }).body,
+ {
+ model: 'grok-imagine-video-1.5',
+ prompt: 'Use all references for style and identity',
+ reference_images: references,
+ aspect_ratio: '9:16',
+ duration: 15,
+ resolution: '720p',
+ }
+ );
+
+ assert.deepEqual(
+ buildBeatApiTaskRequest({
+ effectType: 1,
+ model: 'grok-imagine-video-1.5',
+ input: {
+ prompt: 'Use @Image1 as the first frame',
+ image_urls: ['https://example.com/first-frame.png'],
+ aspect_ratio: '16:9',
+ wmDuration: '6s',
+ wmOutputQuality: '720p',
+ },
+ }).body,
+ {
+ model: 'grok-imagine-video-1.5',
+ prompt: 'Use @Image1 as the first frame',
+ images: ['https://example.com/first-frame.png'],
+ duration: 6,
+ resolution: '720p',
+ }
+ );
+
+ assert.throws(
+ () =>
+ buildBeatApiTaskRequest({
+ effectType: 1,
+ model: 'grok-imagine-video-1.5',
+ input: {
+ prompt: 'Use @Image1 as the first frame and @Image2 as the last frame',
+ image_urls: [
+ 'https://example.com/first.png',
+ 'https://example.com/last.png',
+ ],
+ },
+ }),
+ /supports one first frame only/
+ );
+
+ assert.throws(
+ () =>
+ buildBeatApiTaskRequest({
+ effectType: 1,
+ model: 'grok-imagine-video-1.5',
+ input: {
+ prompt: 'Use multiple references at native resolution',
+ image_urls: [
+ 'https://example.com/reference-one.png',
+ 'https://example.com/reference-two.png',
+ ],
+ wmOutputQuality: '1080p',
+ },
+ }),
+ /1080p accepts at most one image/
+ );
+});
+
test('rejects models outside the public BeatAPI media catalog', () => {
assert.throws(
() =>
diff --git a/src/core/adapters/beatapi-adapter.ts b/src/core/adapters/beatapi-adapter.ts
index 4203898..4b848c6 100644
--- a/src/core/adapters/beatapi-adapter.ts
+++ b/src/core/adapters/beatapi-adapter.ts
@@ -19,6 +19,7 @@ const IMAGE_MODELS = new Set([
'nano-banana-pro',
'gpt-image-2',
'seedream-5-pro',
+ 'grok-imagine-image-2.0',
]);
const VIDEO_MODELS = new Set([
@@ -31,6 +32,7 @@ const VIDEO_MODELS = new Set([
'kling-3',
'kling-2.6-motion-control',
'kling-3-motion-control',
+ 'grok-imagine-video-1.5',
]);
const BEATAPI_REQUEST_TIMEOUT_MS = 30_000;
@@ -249,6 +251,9 @@ const resolveVideoReferenceFields = ({
}
if (frameImages) {
+ if (model === 'grok-imagine-video-1.5' && frameImages.length !== 1) {
+ throw new Error('Grok Imagine Video 1.5 supports one first frame only');
+ }
return {
mode: frameImages.length === 2 ? 'frames' : 'image',
fields: { images: frameImages },
@@ -331,6 +336,15 @@ export const buildBeatApiTaskRequest = ({
throw new Error(`Unsupported BeatAPI image model: ${model}`);
}
assertAtMost(model, images.length, getBeatApiImageReferenceLimit(model));
+ if (
+ model === 'grok-imagine-image-2.0' &&
+ images.length === 0 &&
+ input.aspect_ratio === 'auto'
+ ) {
+ throw new Error(
+ 'Grok Imagine Image 2.0 auto aspect ratio requires a reference image'
+ );
+ }
return {
path: '/v1/images/tasks',
@@ -411,12 +425,23 @@ export const buildBeatApiTaskRequest = ({
videoUrls: input.video_urls ?? [],
audioUrls: input.audio_urls ?? [],
});
+ if (
+ model === 'grok-imagine-video-1.5' &&
+ input.wmOutputQuality === '1080p' &&
+ images.length > 1
+ ) {
+ throw new Error(
+ 'Grok Imagine Video 1.5 1080p accepts at most one image'
+ );
+ }
const aspectRatio =
model === 'minimax-h3' &&
references.mode === 'text' &&
input.aspect_ratio === 'adaptive'
? '16:9'
- : input.aspect_ratio;
+ : model === 'grok-imagine-video-1.5' && references.mode === 'image'
+ ? undefined
+ : input.aspect_ratio;
if (
model === 'seedance-2' &&
references.mode === 'reference' &&
@@ -466,6 +491,8 @@ export const buildBeatApiTaskRequest = ({
} else if (model === 'kling-3') {
body.resolution = mapKlingResolution(input.wmOutputQuality);
body.sound = input.wmSound ?? true;
+ } else if (model === 'grok-imagine-video-1.5') {
+ body.resolution = input.wmOutputQuality || '720p';
}
return { path: '/v1/videos/tasks', body };
diff --git a/src/core/effects/beatapi-model-contract.ts b/src/core/effects/beatapi-model-contract.ts
index 9fb2b07..32af1a9 100644
--- a/src/core/effects/beatapi-model-contract.ts
+++ b/src/core/effects/beatapi-model-contract.ts
@@ -10,6 +10,7 @@ export const BEATAPI_IMAGE_REFERENCE_LIMITS = {
'nano-banana-pro': 8,
'gpt-image-2': 16,
'seedream-5-pro': 10,
+ 'grok-imagine-image-2.0': 5,
} as const;
export const BEATAPI_VIDEO_REFERENCE_CONTRACTS = {
@@ -49,6 +50,12 @@ export const BEATAPI_VIDEO_REFERENCE_CONTRACTS = {
maxReferenceAudios: 0,
allowsAudioOnly: false,
},
+ 'grok-imagine-video-1.5': {
+ maxReferenceImages: 7,
+ maxReferenceVideos: 0,
+ maxReferenceAudios: 0,
+ allowsAudioOnly: false,
+ },
} as const satisfies Record;
export const getBeatApiImageReferenceLimit = (modelId: string) =>
diff --git a/src/core/effects/effect-registry.ts b/src/core/effects/effect-registry.ts
index dcac00a..2f96537 100644
--- a/src/core/effects/effect-registry.ts
+++ b/src/core/effects/effect-registry.ts
@@ -72,6 +72,7 @@ export type WorkspaceEffectRegistryEntry = {
supportedDurations?: WorkspaceRegistryDuration[];
defaultAspectRatio?: WorkspaceRegistryAspectRatio;
supportedAspectRatios?: WorkspaceRegistryAspectRatio[];
+ referenceOnlyAspectRatios?: WorkspaceRegistryAspectRatio[];
defaultOutputQuality?: WorkspaceRegistryOutputQuality;
supportedOutputQualities?: WorkspaceRegistryOutputQuality[];
defaultQuality?: WorkspaceRegistryQualityOption;
@@ -226,6 +227,20 @@ export const WORKSPACE_EFFECT_REGISTRY: readonly WorkspaceEffectRegistryEntry[]
defaultOutputQuality: '1k',
supportedOutputQualities: ['1k', '2k'],
},
+ {
+ id: 'grok-imagine-image-2.0',
+ name: 'Grok Imagine Image 2.0',
+ effectId: 23,
+ workspaceType: 'ai-image',
+ uploadPath: 'effects/grok-imagine-image-2-0',
+ imageBucketName: 'image',
+ routeSlug: 'grok-imagine-image-2-0',
+ routeAliases: ['grok-imagine-image-2.0'],
+ routeOrder: 50,
+ defaultAspectRatio: '1:1',
+ supportedAspectRatios: ['1:1', '2:3', '3:2', '16:9', '9:16'],
+ referenceOnlyAspectRatios: ['auto'],
+ },
{
id: 'seedance-2',
name: 'Seedance 2',
@@ -354,6 +369,23 @@ export const WORKSPACE_EFFECT_REGISTRY: readonly WorkspaceEffectRegistryEntry[]
supportedOutputQualities: ['std', 'pro', '4k'],
modelAliases: ['kling30'],
},
+ {
+ id: 'grok-imagine-video-1.5',
+ name: 'Grok Imagine Video 1.5',
+ effectId: 24,
+ workspaceType: 'ai-video',
+ uploadPath: 'effects/grok-imagine-video-1-5',
+ imageBucketName: 'image',
+ routeSlug: 'grok-imagine-video-1-5',
+ routeAliases: ['grok-imagine-video-1.5'],
+ routeOrder: 55,
+ defaultDuration: '8s',
+ supportedDurations: durationRange(1, 15),
+ defaultAspectRatio: '16:9',
+ supportedAspectRatios: ['1:1', '16:9', '9:16', '3:2', '2:3', 'auto'],
+ defaultOutputQuality: '720p',
+ supportedOutputQualities: ['480p', '720p', '1080p'],
+ },
{
id: 'kling-2.6-motion-control',
name: 'Kling 2.6 Motion Control',
diff --git a/src/core/effects/validation.test.ts b/src/core/effects/validation.test.ts
index 37efee3..0775364 100644
--- a/src/core/effects/validation.test.ts
+++ b/src/core/effects/validation.test.ts
@@ -19,4 +19,8 @@ test('prompt constraints follow the public BeatAPI model contract', () => {
getGenerationPromptMaxChars({ modelId: 'video-analysis' }),
12000
);
+ assert.equal(
+ getGenerationPromptMaxChars({ modelId: 'grok-imagine-video-1.5' }),
+ 4096
+ );
});
diff --git a/src/core/effects/validation.ts b/src/core/effects/validation.ts
index 6b9a8d9..36dbe96 100644
--- a/src/core/effects/validation.ts
+++ b/src/core/effects/validation.ts
@@ -9,6 +9,7 @@ export const MAX_GENERATION_PROMPT_CHARS = 5000;
export const MOTION_CONTROL_GENERATION_PROMPT_CHARS = 2500;
export const VIDEO_ANALYSIS_GENERATION_PROMPT_CHARS = 12000;
const VIDEO_ANALYSIS_MODEL_ID = 'video-analysis';
+const GROK_IMAGINE_VIDEO_MODEL_ID = 'grok-imagine-video-1.5';
const MOTION_CONTROL_MODEL_IDS = new Set([
'kling-2.6-motion-control',
'kling-3-motion-control',
@@ -204,6 +205,12 @@ export const getGenerationPromptConstraints = ({
maxChars: MOTION_CONTROL_GENERATION_PROMPT_CHARS,
} as const;
}
+ if (modelId === GROK_IMAGINE_VIDEO_MODEL_ID) {
+ return {
+ required: true,
+ maxChars: 4096,
+ } as const;
+ }
return {
required: true,
maxChars: MAX_GENERATION_PROMPT_CHARS,
diff --git a/src/core/effects/workspace-media.ts b/src/core/effects/workspace-media.ts
index d7e2980..5e4a16c 100644
--- a/src/core/effects/workspace-media.ts
+++ b/src/core/effects/workspace-media.ts
@@ -122,6 +122,14 @@ export const WORKSPACE_MEDIA_SCHEMAS = {
video: emptySection,
audio: emptySection,
},
+ 'grok-imagine-image-2.0': {
+ image: createGenericSection(
+ 'reference-image',
+ getBeatApiImageReferenceLimit('grok-imagine-image-2.0')
+ ),
+ video: emptySection,
+ audio: emptySection,
+ },
'seedance-2': {
image: createVideoContractSection('seedance-2', 'reference-image'),
video: createVideoContractSection('seedance-2', 'reference-video'),
@@ -152,6 +160,14 @@ export const WORKSPACE_MEDIA_SCHEMAS = {
video: emptySection,
audio: emptySection,
},
+ 'grok-imagine-video-1.5': {
+ image: createVideoContractSection(
+ 'grok-imagine-video-1.5',
+ 'reference-image'
+ ),
+ video: emptySection,
+ audio: emptySection,
+ },
'kling-3': {
image: firstLastFrameSection,
video: emptySection,
diff --git a/src/core/effects/workspace-models.test.ts b/src/core/effects/workspace-models.test.ts
index 7860c56..4f54051 100644
--- a/src/core/effects/workspace-models.test.ts
+++ b/src/core/effects/workspace-models.test.ts
@@ -5,6 +5,7 @@ import {
findWorkspaceModelOption,
getCanonicalWorkspaceModelId,
getDefaultSelectableWorkspaceModel,
+ getWorkspaceAspectRatioOptions,
getWorkspaceModelsByType,
} from './workspace-models';
@@ -17,6 +18,7 @@ test('workspace exposes the official BeatAPI image and video catalog', () => {
'nano-banana',
'gpt-image-2',
'seedream-5-pro',
+ 'grok-imagine-image-2.0',
]);
assert.deepEqual(videoIds, [
'seedance-2',
@@ -26,6 +28,7 @@ test('workspace exposes the official BeatAPI image and video catalog', () => {
'minimax-h3',
'veo-3.1',
'kling-3',
+ 'grok-imagine-video-1.5',
'kling-2.6-motion-control',
'kling-3-motion-control',
]);
@@ -115,3 +118,41 @@ test('Composer metadata matches BeatAPI H3 and Seedance 2.5 contracts', () => {
assert.equal(seedance25?.maxSourceVideos, 10);
assert.equal(seedance25?.maxReferenceAudios, 10);
});
+
+test('Grok Composer metadata matches the public BeatAPI contracts', () => {
+ const image = findWorkspaceModelOption(
+ getWorkspaceModelsByType('ai-image'),
+ 'grok-imagine-image-2.0'
+ );
+ const video = findWorkspaceModelOption(
+ getWorkspaceModelsByType('ai-video'),
+ 'grok-imagine-video-1.5'
+ );
+
+ assert.equal(image?.effectId, 23);
+ assert.equal(image?.maxReferenceImages, 5);
+ assert.deepEqual(
+ getWorkspaceAspectRatioOptions({
+ model: image,
+ hasImageReferences: false,
+ }),
+ ['1:1', '2:3', '3:2', '16:9', '9:16']
+ );
+ assert.deepEqual(
+ getWorkspaceAspectRatioOptions({
+ model: image,
+ hasImageReferences: true,
+ }),
+ ['1:1', '2:3', '3:2', '16:9', '9:16', 'auto']
+ );
+ assert.equal(video?.effectId, 24);
+ assert.equal(video?.defaultDuration, '8s');
+ assert.equal(video?.supportedDurations?.length, 15);
+ assert.deepEqual(video?.supportedOutputQualities, [
+ '480p',
+ '720p',
+ '1080p',
+ ]);
+ assert.equal(video?.maxReferenceImages, 7);
+ assert.equal(video?.maxSourceVideos, 0);
+});
diff --git a/src/core/effects/workspace-models.ts b/src/core/effects/workspace-models.ts
index 8937790..78fe44b 100644
--- a/src/core/effects/workspace-models.ts
+++ b/src/core/effects/workspace-models.ts
@@ -50,6 +50,7 @@ export type WorkspaceModelOption = {
supportedDurations?: WorkspaceDuration[];
defaultAspectRatio?: WorkspaceAspectRatio;
supportedAspectRatios?: WorkspaceAspectRatio[];
+ referenceOnlyAspectRatios?: WorkspaceAspectRatio[];
defaultOutputQuality?: WorkspaceOutputQuality;
supportedOutputQualities?: WorkspaceOutputQuality[];
defaultQuality?: WorkspaceQualityOption;
@@ -112,6 +113,9 @@ const toWorkspaceModelOption = (
supportedAspectRatios: entry.supportedAspectRatios
? [...entry.supportedAspectRatios]
: undefined,
+ referenceOnlyAspectRatios: entry.referenceOnlyAspectRatios
+ ? [...entry.referenceOnlyAspectRatios]
+ : undefined,
defaultOutputQuality: entry.defaultOutputQuality,
supportedOutputQualities: entry.supportedOutputQualities
? [...entry.supportedOutputQualities]
@@ -193,6 +197,17 @@ export const getWorkspaceModelsByType = (
return workspaceType === 'ai-image' ? IMAGE_MODELS : VIDEO_MODELS;
};
+export const getWorkspaceAspectRatioOptions = ({
+ model,
+ hasImageReferences,
+}: {
+ model: WorkspaceModelOption | null;
+ hasImageReferences: boolean;
+}): WorkspaceAspectRatio[] => [
+ ...(model?.supportedAspectRatios ?? []),
+ ...(hasImageReferences ? (model?.referenceOnlyAspectRatios ?? []) : []),
+];
+
export const getCanonicalWorkspaceModelId = (modelId: string): string =>
WORKSPACE_MODEL_ALIASES[modelId] ?? modelId;
diff --git a/src/core/workspace-lib/model-icons.test.ts b/src/core/workspace-lib/model-icons.test.ts
index 0b45782..f9d37ac 100644
--- a/src/core/workspace-lib/model-icons.test.ts
+++ b/src/core/workspace-lib/model-icons.test.ts
@@ -15,3 +15,12 @@ test('all Kling models reuse the verified Kling SVG', () => {
);
}
});
+
+test('Grok Imagine image and video models reuse the Grok SVG', () => {
+ for (const modelId of [
+ 'grok-imagine-image-2.0',
+ 'grok-imagine-video-1.5',
+ ]) {
+ assert.equal(getModelIconPathByModelId(modelId), '/model-icons/grok.svg');
+ }
+});
diff --git a/src/core/workspace-lib/model-icons.ts b/src/core/workspace-lib/model-icons.ts
index cf4d5c0..fd1f273 100644
--- a/src/core/workspace-lib/model-icons.ts
+++ b/src/core/workspace-lib/model-icons.ts
@@ -3,6 +3,7 @@ import { getCanonicalWorkspaceModelId } from '@/core/effects/workspace-models';
const MODEL_ICON_PATHS = {
bytedance: '/model-icons/bytedance-color.svg',
google: '/model-icons/google-color.svg',
+ grok: '/model-icons/grok.svg',
kling: '/model-icons/kling-color.svg',
minimax: '/model-icons/minimax-color.svg',
nanobanana: '/model-icons/nanobanana-color.svg',
@@ -11,6 +12,8 @@ const MODEL_ICON_PATHS = {
const MODEL_ICON_PATH_BY_MODEL_ID: Record = {
'gpt-image-2': MODEL_ICON_PATHS.openai,
+ 'grok-imagine-image-2.0': MODEL_ICON_PATHS.grok,
+ 'grok-imagine-video-1.5': MODEL_ICON_PATHS.grok,
'kling-3': MODEL_ICON_PATHS.kling,
'kling-2.6-motion-control': MODEL_ICON_PATHS.kling,
'kling-3-motion-control': MODEL_ICON_PATHS.kling,
|