From 8d1cb7ab67b62ec68b5ab60c99e3868626f05bb9 Mon Sep 17 00:00:00 2001 From: KKKK Date: Wed, 26 Aug 2026 16:48:38 +0800 Subject: [PATCH] feat: add Grok Imagine models and 1080p --- .../beatcanvas/beatcanvas-front-layer.tsx | 30 ++- src/components/pricing/pricing-catalog.json | 16 ++ .../pricing/pricing-comparison-data.test.ts | 37 +++- .../pricing/pricing-comparison-data.ts | 4 + .../pricing/pricing-comparison-table.tsx | 31 +++- src/components/studio/studio-composer.tsx | 33 +++- src/core/adapters/beatapi-adapter.test.ts | 172 ++++++++++++++++++ src/core/adapters/beatapi-adapter.ts | 29 ++- src/core/effects/beatapi-model-contract.ts | 7 + src/core/effects/effect-registry.ts | 32 ++++ src/core/effects/validation.test.ts | 4 + src/core/effects/validation.ts | 7 + src/core/effects/workspace-media.ts | 16 ++ src/core/effects/workspace-models.test.ts | 41 +++++ src/core/effects/workspace-models.ts | 15 ++ src/core/workspace-lib/model-icons.test.ts | 9 + src/core/workspace-lib/model-icons.ts | 3 + 17 files changed, 473 insertions(+), 13 deletions(-) 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,