Skip to content

Commit 38b8fe9

Browse files
tombeckenhamclaude
andcommitted
feat(examples): OpenRouter video in ts-react-media + fix generateVideo typed-duration constraint
ts-react-media now demos openRouterVideo alongside fal: - add @tanstack/ai-openrouter dep - Seedance 2.0 (text-to-video) and Veo 3.1 (image-to-video) model entries - switch cases showcasing adapter.snapDuration() to coerce raw UI seconds to the model's nearest supported duration (Seedance 7→7, Veo 3.1 7→6) - videoAdapterForModel() resolver routes the poll/status path to the right adapter (the helpers were hardcoded to falVideo) Building the example surfaced a pre-existing bug in the #624 typed-duration contract: generateVideo()'s `TAdapter extends VideoAdapter<string, any, any, any>` bound let the sixth (duration) generic fall back to its `Record<string, number>` default. Because VideoAdapter.createVideoJob is a contravariant function-valued property, no adapter whose `duration` is a per-model literal union (Veo `4|6|8`, Seedance `4..15`) satisfied the bound — so even the documented `generateVideo({ adapter: geminiVideo('veo-3.1-...') })` failed to type-check. Widen the bound to leave size/duration unpinned at all 10 activity sites; per-model types are still recovered via inference (VideoSizeForAdapter / VideoDurationForAdapter). Adds a compile-only regression test and an @tanstack/ai changeset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b9aa0ec commit 38b8fe9

5 files changed

Lines changed: 94 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai': patch
3+
---
4+
5+
Fix `generateVideo()` (and the other `generateVideo` activity entry points) rejecting video adapters that declare per-model typed durations. The activity's `TAdapter extends VideoAdapter<string, any, any, any>` bound let the sixth `TModelDurationByName` generic fall back to its `Record<string, number>` default; because `createVideoJob` is a contravariant function-valued property, a concrete adapter whose `duration` is narrowed to a literal union (e.g. Veo's `4 | 6 | 8`, OpenRouter Seedance's `4..15`) failed the bound, so the documented `generateVideo({ adapter: geminiVideo('veo-3.1-generate-preview'), ... })` pattern did not type-check. The constraint now leaves the size and duration generics unpinned (`VideoAdapter<string, any, any, any, any, any>`); the real per-model types are still recovered by inference (`VideoSizeForAdapter` / `VideoDurationForAdapter`).

examples/ts-react-media/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@tanstack/ai-fal": "workspace:*",
1717
"@tanstack/ai-gemini": "workspace:*",
1818
"@tanstack/ai-grok": "workspace:*",
19+
"@tanstack/ai-openrouter": "workspace:*",
1920
"@tanstack/ai-react": "workspace:*",
2021
"@tanstack/react-router": "^1.158.4",
2122
"@tanstack/react-start": "^1.159.0",

examples/ts-react-media/src/lib/models.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,20 @@ export const VIDEO_MODELS = [
195195
mode: 'text-to-video' as const,
196196
provider: 'byteplus' as const,
197197
},
198+
{
199+
id: 'bytedance/seedance-2.0',
200+
name: 'Seedance 2.0 (Text-to-Video, OpenRouter)',
201+
description:
202+
"OpenRouter's async video API; duration typed 4–15s with snapDuration()",
203+
mode: 'text-to-video' as const,
204+
},
205+
{
206+
id: 'google/veo-3.1',
207+
name: 'Veo 3.1 (Image-to-Video, OpenRouter)',
208+
description:
209+
'OpenRouter async video; duration snaps to the nearest of 4/6/8s',
210+
mode: 'image-to-video' as const,
211+
},
198212
] as const
199213

200214
export type ImageModel = (typeof IMAGE_MODELS)[number]

examples/ts-react-media/src/lib/server-functions.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createServerFn } from '@tanstack/react-start'
22
import { falImage, falVideo } from '@tanstack/ai-fal'
33
import { geminiImage, geminiVideo } from '@tanstack/ai-gemini'
44
import { grokImage, grokVideo } from '@tanstack/ai-grok'
5+
import { openRouterVideo } from '@tanstack/ai-openrouter'
56
import {
67
BYTEPLUS_VIDEO_MODELS,
78
byteplusImage,
@@ -461,6 +462,34 @@ function videoStreamForModel(data: VideoRequest): AsyncIterable<StreamChunk> {
461462
: {}),
462463
})
463464
}
465+
// OpenRouter's dedicated async video API (`POST /api/v1/videos`). Unlike
466+
// fal (which takes duration in `modelOptions`), OpenRouter types the
467+
// top-level `duration` per model from its published metadata, and the
468+
// adapter exposes `snapDuration()` to coerce a raw UI seconds value to
469+
// the model's nearest supported duration.
470+
case 'bytedance/seedance-2.0': {
471+
const adapter = openRouterVideo('bytedance/seedance-2.0')
472+
return generateVideo({
473+
stream: true,
474+
pollingInterval: VIDEO_POLL_INTERVAL_MS,
475+
adapter,
476+
prompt: asTextPrompt(data.prompt),
477+
size: '1280x720',
478+
duration: adapter.snapDuration(7),
479+
modelOptions: { aspectRatio: '16:9' },
480+
})
481+
}
482+
case 'google/veo-3.1': {
483+
const adapter = openRouterVideo('google/veo-3.1')
484+
return generateVideo({
485+
stream: true,
486+
pollingInterval: VIDEO_POLL_INTERVAL_MS,
487+
adapter,
488+
prompt: asImageToVideoPrompt(data.prompt),
489+
size: '1280x720',
490+
duration: adapter.snapDuration(7),
491+
})
492+
}
464493
default:
465494
throw new Error(`Unknown video model: ${data.model}`)
466495
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Per-model type-safety for OpenRouter video durations.
3+
*
4+
* Asserts the typed-duration contract flows through `generateVideo()`: a
5+
* concrete per-model adapter satisfies the activity's `TAdapter extends
6+
* VideoAdapter<...>` bound, `duration` is narrowed to the model's union, and
7+
* out-of-range literals are rejected.
8+
*
9+
* Regression guard: `createVideoJob` is a contravariant function-valued
10+
* property, so if the activity constraints pin the duration generic to its
11+
* default (`Record<string, number>`) instead of `any`, no per-model adapter
12+
* can be passed to `generateVideo` at all. The checks below are compile-only —
13+
* the inner closures are typechecked but never invoked (no network).
14+
*/
15+
import { describe, it } from 'vitest'
16+
import { generateVideo } from '@tanstack/ai'
17+
import { openRouterVideo } from '../src'
18+
19+
describe('OpenRouter video duration type-safety', () => {
20+
it('generateVideo accepts a concrete adapter + snapDuration()', () => {
21+
const check = () => {
22+
const adapter = openRouterVideo('bytedance/seedance-2.0') // 4..15
23+
return generateVideo({
24+
adapter,
25+
prompt: 'A timelapse of clouds',
26+
size: '1280x720',
27+
duration: adapter.snapDuration(7),
28+
})
29+
}
30+
void check
31+
})
32+
33+
it('narrows duration to the model union (rejects out-of-range)', () => {
34+
const check = () => {
35+
const adapter = openRouterVideo('google/veo-3.1') // 4 | 6 | 8
36+
return generateVideo({
37+
adapter,
38+
prompt: 'A close-up of a luthier carving a guitar neck',
39+
// @ts-expect-error 7 is not a valid veo-3.1 duration (4 | 6 | 8)
40+
duration: 7,
41+
})
42+
}
43+
void check
44+
})
45+
})

0 commit comments

Comments
 (0)