Skip to content

Commit 3fdee4e

Browse files
jherrcursoragent
andcommitted
feat: add previousJobId and previousImage for follow-up media edits
Unify video follow-up edits behind previousJobId (adapters resolve job vs media), and add previousImage sugar for image edits, with docs and e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d2dd0c4 commit 3fdee4e

53 files changed

Lines changed: 2402 additions & 154 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/media-edit-from.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@tanstack/ai': minor
3+
'@tanstack/ai-openai': minor
4+
'@tanstack/ai-gemini': minor
5+
'@tanstack/ai-grok': minor
6+
'@tanstack/ai-fal': minor
7+
'@tanstack/ai-client': minor
8+
---
9+
10+
feat: first-class follow-up edits for generated media.
11+
12+
`generateVideo({ ..., previousJobId })` edits a previously generated video instead of generating from scratch. Callers always pass the prior generation's job id; adapters decide how to consume it via a `VideoAdapter` edit-kind map:
13+
14+
- `'job'` — reference the id server-side (OpenAI Sora 2 / Sora 2 Pro remix; Gemini Omni Flash, which maps `previousJobId` onto the Interactions API's `previous_interaction_id` wire field — that field is omitted from Omni `modelOptions`)
15+
- `'media'` — resolve the finished clip via `getVideoUrl(previousJobId)` (xAI `grok-imagine-video``/videos/edits`; fal video-to-video endpoints such as `xai/grok-imagine-video/edit-video` and Seedance 2.0 reference-to-video). Fal generate endpoints with a known edit sibling (e.g. Grok text/image-to-video) resolve on the generate model, then submit to the edit endpoint.
16+
17+
Non-editing models (Veo, `grok-imagine-video-1.5`) reject `previousJobId` at compile time. Sora remix and Grok edits accept only a prompt — `size` / `duration` / media inputs are rejected because the output inherits them from the source video.
18+
19+
`generateImage({ ..., previousImage })` is the image-side counterpart: pass a prior result's `GeneratedImage` (or an array, or the whole result) and it is prepended to the prompt as an image part, flowing through each adapter's existing edit path; type-gated to models that accept image inputs.
20+
21+
Breaking for hand-rolled (non-`BaseVideoAdapter`) `VideoAdapter` implementations: the interface gains `supportedEditKind(): 'job' | 'media' | undefined` (and a 7th, defaulted `TModelEditByName` generic — existing 6-argument instantiations keep compiling). `BaseVideoAdapter` supplies a default returning `undefined`, plus `resolvePreviousJobUrl(previousJobId)`. New exports include `VideoEditKind`, `ModelEditKindByName`, `VideoPreviousJobIdForAdapter`, `ImagePreviousSource`, `ImagePreviousImageForModel`, `generatedImageToImagePart`, `generatedVideoUrlToVideoPart`. Client wire types: `VideoGenerateInput.previousJobId`, `ImageGenerateInput.previousImage`.

docs/config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,13 +288,13 @@
288288
"label": "Image Generation",
289289
"to": "media/image-generation",
290290
"addedAt": "2026-04-15",
291-
"updatedAt": "2026-07-07"
291+
"updatedAt": "2026-07-10"
292292
},
293293
{
294294
"label": "Video Generation",
295295
"to": "media/video-generation",
296296
"addedAt": "2026-04-15",
297-
"updatedAt": "2026-07-02"
297+
"updatedAt": "2026-07-10"
298298
},
299299
{
300300
"label": "Generation Hooks",

docs/media/image-generation.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ All image adapters support these common options:
7979
| `prompt` | `string \| MediaPromptPart[]` | Description of the image to generate (required). A plain string, or — on models that support image-conditioned generation — an ordered array of content parts interleaving text with image inputs. See [Image-Conditioned Generation](#image-conditioned-generation) below. |
8080
| `numberOfImages` | `number` | Number of images to generate |
8181
| `size` | `string` | Size of the generated image in WIDTHxHEIGHT format |
82+
| `previousImage?` | `GeneratedImage \| GeneratedImage[] \| { images }` | A previously generated image (or images) to edit — prepended to the prompt as image input(s). Only offered (at compile time) for models that accept image inputs — see [Editing generated images](#editing-generated-images-previousimage). |
8283
| `modelOptions?` | `object` | Model-specific options (renamed from `providerOptions`) |
8384

8485
### Size Options
@@ -183,6 +184,69 @@ The accepted part types are narrowed **per model at compile time**: passing
183184
an image part to a text-only model (e.g. `dall-e-3`, Imagen) is a type
184185
error, not just a runtime throw.
185186

187+
### Editing generated images (previousImage)
188+
189+
To run a **follow-up edit** on something you just generated, pass the prior
190+
result's image as `previousImage` — sugar that prepends it to the prompt as an
191+
image part, so it flows through the model's regular edit path (OpenAI
192+
`/images/edits`, Gemini `generateContent`, xAI `/images/edits`, fal).
193+
194+
**Server:**
195+
196+
```typescript
197+
import { generateImage } from '@tanstack/ai'
198+
import { openaiImage } from '@tanstack/ai-openai'
199+
200+
const adapter = openaiImage('gpt-image-2')
201+
202+
const first = await generateImage({ adapter, prompt: 'A city street at dusk' })
203+
204+
const edited = await generateImage({
205+
adapter,
206+
prompt: 'Same scene, but make it rain',
207+
previousImage: first.images[0],
208+
})
209+
```
210+
211+
`previousImage` accepts a single `GeneratedImage`, an array of them, or the
212+
whole prior result (`{ images }`). URL results pass through as `url`
213+
sources (`data:` URLs are decomposed into raw bytes for adapters that
214+
upload files); `b64Json` results become `data` sources with the mime type
215+
sniffed from the payload (defaulting to `image/png`). Like image parts, the
216+
option is offered **per model at compile time** — text-only models
217+
(`dall-e-3`, Imagen) reject it as a type error.
218+
219+
**Client** — the hook's `ImageGenerateInput.previousImage` is a wire-friendly
220+
`{ url? }` / `{ b64Json? }` shape; your server route should narrow it back
221+
to a `GeneratedImage` before calling `generateImage`:
222+
223+
```tsx
224+
import { useGenerateImage, fetchServerSentEvents } from '@tanstack/ai-react'
225+
226+
function ImageEditor() {
227+
const { generate, result, isLoading } = useGenerateImage({
228+
connection: fetchServerSentEvents('/api/generate/image'),
229+
})
230+
231+
const handleEdit = () => {
232+
const image = result?.images[0]
233+
if (!image) return
234+
void generate({
235+
prompt: 'Same scene, but make it rain',
236+
previousImage: image.url
237+
? { url: image.url }
238+
: { b64Json: image.b64Json },
239+
})
240+
}
241+
242+
return (
243+
<button onClick={handleEdit} disabled={isLoading || !result}>
244+
Edit
245+
</button>
246+
)
247+
}
248+
```
249+
186250
### Referencing images from your prompt
187251

188252
**Your prompt text is always sent verbatim — the SDK never injects or

docs/media/video-generation.md

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ And returns:
383383
| `prompt` | `string \| MediaPromptPart[]` | Description of the video to generate (required). A plain string, or — on models that support conditioned generation — an ordered array of content parts interleaving text with image / video / audio inputs. See [Image-to-Video](#image-to-video) below. |
384384
| `size` | `string` | Video resolution in WIDTHxHEIGHT format |
385385
| `duration` | `number` | Video duration in seconds (maps to `seconds` parameter in API) |
386+
| `previousJobId?` | `string` | Prior generation's job id to edit instead of generating from scratch. Only offered (at compile time) for models that support follow-up edits — see [Editing Generated Videos](#editing-generated-videos-previousjobid). |
386387
| `modelOptions?` | `object` | Model-specific options (renamed from `providerOptions`) |
387388

388389
## Image-to-Video
@@ -489,6 +490,91 @@ The API uses the `seconds` parameter. Allowed values:
489490
- `8` seconds (default)
490491
- `12` seconds
491492

493+
## Editing Generated Videos (previousJobId)
494+
495+
Models that support **follow-up runs** can edit a previously generated
496+
video instead of generating from scratch: pass the prior generation's
497+
job id as `previousJobId` and describe the change in the prompt. The
498+
canonical call is the same for every provider.
499+
500+
**Server:**
501+
502+
```typescript ignore
503+
import { generateVideo, getVideoJobStatus } from '@tanstack/ai'
504+
import { openaiVideo } from '@tanstack/ai-openai'
505+
506+
const adapter = openaiVideo('sora-2')
507+
508+
// Turn 1: generate
509+
const first = await generateVideo({ adapter, prompt: 'A city street at dusk' })
510+
// …poll first.jobId to completion…
511+
512+
// Turn 2: edit the result
513+
const edited = await generateVideo({
514+
adapter,
515+
prompt: 'Make it rain',
516+
previousJobId: first.jobId,
517+
})
518+
```
519+
520+
**Client** — pass the completed job's id through the hook; your server
521+
forwards it to `generateVideo`:
522+
523+
```tsx
524+
import { useGenerateVideo, fetchServerSentEvents } from '@tanstack/ai-react'
525+
526+
function VideoEditor() {
527+
const { generate, result, isLoading } = useGenerateVideo({
528+
connection: fetchServerSentEvents('/api/generate/video'),
529+
})
530+
531+
const handleEdit = () => {
532+
if (!result?.jobId) return
533+
void generate({
534+
prompt: 'Make it rain',
535+
previousJobId: result.jobId,
536+
})
537+
}
538+
539+
return (
540+
<button onClick={handleEdit} disabled={isLoading || !result}>
541+
Edit
542+
</button>
543+
)
544+
}
545+
```
546+
547+
Each model declares **how** it consumes that job id via
548+
`adapter.supportedEditKind()`:
549+
550+
| Kind | How the adapter uses `previousJobId` | Providers |
551+
| --- | --- | --- |
552+
| `'job'` | References the prior job server-side | OpenAI Sora 2 / Sora 2 Pro (remix), Gemini Omni Flash |
553+
| `'media'` | Resolves the finished clip via `getVideoUrl(previousJobId)` | xAI `grok-imagine-video` (`/videos/edits`), fal video-to-video endpoints (`xai/grok-imagine-video/edit-video`, `fal-ai/wan/v2.7/edit-video`, Seedance 2.0 reference-to-video) |
554+
555+
Models without follow-up support (Veo, `grok-imagine-video-1.5`, fal
556+
text/image-to-video endpoints without a known edit sibling) reject
557+
`previousJobId` at compile time.
558+
559+
Provider-specific constraints:
560+
561+
- **OpenAI Sora (remix)** accepts only a text prompt — the output inherits
562+
the source video's size and duration, so `size`, `duration`, and image
563+
parts are rejected when `previousJobId` is set.
564+
- **Grok `/videos/edits`** inherits duration and aspect ratio from the
565+
source (capped at 720p, input truncated to 8 seconds); `size` / `duration`
566+
options are rejected when `previousJobId` is set. The resolved source rides
567+
`video_url` (public URL, base64 `data:` URI, or `file_id`).
568+
- **Gemini Omni Flash** maps `previousJobId` onto the Interactions API's
569+
`previous_interaction_id` wire field internally. That field is **not**
570+
exposed on Omni `modelOptions` — use `previousJobId` only.
571+
- **fal** resolves `previousJobId` via `getVideoUrl` on the generate
572+
model, then routes the URL onto the edit endpoint's video input
573+
(`video_url`, or the endpoint's list field — Seedance 2.0's
574+
reference-to-video takes `video_urls`). Generate endpoints with a known
575+
edit sibling (e.g. Grok text/image-to-video → `edit-video`) do this
576+
automatically.
577+
492578
## Model Options
493579

494580
### OpenAI Model Options
@@ -618,10 +704,10 @@ the Files API first).
618704

619705
#### Conversational video editing
620706

621-
Omni's headline capability is iterative refinement: pass the interaction id
622-
of a prior generation (its `jobId`) as
623-
`modelOptions.previous_interaction_id` and describe the change — the model
624-
edits the video while preserving everything you didn't mention:
707+
Omni's headline capability is iterative refinement: pass a prior
708+
generation's `jobId` (its interaction id) as `previousJobId` and describe the
709+
change — the model edits the video while preserving everything you didn't
710+
mention (see [Editing Generated Videos](#editing-generated-videos-previousjobid)):
625711

626712
```typescript ignore
627713
import { generateVideo } from '@tanstack/ai'
@@ -641,12 +727,15 @@ const first = await generateVideo({
641727
const second = await generateVideo({
642728
adapter,
643729
prompt: 'Make the violin invisible',
644-
modelOptions: { previous_interaction_id: first.jobId },
730+
previousJobId: first.jobId,
645731
})
646732
```
647733

648-
`modelOptions` also passes through the Interactions API's request fields
649-
(e.g. `generation_config.video_config.task` to pin
734+
The adapter maps `previousJobId` onto the Interactions API's
735+
`previous_interaction_id` wire field. That field is not available on Omni
736+
`modelOptions` — always use `previousJobId`. `modelOptions` still passes
737+
through the Interactions API's other request fields (e.g.
738+
`generation_config.video_config.task` to pin
650739
`'text_to_video' | 'image_to_video' | 'reference_to_video' | 'edit'`
651740
instead of letting the model infer the task mode).
652741

@@ -708,6 +797,8 @@ adapter.snapDuration(99) // 15
708797

709798
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.
710799

800+
`grok-imagine-video` can also edit a previously generated clip via [`previousJobId`](#editing-generated-videos-previousjobid) — pass the prior job id and an edit prompt; the adapter resolves the finished clip and posts to xAI's `/videos/edits` endpoint, then polls like any other job. The output inherits duration and aspect ratio from the source (capped at 720p, input truncated to 8 seconds).
801+
711802
## Response Types
712803

713804
> **Note:** The interfaces below are the underlying adapter-level types. The `getVideoJobStatus()` helper returns a single merged object, `{ status, progress?, url?, error?, usage? }` — it does not return `jobId` or `expiresAt`.

examples/ts-react-media/src/components/ImageGenerator.tsx

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useRef, useState } from 'react'
2-
import { ImageIcon, Loader2, Plus, Shuffle, X } from 'lucide-react'
2+
import { ImageIcon, Loader2, Plus, Shuffle, Wand2, X } from 'lucide-react'
33
import type { ImageGenerationResult } from '@tanstack/ai'
4-
import type { MediaPrompt } from '@tanstack/ai/client'
4+
import type { GeneratedImage, MediaPrompt } from '@tanstack/ai/client'
55

66
import { generateImageFn } from '@/lib/server-functions'
77
import { getRandomImagePrompt } from '@/lib/prompts'
@@ -37,6 +37,7 @@ export default function ImageGenerator({
3737
const [isLoading, setIsLoading] = useState(false)
3838
const [results, setResults] = useState<Record<string, ModelResult>>({})
3939
const [images, setImages] = useState<Array<AttachedMedia>>([])
40+
const [editPrompts, setEditPrompts] = useState<Record<string, string>>({})
4041
const fileInputRef = useRef<HTMLInputElement>(null)
4142

4243
const currentModel = IMAGE_MODELS.find((m) => m.id === selectedModel)
@@ -64,6 +65,40 @@ export default function ImageGenerator({
6465
setImages((prev) => prev.filter((image) => image.id !== id))
6566
}
6667

68+
/**
69+
* Follow-up edit of a generated image: the prior image rides `previousImage`
70+
* and the server prepends it to the prompt as an image input for the
71+
* model's edit path.
72+
*/
73+
const handleEditImage = async (modelId: string, image: GeneratedImage) => {
74+
const editPrompt = editPrompts[modelId]?.trim()
75+
if (!editPrompt) return
76+
77+
setResults((prev) => ({ ...prev, [modelId]: { status: 'loading' } }))
78+
try {
79+
const response = await generateImageFn({
80+
data: { prompt: editPrompt, model: modelId, previousImage: image },
81+
})
82+
setResults((prev) => ({
83+
...prev,
84+
[modelId]: { status: 'success', result: response },
85+
}))
86+
setEditPrompts((prev) => ({ ...prev, [modelId]: '' }))
87+
const edited = response.images[0]
88+
if (edited) {
89+
onImageGenerated?.(getImageSrc(edited))
90+
}
91+
} catch (err) {
92+
setResults((prev) => ({
93+
...prev,
94+
[modelId]: {
95+
status: 'error',
96+
error: err instanceof Error ? err.message : 'Failed to edit image',
97+
},
98+
}))
99+
}
100+
}
101+
67102
const handleGenerate = async () => {
68103
if (!prompt.trim()) return
69104
const builtPrompt = buildPrompt()
@@ -313,6 +348,45 @@ export default function ImageGenerator({
313348
— multiply by the endpoint unit price for USD cost
314349
</p>
315350
)}
351+
{model?.editable && (
352+
<div className="flex gap-2">
353+
<input
354+
type="text"
355+
value={editPrompts[modelId] ?? ''}
356+
onChange={(e) =>
357+
setEditPrompts((prev) => ({
358+
...prev,
359+
[modelId]: e.target.value,
360+
}))
361+
}
362+
onKeyDown={(e) => {
363+
if (e.key === 'Enter')
364+
handleEditImage(
365+
modelId,
366+
modelResult.result!.images[0]!,
367+
)
368+
}}
369+
placeholder="Describe an edit — e.g. 'make it night time'..."
370+
disabled={isLoading}
371+
className="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
372+
/>
373+
<button
374+
onClick={() =>
375+
handleEditImage(
376+
modelId,
377+
modelResult.result!.images[0]!,
378+
)
379+
}
380+
disabled={
381+
isLoading || !editPrompts[modelId]?.trim()
382+
}
383+
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-700 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-1.5"
384+
>
385+
<Wand2 className="w-4 h-4" />
386+
Edit
387+
</button>
388+
</div>
389+
)}
316390
</>
317391
)}
318392
</div>

examples/ts-react-media/src/components/OmniStudio.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ type AspectRatio = '16:9' | '9:16'
3131
* One turn of an Omni session: the prompt (plus attachments and generation
3232
* settings) and the clip it produced. `parentLocalId` records which earlier
3333
* turn this one continued from — the session is a tree, not a line, since
34-
* `previous_interaction_id` can point at any prior generation. `duration`
34+
* `previousJobId` can point at any prior generation. `duration`
3535
* is null for edit requests, which follow the source clip's length.
3636
*/
3737
interface OmniTurn {
@@ -62,7 +62,7 @@ const TASK_OPTIONS: Array<{ value: OmniTaskMode | 'auto'; label: string }> = [
6262
* Chat-style session view for Gemini Omni Flash. Unlike the one-shot job
6363
* form in VideoGenerator, every generation here is an interaction you can
6464
* continue from: sending a new prompt chains it onto the selected clip via
65-
* `previous_interaction_id`, and "Continue from here" on any earlier clip
65+
* `previousJobId`, and "Continue from here" on any earlier clip
6666
* branches the session from that point.
6767
*/
6868
export default function OmniStudio() {
@@ -238,7 +238,7 @@ export default function OmniStudio() {
238238
data: {
239239
prompt: builtPrompt,
240240
model: OMNI_MODEL,
241-
...(parentJobId ? { previousInteractionId: parentJobId } : {}),
241+
...(parentJobId ? { previousJobId: parentJobId } : {}),
242242
omniOptions: {
243243
...(durationLocked ? {} : { duration }),
244244
aspectRatio,

0 commit comments

Comments
 (0)