Skip to content

Commit 3d09c62

Browse files
tombeckenhamclaude
andcommitted
feat(ai-grok): add duration range options to grok video adapter
Replace the throwing `validateVideoDuration` with the standard duration-options mechanism. Both grok-imagine video models declare a continuous 1–15 integer- second range via a `GROK_VIDEO_DURATIONS` table, and the adapter overrides `availableDurations()` / `snapDuration()` (backed by the shared `snapToDurationOption` helper) so consumers can discover and pre-snap durations. `createVideoJob` now snaps the requested duration into range (clamp + round) instead of rejecting it, and the snapped value is spread after `...modelOptions` so it is authoritative. Adds the per-model `GrokVideoModelDurationByName` generic, narrows the `createVideoJob` signature to carry the size/duration type params, exports the new helpers/type, and documents the range in the media docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2b46fc8 commit 3d09c62

5 files changed

Lines changed: 171 additions & 50 deletions

File tree

docs/media/video-generation.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,16 @@ const { jobId } = await generateVideo({
597597
})
598598
```
599599

600+
Both models accept any whole second in the **1–15** range. A raw `duration` is coerced into that range rather than rejected — values are clamped to `[1, 15]` and rounded to the nearest second. Inspect or pre-snap the range the same way as Veo:
601+
602+
```typescript
603+
const adapter = grokVideo('grok-imagine-video')
604+
605+
adapter.availableDurations() // { kind: 'range', min: 1, max: 15, step: 1, unit: 'seconds' }
606+
adapter.snapDuration(2.5) // 3 — clamped/rounded into range
607+
adapter.snapDuration(99) // 15
608+
```
609+
600610
Generated clips include an audio track. When the job completes, the adapter reports `usage.unitsBilled` (billed seconds of video) and `usage.cost` (exact USD cost as returned by the API) on the result.
601611

602612
## Response Types

packages/ai-grok/src/adapters/video.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { resolveMediaPrompt } from '@tanstack/ai'
2-
import { BaseVideoAdapter } from '@tanstack/ai/adapters'
2+
import { BaseVideoAdapter, snapToDurationOption } from '@tanstack/ai/adapters'
33
import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
44
import { getGrokApiKeyFromEnv, withGrokDefaults } from '../utils/client'
55
import {
6+
getGrokVideoDurationOptions,
67
isImageToVideoOnlyModel,
78
parseGrokVideoSize,
8-
validateVideoDuration,
99
validateVideoSize,
1010
} from '../video/video-provider-options'
11+
import type { DurationOptions } from '@tanstack/ai/adapters'
1112
import type {
1213
ImagePart,
1314
MediaInputMetadata,
@@ -19,6 +20,7 @@ import type {
1920
} from '@tanstack/ai'
2021
import type { GrokVideoModel } from '../model-meta'
2122
import type {
23+
GrokVideoModelDurationByName,
2224
GrokVideoModelInputModalitiesByName,
2325
GrokVideoModelProviderOptionsByName,
2426
GrokVideoModelSizeByName,
@@ -116,7 +118,8 @@ export class GrokVideoAdapter<
116118
GrokVideoProviderOptions,
117119
GrokVideoModelProviderOptionsByName,
118120
GrokVideoModelSizeByName,
119-
GrokVideoModelInputModalitiesByName
121+
GrokVideoModelInputModalitiesByName,
122+
GrokVideoModelDurationByName
120123
> {
121124
readonly name = 'grok' as const
122125

@@ -170,14 +173,23 @@ export class GrokVideoAdapter<
170173
}
171174

172175
async createVideoJob(
173-
options: VideoGenerationOptions<GrokVideoProviderOptions>,
176+
options: VideoGenerationOptions<
177+
GrokVideoProviderOptions,
178+
GrokVideoModelSizeByName[TModel],
179+
GrokVideoModelDurationByName[TModel]
180+
>,
174181
): Promise<VideoJobResult> {
175182
const { model, size, modelOptions, logger } = options
176183

177184
validateVideoSize(model, size)
178-
validateVideoDuration(model, options.duration)
179-
validateVideoDuration(model, modelOptions?.duration)
180-
const duration = options.duration ?? modelOptions?.duration
185+
186+
// Coerce the requested duration into the model's valid range (1–15s,
187+
// integer) instead of rejecting it — `snapDuration` clamps and rounds.
188+
// modelOptions wins over the generic `duration`, mirroring the size
189+
// precedence below.
190+
const rawDuration = modelOptions?.duration ?? options.duration
191+
const duration =
192+
rawDuration !== undefined ? this.snapDuration(rawDuration) : undefined
181193

182194
// The interleaved prompt decomposes into verbatim text plus typed media
183195
// buckets. The Imagine video endpoint takes a text prompt and an optional
@@ -227,8 +239,10 @@ export class GrokVideoAdapter<
227239
resolution: parsedSize.resolution,
228240
}),
229241
}),
230-
...(duration !== undefined && { duration }),
231242
...modelOptions,
243+
// Spread after modelOptions so the snapped duration is authoritative
244+
// (modelOptions.duration is folded into `duration` via snapDuration above).
245+
...(duration !== undefined && { duration }),
232246
}
233247

234248
try {
@@ -352,6 +366,26 @@ export class GrokVideoAdapter<
352366
return 'processing'
353367
}
354368
}
369+
370+
/**
371+
* Both grok-imagine video models accept a continuous 1–15 integer-second
372+
* range. Consumers can use this to render UI without provider knowledge.
373+
*/
374+
override availableDurations(): DurationOptions<
375+
GrokVideoModelDurationByName[TModel]
376+
> {
377+
return getGrokVideoDurationOptions(this.model)
378+
}
379+
380+
/**
381+
* Coerce a raw seconds value to the closest valid duration (clamped to
382+
* [1, 15] and rounded to whole seconds).
383+
*/
384+
override snapDuration(
385+
seconds: number,
386+
): GrokVideoModelDurationByName[TModel] | undefined {
387+
return snapToDurationOption(seconds, this.availableDurations())
388+
}
355389
}
356390

357391
/**

packages/ai-grok/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,15 @@ export {
3838
grokVideo,
3939
type GrokVideoConfig,
4040
} from './adapters/video'
41+
export {
42+
GROK_VIDEO_DURATIONS,
43+
getGrokVideoDurationOptions,
44+
} from './video/video-provider-options'
4145
export type {
4246
GrokVideoProviderOptions,
4347
GrokVideoModelProviderOptionsByName,
4448
GrokVideoModelSizeByName,
49+
GrokVideoModelDurationByName,
4550
GrokVideoAspectRatio,
4651
GrokVideoResolution,
4752
GrokVideoSize,

packages/ai-grok/src/video/video-provider-options.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
* @experimental Video generation is an experimental feature and may change.
77
*/
88

9+
import type { DurationOptions } from '@tanstack/ai/adapters'
10+
import type { GrokVideoModel } from '../model-meta'
11+
912
/**
1013
* Aspect ratios accepted by the grok-imagine video models.
1114
*
@@ -107,23 +110,55 @@ export function validateVideoSize(
107110
}
108111

109112
/**
110-
* Validate video duration (seconds) for a given grok video model.
111-
* The Imagine API accepts integer durations between 1 and 15 seconds.
113+
* Per-model duration type. The Imagine API accepts any integer second in the
114+
* 1–15 range, so this is a continuous range expressed as `number` (a literal
115+
* union can't represent it). `snapDuration()` coerces a raw seconds value into
116+
* the valid range at runtime.
112117
*
113118
* @experimental Video generation is an experimental feature and may change.
114119
*/
115-
export function validateVideoDuration(model: string, duration?: number): void {
116-
if (duration === undefined) return
117-
if (
118-
!Number.isInteger(duration) ||
119-
duration < GROK_VIDEO_MIN_DURATION ||
120-
duration > GROK_VIDEO_MAX_DURATION
121-
) {
122-
throw new Error(
123-
`Duration "${duration}" is not supported by model "${model}". ` +
124-
`Supported durations: integer seconds between ${GROK_VIDEO_MIN_DURATION} and ${GROK_VIDEO_MAX_DURATION}`,
125-
)
126-
}
120+
export type GrokVideoModelDurationByName = {
121+
'grok-imagine-video': number
122+
'grok-imagine-video-1.5': number
123+
}
124+
125+
/**
126+
* Runtime duration table backing `availableDurations()` / `snapDuration()`.
127+
* Both grok-imagine video models accept the same continuous 1–15 integer-second
128+
* range.
129+
*
130+
* @experimental Video generation is an experimental feature and may change.
131+
*/
132+
export const GROK_VIDEO_DURATIONS: {
133+
readonly [TModel in GrokVideoModel]: DurationOptions<
134+
GrokVideoModelDurationByName[TModel]
135+
>
136+
} = {
137+
'grok-imagine-video': {
138+
kind: 'range',
139+
min: GROK_VIDEO_MIN_DURATION,
140+
max: GROK_VIDEO_MAX_DURATION,
141+
step: 1,
142+
unit: 'seconds',
143+
},
144+
'grok-imagine-video-1.5': {
145+
kind: 'range',
146+
min: GROK_VIDEO_MIN_DURATION,
147+
max: GROK_VIDEO_MAX_DURATION,
148+
step: 1,
149+
unit: 'seconds',
150+
},
151+
}
152+
153+
/**
154+
* Look up the duration options for a grok video model.
155+
*
156+
* @experimental Video generation is an experimental feature and may change.
157+
*/
158+
export function getGrokVideoDurationOptions<TModel extends GrokVideoModel>(
159+
model: TModel,
160+
): DurationOptions<GrokVideoModelDurationByName[TModel]> {
161+
return GROK_VIDEO_DURATIONS[model]
127162
}
128163

129164
/**

packages/ai-grok/tests/video-adapter.test.ts

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import {
66
grokVideo,
77
} from '../src/adapters/video'
88
import {
9+
getGrokVideoDurationOptions,
910
parseGrokVideoSize,
10-
validateVideoDuration,
1111
validateVideoSize,
1212
} from '../src/video/video-provider-options'
1313

@@ -317,6 +317,7 @@ describe('Grok Video Adapter', () => {
317317
adapter.createVideoJob({
318318
model: 'grok-imagine-video-1.5',
319319
prompt: 'p',
320+
// @ts-expect-error invalid size is also rejected at compile time
320321
size: '7:5',
321322
logger: testLogger,
322323
}),
@@ -325,36 +326,49 @@ describe('Grok Video Adapter', () => {
325326
adapter.createVideoJob({
326327
model: 'grok-imagine-video-1.5',
327328
prompt: 'p',
329+
// @ts-expect-error invalid resolution is also rejected at compile time
328330
size: '16:9_9k',
329331
logger: testLogger,
330332
}),
331333
).rejects.toThrow(/Resolution "9k" is not supported/)
332334
expect(fetchMock).not.toHaveBeenCalled()
333335
})
334336

335-
it('rejects out-of-range and non-integer durations before calling the API', async () => {
337+
it('snaps out-of-range and non-integer durations into the valid range', async () => {
338+
// [requested, snapped]: clamp to [1, 15], round to whole seconds.
339+
const cases: Array<[number, number]> = [
340+
[0, 1],
341+
[16, 15],
342+
[2.5, 3],
343+
[7, 7],
344+
]
345+
for (const [requested, snapped] of cases) {
346+
const fetchMock = mockFetch(() => jsonResponse({ request_id: 'r' }))
347+
const adapter = adapterWithFetch(fetchMock)
348+
await adapter.createVideoJob({
349+
model: 'grok-imagine-video-1.5',
350+
prompt: i2vPrompt(),
351+
duration: requested,
352+
logger: testLogger,
353+
})
354+
const body = JSON.parse(String(fetchMock.mock.calls[0]![1]?.body))
355+
expect(body.duration).toBe(snapped)
356+
}
357+
})
358+
359+
it('snaps a duration supplied via modelOptions', async () => {
336360
const fetchMock = mockFetch(() => jsonResponse({ request_id: 'r' }))
337361
const adapter = adapterWithFetch(fetchMock)
338362

339-
for (const duration of [0, 16, 2.5]) {
340-
await expect(
341-
adapter.createVideoJob({
342-
model: 'grok-imagine-video-1.5',
343-
prompt: 'p',
344-
duration,
345-
logger: testLogger,
346-
}),
347-
).rejects.toThrow(/Duration .* is not supported/)
348-
}
349-
await expect(
350-
adapter.createVideoJob({
351-
model: 'grok-imagine-video-1.5',
352-
prompt: 'p',
353-
modelOptions: { duration: 99 },
354-
logger: testLogger,
355-
}),
356-
).rejects.toThrow(/Duration "99" is not supported/)
357-
expect(fetchMock).not.toHaveBeenCalled()
363+
await adapter.createVideoJob({
364+
model: 'grok-imagine-video-1.5',
365+
prompt: i2vPrompt(),
366+
modelOptions: { duration: 99 },
367+
logger: testLogger,
368+
})
369+
370+
const body = JSON.parse(String(fetchMock.mock.calls[0]![1]?.body))
371+
expect(body.duration).toBe(15)
358372
})
359373

360374
it('surfaces API error messages from the xAI error body', async () => {
@@ -595,13 +609,36 @@ describe('Grok Video Adapter', () => {
595609
expect(() => validateVideoSize('m', '16:9_2k')).toThrow(/Resolution/)
596610
})
597611

598-
it('validates durations', () => {
599-
expect(() => validateVideoDuration('m', undefined)).not.toThrow()
600-
expect(() => validateVideoDuration('m', 1)).not.toThrow()
601-
expect(() => validateVideoDuration('m', 15)).not.toThrow()
602-
expect(() => validateVideoDuration('m', 0)).toThrow(/Duration/)
603-
expect(() => validateVideoDuration('m', 16)).toThrow(/Duration/)
604-
expect(() => validateVideoDuration('m', 1.5)).toThrow(/Duration/)
612+
it('exposes the 1–15s duration range via getGrokVideoDurationOptions', () => {
613+
expect(getGrokVideoDurationOptions('grok-imagine-video')).toEqual({
614+
kind: 'range',
615+
min: 1,
616+
max: 15,
617+
step: 1,
618+
unit: 'seconds',
619+
})
620+
expect(getGrokVideoDurationOptions('grok-imagine-video-1.5')).toEqual({
621+
kind: 'range',
622+
min: 1,
623+
max: 15,
624+
step: 1,
625+
unit: 'seconds',
626+
})
627+
})
628+
629+
it('availableDurations / snapDuration coerce raw seconds into range', () => {
630+
const adapter = createGrokVideo('grok-imagine-video', 'test-api-key')
631+
expect(adapter.availableDurations()).toEqual({
632+
kind: 'range',
633+
min: 1,
634+
max: 15,
635+
step: 1,
636+
unit: 'seconds',
637+
})
638+
expect(adapter.snapDuration(0)).toBe(1)
639+
expect(adapter.snapDuration(16)).toBe(15)
640+
expect(adapter.snapDuration(2.5)).toBe(3)
641+
expect(adapter.snapDuration(7)).toBe(7)
605642
})
606643
})
607644
})

0 commit comments

Comments
 (0)