From c1e5b1bbea81fb1f14f36b915918b471d54fb52e Mon Sep 17 00:00:00 2001
From: Jack Herrington
Date: Thu, 16 Apr 2026 10:40:14 -0700
Subject: [PATCH 1/5] chore: update project agent configuration
---
.agentsroom/.gitignore | 4 ++++
.agentsroom/agents.json | 1 +
.agentsroom/prompts.json | 4 ++++
3 files changed, 9 insertions(+)
create mode 100644 .agentsroom/.gitignore
create mode 100644 .agentsroom/agents.json
create mode 100644 .agentsroom/prompts.json
diff --git a/.agentsroom/.gitignore b/.agentsroom/.gitignore
new file mode 100644
index 000000000..1acd1a387
--- /dev/null
+++ b/.agentsroom/.gitignore
@@ -0,0 +1,4 @@
+# AgentsRoom: personal files (not committed to git)
+*-personal.json
+agents-local.json
+sessions/
diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json
new file mode 100644
index 000000000..0637a088a
--- /dev/null
+++ b/.agentsroom/agents.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/.agentsroom/prompts.json b/.agentsroom/prompts.json
new file mode 100644
index 000000000..f4455d843
--- /dev/null
+++ b/.agentsroom/prompts.json
@@ -0,0 +1,4 @@
+{
+ "folders": [],
+ "prompts": []
+}
\ No newline at end of file
From 45518bf114e5c9433b65d754522388a81605f9f0 Mon Sep 17 00:00:00 2001
From: Jack Herrington
Date: Thu, 16 Apr 2026 10:40:44 -0700
Subject: [PATCH 2/5] chore: update project agent configuration
---
.agentsroom/agents.json | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json
index 0637a088a..61f6b9421 100644
--- a/.agentsroom/agents.json
+++ b/.agentsroom/agents.json
@@ -1 +1,9 @@
-[]
\ No newline at end of file
+[
+ {
+ "role": "fullstack",
+ "model": "opus",
+ "customName": "Full-Stack Developer",
+ "isPersonal": false,
+ "id": "agent-1776361243376-3sekdc"
+ }
+]
\ No newline at end of file
From 88f018e20a8513933d35ce1e0f82b048535be42b Mon Sep 17 00:00:00 2001
From: Jack Herrington
Date: Thu, 16 Apr 2026 10:40:59 -0700
Subject: [PATCH 3/5] chore: update project agent configuration
---
.agentsroom/agents.json | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.agentsroom/agents.json b/.agentsroom/agents.json
index 61f6b9421..e9a83e418 100644
--- a/.agentsroom/agents.json
+++ b/.agentsroom/agents.json
@@ -4,6 +4,7 @@
"model": "opus",
"customName": "Full-Stack Developer",
"isPersonal": false,
- "id": "agent-1776361243376-3sekdc"
+ "id": "agent-1776361243376-3sekdc",
+ "claudeSessionId": "96773a93-be2a-45a9-a732-ceb224d3d0e5"
}
]
\ No newline at end of file
From 3fdee4ec7eb8928b2790e8ca461134040cfb027e Mon Sep 17 00:00:00 2001
From: Jack Herrington
Date: Fri, 10 Jul 2026 20:05:32 -0700
Subject: [PATCH 4/5] 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
---
.changeset/media-edit-from.md | 21 ++
docs/config.json | 4 +-
docs/media/image-generation.md | 64 ++++++
docs/media/video-generation.md | 105 ++++++++-
.../src/components/ImageGenerator.tsx | 78 ++++++-
.../src/components/OmniStudio.tsx | 6 +-
.../src/components/VideoGenerator.tsx | 80 ++++---
examples/ts-react-media/src/lib/models.ts | 23 ++
.../src/lib/server-functions.ts | 90 +++++---
packages/ai-client/src/generation-types.ts | 11 +
packages/ai-fal/src/adapters/video.ts | 72 +++++-
packages/ai-fal/src/index.ts | 3 +
packages/ai-fal/src/model-meta.ts | 39 +++-
packages/ai-fal/tests/video-adapter.test.ts | 97 ++++++++
packages/ai-gemini/src/adapters/video.ts | 22 +-
packages/ai-gemini/src/index.ts | 1 +
.../src/video/video-provider-options.ts | 21 +-
.../ai-gemini/tests/video-adapter.test.ts | 37 ++-
packages/ai-grok/src/adapters/video.ts | 111 ++++++++-
packages/ai-grok/src/index.ts | 2 +
.../src/video/video-provider-options.ts | 25 +++
packages/ai-grok/tests/video-adapter.test.ts | 143 ++++++++++++
packages/ai-openai/src/adapters/video.ts | 77 ++++++-
packages/ai-openai/src/index.ts | 1 +
.../src/video/video-provider-options.ts | 13 ++
.../ai-openai/tests/video-adapter.test.ts | 93 +++++++-
.../skills/ai-core/media-generation/SKILL.md | 25 ++-
.../ai/src/activities/generateImage/index.ts | 90 +++++++-
.../src/activities/generateVideo/adapter.ts | 46 +++-
.../ai/src/activities/generateVideo/index.ts | 108 ++++++++-
packages/ai/src/activities/index.ts | 3 +
packages/ai/src/client.ts | 3 +
packages/ai/src/index.ts | 6 +-
packages/ai/src/types.ts | 29 +++
packages/ai/src/utilities/media-prompt.ts | 62 ++++++
.../generate-image-previous-image.test.ts | 210 ++++++++++++++++++
.../generate-video-previous-job-id.test.ts | 207 +++++++++++++++++
packages/ai/tests/stream-generation.test.ts | 1 +
testing/e2e/global-setup.ts | 96 +++++++-
testing/e2e/src/components/ImageGenUI.tsx | 45 +++-
testing/e2e/src/components/VideoGenUI.tsx | 50 ++++-
testing/e2e/src/lib/feature-support.ts | 11 +
testing/e2e/src/lib/features.ts | 8 +
testing/e2e/src/lib/media-providers.ts | 10 +-
testing/e2e/src/lib/server-functions.ts | 10 +
testing/e2e/src/lib/types.ts | 4 +
testing/e2e/src/routes/$provider/$feature.tsx | 23 ++
testing/e2e/src/routes/api.image.stream.ts | 32 ++-
testing/e2e/src/routes/api.image.ts | 32 ++-
testing/e2e/src/routes/api.video.stream.ts | 17 +-
testing/e2e/src/routes/api.video.ts | 17 +-
testing/e2e/tests/image-edit.spec.ts | 92 ++++++++
testing/e2e/tests/video-edit.spec.ts | 80 +++++++
53 files changed, 2402 insertions(+), 154 deletions(-)
create mode 100644 .changeset/media-edit-from.md
create mode 100644 packages/ai/tests/generate-image-previous-image.test.ts
create mode 100644 packages/ai/tests/generate-video-previous-job-id.test.ts
create mode 100644 testing/e2e/tests/image-edit.spec.ts
create mode 100644 testing/e2e/tests/video-edit.spec.ts
diff --git a/.changeset/media-edit-from.md b/.changeset/media-edit-from.md
new file mode 100644
index 000000000..c0439d808
--- /dev/null
+++ b/.changeset/media-edit-from.md
@@ -0,0 +1,21 @@
+---
+'@tanstack/ai': minor
+'@tanstack/ai-openai': minor
+'@tanstack/ai-gemini': minor
+'@tanstack/ai-grok': minor
+'@tanstack/ai-fal': minor
+'@tanstack/ai-client': minor
+---
+
+feat: first-class follow-up edits for generated media.
+
+`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:
+
+- `'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`)
+- `'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.
+
+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.
+
+`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.
+
+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`.
diff --git a/docs/config.json b/docs/config.json
index ceb80c4f7..c6e88f61c 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -288,13 +288,13 @@
"label": "Image Generation",
"to": "media/image-generation",
"addedAt": "2026-04-15",
- "updatedAt": "2026-07-07"
+ "updatedAt": "2026-07-10"
},
{
"label": "Video Generation",
"to": "media/video-generation",
"addedAt": "2026-04-15",
- "updatedAt": "2026-07-02"
+ "updatedAt": "2026-07-10"
},
{
"label": "Generation Hooks",
diff --git a/docs/media/image-generation.md b/docs/media/image-generation.md
index 9f20e59ab..81b1b2b76 100644
--- a/docs/media/image-generation.md
+++ b/docs/media/image-generation.md
@@ -79,6 +79,7 @@ All image adapters support these common options:
| `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. |
| `numberOfImages` | `number` | Number of images to generate |
| `size` | `string` | Size of the generated image in WIDTHxHEIGHT format |
+| `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). |
| `modelOptions?` | `object` | Model-specific options (renamed from `providerOptions`) |
### Size Options
@@ -183,6 +184,69 @@ The accepted part types are narrowed **per model at compile time**: passing
an image part to a text-only model (e.g. `dall-e-3`, Imagen) is a type
error, not just a runtime throw.
+### Editing generated images (previousImage)
+
+To run a **follow-up edit** on something you just generated, pass the prior
+result's image as `previousImage` — sugar that prepends it to the prompt as an
+image part, so it flows through the model's regular edit path (OpenAI
+`/images/edits`, Gemini `generateContent`, xAI `/images/edits`, fal).
+
+**Server:**
+
+```typescript
+import { generateImage } from '@tanstack/ai'
+import { openaiImage } from '@tanstack/ai-openai'
+
+const adapter = openaiImage('gpt-image-2')
+
+const first = await generateImage({ adapter, prompt: 'A city street at dusk' })
+
+const edited = await generateImage({
+ adapter,
+ prompt: 'Same scene, but make it rain',
+ previousImage: first.images[0],
+})
+```
+
+`previousImage` accepts a single `GeneratedImage`, an array of them, or the
+whole prior result (`{ images }`). URL results pass through as `url`
+sources (`data:` URLs are decomposed into raw bytes for adapters that
+upload files); `b64Json` results become `data` sources with the mime type
+sniffed from the payload (defaulting to `image/png`). Like image parts, the
+option is offered **per model at compile time** — text-only models
+(`dall-e-3`, Imagen) reject it as a type error.
+
+**Client** — the hook's `ImageGenerateInput.previousImage` is a wire-friendly
+`{ url? }` / `{ b64Json? }` shape; your server route should narrow it back
+to a `GeneratedImage` before calling `generateImage`:
+
+```tsx
+import { useGenerateImage, fetchServerSentEvents } from '@tanstack/ai-react'
+
+function ImageEditor() {
+ const { generate, result, isLoading } = useGenerateImage({
+ connection: fetchServerSentEvents('/api/generate/image'),
+ })
+
+ const handleEdit = () => {
+ const image = result?.images[0]
+ if (!image) return
+ void generate({
+ prompt: 'Same scene, but make it rain',
+ previousImage: image.url
+ ? { url: image.url }
+ : { b64Json: image.b64Json },
+ })
+ }
+
+ return (
+
+ Edit
+
+ )
+}
+```
+
### Referencing images from your prompt
**Your prompt text is always sent verbatim — the SDK never injects or
diff --git a/docs/media/video-generation.md b/docs/media/video-generation.md
index 2386de6fa..6036fe316 100644
--- a/docs/media/video-generation.md
+++ b/docs/media/video-generation.md
@@ -383,6 +383,7 @@ And returns:
| `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. |
| `size` | `string` | Video resolution in WIDTHxHEIGHT format |
| `duration` | `number` | Video duration in seconds (maps to `seconds` parameter in API) |
+| `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). |
| `modelOptions?` | `object` | Model-specific options (renamed from `providerOptions`) |
## Image-to-Video
@@ -489,6 +490,91 @@ The API uses the `seconds` parameter. Allowed values:
- `8` seconds (default)
- `12` seconds
+## Editing Generated Videos (previousJobId)
+
+Models that support **follow-up runs** can edit a previously generated
+video instead of generating from scratch: pass the prior generation's
+job id as `previousJobId` and describe the change in the prompt. The
+canonical call is the same for every provider.
+
+**Server:**
+
+```typescript ignore
+import { generateVideo, getVideoJobStatus } from '@tanstack/ai'
+import { openaiVideo } from '@tanstack/ai-openai'
+
+const adapter = openaiVideo('sora-2')
+
+// Turn 1: generate
+const first = await generateVideo({ adapter, prompt: 'A city street at dusk' })
+// …poll first.jobId to completion…
+
+// Turn 2: edit the result
+const edited = await generateVideo({
+ adapter,
+ prompt: 'Make it rain',
+ previousJobId: first.jobId,
+})
+```
+
+**Client** — pass the completed job's id through the hook; your server
+forwards it to `generateVideo`:
+
+```tsx
+import { useGenerateVideo, fetchServerSentEvents } from '@tanstack/ai-react'
+
+function VideoEditor() {
+ const { generate, result, isLoading } = useGenerateVideo({
+ connection: fetchServerSentEvents('/api/generate/video'),
+ })
+
+ const handleEdit = () => {
+ if (!result?.jobId) return
+ void generate({
+ prompt: 'Make it rain',
+ previousJobId: result.jobId,
+ })
+ }
+
+ return (
+
+ Edit
+
+ )
+}
+```
+
+Each model declares **how** it consumes that job id via
+`adapter.supportedEditKind()`:
+
+| Kind | How the adapter uses `previousJobId` | Providers |
+| --- | --- | --- |
+| `'job'` | References the prior job server-side | OpenAI Sora 2 / Sora 2 Pro (remix), Gemini Omni Flash |
+| `'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) |
+
+Models without follow-up support (Veo, `grok-imagine-video-1.5`, fal
+text/image-to-video endpoints without a known edit sibling) reject
+`previousJobId` at compile time.
+
+Provider-specific constraints:
+
+- **OpenAI Sora (remix)** accepts only a text prompt — the output inherits
+ the source video's size and duration, so `size`, `duration`, and image
+ parts are rejected when `previousJobId` is set.
+- **Grok `/videos/edits`** inherits duration and aspect ratio from the
+ source (capped at 720p, input truncated to 8 seconds); `size` / `duration`
+ options are rejected when `previousJobId` is set. The resolved source rides
+ `video_url` (public URL, base64 `data:` URI, or `file_id`).
+- **Gemini Omni Flash** maps `previousJobId` onto the Interactions API's
+ `previous_interaction_id` wire field internally. That field is **not**
+ exposed on Omni `modelOptions` — use `previousJobId` only.
+- **fal** resolves `previousJobId` via `getVideoUrl` on the generate
+ model, then routes the URL onto the edit endpoint's video input
+ (`video_url`, or the endpoint's list field — Seedance 2.0's
+ reference-to-video takes `video_urls`). Generate endpoints with a known
+ edit sibling (e.g. Grok text/image-to-video → `edit-video`) do this
+ automatically.
+
## Model Options
### OpenAI Model Options
@@ -618,10 +704,10 @@ the Files API first).
#### Conversational video editing
-Omni's headline capability is iterative refinement: pass the interaction id
-of a prior generation (its `jobId`) as
-`modelOptions.previous_interaction_id` and describe the change — the model
-edits the video while preserving everything you didn't mention:
+Omni's headline capability is iterative refinement: pass a prior
+generation's `jobId` (its interaction id) as `previousJobId` and describe the
+change — the model edits the video while preserving everything you didn't
+mention (see [Editing Generated Videos](#editing-generated-videos-previousjobid)):
```typescript ignore
import { generateVideo } from '@tanstack/ai'
@@ -641,12 +727,15 @@ const first = await generateVideo({
const second = await generateVideo({
adapter,
prompt: 'Make the violin invisible',
- modelOptions: { previous_interaction_id: first.jobId },
+ previousJobId: first.jobId,
})
```
-`modelOptions` also passes through the Interactions API's request fields
-(e.g. `generation_config.video_config.task` to pin
+The adapter maps `previousJobId` onto the Interactions API's
+`previous_interaction_id` wire field. That field is not available on Omni
+`modelOptions` — always use `previousJobId`. `modelOptions` still passes
+through the Interactions API's other request fields (e.g.
+`generation_config.video_config.task` to pin
`'text_to_video' | 'image_to_video' | 'reference_to_video' | 'edit'`
instead of letting the model infer the task mode).
@@ -708,6 +797,8 @@ adapter.snapDuration(99) // 15
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.
+`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).
+
## Response Types
> **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`.
diff --git a/examples/ts-react-media/src/components/ImageGenerator.tsx b/examples/ts-react-media/src/components/ImageGenerator.tsx
index 09e2eb7d4..a3ea2bfcf 100644
--- a/examples/ts-react-media/src/components/ImageGenerator.tsx
+++ b/examples/ts-react-media/src/components/ImageGenerator.tsx
@@ -1,7 +1,7 @@
import { useRef, useState } from 'react'
-import { ImageIcon, Loader2, Plus, Shuffle, X } from 'lucide-react'
+import { ImageIcon, Loader2, Plus, Shuffle, Wand2, X } from 'lucide-react'
import type { ImageGenerationResult } from '@tanstack/ai'
-import type { MediaPrompt } from '@tanstack/ai/client'
+import type { GeneratedImage, MediaPrompt } from '@tanstack/ai/client'
import { generateImageFn } from '@/lib/server-functions'
import { getRandomImagePrompt } from '@/lib/prompts'
@@ -37,6 +37,7 @@ export default function ImageGenerator({
const [isLoading, setIsLoading] = useState(false)
const [results, setResults] = useState>({})
const [images, setImages] = useState>([])
+ const [editPrompts, setEditPrompts] = useState>({})
const fileInputRef = useRef(null)
const currentModel = IMAGE_MODELS.find((m) => m.id === selectedModel)
@@ -64,6 +65,40 @@ export default function ImageGenerator({
setImages((prev) => prev.filter((image) => image.id !== id))
}
+ /**
+ * Follow-up edit of a generated image: the prior image rides `previousImage`
+ * and the server prepends it to the prompt as an image input for the
+ * model's edit path.
+ */
+ const handleEditImage = async (modelId: string, image: GeneratedImage) => {
+ const editPrompt = editPrompts[modelId]?.trim()
+ if (!editPrompt) return
+
+ setResults((prev) => ({ ...prev, [modelId]: { status: 'loading' } }))
+ try {
+ const response = await generateImageFn({
+ data: { prompt: editPrompt, model: modelId, previousImage: image },
+ })
+ setResults((prev) => ({
+ ...prev,
+ [modelId]: { status: 'success', result: response },
+ }))
+ setEditPrompts((prev) => ({ ...prev, [modelId]: '' }))
+ const edited = response.images[0]
+ if (edited) {
+ onImageGenerated?.(getImageSrc(edited))
+ }
+ } catch (err) {
+ setResults((prev) => ({
+ ...prev,
+ [modelId]: {
+ status: 'error',
+ error: err instanceof Error ? err.message : 'Failed to edit image',
+ },
+ }))
+ }
+ }
+
const handleGenerate = async () => {
if (!prompt.trim()) return
const builtPrompt = buildPrompt()
@@ -313,6 +348,45 @@ export default function ImageGenerator({
— multiply by the endpoint unit price for USD cost
)}
+ {model?.editable && (
+
+
+ setEditPrompts((prev) => ({
+ ...prev,
+ [modelId]: e.target.value,
+ }))
+ }
+ onKeyDown={(e) => {
+ if (e.key === 'Enter')
+ handleEditImage(
+ modelId,
+ modelResult.result!.images[0]!,
+ )
+ }}
+ placeholder="Describe an edit — e.g. 'make it night time'..."
+ disabled={isLoading}
+ 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"
+ />
+
+ handleEditImage(
+ modelId,
+ modelResult.result!.images[0]!,
+ )
+ }
+ disabled={
+ isLoading || !editPrompts[modelId]?.trim()
+ }
+ 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"
+ >
+
+ Edit
+
+
+ )}
>
)}
diff --git a/examples/ts-react-media/src/components/OmniStudio.tsx b/examples/ts-react-media/src/components/OmniStudio.tsx
index 81b55efd5..3cd7a5da8 100644
--- a/examples/ts-react-media/src/components/OmniStudio.tsx
+++ b/examples/ts-react-media/src/components/OmniStudio.tsx
@@ -31,7 +31,7 @@ type AspectRatio = '16:9' | '9:16'
* One turn of an Omni session: the prompt (plus attachments and generation
* settings) and the clip it produced. `parentLocalId` records which earlier
* turn this one continued from — the session is a tree, not a line, since
- * `previous_interaction_id` can point at any prior generation. `duration`
+ * `previousJobId` can point at any prior generation. `duration`
* is null for edit requests, which follow the source clip's length.
*/
interface OmniTurn {
@@ -62,7 +62,7 @@ const TASK_OPTIONS: Array<{ value: OmniTaskMode | 'auto'; label: string }> = [
* Chat-style session view for Gemini Omni Flash. Unlike the one-shot job
* form in VideoGenerator, every generation here is an interaction you can
* continue from: sending a new prompt chains it onto the selected clip via
- * `previous_interaction_id`, and "Continue from here" on any earlier clip
+ * `previousJobId`, and "Continue from here" on any earlier clip
* branches the session from that point.
*/
export default function OmniStudio() {
@@ -238,7 +238,7 @@ export default function OmniStudio() {
data: {
prompt: builtPrompt,
model: OMNI_MODEL,
- ...(parentJobId ? { previousInteractionId: parentJobId } : {}),
+ ...(parentJobId ? { previousJobId: parentJobId } : {}),
omniOptions: {
...(durationLocked ? {} : { duration }),
aspectRatio,
diff --git a/examples/ts-react-media/src/components/VideoGenerator.tsx b/examples/ts-react-media/src/components/VideoGenerator.tsx
index e039f8c2c..5133ac2cd 100644
--- a/examples/ts-react-media/src/components/VideoGenerator.tsx
+++ b/examples/ts-react-media/src/components/VideoGenerator.tsx
@@ -109,17 +109,29 @@ export default function VideoGenerator({
if (videoInputRef.current) videoInputRef.current.value = ''
}
- const pollStatus = async (jobId: string, model: string) => {
+ // `cardId` keys the UI card (the entry the user picked); `pollModel` is
+ // the model id the server resolves an adapter from. They differ for
+ // follow-up edits that run on a dedicated endpoint (fal's
+ // `xai/grok-imagine-video/edit-video`).
+ const pollStatus = async (
+ cardId: string,
+ jobId: string,
+ pollModel: string,
+ ) => {
try {
- const status = await getVideoStatusFn({ data: { jobId, model } })
+ const status = await getVideoStatusFn({
+ data: { jobId, model: pollModel },
+ })
if (status.status === 'completed') {
- const interval = pollingRefs.current.get(model)
+ const interval = pollingRefs.current.get(cardId)
if (interval) {
clearInterval(interval)
- pollingRefs.current.delete(model)
+ pollingRefs.current.delete(cardId)
}
- const urlResult = await getVideoUrlFn({ data: { jobId, model } })
+ const urlResult = await getVideoUrlFn({
+ data: { jobId, model: pollModel },
+ })
if (!urlResult.url) {
throw new Error('No URL found')
}
@@ -127,7 +139,7 @@ export default function VideoGenerator({
setJobStates((prev) => ({
...prev,
- [model]: {
+ [cardId]: {
status: 'completed',
url: url,
jobId,
@@ -138,22 +150,22 @@ export default function VideoGenerator({
} else if (status.status === 'processing') {
setJobStates((prev) => ({
...prev,
- [model]: {
+ [cardId]: {
status: 'processing',
jobId,
- model,
+ model: pollModel,
progress: status.progress,
},
}))
} else if (status.status === 'failed') {
- const interval = pollingRefs.current.get(model)
+ const interval = pollingRefs.current.get(cardId)
if (interval) {
clearInterval(interval)
- pollingRefs.current.delete(model)
+ pollingRefs.current.delete(cardId)
}
setJobStates((prev) => ({
...prev,
- [model]: {
+ [cardId]: {
status: 'error',
message: status.error ?? 'Video generation failed',
},
@@ -161,18 +173,18 @@ export default function VideoGenerator({
} else {
setJobStates((prev) => ({
...prev,
- [model]: { status: 'pending', jobId, model },
+ [cardId]: { status: 'pending', jobId, model: pollModel },
}))
}
} catch (err) {
- const interval = pollingRefs.current.get(model)
+ const interval = pollingRefs.current.get(cardId)
if (interval) {
clearInterval(interval)
- pollingRefs.current.delete(model)
+ pollingRefs.current.delete(cardId)
}
setJobStates((prev) => ({
...prev,
- [model]: {
+ [cardId]: {
status: 'error',
message: err instanceof Error ? err.message : 'Failed to get status',
},
@@ -183,11 +195,11 @@ export default function VideoGenerator({
// Poll keyed by the UI model id, not result.model: the direct-xAI
// entries share one adapter model ('grok-imagine-video-1.5'),
// so result.model wouldn't identify the card (or the adapter) uniquely.
- const beginPolling = (modelId: string, jobId: string) => {
+ const beginPolling = (cardId: string, jobId: string, pollModel = cardId) => {
const interval = setInterval(() => {
- pollStatus(jobId, modelId)
+ pollStatus(cardId, jobId, pollModel)
}, 4000)
- pollingRefs.current.set(modelId, interval)
+ pollingRefs.current.set(cardId, interval)
}
const startJobForModel = async (modelId: string) => {
@@ -242,13 +254,17 @@ export default function VideoGenerator({
}
/**
- * Gemini Omni Flash conversational editing: chain a new prompt onto a
- * completed generation via its interaction id (the jobId). The model
- * applies the change while preserving everything else in the video.
+ * Follow-up edit of a completed generation. Always pass the prior jobId;
+ * adapters that need a URL resolve it via getVideoUrl.
*/
- const handleEditVideo = async (modelId: string, previousJobId: string) => {
+ const handleEditVideo = async (
+ modelId: string,
+ previous: { jobId: string; url: string },
+ ) => {
const editPrompt = editPrompts[modelId]?.trim()
if (!editPrompt) return
+ const model = VIDEO_MODELS.find((m) => m.id === modelId)
+ if (!model?.editable) return
setJobStates((prev) => ({
...prev,
@@ -260,7 +276,7 @@ export default function VideoGenerator({
data: {
prompt: editPrompt,
model: modelId,
- previousInteractionId: previousJobId,
+ previousJobId: previous.jobId,
},
})
@@ -274,7 +290,9 @@ export default function VideoGenerator({
}))
setEditPrompts((prev) => ({ ...prev, [modelId]: '' }))
- beginPolling(modelId, result.jobId)
+ // fal generate→edit routing may return a different model id (the edit
+ // sibling endpoint) — poll against that.
+ beginPolling(modelId, result.jobId, result.model)
} catch (err) {
setJobStates((prev) => ({
...prev,
@@ -587,7 +605,7 @@ export default function VideoGenerator({
)
)}
- {model?.provider === 'gemini' && (
+ {model?.editable && (
{
if (e.key === 'Enter')
- handleEditVideo(modelId, state.jobId)
+ handleEditVideo(modelId, {
+ jobId: state.jobId,
+ url: state.url,
+ })
}}
placeholder="Describe an edit — e.g. 'make it nighttime'..."
disabled={isGenerating}
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-blue-500 focus:border-transparent disabled:opacity-50"
/>
handleEditVideo(modelId, state.jobId)}
+ onClick={() =>
+ handleEditVideo(modelId, {
+ jobId: state.jobId,
+ url: state.url,
+ })
+ }
disabled={
isGenerating || !editPrompts[modelId]?.trim()
}
diff --git a/examples/ts-react-media/src/lib/models.ts b/examples/ts-react-media/src/lib/models.ts
index bce19eb21..dc511f39e 100644
--- a/examples/ts-react-media/src/lib/models.ts
+++ b/examples/ts-react-media/src/lib/models.ts
@@ -6,6 +6,7 @@ export const IMAGE_MODELS = [
defaultSize: 'landscape_16_9' as const,
sizeType: 'standard' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'xai/grok-imagine-image',
@@ -14,6 +15,7 @@ export const IMAGE_MODELS = [
defaultSize: '16:9' as const,
sizeType: 'aspect_ratio' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'grok-imagine-image',
@@ -22,6 +24,7 @@ export const IMAGE_MODELS = [
defaultSize: '16:9' as const,
sizeType: 'aspect_ratio' as const,
provider: 'xai' as const,
+ editable: true as const,
},
{
id: 'grok-imagine-image-quality',
@@ -30,6 +33,7 @@ export const IMAGE_MODELS = [
defaultSize: '16:9' as const,
sizeType: 'aspect_ratio' as const,
provider: 'xai' as const,
+ editable: true as const,
},
{
id: 'fal-ai/flux-2/klein/9b',
@@ -38,6 +42,7 @@ export const IMAGE_MODELS = [
defaultSize: 'landscape_16_9' as const,
sizeType: 'standard' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'fal-ai/z-image/turbo',
@@ -46,6 +51,7 @@ export const IMAGE_MODELS = [
defaultSize: 'landscape_16_9' as const,
sizeType: 'standard' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'gemini-3.1-flash-image-preview',
@@ -54,6 +60,7 @@ export const IMAGE_MODELS = [
defaultSize: '16:9_4K' as const,
sizeType: 'native' as const,
provider: 'gemini' as const,
+ editable: true as const,
},
{
id: 'gemini-3-pro-image-preview',
@@ -62,6 +69,7 @@ export const IMAGE_MODELS = [
defaultSize: '16:9_4K' as const,
sizeType: 'native' as const,
provider: 'gemini' as const,
+ editable: true as const,
},
{
id: 'imagen-4.0-ultra-generate-001',
@@ -70,6 +78,7 @@ export const IMAGE_MODELS = [
defaultSize: '1024x1024' as const,
sizeType: 'standard' as const,
provider: 'gemini' as const,
+ editable: false as const,
},
{
id: 'imagen-4.0-generate-001',
@@ -78,6 +87,7 @@ export const IMAGE_MODELS = [
defaultSize: '1024x1024' as const,
sizeType: 'standard' as const,
provider: 'gemini' as const,
+ editable: false as const,
},
{
id: 'imagen-4.0-fast-generate-001',
@@ -86,6 +96,7 @@ export const IMAGE_MODELS = [
defaultSize: '1024x1024' as const,
sizeType: 'standard' as const,
provider: 'gemini' as const,
+ editable: false as const,
},
] as const
@@ -96,6 +107,7 @@ export const VIDEO_MODELS = [
description: 'High-quality text-to-video generation',
mode: 'text-to-video' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'fal-ai/kling-video/v3/pro/image-to-video',
@@ -103,6 +115,7 @@ export const VIDEO_MODELS = [
description: 'Animate images with Kling',
mode: 'image-to-video' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'fal-ai/veo3.1',
@@ -110,6 +123,7 @@ export const VIDEO_MODELS = [
description: 'Google Veo text-to-video',
mode: 'text-to-video' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'fal-ai/veo3.1/image-to-video',
@@ -117,6 +131,7 @@ export const VIDEO_MODELS = [
description: 'Google Veo image-to-video',
mode: 'image-to-video' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'xai/grok-imagine-video/text-to-video',
@@ -124,6 +139,7 @@ export const VIDEO_MODELS = [
description: 'xAI video generation from text',
mode: 'text-to-video' as const,
provider: 'fal' as const,
+ editable: true as const,
},
{
id: 'xai/grok-imagine-video/image-to-video',
@@ -131,6 +147,7 @@ export const VIDEO_MODELS = [
description: 'xAI animate images to video',
mode: 'image-to-video' as const,
provider: 'fal' as const,
+ editable: true as const,
},
{
id: 'grok-imagine-video',
@@ -139,6 +156,7 @@ export const VIDEO_MODELS = [
'xAI Imagine API via the native grokVideo adapter (v1.0 supports text-to-video)',
mode: 'text-to-video' as const,
provider: 'xai' as const,
+ editable: true as const,
},
{
id: 'grok-imagine-video-1.5/image-to-video',
@@ -147,6 +165,7 @@ export const VIDEO_MODELS = [
'Animate a starting frame via the native grokVideo adapter (1.5 is image-to-video only)',
mode: 'image-to-video' as const,
provider: 'xai' as const,
+ editable: false as const,
},
{
id: 'fal-ai/ltx-2.3/text-to-video/fast',
@@ -154,6 +173,7 @@ export const VIDEO_MODELS = [
description: 'Fast text-to-video generation',
mode: 'text-to-video' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'fal-ai/ltx-2.3/image-to-video/fast',
@@ -161,6 +181,7 @@ export const VIDEO_MODELS = [
description: 'Fast image-to-video animation',
mode: 'image-to-video' as const,
provider: 'fal' as const,
+ editable: false as const,
},
{
id: 'gemini-omni-flash-preview',
@@ -169,6 +190,7 @@ export const VIDEO_MODELS = [
'Google multimodal video generation with conversational editing, via the Interactions API (3-10s, 720p)',
mode: 'text-to-video' as const,
provider: 'gemini' as const,
+ editable: true as const,
},
{
id: 'gemini-omni-flash-preview/image-to-video',
@@ -177,6 +199,7 @@ export const VIDEO_MODELS = [
'Animate an image with Gemini Omni Flash via the Interactions API',
mode: 'image-to-video' as const,
provider: 'gemini' as const,
+ editable: true as const,
},
] as const
diff --git a/examples/ts-react-media/src/lib/server-functions.ts b/examples/ts-react-media/src/lib/server-functions.ts
index 53aa3f10c..572fc37bc 100644
--- a/examples/ts-react-media/src/lib/server-functions.ts
+++ b/examples/ts-react-media/src/lib/server-functions.ts
@@ -5,6 +5,7 @@ import { grokImage, grokVideo } from '@tanstack/ai-grok'
import { generateImage, generateVideo, getVideoJobStatus } from '@tanstack/ai'
import type {
+ GeneratedImage,
ImagePart,
MediaInputMetadata,
MediaPrompt,
@@ -109,12 +110,28 @@ function videoAdapterForModel(model: string) {
}
export const generateImageFn = createServerFn({ method: 'POST' })
- .inputValidator((data: { prompt: MediaPrompt; model: string }) => {
- if (!hasPromptContent(data.prompt)) throw new Error('Prompt is required')
- if (!data.model) throw new Error('Model is required')
- return data
- })
+ .inputValidator(
+ (data: {
+ prompt: MediaPrompt
+ model: string
+ /**
+ * A previously generated image to edit — prepended to the prompt as an
+ * image input by `generateImage`. Only supported by image-conditioned
+ * models (Gemini native image, grok-imagine).
+ */
+ previousImage?: GeneratedImage
+ }) => {
+ if (!hasPromptContent(data.prompt)) throw new Error('Prompt is required')
+ if (!data.model) throw new Error('Model is required')
+ return data
+ },
+ )
.handler(async ({ data }) => {
+ // Follow-up edit of a previous generation: pass the prior image through
+ // generateImage's previousImage for the image-conditioned models below.
+ const previousImage = data.previousImage
+ ? { previousImage: data.previousImage }
+ : {}
// NOTE: Use string literals when instantiating adapters to preserve type safety
// The Fal adapater also accepts any string for very latest models which is why new models appear to accept any paramater
// Pass size information in modelOptions for the Fal adapter instead of size to be sure you are using the correct resolution
@@ -155,6 +172,7 @@ export const generateImageFn = createServerFn({ method: 'POST' })
prompt: asImagePrompt(data.prompt),
numberOfImages: 1,
size: '16:9',
+ ...previousImage,
})
}
case 'grok-imagine-image-quality': {
@@ -163,6 +181,7 @@ export const generateImageFn = createServerFn({ method: 'POST' })
prompt: asImagePrompt(data.prompt),
numberOfImages: 1,
size: '16:9',
+ ...previousImage,
})
}
case 'fal-ai/flux-2/klein/9b': {
@@ -192,6 +211,7 @@ export const generateImageFn = createServerFn({ method: 'POST' })
prompt: asImagePrompt(data.prompt),
numberOfImages: 1,
size: '16:9_4K',
+ ...previousImage,
})
}
case 'gemini-3-pro-image-preview': {
@@ -200,6 +220,7 @@ export const generateImageFn = createServerFn({ method: 'POST' })
prompt: asImagePrompt(data.prompt),
numberOfImages: 1,
size: '16:9_4K',
+ ...previousImage,
})
}
case 'imagen-4.0-ultra-generate-001': {
@@ -237,10 +258,10 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
prompt: MediaPrompt
model: string
/**
- * Gemini Omni Flash conversational editing: the jobId (interaction id)
- * of a prior Omni generation to refine. Ignored by other models.
+ * Prior generation's job id to edit instead of generating from scratch.
+ * Adapters that need a URL resolve it via `getVideoUrl`.
*/
- previousInteractionId?: string
+ previousJobId?: string
/**
* Gemini Omni Flash generation controls (ignored by other models):
* clip duration in seconds (3-10, fractional OK, default 10), output
@@ -259,6 +280,7 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
},
)
.handler(async ({ data }) => {
+ const previousJobId = data.previousJobId
// Image-to-video models receive the start frame as a prompt part
// (role: 'start_frame') — the fal adapter routes it to the endpoint's
// start-image field. Text-to-video models take the text prompt only.
@@ -287,13 +309,17 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
})
}
case 'xai/grok-imagine-video/text-to-video': {
+ // Follow-up edits: the fal adapter resolves the prior clip from
+ // previousJobId on this generate endpoint, then submits to edit-video.
return generateVideo({
adapter: falVideo('xai/grok-imagine-video/text-to-video'),
prompt: asTextPrompt(data.prompt),
- size: '16:9_720p',
- modelOptions: {
- duration: 5,
- },
+ ...(previousJobId
+ ? { previousJobId }
+ : {
+ size: '16:9_720p' as const,
+ modelOptions: { duration: 5 },
+ }),
})
}
case 'grok-imagine-video': {
@@ -301,6 +327,18 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
// grok-imagine-video (v1.0) supports text-to-video; durations are
// 1-15 integer seconds. Completed jobs report usage.unitsBilled
// (billed seconds) and usage.cost (exact USD).
+ //
+ // Follow-up edits go through `/videos/edits`. Pass previousJobId;
+ // the adapter resolves the finished clip URL. Edits inherit
+ // duration/aspect ratio from the source, so size/duration options
+ // are only sent on creation.
+ if (previousJobId) {
+ return generateVideo({
+ adapter: grokVideo('grok-imagine-video'),
+ prompt: asTextPrompt(data.prompt),
+ previousJobId,
+ })
+ }
return generateVideo({
adapter: grokVideo('grok-imagine-video'),
prompt: asTextPrompt(data.prompt),
@@ -340,10 +378,12 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
return generateVideo({
adapter: falVideo('xai/grok-imagine-video/image-to-video'),
prompt: asImageToVideoPrompt(data.prompt),
- size: '16:9_720p',
- modelOptions: {
- duration: 5,
- },
+ ...(previousJobId
+ ? { previousJobId }
+ : {
+ size: '16:9_720p' as const,
+ modelOptions: { duration: 5 },
+ }),
})
}
case 'grok-imagine-video-1.5/image-to-video': {
@@ -368,15 +408,15 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
// serves both UI entries; it accepts text, image, AND video prompt
// parts (sent as interaction content blocks: images, then videos,
// then text). Clips are 3–10s at 720p (default 10s when `duration`
- // is omitted); `size` is the output aspect ratio. Passing
- // `previous_interaction_id` chains a prompt onto a prior generation
- // for conversational editing.
+ // is omitted); `size` is the output aspect ratio. `previousJobId`
+ // chains a prompt onto a prior generation (its interaction id) for
+ // conversational editing.
case 'gemini-omni-flash-preview':
case 'gemini-omni-flash-preview/image-to-video': {
const prompt = asOmniPrompt(data.prompt)
if (
data.model.endsWith('/image-to-video') &&
- !data.previousInteractionId &&
+ !previousJobId &&
(typeof prompt === 'string' ||
!prompt.some((part) => part.type === 'image'))
) {
@@ -388,15 +428,11 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
prompt,
size: aspectRatio ?? '16:9',
...(duration !== undefined ? { duration } : {}),
- ...(data.previousInteractionId || task
+ ...(previousJobId ? { previousJobId } : {}),
+ ...(task
? {
modelOptions: {
- ...(data.previousInteractionId
- ? { previous_interaction_id: data.previousInteractionId }
- : {}),
- ...(task
- ? { generation_config: { video_config: { task } } }
- : {}),
+ generation_config: { video_config: { task } },
},
}
: {}),
diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts
index b11e8ca2a..ab42496e1 100644
--- a/packages/ai-client/src/generation-types.ts
+++ b/packages/ai-client/src/generation-types.ts
@@ -247,6 +247,11 @@ export interface ImageGenerateInput {
numberOfImages?: number
/** Image size in WIDTHxHEIGHT format (e.g., "1024x1024") */
size?: string
+ /**
+ * A previously generated image to edit — forwarded to generateImage's
+ * `previousImage`, which prepends it to the prompt as an image input.
+ */
+ previousImage?: { url?: string; b64Json?: string }
/** Model-specific options */
modelOptions?: Record
}
@@ -325,6 +330,12 @@ export interface VideoGenerateInput {
size?: string
/** Video duration in seconds */
duration?: number
+ /**
+ * Prior generation's job id to edit instead of generating from scratch.
+ * Job-kind adapters reference it server-side; media-kind adapters resolve
+ * the finished clip via `getVideoUrl`.
+ */
+ previousJobId?: string
/** Model-specific options */
modelOptions?: Record
}
diff --git a/packages/ai-fal/src/adapters/video.ts b/packages/ai-fal/src/adapters/video.ts
index e2d19e9b0..b0266e667 100644
--- a/packages/ai-fal/src/adapters/video.ts
+++ b/packages/ai-fal/src/adapters/video.ts
@@ -1,5 +1,5 @@
import { fal } from '@fal-ai/client'
-import { resolveMediaPrompt } from '@tanstack/ai'
+import { generatedVideoUrlToVideoPart, resolveMediaPrompt } from '@tanstack/ai'
import { BaseVideoAdapter } from '@tanstack/ai/adapters'
import {
configureFalClient,
@@ -8,9 +8,11 @@ import {
import { buildFalUsage, takeBillableUnits } from '../utils/billing'
import { mapVideoSizeToFalFormat } from '../video/video-provider-options'
import { mapImageInputsToFalVideoFields } from '../image/image-inputs'
+import { FAL_VIDEO_EDIT_BY_SOURCE } from '../model-meta'
import type {
AudioPart,
MediaInputMetadata,
+ VideoEditKind,
VideoGenerationOptions,
VideoJobResult,
VideoPart,
@@ -21,18 +23,35 @@ import type {
FalModel,
FalModelInput,
FalModelVideoSize,
+ FalVideoEditKindFor,
FalVideoPromptModalitiesFor,
FalVideoProviderOptions,
} from '../model-meta'
import type { FalClientConfig } from '../utils/client'
+/**
+ * Endpoints whose source-video field deviates from the `video_url` default.
+ * Seedance 2.0's reference-to-video endpoints take reference clips as a
+ * `video_urls` list (referenced from the prompt as `@Video1`, `@Video2`, …)
+ * and have no singular `video_url` field.
+ */
+const FAL_VIDEO_SOURCE_FIELD_OVERRIDES: Record<
+ string,
+ 'video_url' | 'video_urls'
+> = {
+ 'bytedance/seedance-2.0/reference-to-video': 'video_urls',
+ 'bytedance/seedance-2.0/fast/reference-to-video': 'video_urls',
+}
+
/**
* Map video conditioning inputs onto fal field names.
* Video-to-video endpoints on fal almost universally use `video_url`; the
- * occasional model takes `video_urls` (rare). Mirror the image-input logic
+ * occasional model takes `video_urls` (list-only endpoints live in
+ * `FAL_VIDEO_SOURCE_FIELD_OVERRIDES`). Mirror the image-input logic
* positionally with a `reference` role escape hatch via `reference_video_urls`.
*/
function mapVideoInputsToFalFields(
+ model: string,
videoInputs?: ReadonlyArray>,
): Record {
if (!videoInputs || videoInputs.length === 0) return {}
@@ -51,7 +70,10 @@ function mapVideoInputsToFalFields(
}
const out: Record = {}
if (references.length > 0) out.reference_video_urls = references
- if (sources.length === 1) {
+ const sourceField = FAL_VIDEO_SOURCE_FIELD_OVERRIDES[model]
+ if (sourceField === 'video_urls') {
+ if (sources.length > 0) out.video_urls = sources
+ } else if (sources.length === 1) {
out.video_url = sources[0]
} else if (sources.length > 1) {
out.video_urls = sources
@@ -132,7 +154,9 @@ export class FalVideoAdapter extends BaseVideoAdapter<
FalVideoProviderOptions,
Record>,
Record>,
- Record>
+ Record>,
+ Record,
+ Record>
> {
override readonly kind = 'video' as const
readonly name = 'fal' as const
@@ -142,13 +166,26 @@ export class FalVideoAdapter extends BaseVideoAdapter<
configureFalClient(config)
}
+ /**
+ * fal endpoints consume a previously generated video by URL. Callers pass
+ * `previousJobId`; the adapter resolves the finished clip via
+ * `getVideoUrl`. Generate endpoints with a known edit sibling (see
+ * `FAL_VIDEO_EDIT_BY_SOURCE`) resolve on the generate model, then submit
+ * to the edit endpoint. The endpoint set is open-world, so this always
+ * reports `'media'` at runtime; per-endpoint support is narrowed at the
+ * type level via `FalVideoEditKindFor`.
+ */
+ override supportedEditKind(): VideoEditKind {
+ return 'media'
+ }
+
async createVideoJob(
options: VideoGenerationOptions<
FalVideoProviderOptions,
FalModelVideoSize
>,
): Promise {
- const { size, duration, modelOptions, logger } = options
+ const { size, duration, modelOptions, logger, previousJobId } = options
logger.request(`activity=generateVideo provider=fal model=${this.model}`, {
provider: 'fal',
@@ -157,12 +194,29 @@ export class FalVideoAdapter extends BaseVideoAdapter<
try {
const resolved = resolveMediaPrompt(options.prompt)
+ // Resolve the prior clip from previousJobId on this (generate) model, then
+ // submit to the edit sibling when one is mapped.
+ const editUrl = previousJobId
+ ? await this.resolvePreviousJobUrl(previousJobId)
+ : undefined
+ const submitModel =
+ previousJobId && this.model in FAL_VIDEO_EDIT_BY_SOURCE
+ ? FAL_VIDEO_EDIT_BY_SOURCE[
+ this.model as keyof typeof FAL_VIDEO_EDIT_BY_SOURCE
+ ]
+ : this.model
+ // The edited video rides the endpoint's regular video input: prepend
+ // it as a video part so the field mapping below routes it (video_url,
+ // or the endpoint's list field), ahead of any explicit video parts.
+ const videos = editUrl
+ ? [generatedVideoUrlToVideoPart(editUrl), ...resolved.videos]
+ : resolved.videos
const sizeParams = mapVideoSizeToFalFormat(size)
const inputImageFields = mapImageInputsToFalVideoFields(
- this.model,
+ submitModel,
resolved.images,
)
- const videoFields = mapVideoInputsToFalFields(resolved.videos)
+ const videoFields = mapVideoInputsToFalFields(submitModel, videos)
const audioFields = mapAudioInputsToFalFields(resolved.audios)
const input = {
@@ -180,13 +234,13 @@ export class FalVideoAdapter extends BaseVideoAdapter<
} as FalModelInput
// Submit to queue and get request ID
- const { request_id } = await fal.queue.submit(this.model, {
+ const { request_id } = await fal.queue.submit(submitModel, {
input,
})
return {
jobId: request_id,
- model: this.model,
+ model: submitModel,
}
} catch (error) {
logger.errors('fal.createVideoJob fatal', {
diff --git a/packages/ai-fal/src/index.ts b/packages/ai-fal/src/index.ts
index d4a73058f..ab1f5b141 100644
--- a/packages/ai-fal/src/index.ts
+++ b/packages/ai-fal/src/index.ts
@@ -46,6 +46,9 @@ export {
type FalModelOutput,
type FalModelImageSize,
type FalModelVideoSize,
+ type FalVideoEditKindFor,
+ type FalVideoEditSourceModel,
+ FAL_VIDEO_EDIT_BY_SOURCE,
} from './model-meta'
// ============================================================================
// Utils
diff --git a/packages/ai-fal/src/model-meta.ts b/packages/ai-fal/src/model-meta.ts
index c6f408356..0aea8ba0e 100644
--- a/packages/ai-fal/src/model-meta.ts
+++ b/packages/ai-fal/src/model-meta.ts
@@ -4,7 +4,7 @@
* These types give you full autocomplete and type safety for any model.
*/
import type { EndpointTypeMap } from '@fal-ai/client/endpoints'
-import type { MediaPromptModality } from '@tanstack/ai'
+import type { MediaPromptModality, VideoEditKind } from '@tanstack/ai'
import type { FalImageFieldName } from './image/generated/image-field-overrides'
export type { EndpointTypeMap } from '@fal-ai/client/endpoints'
@@ -184,6 +184,43 @@ export type FalVideoPromptModalitiesFor =
>
: ReadonlyArray
+/**
+ * Generate endpoints that edit via a dedicated video-to-video sibling.
+ * When `previousJobId` is set on the generate model, the fal adapter resolves
+ * the prior clip via `getVideoUrl(previousJobId)` on the generate endpoint,
+ * then submits to the mapped edit endpoint.
+ */
+export const FAL_VIDEO_EDIT_BY_SOURCE = {
+ 'xai/grok-imagine-video/text-to-video':
+ 'xai/grok-imagine-video/edit-video',
+ 'xai/grok-imagine-video/image-to-video':
+ 'xai/grok-imagine-video/edit-video',
+} as const
+
+export type FalVideoEditSourceModel = keyof typeof FAL_VIDEO_EDIT_BY_SOURCE
+
+/**
+ * Follow-up edit kind for a fal video endpoint. An endpoint supports
+ * `previousJobId` when:
+ * - it declares a video-conditioning input (`video_url` / `video_urls` /
+ * `reference_video_urls`), or
+ * - it is a generate endpoint with a known edit sibling in
+ * `FAL_VIDEO_EDIT_BY_SOURCE` (e.g. Grok text/image-to-video → edit-video).
+ * Endpoints unknown to the installed SDK are unconstrained and gated at the
+ * API instead.
+ */
+export type FalVideoEditKindFor =
+ TModel extends FalVideoEditSourceModel
+ ? 'media'
+ : TModel extends keyof EndpointTypeMap
+ ? Extract<
+ keyof FalModelInput,
+ 'video_url' | 'video_urls' | 'reference_video_urls'
+ > extends never
+ ? undefined
+ : 'media'
+ : VideoEditKind
+
/**
* Provider options for video generation, excluding fields TanStack AI handles.
* Use this for the `modelOptions` parameter in video generation.
diff --git a/packages/ai-fal/tests/video-adapter.test.ts b/packages/ai-fal/tests/video-adapter.test.ts
index b3778406f..73bea84c2 100644
--- a/packages/ai-fal/tests/video-adapter.test.ts
+++ b/packages/ai-fal/tests/video-adapter.test.ts
@@ -259,6 +259,103 @@ describe('Fal Video Adapter', () => {
})
})
+ describe('previousJobId (video-to-video endpoints)', () => {
+ it('reports media-kind edit support', () => {
+ const adapter = createAdapter()
+ expect(adapter.supportedEditKind()).toBe('media')
+ })
+
+ it('resolves previousJobId and routes onto video_url', async () => {
+ mockQueueResult.mockResolvedValueOnce({
+ data: { video: { url: 'https://example.com/source.mp4' } },
+ })
+ mockQueueSubmit.mockResolvedValueOnce({ request_id: 'job-edit' })
+
+ const adapter = falVideo('xai/grok-imagine-video/edit-video', {
+ apiKey: 'test-key',
+ })
+
+ await adapter.createVideoJob({
+ model: 'xai/grok-imagine-video/edit-video',
+ prompt: 'Give the rider a red scarf',
+ previousJobId: 'prior-job',
+ logger: testLogger,
+ })
+
+ expect(mockQueueResult).toHaveBeenCalledWith(
+ 'xai/grok-imagine-video/edit-video',
+ { requestId: 'prior-job' },
+ )
+ const [, options] = mockQueueSubmit.mock.calls[0]!
+ expect(options.input).toEqual({
+ prompt: 'Give the rider a red scarf',
+ video_url: 'https://example.com/source.mp4',
+ })
+ })
+
+ it('routes previousJobId onto the video_urls list for Seedance reference-to-video', async () => {
+ mockQueueResult.mockResolvedValueOnce({
+ data: { video: { url: 'https://example.com/source.mp4' } },
+ })
+ mockQueueSubmit.mockResolvedValueOnce({ request_id: 'job-seedance' })
+
+ const adapter = falVideo('bytedance/seedance-2.0/reference-to-video', {
+ apiKey: 'test-key',
+ })
+
+ await adapter.createVideoJob({
+ model: 'bytedance/seedance-2.0/reference-to-video',
+ prompt: 'Replace the background with a beach (@Video1)',
+ previousJobId: 'prior-job',
+ logger: testLogger,
+ })
+
+ const [, options] = mockQueueSubmit.mock.calls[0]!
+ expect(options.input).toEqual({
+ prompt: 'Replace the background with a beach (@Video1)',
+ video_urls: ['https://example.com/source.mp4'],
+ })
+ })
+
+ it('routes generate-model edits onto the mapped edit-video endpoint', async () => {
+ mockQueueResult.mockResolvedValueOnce({
+ data: { video: { url: 'https://example.com/resolved.mp4' } },
+ })
+ mockQueueSubmit.mockResolvedValueOnce({ request_id: 'job-edit' })
+
+ const adapter = falVideo('xai/grok-imagine-video/text-to-video', {
+ apiKey: 'test-key',
+ })
+
+ const result = await adapter.createVideoJob({
+ model: 'xai/grok-imagine-video/text-to-video',
+ prompt: 'Make it rain',
+ previousJobId: 'prior-job',
+ logger: testLogger,
+ })
+
+ // Resolve against the generate endpoint that produced the job.
+ expect(mockQueueResult).toHaveBeenCalledWith(
+ 'xai/grok-imagine-video/text-to-video',
+ { requestId: 'prior-job' },
+ )
+ // Submit to the edit sibling.
+ expect(mockQueueSubmit).toHaveBeenCalledWith(
+ 'xai/grok-imagine-video/edit-video',
+ expect.objectContaining({
+ input: {
+ prompt: 'Make it rain',
+ video_url: 'https://example.com/resolved.mp4',
+ },
+ }),
+ )
+ expect(result).toEqual({
+ jobId: 'job-edit',
+ model: 'xai/grok-imagine-video/edit-video',
+ })
+ })
+ })
+
describe('getVideoStatus', () => {
it('returns pending status for queued jobs', async () => {
mockQueueStatus.mockResolvedValueOnce({
diff --git a/packages/ai-gemini/src/adapters/video.ts b/packages/ai-gemini/src/adapters/video.ts
index 4fc2897fd..994f5cefd 100644
--- a/packages/ai-gemini/src/adapters/video.ts
+++ b/packages/ai-gemini/src/adapters/video.ts
@@ -15,6 +15,7 @@ import type {
ImagePart,
MediaInputMetadata,
TokenUsage,
+ VideoEditKind,
VideoGenerationOptions,
VideoJobResult,
VideoPart,
@@ -32,6 +33,7 @@ import type {
GeminiOmniVideoProviderOptions,
GeminiVideoModel,
GeminiVideoModelDurationByName,
+ GeminiVideoModelEditByName,
GeminiVideoModelInputModalitiesByName,
GeminiVideoModelProviderOptionsByName,
GeminiVideoModelSizeByName,
@@ -235,8 +237,7 @@ function interactionUsageToTokenUsage(
* Files API URI when the server delivers by reference). Image and video
* prompt parts are sent as interaction content blocks, grouped as images,
* then videos, then the text prompt (interleaving is not preserved); pass
- * `modelOptions.previous_interaction_id` to conversationally edit a prior
- * Omni generation.
+ * `previousJobId` to conversationally edit a prior Omni generation.
*
* @experimental Video generation is an experimental feature and may change.
*/
@@ -248,7 +249,8 @@ export class GeminiVideoAdapter<
GeminiVideoModelProviderOptionsByName,
GeminiVideoModelSizeByName,
GeminiVideoModelInputModalitiesByName,
- GeminiVideoModelDurationByName
+ GeminiVideoModelDurationByName,
+ GeminiVideoModelEditByName
> {
readonly name = 'gemini' as const
@@ -261,6 +263,15 @@ export class GeminiVideoAdapter<
this.allowUrlFetch = config.allowUrlFetch ?? false
}
+ /**
+ * Omni Flash edits a prior generation by chaining its interaction id
+ * (`previous_interaction_id`); Veo models cannot edit previous
+ * generations.
+ */
+ override supportedEditKind(): VideoEditKind | undefined {
+ return isInteractionsVideoModel(this.model) ? 'job' : undefined
+ }
+
async createVideoJob(
options: VideoGenerationOptions<
GeminiVideoModelProviderOptionsByName[TModel],
@@ -343,7 +354,7 @@ export class GeminiVideoAdapter<
GeminiVideoModelDurationByName[TModel]
>,
): Promise {
- const { prompt, size, duration, logger } = options
+ const { prompt, size, duration, logger, previousJobId } = options
const modelOptions = options.modelOptions as
| GeminiOmniVideoProviderOptions
| undefined
@@ -401,6 +412,9 @@ export class GeminiVideoAdapter<
const interaction = await this.client.interactions.create({
...modelOptions,
+ // previousJobId chains a follow-up edit onto the prior interaction;
+ // map it onto the wire's previous_interaction_id.
+ ...(previousJobId && { previous_interaction_id: previousJobId }),
model: this.model,
input: [{ type: 'user_input', content }],
response_modalities: ['video'],
diff --git a/packages/ai-gemini/src/index.ts b/packages/ai-gemini/src/index.ts
index 245f892cb..e157dc16d 100644
--- a/packages/ai-gemini/src/index.ts
+++ b/packages/ai-gemini/src/index.ts
@@ -81,6 +81,7 @@ export type {
GeminiOmniVideoProviderOptions,
GeminiVideoModel,
GeminiVideoModelDurationByName,
+ GeminiVideoModelEditByName,
GeminiVideoModelInputModalitiesByName,
GeminiVideoModelProviderOptionsByName,
GeminiVideoModelSizeByName,
diff --git a/packages/ai-gemini/src/video/video-provider-options.ts b/packages/ai-gemini/src/video/video-provider-options.ts
index 487e5d2df..38571e09e 100644
--- a/packages/ai-gemini/src/video/video-provider-options.ts
+++ b/packages/ai-gemini/src/video/video-provider-options.ts
@@ -89,12 +89,14 @@ export type GeminiVideoProviderOptions = Omit<
* - `tools` / `response_mime_type` — not applicable to video generation
*
* Notable passthroughs:
- * - `previous_interaction_id` — conversational video editing: chain a new
- * prompt onto a prior Omni interaction to refine its video
* - `generation_config.video_config.task` — pin the task mode
* (`'text_to_video' | 'image_to_video' | 'reference_to_video' | 'edit'`)
* instead of letting the model infer it
*
+ * Conversational video editing uses the top-level `previousJobId` option
+ * (mapped onto the wire's `previous_interaction_id` by the adapter), not
+ * a modelOptions field.
+ *
* @experimental Omni video generation is an experimental feature and may change.
*/
export type GeminiOmniVideoProviderOptions = Omit<
@@ -107,6 +109,7 @@ export type GeminiOmniVideoProviderOptions = Omit<
| 'response_format'
| 'response_mime_type'
| 'tools'
+ | 'previous_interaction_id'
>
/**
@@ -143,6 +146,20 @@ export type GeminiVideoModelInputModalitiesByName = {
: readonly ['image']
}
+/**
+ * Per-model follow-up edit support. Omni Flash conversationally edits a
+ * prior generation by chaining its interaction id (the job id) via the
+ * Interactions API's `previous_interaction_id`, so `previousJobId` is the
+ * prior generation's job id. Veo models cannot edit previous generations.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export type GeminiVideoModelEditByName = {
+ [TModel in GeminiVideoModel]: TModel extends GeminiInteractionsVideoModel
+ ? 'job'
+ : undefined
+}
+
/**
* Per-model duration unions (seconds, as numbers — Veo's
* `parameters.durationSeconds` field is numeric; Omni Flash accepts a
diff --git a/packages/ai-gemini/tests/video-adapter.test.ts b/packages/ai-gemini/tests/video-adapter.test.ts
index 627ad991b..9d4c3e7e2 100644
--- a/packages/ai-gemini/tests/video-adapter.test.ts
+++ b/packages/ai-gemini/tests/video-adapter.test.ts
@@ -720,7 +720,7 @@ describe('Gemini Omni Flash Video Adapter (Interactions API)', () => {
await adapter.createVideoJob({
model: 'gemini-omni-flash-preview',
prompt: 'make the violin invisible',
- modelOptions: { previous_interaction_id: 'v1_prior-turn' },
+ previousJobId: 'v1_prior-turn',
logger: testLogger,
})
@@ -858,6 +858,41 @@ describe('Gemini Omni Flash Video Adapter (Interactions API)', () => {
})
})
+ describe('previousJobId (previous_interaction_id chaining)', () => {
+ it('reports job-kind edit support for Omni and none for Veo', () => {
+ expect(
+ createGeminiVideo(
+ 'gemini-omni-flash-preview',
+ 'test-key',
+ ).supportedEditKind(),
+ ).toBe('job')
+ expect(
+ createGeminiVideo(
+ 'veo-3.1-generate-preview',
+ 'test-key',
+ ).supportedEditKind(),
+ ).toBeUndefined()
+ })
+
+ it('maps previousJobId onto previous_interaction_id', async () => {
+ const stub = createInteractionsClientStub()
+ const adapter = new StubbedGeminiOmniVideoAdapter(stub)
+
+ await adapter.createVideoJob({
+ model: 'gemini-omni-flash-preview',
+ prompt: 'make the violin invisible',
+ previousJobId: 'v1_prior-turn',
+ logger: testLogger,
+ })
+
+ expect(stub.interactions.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ previous_interaction_id: 'v1_prior-turn',
+ }),
+ )
+ })
+ })
+
describe('getVideoStatus', () => {
const jobId = 'v1_omni-job-123'
diff --git a/packages/ai-grok/src/adapters/video.ts b/packages/ai-grok/src/adapters/video.ts
index 21807360e..2b5617e7e 100644
--- a/packages/ai-grok/src/adapters/video.ts
+++ b/packages/ai-grok/src/adapters/video.ts
@@ -3,6 +3,7 @@ import { BaseVideoAdapter, snapToDurationOption } from '@tanstack/ai/adapters'
import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
import { getGrokApiKeyFromEnv, withGrokDefaults } from '../utils/client'
import {
+ GROK_VIDEO_EDIT_KINDS,
getGrokVideoDurationOptions,
isImageToVideoOnlyModel,
parseGrokVideoSize,
@@ -13,6 +14,7 @@ import type {
ImagePart,
MediaInputMetadata,
TokenUsage,
+ VideoEditKind,
VideoGenerationOptions,
VideoJobResult,
VideoStatusResult,
@@ -21,6 +23,7 @@ import type {
import type { GrokVideoModel } from '../model-meta'
import type {
GrokVideoModelDurationByName,
+ GrokVideoModelEditByName,
GrokVideoModelInputModalitiesByName,
GrokVideoModelProviderOptionsByName,
GrokVideoModelSizeByName,
@@ -119,7 +122,8 @@ export class GrokVideoAdapter<
GrokVideoModelProviderOptionsByName,
GrokVideoModelSizeByName,
GrokVideoModelInputModalitiesByName,
- GrokVideoModelDurationByName
+ GrokVideoModelDurationByName,
+ GrokVideoModelEditByName
> {
readonly name = 'grok' as const
@@ -172,6 +176,14 @@ export class GrokVideoAdapter<
return body
}
+ /**
+ * `grok-imagine-video` edits a prior clip by URL via `/videos/edits`;
+ * `grok-imagine-video-1.5` has no documented edit endpoint.
+ */
+ override supportedEditKind(): VideoEditKind | undefined {
+ return GROK_VIDEO_EDIT_KINDS[this.model]
+ }
+
async createVideoJob(
options: VideoGenerationOptions<
GrokVideoProviderOptions,
@@ -181,6 +193,10 @@ export class GrokVideoAdapter<
): Promise {
const { model, size, modelOptions, logger } = options
+ if (options.previousJobId) {
+ return this.editVideoJob(options)
+ }
+
validateVideoSize(model, size)
// Coerce the requested duration into the model's valid range (1–15s,
@@ -277,6 +293,99 @@ export class GrokVideoAdapter<
}
}
+ /**
+ * Edit a previously generated clip via `POST /videos/edits`. The endpoint
+ * takes the source video by URL (public URL or base64 data URI) plus an
+ * edit prompt, and returns the same `{ request_id }` polled by
+ * `getVideoStatus` / `getVideoUrl`. The output inherits its duration and
+ * aspect ratio from the input (capped at 720p, truncated to 8 seconds),
+ * so conflicting options are rejected up front instead of being silently
+ * ignored by the API.
+ */
+ private async editVideoJob(
+ options: VideoGenerationOptions<
+ GrokVideoProviderOptions,
+ GrokVideoModelSizeByName[TModel],
+ GrokVideoModelDurationByName[TModel]
+ >,
+ ): Promise {
+ const { model, size, duration, modelOptions, logger, previousJobId } =
+ options
+
+ if (this.supportedEditKind() === undefined) {
+ throw new Error(
+ `${this.name}: model "${model}" does not support editing previous generations (previousJobId).`,
+ )
+ }
+ if (!previousJobId) {
+ throw new Error(
+ `${this.name}: previousJobId is required to edit a previous generation with model "${model}".`,
+ )
+ }
+ if (
+ size !== undefined ||
+ duration !== undefined ||
+ modelOptions?.aspect_ratio !== undefined ||
+ modelOptions?.resolution !== undefined ||
+ modelOptions?.duration !== undefined
+ ) {
+ throw new Error(
+ `${this.name}: video edits inherit duration and aspect ratio from the source video — remove the size/duration options when using previousJobId.`,
+ )
+ }
+
+ const resolved = resolveMediaPrompt(options.prompt)
+ if (
+ resolved.images.length > 0 ||
+ resolved.videos.length > 0 ||
+ resolved.audios.length > 0
+ ) {
+ throw new Error(
+ `${this.name}: video edits accept only a text prompt; media prompt parts are not supported when previousJobId is set.`,
+ )
+ }
+ if (!resolved.text) {
+ throw new Error(
+ `${this.name}: video edits require a text prompt describing the edit.`,
+ )
+ }
+
+ const sourceUrl = await this.resolvePreviousJobUrl(previousJobId)
+
+ try {
+ logger.request(
+ `activity=video.edit provider=${this.name} model=${model}`,
+ { provider: this.name, model },
+ )
+
+ const response = await this.request('/videos/edits', {
+ method: 'POST',
+ body: JSON.stringify({
+ model,
+ prompt: resolved.text,
+ video_url: sourceUrl,
+ }),
+ })
+ if (!response.ok) {
+ throw new Error(
+ `grok: video edit request failed (${response.status} ${response.statusText}): ${await this.errorMessage(response)}`,
+ )
+ }
+
+ const result = (await response.json()) as GrokVideoCreateResponse
+ if (!result.request_id) {
+ throw new Error('grok: video edit response contained no request_id')
+ }
+ return { jobId: result.request_id, model }
+ } catch (error: unknown) {
+ logger.errors(`${this.name}.editVideoJob fatal`, {
+ error: toRunErrorPayload(error, `${this.name}.editVideoJob failed`),
+ source: `${this.name}.editVideoJob`,
+ })
+ throw error
+ }
+ }
+
private async retrieveJob(jobId: string): Promise {
const response = await this.request(`/videos/${jobId}`)
if (!response.ok) {
diff --git a/packages/ai-grok/src/index.ts b/packages/ai-grok/src/index.ts
index e342645ca..4a53c64eb 100644
--- a/packages/ai-grok/src/index.ts
+++ b/packages/ai-grok/src/index.ts
@@ -40,6 +40,7 @@ export {
} from './adapters/video'
export {
GROK_VIDEO_DURATIONS,
+ GROK_VIDEO_EDIT_KINDS,
getGrokVideoDurationOptions,
} from './video/video-provider-options'
export type {
@@ -47,6 +48,7 @@ export type {
GrokVideoModelProviderOptionsByName,
GrokVideoModelSizeByName,
GrokVideoModelDurationByName,
+ GrokVideoModelEditByName,
GrokVideoAspectRatio,
GrokVideoResolution,
GrokVideoSize,
diff --git a/packages/ai-grok/src/video/video-provider-options.ts b/packages/ai-grok/src/video/video-provider-options.ts
index b84c03f8b..91395ae7a 100644
--- a/packages/ai-grok/src/video/video-provider-options.ts
+++ b/packages/ai-grok/src/video/video-provider-options.ts
@@ -218,6 +218,31 @@ export type GrokVideoModelInputModalitiesByName = {
'grok-imagine-video-1.5': readonly ['image']
}
+/**
+ * Per-model follow-up edit support. `grok-imagine-video` edits a previously
+ * generated clip via `POST /v1/videos/edits`. Callers pass `previousJobId`;
+ * the adapter resolves the finished clip via `getVideoUrl`. The edit
+ * endpoint is documented for `grok-imagine-video` only.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export type GrokVideoModelEditByName = {
+ 'grok-imagine-video': 'media'
+ 'grok-imagine-video-1.5': undefined
+}
+
+/**
+ * Runtime table backing `supportedEditKind()`.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export const GROK_VIDEO_EDIT_KINDS: {
+ readonly [TModel in GrokVideoModel]: GrokVideoModelEditByName[TModel]
+} = {
+ 'grok-imagine-video': 'media',
+ 'grok-imagine-video-1.5': undefined,
+}
+
/**
* Models that only support image-to-video — a starting-frame image is
* required and text-to-video is rejected by the Imagine API. Used by the
diff --git a/packages/ai-grok/tests/video-adapter.test.ts b/packages/ai-grok/tests/video-adapter.test.ts
index a6239adbc..1a7ba5d17 100644
--- a/packages/ai-grok/tests/video-adapter.test.ts
+++ b/packages/ai-grok/tests/video-adapter.test.ts
@@ -641,4 +641,147 @@ describe('Grok Video Adapter', () => {
expect(adapter.snapDuration(7)).toBe(7)
})
})
+
+ describe('previousJobId (/videos/edits)', () => {
+ it('reports media-kind edit support for grok-imagine-video only', () => {
+ expect(
+ createGrokVideo('grok-imagine-video', 'k').supportedEditKind(),
+ ).toBe('media')
+ expect(
+ createGrokVideo('grok-imagine-video-1.5', 'k').supportedEditKind(),
+ ).toBeUndefined()
+ })
+
+ it('posts the edit prompt and resolved source URL to /videos/edits', async () => {
+ const fetchMock = vi.fn(
+ async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = String(input)
+ if (url.includes('/videos/edits')) {
+ return jsonResponse({ request_id: 'edit-req-1' })
+ }
+ return jsonResponse({
+ status: 'done',
+ video: { url: 'https://example.com/source.mp4' },
+ })
+ },
+ )
+ const adapter = createGrokVideo('grok-imagine-video', 'test-api-key', {
+ fetch: fetchMock,
+ })
+
+ const result = await adapter.createVideoJob({
+ model: 'grok-imagine-video',
+ prompt: 'Give the rider a red scarf',
+ previousJobId: 'prior-job',
+ logger: testLogger,
+ })
+
+ expect(result).toEqual({
+ jobId: 'edit-req-1',
+ model: 'grok-imagine-video',
+ })
+ expect(String(fetchMock.mock.calls[0]![0])).toContain('/videos/prior-job')
+ const [, editInit] = fetchMock.mock.calls[1]!
+ expect(String(fetchMock.mock.calls[1]![0])).toContain('/videos/edits')
+ expect(JSON.parse(String(editInit?.body))).toEqual({
+ model: 'grok-imagine-video',
+ prompt: 'Give the rider a red scarf',
+ video_url: 'https://example.com/source.mp4',
+ })
+ })
+
+ it('throws for the image-to-video-only model', async () => {
+ const fetchMock = mockFetch(() =>
+ jsonResponse({ request_id: 'edit-req-1' }),
+ )
+ const adapter = adapterWithFetch(fetchMock)
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'grok-imagine-video-1.5',
+ prompt: 'x',
+ previousJobId: 'prior-job',
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/does not support editing previous generations/)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects size/duration options and media parts on edit', async () => {
+ const fetchMock = mockFetch(() =>
+ jsonResponse({ request_id: 'edit-req-1' }),
+ )
+ const adapter = createGrokVideo('grok-imagine-video', 'test-api-key', {
+ fetch: fetchMock,
+ })
+ const previousJobId = 'prior-job'
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'grok-imagine-video',
+ prompt: 'x',
+ size: '16:9_720p',
+ previousJobId,
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/inherit duration and aspect ratio/)
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'grok-imagine-video',
+ prompt: 'x',
+ duration: 8,
+ previousJobId,
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/inherit duration and aspect ratio/)
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'grok-imagine-video',
+ prompt: 'x',
+ modelOptions: { resolution: '720p' },
+ previousJobId,
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/inherit duration and aspect ratio/)
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'grok-imagine-video',
+ prompt: i2vPrompt('x'),
+ previousJobId,
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/media prompt parts are not supported/)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('surfaces edit endpoint errors with the API message', async () => {
+ const fetchMock = vi.fn(
+ async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = String(input)
+ if (url.includes('/videos/edits')) {
+ return jsonResponse({ code: 'bad', error: 'video too long' }, 400)
+ }
+ return jsonResponse({
+ status: 'done',
+ video: { url: 'https://example.com/source.mp4' },
+ })
+ },
+ )
+ const adapter = createGrokVideo('grok-imagine-video', 'test-api-key', {
+ fetch: fetchMock,
+ })
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'grok-imagine-video',
+ prompt: 'x',
+ previousJobId: 'prior-job',
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/video edit request failed/)
+ })
+ })
})
diff --git a/packages/ai-openai/src/adapters/video.ts b/packages/ai-openai/src/adapters/video.ts
index 3e76478d6..1e7431532 100644
--- a/packages/ai-openai/src/adapters/video.ts
+++ b/packages/ai-openai/src/adapters/video.ts
@@ -11,6 +11,7 @@ import {
validateVideoSize,
} from '../video/video-provider-options'
import type {
+ VideoEditKind,
VideoGenerationOptions,
VideoJobResult,
VideoStatusResult,
@@ -19,6 +20,7 @@ import type {
import type OpenAI_SDK from 'openai'
import type { OpenAIVideoModel } from '../model-meta'
import type {
+ OpenAIVideoModelEditByName,
OpenAIVideoModelInputModalitiesByName,
OpenAIVideoModelProviderOptionsByName,
OpenAIVideoModelSizeByName,
@@ -81,7 +83,9 @@ export class OpenAIVideoAdapter<
OpenAIVideoProviderOptions,
OpenAIVideoModelProviderOptionsByName,
OpenAIVideoModelSizeByName,
- OpenAIVideoModelInputModalitiesByName
+ OpenAIVideoModelInputModalitiesByName,
+ Record,
+ OpenAIVideoModelEditByName
> {
readonly name = 'openai' as const
@@ -98,11 +102,20 @@ export class OpenAIVideoAdapter<
this.client = new OpenAI(clientOptions)
}
+ /** Sora models remix a completed video by its job id. */
+ override supportedEditKind(): VideoEditKind {
+ return 'job'
+ }
+
async createVideoJob(
options: VideoGenerationOptions,
): Promise {
const { model, size, duration, modelOptions } = options
+ if (options.previousJobId) {
+ return this.remixVideoJob(options)
+ }
+
const resolvedSize = size ?? modelOptions?.size
validateVideoSize(model, resolvedSize)
const seconds = duration ?? modelOptions?.seconds
@@ -177,6 +190,67 @@ export class OpenAIVideoAdapter<
}
}
+ /**
+ * Remix a previously generated Sora video (`POST /videos/{id}/remix`).
+ * The endpoint accepts only an updated prompt — the output inherits size
+ * and duration from the source video — so conflicting options are
+ * rejected up front instead of being silently dropped.
+ */
+ private async remixVideoJob(
+ options: VideoGenerationOptions,
+ ): Promise {
+ const { model, size, duration, modelOptions, previousJobId } = options
+ if (!previousJobId) {
+ throw new Error(
+ `${this.name}: previousJobId is required to remix a previous Sora generation.`,
+ )
+ }
+ if (size !== undefined || modelOptions?.size !== undefined) {
+ throw new Error(
+ `${this.name}: Sora remix accepts only a prompt — the output inherits the source video's size. Remove the size option.`,
+ )
+ }
+ if (duration !== undefined || modelOptions?.seconds !== undefined) {
+ throw new Error(
+ `${this.name}: Sora remix accepts only a prompt — the output inherits the source video's duration. Remove the duration/seconds option.`,
+ )
+ }
+
+ const resolved = resolveMediaPrompt(options.prompt)
+ if (
+ resolved.images.length > 0 ||
+ resolved.videos.length > 0 ||
+ resolved.audios.length > 0
+ ) {
+ throw new Error(
+ `${this.name}: Sora remix accepts only a text prompt; media prompt parts are not supported when previousJobId is set.`,
+ )
+ }
+ if (!resolved.text) {
+ throw new Error(
+ `${this.name}: Sora remix requires a text prompt describing the edit.`,
+ )
+ }
+
+ try {
+ options.logger.request(
+ `activity=video.remix provider=${this.name} model=${model} source=${previousJobId}`,
+ { provider: this.name, model },
+ )
+ const videosClient = this.getVideosClient()
+ const response = await videosClient.remix(previousJobId, {
+ prompt: resolved.text,
+ })
+ return { jobId: response.id, model }
+ } catch (error: any) {
+ options.logger.errors(`${this.name}.remixVideoJob fatal`, {
+ error: toRunErrorPayload(error, `${this.name}.remixVideoJob failed`),
+ source: `${this.name}.remixVideoJob`,
+ })
+ throw error
+ }
+ }
+
/**
* The video API on the OpenAI SDK is still experimental and shipped on some
* SDK versions but not others; access through `videosClient` lets us treat
@@ -184,6 +258,7 @@ export class OpenAIVideoAdapter<
*/
private getVideosClient(): {
create: (req: Record) => Promise<{ id: string }>
+ remix: (id: string, body: { prompt: string }) => Promise<{ id: string }>
retrieve: (id: string) => Promise<{
id: string
status: string
diff --git a/packages/ai-openai/src/index.ts b/packages/ai-openai/src/index.ts
index fdeb79d57..307240d29 100644
--- a/packages/ai-openai/src/index.ts
+++ b/packages/ai-openai/src/index.ts
@@ -52,6 +52,7 @@ export {
export type {
OpenAIVideoProviderOptions,
OpenAIVideoModelProviderOptionsByName,
+ OpenAIVideoModelEditByName,
OpenAIVideoSize,
// OpenAIVideoDuration,
} from './video/video-provider-options'
diff --git a/packages/ai-openai/src/video/video-provider-options.ts b/packages/ai-openai/src/video/video-provider-options.ts
index 837c2c7b2..f8380ada2 100644
--- a/packages/ai-openai/src/video/video-provider-options.ts
+++ b/packages/ai-openai/src/video/video-provider-options.ts
@@ -77,6 +77,19 @@ export type OpenAIVideoModelInputModalitiesByName = {
'sora-2-pro': readonly ['image']
}
+/**
+ * Per-model follow-up edit support. Sora models remix a completed video by
+ * its job id (`POST /videos/{id}/remix`), so `previousJobId` is the prior
+ * generation's job id. Remix accepts only a prompt — size, duration, and
+ * image inputs are rejected.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export type OpenAIVideoModelEditByName = {
+ 'sora-2': 'job'
+ 'sora-2-pro': 'job'
+}
+
/**
* Validate video size for a given model.
*
diff --git a/packages/ai-openai/tests/video-adapter.test.ts b/packages/ai-openai/tests/video-adapter.test.ts
index 158b85ce8..09123a334 100644
--- a/packages/ai-openai/tests/video-adapter.test.ts
+++ b/packages/ai-openai/tests/video-adapter.test.ts
@@ -12,10 +12,11 @@ const testLogger = resolveDebugOption(false)
function mockedAdapter() {
const adapter = createOpenaiVideo('sora-2', 'test-api-key')
const mockCreate = vi.fn().mockResolvedValue({ id: 'video-job-1' })
+ const mockRemix = vi.fn().mockResolvedValue({ id: 'video-job-remix-1' })
;(adapter as unknown as { client: { videos: unknown } }).client = {
- videos: { create: mockCreate },
+ videos: { create: mockCreate, remix: mockRemix },
}
- return { adapter, mockCreate }
+ return { adapter, mockCreate, mockRemix }
}
describe('OpenAI Video Adapter', () => {
@@ -165,4 +166,92 @@ describe('OpenAI Video Adapter', () => {
expect(mockCreate).not.toHaveBeenCalled()
})
})
+
+ describe('previousJobId (Sora remix)', () => {
+ it('reports job-kind edit support', () => {
+ const adapter = createOpenaiVideo('sora-2', 'test-api-key')
+ expect(adapter.supportedEditKind()).toBe('job')
+ })
+
+ it('remixes the source video with the new prompt', async () => {
+ const { adapter, mockCreate, mockRemix } = mockedAdapter()
+
+ const result = await adapter.createVideoJob({
+ model: 'sora-2',
+ prompt: 'Make the sky stormy',
+ previousJobId: 'video-job-1',
+ logger: testLogger,
+ })
+
+ expect(mockRemix).toHaveBeenCalledWith('video-job-1', {
+ prompt: 'Make the sky stormy',
+ })
+ expect(mockCreate).not.toHaveBeenCalled()
+ expect(result).toEqual({ jobId: 'video-job-remix-1', model: 'sora-2' })
+ })
+
+ it('rejects size and duration options on remix', async () => {
+ const { adapter, mockRemix } = mockedAdapter()
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'sora-2',
+ prompt: 'x',
+ size: '1280x720',
+ previousJobId: 'video-job-1',
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/inherits the source video's size/)
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'sora-2',
+ prompt: 'x',
+ duration: 8,
+ previousJobId: 'video-job-1',
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/inherits the source video's duration/)
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'sora-2',
+ prompt: 'x',
+ modelOptions: { seconds: '8' },
+ previousJobId: 'video-job-1',
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/inherits the source video's duration/)
+ expect(mockRemix).not.toHaveBeenCalled()
+ })
+
+ it('rejects media prompt parts and empty prompts on remix', async () => {
+ const { adapter, mockRemix } = mockedAdapter()
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'sora-2',
+ prompt: [
+ { type: 'text', content: 'x' },
+ {
+ type: 'image',
+ source: { type: 'data', value: 'aGk=', mimeType: 'image/png' },
+ },
+ ],
+ previousJobId: 'video-job-1',
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/media prompt parts are not supported/)
+
+ await expect(
+ adapter.createVideoJob({
+ model: 'sora-2',
+ prompt: '',
+ previousJobId: 'video-job-1',
+ logger: testLogger,
+ }),
+ ).rejects.toThrow(/requires a text prompt/)
+ expect(mockRemix).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md
index 268a85f74..ce734ea05 100644
--- a/packages/ai/skills/ai-core/media-generation/SKILL.md
+++ b/packages/ai/skills/ai-core/media-generation/SKILL.md
@@ -493,8 +493,8 @@ Image/video prompt parts are sent as interaction content blocks, grouped
as images, then videos, then text (no
`metadata.role` routing); `data` sources go inline, `url` sources pass
through as-is (never downloaded — use Gemini Files API URIs for remote
-media). For conversational editing, pass a prior generation's `jobId` as
-`modelOptions.previous_interaction_id` with a prompt describing the change:
+media). For conversational editing, pass a prior generation's job id via
+`previousJobId` with a prompt describing the change:
```typescript
import { geminiVideo } from '@tanstack/ai-gemini'
@@ -508,13 +508,28 @@ const first = await generateVideo({
const edited = await generateVideo({
adapter: omni,
prompt: 'Make the violin invisible',
- modelOptions: { previous_interaction_id: first.jobId },
+ previousJobId: first.jobId,
})
```
+`previousJobId` is the general follow-up-edit option for video. Callers pass
+the prior generation's job id; `'job'`-kind models reference it server-side
+(Sora 2 / Sora 2 Pro remix, Omni Flash interaction chaining) while
+`'media'`-kind models resolve the finished clip via `getVideoUrl`
+(`grokVideo('grok-imagine-video')` → xAI `/videos/edits`; fal video-to-video
+endpoints like `xai/grok-imagine-video/edit-video`). Non-editing models (Veo,
+`grok-imagine-video-1.5`) reject the option at compile time;
+`adapter.supportedEditKind()` reports `'job' | 'media' | undefined` at
+runtime. Sora remix and Grok edits take only a prompt — `size`/`duration`
+are rejected because the output inherits them from the source video.
+`generateImage` has the same option for image models that accept image
+inputs: `previousImage: priorResult.images[0]` prepends the previous image to
+the prompt so it flows through the model's regular edit path.
+
Other video adapters: `openaiVideo('sora-2')` (pixel sizes like `'1280x720'`,
-durations 4/8/12s, single `input_reference` image prompt part), `grokVideo(...)`
-(`grok-imagine-video` does text-to-video + image-to-video; `grok-imagine-video-1.5` is
+durations 4/8/12s, single `input_reference` image prompt part, remix via
+`previousJobId`), `grokVideo(...)`
+(`grok-imagine-video` does text-to-video + image-to-video + edits via `previousJobId`; `grok-imagine-video-1.5` is
image-to-video only — needs an `image` prompt part as the starting frame, text-only throws;
aspect-ratio size template like `'16:9_720p'`, integer durations 1-15s, reports
`usage.unitsBilled` seconds and exact `usage.cost`), and `falVideo(...)` (hosted models, see cost tracking below).
diff --git a/packages/ai/src/activities/generateImage/index.ts b/packages/ai/src/activities/generateImage/index.ts
index 8021e0ce2..6d2034817 100644
--- a/packages/ai/src/activities/generateImage/index.ts
+++ b/packages/ai/src/activities/generateImage/index.ts
@@ -15,15 +15,20 @@ import {
runGenerationStart,
runGenerationUsage,
} from '../middleware/run'
-import { resolveMediaPrompt } from '../../utilities/media-prompt'
+import {
+ generatedImageToImagePart,
+ resolveMediaPrompt,
+} from '../../utilities/media-prompt'
import type { InternalLogger } from '../../logger/internal-logger'
import type { DebugOption } from '../../logger/types'
import type { GenerationMiddleware } from '../middleware/types'
import type { ImageAdapter } from './adapter'
import type {
+ GeneratedImage,
ImageGenerationResult,
MediaPrompt,
MediaPromptFor,
+ MediaPromptPart,
StreamChunk,
} from '../../types'
@@ -86,6 +91,35 @@ export type ImagePromptForModel =
: MediaPrompt
: MediaPrompt
+/**
+ * Previously generated image(s) accepted by `generateImage`'s `previousImage`:
+ * a single {@link GeneratedImage}, an array of them, or the whole prior
+ * {@link ImageGenerationResult} (its `images` are used).
+ */
+export type ImagePreviousSource =
+ | GeneratedImage
+ | ReadonlyArray
+ | Pick
+
+/**
+ * Extract the `previousImage` type for an ImageAdapter's model via ~types.
+ * Follow-up image edits work by re-passing the generated image as an image
+ * prompt part, so the option is offered exactly when the model accepts
+ * image inputs (`'image'` in its input-modality map); text-only models
+ * (DALL·E 3, Imagen) reject it at compile time. Adapters without a map fall
+ * back to accepting it, gated by the adapter's own runtime errors.
+ */
+export type ImagePreviousImageForModel =
+ TAdapter extends ImageAdapter
+ ? string extends keyof ModsByName
+ ? ImagePreviousSource
+ : TModel extends keyof ModsByName
+ ? 'image' extends ModsByName[TModel][number]
+ ? ImagePreviousSource
+ : never
+ : ImagePreviousSource
+ : ImagePreviousSource
+
// ===========================
// Activity Options Type
// ===========================
@@ -149,7 +183,22 @@ export type ImageActivityOptions<
TAdapter,
TAdapter['model']
>
- })
+ }) &
+ ([ImagePreviousImageForModel] extends [never]
+ ? {
+ /** This model does not accept image inputs, so it cannot edit previous generations. */
+ previousImage?: never
+ }
+ : {
+ /**
+ * Previously generated image(s) to edit instead of generating from
+ * scratch — pass a `GeneratedImage`, an array of them, or the whole
+ * prior `ImageGenerationResult`. They are prepended to the prompt as
+ * image parts and consumed by the model's existing edit path. Only
+ * offered for models that accept image inputs.
+ */
+ previousImage?: ImagePreviousImageForModel
+ })
// ===========================
// Activity Result Type
@@ -233,6 +282,30 @@ export function generateImage<
return runGenerateImage(options) as ImageActivityResult
}
+/**
+ * Normalize a `previousImage` value to the images it references and prepend
+ * them to the prompt as image parts, so the adapter's existing
+ * image-conditioned path (edit endpoint, `inlineData`, ...) consumes them.
+ */
+function prependPreviousImages(
+ prompt: MediaPrompt,
+ previousImage: ImagePreviousSource,
+): Array {
+ const images: ReadonlyArray =
+ 'images' in previousImage
+ ? previousImage.images
+ : Array.isArray(previousImage)
+ ? previousImage
+ : [previousImage]
+ if (images.length === 0) {
+ throw new Error('generateImage: previousImage contained no images.')
+ }
+ const imageParts = images.map(generatedImageToImagePart)
+ const promptParts: Array =
+ typeof prompt === 'string' ? [{ type: 'text', content: prompt }] : prompt
+ return [...imageParts, ...promptParts]
+}
+
/**
* Internal implementation of image generation (always non-streaming).
* Contains all devtools event emission logic.
@@ -247,8 +320,12 @@ async function runGenerateImage<
stream: _stream,
debug: _debug,
middleware,
+ previousImage,
...rest
} = options
+ const prompt: MediaPrompt = previousImage
+ ? prependPreviousImages(rest.prompt, previousImage)
+ : rest.prompt
const model = adapter.model
const requestId = createId('image')
const startTime = Date.now()
@@ -267,7 +344,7 @@ async function runGenerateImage<
// Devtools events carry the flattened prompt text plus media-part counts —
// the wire payload stays `prompt: string` regardless of the prompt shape.
- const resolved = resolveMediaPrompt(rest.prompt)
+ const resolved = resolveMediaPrompt(prompt)
aiEventClient.emit('image:request:started', {
requestId,
@@ -295,7 +372,12 @@ async function runGenerateImage<
})
try {
- const result = await adapter.generateImages({ ...rest, model, logger })
+ const result = await adapter.generateImages({
+ ...rest,
+ prompt,
+ model,
+ logger,
+ })
const duration = Date.now() - startTime
aiEventClient.emit('image:request:completed', {
diff --git a/packages/ai/src/activities/generateVideo/adapter.ts b/packages/ai/src/activities/generateVideo/adapter.ts
index 64dd0162e..0e01a8ad5 100644
--- a/packages/ai/src/activities/generateVideo/adapter.ts
+++ b/packages/ai/src/activities/generateVideo/adapter.ts
@@ -1,5 +1,7 @@
import type {
+ ModelEditKindByName,
ModelInputModalitiesByName,
+ VideoEditKind,
VideoGenerationOptions,
VideoJobResult,
VideoStatusResult,
@@ -56,6 +58,10 @@ export interface VideoAdapterConfig {
* - TModelDurationByName: Map from model name to its supported duration
* union. Defaults to `Record` so adapters that haven't
* declared a map keep today's `duration?: number` typing.
+ * - TModelEditByName: Map from model name to how it edits previously
+ * generated videos (`'job' | 'media' | undefined`). Defaults to the loose
+ * `ModelEditKindByName` so adapters that haven't declared a map keep an
+ * unconstrained `previousJobId` option (runtime-gated).
*/
export interface VideoAdapter<
TModel extends string = string,
@@ -69,6 +75,7 @@ export interface VideoAdapter<
ModelInputModalitiesByName,
TModelDurationByName extends Record =
Record,
+ TModelEditByName extends ModelEditKindByName = ModelEditKindByName,
> {
/** Discriminator for adapter kind - used to determine API shape */
readonly kind: 'video'
@@ -86,6 +93,7 @@ export interface VideoAdapter<
modelSizeByName: TModelSizeByName
modelInputModalitiesByName: TModelInputModalitiesByName
modelDurationByName: TModelDurationByName
+ modelEditByName: TModelEditByName
}
/**
@@ -123,13 +131,20 @@ export interface VideoAdapter<
* Returns `undefined` for models with no duration field.
*/
snapDuration: (seconds: number) => TModelDurationByName[TModel] | undefined
+
+ /**
+ * How this adapter's model edits previously generated videos, or
+ * `undefined` when it cannot. Consumed by the generateVideo() activity to
+ * gate `previousJobId` at runtime.
+ */
+ supportedEditKind: () => VideoEditKind | undefined
}
/**
* A VideoAdapter with any/unknown type parameters.
* Useful as a constraint in generic functions and interfaces.
*/
-export type AnyVideoAdapter = VideoAdapter
+export type AnyVideoAdapter = VideoAdapter
/**
* Abstract base class for video generation adapters.
@@ -151,13 +166,15 @@ export abstract class BaseVideoAdapter<
ModelInputModalitiesByName,
TModelDurationByName extends Record =
Record,
+ TModelEditByName extends ModelEditKindByName = ModelEditKindByName,
> implements VideoAdapter<
TModel,
TProviderOptions,
TModelProviderOptionsByName,
TModelSizeByName,
TModelInputModalitiesByName,
- TModelDurationByName
+ TModelDurationByName,
+ TModelEditByName
> {
readonly kind = 'video' as const
abstract readonly name: string
@@ -170,6 +187,7 @@ export abstract class BaseVideoAdapter<
modelSizeByName: TModelSizeByName
modelInputModalitiesByName: TModelInputModalitiesByName
modelDurationByName: TModelDurationByName
+ modelEditByName: TModelEditByName
}
protected config: VideoAdapterConfig
@@ -207,6 +225,30 @@ export abstract class BaseVideoAdapter<
return undefined
}
+ /**
+ * Default implementation returns `undefined` (no follow-up editing).
+ * Adapters whose models can edit previous generations should override.
+ */
+ supportedEditKind(): VideoEditKind | undefined {
+ return undefined
+ }
+
+ /**
+ * Resolve the source video URL for a media-kind edit by fetching the
+ * finished clip for `previousJobId`. Callers always pass the prior
+ * generation's job id; media-kind adapters call this instead of
+ * requiring a URL.
+ */
+ protected async resolvePreviousJobUrl(previousJobId: string): Promise {
+ const result = await this.getVideoUrl(previousJobId)
+ if (!result.url) {
+ throw new Error(
+ `${this.name}: could not resolve a video URL from previousJobId "${previousJobId}".`,
+ )
+ }
+ return result.url
+ }
+
protected generateId(): string {
return `${this.name}-${Date.now()}-${Math.random().toString(36).substring(7)}`
}
diff --git a/packages/ai/src/activities/generateVideo/index.ts b/packages/ai/src/activities/generateVideo/index.ts
index 1f1a5f5ea..87bb21c86 100644
--- a/packages/ai/src/activities/generateVideo/index.ts
+++ b/packages/ai/src/activities/generateVideo/index.ts
@@ -27,6 +27,7 @@ import type {
MediaPromptFor,
StreamChunk,
TokenUsage,
+ VideoEditKind,
VideoJobResult,
VideoStatusResult,
VideoUrlResult,
@@ -47,7 +48,7 @@ export const kind = 'video' as const
* Extract provider options from a VideoAdapter via ~types.
*/
export type VideoProviderOptions =
- TAdapter extends VideoAdapter
+ TAdapter extends VideoAdapter
? TAdapter['~types']['providerOptions']
: object
@@ -61,6 +62,7 @@ export type VideoSizeForAdapter =
any,
infer TSizeMap,
any,
+ any,
any
>
? TModel extends keyof TSizeMap
@@ -81,6 +83,7 @@ export type VideoPromptForAdapter =
any,
any,
infer ModsByName,
+ any,
any
>
? string extends keyof ModsByName
@@ -102,13 +105,46 @@ export type VideoDurationForAdapter =
any,
any,
any,
- infer TDurationMap
+ infer TDurationMap,
+ any
>
? TModel extends keyof TDurationMap
? TDurationMap[TModel]
: number
: number
+/**
+ * Extract the `previousJobId` type for a VideoAdapter's model via ~types.
+ *
+ * Models that support follow-up edits (any declared `VideoEditKind`) accept
+ * a prior generation's job id as a string. `'media'`-kind adapters resolve
+ * a URL from it via `getVideoUrl`. Models declared uneditable (`undefined`
+ * in the map) resolve to `never`, rejecting `previousJobId` at compile
+ * time. Adapters without a declared map — and open-world model unions
+ * (e.g. fal's arbitrary endpoint strings) — fall back to `string`, gated
+ * at runtime instead.
+ */
+export type VideoPreviousJobIdForAdapter =
+ TAdapter extends VideoAdapter<
+ infer TModel,
+ any,
+ any,
+ any,
+ any,
+ any,
+ infer TEditMap
+ >
+ ? string extends keyof TEditMap
+ ? string
+ : TModel extends keyof TEditMap
+ ? TEditMap[TModel] extends VideoEditKind
+ ? string
+ : VideoEditKind extends TEditMap[TModel]
+ ? string
+ : never
+ : never
+ : never
+
// ===========================
// Activity Options Types
@@ -122,7 +158,7 @@ function createId(prefix: string): string {
* The model is extracted from the adapter's model property.
*/
interface VideoActivityBaseOptions<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
> {
/** The video adapter to use (must be created with a model) */
adapter: TAdapter & { kind: typeof kind }
@@ -138,7 +174,7 @@ interface VideoActivityBaseOptions<
* @experimental Video generation is an experimental feature and may change.
*/
export type VideoCreateOptions<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
TStream extends boolean = false,
> = VideoActivityBaseOptions & {
/** Request type - create a new job (default if not specified) */
@@ -197,6 +233,21 @@ export type VideoCreateOptions<
}
: {
/** Provider-specific options for video generation */ modelOptions: VideoProviderOptions
+ }) &
+ ([VideoPreviousJobIdForAdapter] extends [never]
+ ? {
+ /** This model does not support editing previous generations. */
+ previousJobId?: never
+ }
+ : {
+ /**
+ * Edit a previously generated video instead of generating from
+ * scratch. Pass the prior generation's job id. `'job'`-kind models
+ * reference it server-side; `'media'`-kind models resolve the
+ * finished clip via `getVideoUrl`. Only offered for models that
+ * support follow-up edits.
+ */
+ previousJobId?: VideoPreviousJobIdForAdapter
})
/**
@@ -205,7 +256,7 @@ export type VideoCreateOptions<
* @experimental Video generation is an experimental feature and may change.
*/
export interface VideoStatusOptions<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
> extends VideoActivityBaseOptions {
/** Request type - get job status */
request: 'status'
@@ -219,7 +270,7 @@ export interface VideoStatusOptions<
* @experimental Video generation is an experimental feature and may change.
*/
export interface VideoUrlOptions<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
> extends VideoActivityBaseOptions {
/** Request type - get video URL */
request: 'url'
@@ -234,7 +285,7 @@ export interface VideoUrlOptions<
* @experimental Video generation is an experimental feature and may change.
*/
export type VideoActivityOptions<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
TRequest extends 'create' | 'status' | 'url' = 'create',
TStream extends boolean = false,
> = TRequest extends 'status'
@@ -310,7 +361,7 @@ export type VideoActivityResult<
* ```
*/
export function generateVideo<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
TStream extends boolean = false,
>(
options: VideoCreateOptions,
@@ -324,11 +375,40 @@ export function generateVideo<
return runCreateVideoJob(options) as VideoActivityResult<'create', TStream>
}
+/**
+ * Validate a `previousJobId` against the adapter's declared edit support
+ * before the adapter is called, so every provider surfaces the same clear
+ * errors. Media-kind adapters resolve a URL from the id themselves. Returns
+ * the validated id (or undefined when absent).
+ */
+function validatePreviousJobId(
+ adapter: {
+ name: string
+ model: string
+ supportedEditKind: () => VideoEditKind | undefined
+ },
+ previousJobId: string | undefined,
+): string | undefined {
+ if (previousJobId === undefined) return undefined
+ const kind = adapter.supportedEditKind()
+ if (kind === undefined) {
+ throw new Error(
+ `${adapter.name}: model "${adapter.model}" does not support editing previous generations (previousJobId).`,
+ )
+ }
+ if (!previousJobId) {
+ throw new Error(
+ `${adapter.name}: previousJobId is required to edit a previous generation with model "${adapter.model}".`,
+ )
+ }
+ return previousJobId
+}
+
/**
* Internal implementation of non-streaming video job creation.
*/
async function runCreateVideoJob<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
>(options: VideoCreateOptions): Promise {
const { adapter, prompt, size, duration, modelOptions, middleware } = options
const model = adapter.model
@@ -357,6 +437,7 @@ async function runCreateVideoJob<
})
try {
+ const previousJobId = validatePreviousJobId(adapter, options.previousJobId)
const result = await adapter.createVideoJob({
model,
prompt,
@@ -364,6 +445,7 @@ async function runCreateVideoJob<
duration,
modelOptions,
logger,
+ ...(previousJobId ? { previousJobId } : {}),
})
logger.output(`activity=generateVideo jobId=${result.jobId}`, {
jobId: result.jobId,
@@ -397,7 +479,7 @@ function sleep(ms: number): Promise {
* Handles the full job lifecycle: create job → poll for status → stream updates → yield final result.
*/
async function* runStreamingVideoGeneration<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
>(options: VideoCreateOptions): AsyncIterable {
const { adapter, prompt, size, duration, modelOptions, middleware } = options
const model = adapter.model
@@ -445,6 +527,7 @@ async function* runStreamingVideoGeneration<
let settled = false
try {
// Create the video generation job
+ const previousJobId = validatePreviousJobId(adapter, options.previousJobId)
const jobResult = await adapter.createVideoJob({
model,
prompt,
@@ -452,6 +535,7 @@ async function* runStreamingVideoGeneration<
duration,
modelOptions,
logger,
+ ...(previousJobId ? { previousJobId } : {}),
})
yield {
@@ -596,7 +680,7 @@ async function* runStreamingVideoGeneration<
* ```
*/
export async function getVideoJobStatus<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
>(options: {
adapter: TAdapter & { kind: typeof kind }
jobId: string
@@ -706,7 +790,7 @@ export async function getVideoJobStatus<
* Create typed options for the generateVideo() function without executing.
*/
export function createVideoOptions<
- TAdapter extends VideoAdapter,
+ TAdapter extends VideoAdapter,
TStream extends boolean = false,
>(
options: VideoCreateOptions,
diff --git a/packages/ai/src/activities/index.ts b/packages/ai/src/activities/index.ts
index 07fdbe73a..75f3d88d2 100644
--- a/packages/ai/src/activities/index.ts
+++ b/packages/ai/src/activities/index.ts
@@ -75,6 +75,8 @@ export {
generateImage,
type ImageActivityOptions,
type ImageActivityResult,
+ type ImagePreviousImageForModel,
+ type ImagePreviousSource,
type ImageProviderOptionsForModel,
type ImageSizeForModel,
} from './generateImage/index'
@@ -120,6 +122,7 @@ export {
type VideoStatusOptions,
type VideoUrlOptions,
type VideoDurationForAdapter,
+ type VideoPreviousJobIdForAdapter,
} from './generateVideo/index'
export {
diff --git a/packages/ai/src/client.ts b/packages/ai/src/client.ts
index 254daf736..920c5e777 100644
--- a/packages/ai/src/client.ts
+++ b/packages/ai/src/client.ts
@@ -96,6 +96,8 @@ export type {
ContentPartUrlSource,
CustomEvent,
DocumentPart,
+ GeneratedImage,
+ GeneratedMediaSource,
ImagePart,
MediaInputMetadata,
MediaInputRole,
@@ -115,6 +117,7 @@ export type {
ToolResultPart,
UIMessage,
UIResourcePart,
+ VideoEditKind,
VideoPart,
InferSchemaType,
} from './types'
diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts
index 102987c02..69b9e4188 100644
--- a/packages/ai/src/index.ts
+++ b/packages/ai/src/index.ts
@@ -168,7 +168,11 @@ export {
export { buildBaseUsage, type BaseUsageInput } from './utilities/usage'
// Media-generation prompt resolution (used by image / video adapters)
-export { resolveMediaPrompt } from './utilities/media-prompt'
+export {
+ resolveMediaPrompt,
+ generatedImageToImagePart,
+ generatedVideoUrlToVideoPart,
+} from './utilities/media-prompt'
export type { ResolvedMediaPrompt } from './utilities/media-prompt'
// System prompts (type + normaliser used by adapters)
diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts
index 6917c8140..c2439cdb6 100644
--- a/packages/ai/src/types.ts
+++ b/packages/ai/src/types.ts
@@ -1885,6 +1885,27 @@ export interface AudioGenerationResult {
// Video Generation Types (Experimental)
// ============================================================================
+/**
+ * How a video model edits previously generated media in a follow-up run:
+ * - `'job'` — the provider references the prior generation by its job id
+ * (OpenAI Sora remix, Gemini Omni `previous_interaction_id`).
+ * - `'media'` — the provider takes the prior video itself, by URL or data URI
+ * (xAI Grok `/videos/edits`, fal video-to-video endpoints).
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export type VideoEditKind = 'job' | 'media'
+
+/**
+ * Per-model map from model name to its edit kind (`undefined` = the model
+ * cannot edit previous generations). Adapters declare this map to narrow the
+ * `previousJobId` option per model at compile time, mirroring
+ * `ModelInputModalitiesByName`.
+ *
+ * @experimental Video generation is an experimental feature and may change.
+ */
+export type ModelEditKindByName = Record
+
/**
* Options for video generation.
* These are the common options supported across providers.
@@ -1917,6 +1938,14 @@ export interface VideoGenerationOptions<
duration?: TDuration
/** Model-specific options for video generation */
modelOptions?: TProviderOptions
+ /**
+ * Prior generation's job id to edit instead of generating from scratch.
+ * Only present when the model supports follow-up edits (see
+ * `VideoEditKind`); the activity validates support before the adapter is
+ * called. `'job'`-kind adapters reference it server-side; `'media'`-kind
+ * adapters resolve the finished clip via `getVideoUrl`.
+ */
+ previousJobId?: string
/**
* Internal logger threaded from the generateVideo() entry point. Adapters must
* call logger.request() before the SDK call and logger.errors() in catch blocks.
diff --git a/packages/ai/src/utilities/media-prompt.ts b/packages/ai/src/utilities/media-prompt.ts
index 9cefb64ff..fccf962e9 100644
--- a/packages/ai/src/utilities/media-prompt.ts
+++ b/packages/ai/src/utilities/media-prompt.ts
@@ -1,5 +1,8 @@
+import { detectImageMimeType } from '../utils'
import type {
AudioPart,
+ ContentPartSource,
+ GeneratedImage,
ImagePart,
MediaInputMetadata,
MediaPrompt,
@@ -84,3 +87,62 @@ export function resolveMediaPrompt(prompt: MediaPrompt): ResolvedMediaPrompt {
audios,
}
}
+
+/**
+ * Convert a media URL into a {@link ContentPartSource}: remote URLs pass
+ * through as `url` sources; `data:` URLs are decomposed into `data` sources
+ * so adapters that upload raw bytes (OpenAI edits, Gemini `inlineData`) get
+ * the payload without re-parsing.
+ */
+function mediaUrlToSource(url: string): ContentPartSource {
+ if (!url.startsWith('data:')) {
+ return { type: 'url', value: url }
+ }
+ const comma = url.indexOf(',')
+ const mimeType = comma === -1 ? '' : url.slice(5, comma).split(';')[0]
+ if (comma === -1 || !mimeType) {
+ throw new Error('data: URL is missing a mime type')
+ }
+ return { type: 'data', value: url.slice(comma + 1), mimeType }
+}
+
+/**
+ * Convert a previously generated image into an {@link ImagePart} so it can
+ * be fed back into a media prompt (follow-up edits, image-to-image). URL
+ * results pass through as `url` sources (`data:` URLs are decomposed into
+ * `data` sources); `b64Json` results become `data` sources with the mime
+ * type sniffed from the payload's magic bytes (defaulting to `image/png`,
+ * the format every provider's b64 output uses today).
+ */
+export function generatedImageToImagePart(
+ image: GeneratedImage,
+): ImagePart {
+ if (image.url !== undefined) {
+ return { type: 'image', source: mediaUrlToSource(image.url) }
+ }
+ return {
+ type: 'image',
+ source: {
+ type: 'data',
+ value: image.b64Json,
+ mimeType: detectImageMimeType(image.b64Json) ?? 'image/png',
+ },
+ }
+}
+
+/**
+ * Convert a previously generated video URL into a {@link VideoPart} so it
+ * can be fed back into a media prompt (follow-up edits). Remote URLs pass
+ * through; `data:` URLs (e.g. Gemini Omni's inline MP4 results) are
+ * decomposed into `data` sources.
+ */
+export function generatedVideoUrlToVideoPart(
+ url: string,
+ metadata?: MediaInputMetadata,
+): VideoPart {
+ return {
+ type: 'video',
+ source: mediaUrlToSource(url),
+ ...(metadata ? { metadata } : {}),
+ }
+}
diff --git a/packages/ai/tests/generate-image-previous-image.test.ts b/packages/ai/tests/generate-image-previous-image.test.ts
new file mode 100644
index 000000000..7ef63d5e6
--- /dev/null
+++ b/packages/ai/tests/generate-image-previous-image.test.ts
@@ -0,0 +1,210 @@
+/**
+ * Tests for generateImage's `previousImage` sugar: previously generated images
+ * are normalized and prepended to the prompt as image parts, and the option
+ * itself never leaks into the adapter's options.
+ */
+import { describe, expect, expectTypeOf, it, vi } from 'vitest'
+import { generateImage } from '../src/activities/generateImage'
+import { BaseImageAdapter } from '../src/activities/generateImage/adapter'
+import { generatedImageToImagePart } from '../src/utilities/media-prompt'
+import type { ImageActivityOptions } from '../src/activities/generateImage'
+import type {
+ ImageGenerationOptions,
+ ImageGenerationResult,
+ MediaPromptPart,
+} from '../src/types'
+
+function mockImageAdapter() {
+ const generateImages = vi.fn(async (options: ImageGenerationOptions) => ({
+ id: 'img-1',
+ model: options.model,
+ images: [{ url: 'https://example.com/out.png' }],
+ }))
+ const adapter = {
+ kind: 'image' as const,
+ name: 'mock',
+ model: 'mock-image-model',
+ generateImages,
+ }
+ return { adapter, generateImages }
+}
+
+function partsOf(call: ImageGenerationOptions): Array {
+ return call.prompt as Array
+}
+
+describe('generateImage previousImage', () => {
+ it('prepends a single GeneratedImage as an image part before the prompt', async () => {
+ const { adapter, generateImages } = mockImageAdapter()
+
+ await generateImage({
+ adapter: adapter as any,
+ prompt: 'make it night time',
+ previousImage: { url: 'https://example.com/v1.png' },
+ })
+
+ const options = generateImages.mock.calls[0]![0]
+ expect(partsOf(options)).toEqual([
+ {
+ type: 'image',
+ source: { type: 'url', value: 'https://example.com/v1.png' },
+ },
+ { type: 'text', content: 'make it night time' },
+ ])
+ expect('previousImage' in options).toBe(false)
+ })
+
+ it('accepts an array and the whole prior result, preserving order', async () => {
+ const { adapter, generateImages } = mockImageAdapter()
+
+ await generateImage({
+ adapter: adapter as any,
+ prompt: 'blend these',
+ previousImage: [
+ { url: 'https://example.com/a.png' },
+ { url: 'https://example.com/b.png' },
+ ],
+ })
+
+ await generateImage({
+ adapter: adapter as any,
+ prompt: 'refine',
+ previousImage: {
+ images: [{ url: 'https://example.com/c.png' }],
+ },
+ })
+
+ const first = partsOf(generateImages.mock.calls[0]![0])
+ expect(first.map((p) => p.type)).toEqual(['image', 'image', 'text'])
+ const second = partsOf(generateImages.mock.calls[1]![0])
+ expect(second[0]).toEqual({
+ type: 'image',
+ source: { type: 'url', value: 'https://example.com/c.png' },
+ })
+ })
+
+ it('prepends to an existing parts-array prompt', async () => {
+ const { adapter, generateImages } = mockImageAdapter()
+
+ await generateImage({
+ adapter: adapter as any,
+ prompt: [
+ { type: 'text', content: 'use the attached style' },
+ {
+ type: 'image',
+ source: { type: 'url', value: 'https://example.com/style.png' },
+ metadata: { role: 'reference' },
+ },
+ ],
+ previousImage: { url: 'https://example.com/v1.png' },
+ })
+
+ const parts = partsOf(generateImages.mock.calls[0]![0])
+ expect(parts).toHaveLength(3)
+ expect(parts[0]).toEqual({
+ type: 'image',
+ source: { type: 'url', value: 'https://example.com/v1.png' },
+ })
+ expect(parts[1]?.type).toBe('text')
+ })
+
+ it('throws when previousImage contains no images', async () => {
+ const { adapter, generateImages } = mockImageAdapter()
+
+ await expect(
+ generateImage({
+ adapter: adapter as any,
+ prompt: 'x',
+ previousImage: { images: [] },
+ }),
+ ).rejects.toThrow(/previousImage contained no images/)
+ expect(generateImages).not.toHaveBeenCalled()
+ })
+})
+
+describe('generatedImageToImagePart', () => {
+ it('passes remote URLs through as url sources', () => {
+ expect(
+ generatedImageToImagePart({ url: 'https://example.com/v1.png' }),
+ ).toEqual({
+ type: 'image',
+ source: { type: 'url', value: 'https://example.com/v1.png' },
+ })
+ })
+
+ it('decomposes data: URLs into data sources', () => {
+ expect(
+ generatedImageToImagePart({ url: 'data:image/jpeg;base64,/9j/AAA=' }),
+ ).toEqual({
+ type: 'image',
+ source: { type: 'data', value: '/9j/AAA=', mimeType: 'image/jpeg' },
+ })
+ })
+
+ it('throws on a data: URL without a mime type', () => {
+ expect(() => generatedImageToImagePart({ url: 'data:,hello' })).toThrow(
+ /missing a mime type/,
+ )
+ })
+
+ it('sniffs the mime type of b64Json payloads from magic bytes', () => {
+ expect(generatedImageToImagePart({ b64Json: 'iVBORw0KGgoAAA' })).toEqual({
+ type: 'image',
+ source: { type: 'data', value: 'iVBORw0KGgoAAA', mimeType: 'image/png' },
+ })
+ expect(
+ generatedImageToImagePart({ b64Json: '/9j/4AAQSkZJRg' }).source,
+ ).toMatchObject({ mimeType: 'image/jpeg' })
+ // Unknown payloads default to png.
+ expect(generatedImageToImagePart({ b64Json: 'AAAA' }).source).toMatchObject(
+ { mimeType: 'image/png' },
+ )
+ })
+})
+
+// ===========================
+// Compile-time typing
+// ===========================
+
+type MockEditModel = 'edit-capable' | 'text-only'
+
+type MockEditModelSizeByName = {
+ 'edit-capable': '1024x1024'
+ 'text-only': '1024x1024'
+}
+
+type MockEditModelInputModalitiesByName = {
+ 'edit-capable': readonly ['image']
+ 'text-only': readonly []
+}
+
+class MockEditImageAdapter<
+ TModel extends MockEditModel,
+> extends BaseImageAdapter<
+ TModel,
+ Record,
+ Record>,
+ MockEditModelSizeByName,
+ MockEditModelInputModalitiesByName
+> {
+ override readonly kind = 'image' as const
+ readonly name = 'mock' as const
+
+ generateImages = async (): Promise => {
+ return { id: 'mock-id', model: this.model, images: [] }
+ }
+}
+
+describe('previousImage per-model typing', () => {
+ it('offers previousImage only for models that accept image inputs', () => {
+ type EditCapable = ImageActivityOptions<
+ MockEditImageAdapter<'edit-capable'>
+ >['previousImage']
+ type TextOnly = ImageActivityOptions<
+ MockEditImageAdapter<'text-only'>
+ >['previousImage']
+
+ expectTypeOf<{ url: string }>().toExtend>()
+ expectTypeOf().toEqualTypeOf()
+ })
+})
diff --git a/packages/ai/tests/generate-video-previous-job-id.test.ts b/packages/ai/tests/generate-video-previous-job-id.test.ts
new file mode 100644
index 000000000..b93f4cd9c
--- /dev/null
+++ b/packages/ai/tests/generate-video-previous-job-id.test.ts
@@ -0,0 +1,207 @@
+/**
+ * Tests for generateVideo's `previousJobId` option: the core gate that
+ * validates edit support before the adapter is called, the passthrough into
+ * `createVideoJob`, and the per-model compile-time typing.
+ */
+import { describe, expect, expectTypeOf, it, vi } from 'vitest'
+import { generateVideo } from '../src/activities/generateVideo'
+import { BaseVideoAdapter } from '../src/activities/generateVideo/adapter'
+import type { VideoCreateOptions } from '../src/activities/generateVideo'
+import type {
+ VideoEditKind,
+ VideoGenerationOptions,
+ VideoJobResult,
+ VideoStatusResult,
+ VideoUrlResult,
+} from '../src/types'
+
+class MockVideoAdapter extends BaseVideoAdapter<'mock-model'> {
+ readonly name = 'mock'
+ editKind: VideoEditKind | undefined
+ lastOptions: VideoGenerationOptions | undefined
+
+ constructor(editKind?: VideoEditKind) {
+ super({}, 'mock-model')
+ this.editKind = editKind
+ }
+
+ override supportedEditKind(): VideoEditKind | undefined {
+ return this.editKind
+ }
+
+ createVideoJob = vi.fn(
+ async (options: VideoGenerationOptions): Promise => {
+ this.lastOptions = options
+ return { jobId: 'job-1', model: this.model }
+ },
+ )
+
+ getVideoStatus = vi.fn(
+ async (jobId: string): Promise => ({
+ jobId,
+ status: 'completed',
+ }),
+ )
+
+ getVideoUrl = vi.fn(
+ async (jobId: string): Promise => ({
+ jobId,
+ url: 'https://example.com/video.mp4',
+ }),
+ )
+}
+
+describe('generateVideo previousJobId gate', () => {
+ it('throws when the model does not support editing', async () => {
+ const adapter = new MockVideoAdapter(undefined)
+
+ await expect(
+ generateVideo({
+ adapter,
+ prompt: 'x',
+ previousJobId: 'prior-job',
+ }),
+ ).rejects.toThrow(/does not support editing previous generations/)
+ expect(adapter.createVideoJob).not.toHaveBeenCalled()
+ })
+
+ it('throws when previousJobId is empty', async () => {
+ const adapter = new MockVideoAdapter('job')
+
+ await expect(
+ generateVideo({
+ adapter,
+ prompt: 'x',
+ previousJobId: '',
+ }),
+ ).rejects.toThrow(/previousJobId is required/)
+ expect(adapter.createVideoJob).not.toHaveBeenCalled()
+ })
+
+ it('forwards previousJobId to media-kind adapters (URL resolve is adapter-side)', async () => {
+ const adapter = new MockVideoAdapter('media')
+
+ await generateVideo({
+ adapter,
+ prompt: 'x',
+ previousJobId: 'prior-job',
+ })
+
+ expect(adapter.lastOptions?.previousJobId).toBe('prior-job')
+ // Core does not resolve the URL — media adapters call getVideoUrl themselves.
+ expect(adapter.getVideoUrl).not.toHaveBeenCalled()
+ })
+
+ it('forwards a valid previousJobId to the adapter', async () => {
+ const adapter = new MockVideoAdapter('job')
+
+ const result = await generateVideo({
+ adapter,
+ prompt: 'make it stormy',
+ previousJobId: 'prior-job',
+ })
+
+ expect(result).toEqual({ jobId: 'job-1', model: 'mock-model' })
+ expect(adapter.lastOptions?.previousJobId).toBe('prior-job')
+ })
+
+ it('omits previousJobId from adapter options when not provided', async () => {
+ const adapter = new MockVideoAdapter('job')
+
+ await generateVideo({ adapter, prompt: 'x' })
+
+ expect(adapter.lastOptions).toBeDefined()
+ expect('previousJobId' in adapter.lastOptions!).toBe(false)
+ })
+
+ it('gates previousJobId in streaming mode too', async () => {
+ const adapter = new MockVideoAdapter(undefined)
+
+ const chunks = []
+ for await (const chunk of generateVideo({
+ adapter,
+ prompt: 'x',
+ previousJobId: 'prior-job',
+ stream: true,
+ pollingInterval: 1,
+ })) {
+ chunks.push(chunk)
+ }
+
+ const errorChunk = chunks.find((c) => c.type === 'RUN_ERROR')
+ expect(errorChunk).toBeDefined()
+ expect((errorChunk as { message: string }).message).toMatch(
+ /does not support editing previous generations/,
+ )
+ expect(adapter.createVideoJob).not.toHaveBeenCalled()
+ })
+
+ it('forwards previousJobId to the adapter in streaming mode', async () => {
+ const adapter = new MockVideoAdapter('media')
+
+ for await (const _chunk of generateVideo({
+ adapter,
+ prompt: 'x',
+ previousJobId: 'prior-job',
+ stream: true,
+ pollingInterval: 1,
+ })) {
+ // drain
+ }
+
+ expect(adapter.lastOptions?.previousJobId).toBe('prior-job')
+ })
+})
+
+// ===========================
+// Compile-time typing
+// ===========================
+
+type JobEditAdapter = BaseVideoAdapter<
+ 'job-model',
+ Record,
+ Record,
+ Record,
+ { 'job-model': readonly ['image'] },
+ Record,
+ { 'job-model': 'job' }
+>
+
+type MediaEditAdapter = BaseVideoAdapter<
+ 'media-model',
+ Record,
+ Record,
+ Record,
+ { 'media-model': readonly ['image'] },
+ Record,
+ { 'media-model': 'media' }
+>
+
+type NoEditAdapter = BaseVideoAdapter<
+ 'no-edit-model',
+ Record,
+ Record,
+ Record,
+ { 'no-edit-model': readonly [] },
+ Record,
+ { 'no-edit-model': undefined }
+>
+
+describe('previousJobId per-model typing', () => {
+ it('accepts a string job id for every editable model', () => {
+ type JobPreviousJobId = NonNullable<
+ VideoCreateOptions['previousJobId']
+ >
+ type MediaPreviousJobId = NonNullable<
+ VideoCreateOptions['previousJobId']
+ >
+
+ expectTypeOf().toEqualTypeOf()
+ expectTypeOf().toEqualTypeOf()
+ })
+
+ it('rejects previousJobId entirely for non-editing models', () => {
+ type NoPreviousJobId = VideoCreateOptions['previousJobId']
+ expectTypeOf().toEqualTypeOf()
+ })
+})
diff --git a/packages/ai/tests/stream-generation.test.ts b/packages/ai/tests/stream-generation.test.ts
index 2cb2741c3..e70810853 100644
--- a/packages/ai/tests/stream-generation.test.ts
+++ b/packages/ai/tests/stream-generation.test.ts
@@ -172,6 +172,7 @@ describe('generateVideo({ stream: true })', () => {
availableDurations: () => ({ kind: 'none' as const }),
snapDuration: () => undefined,
+ supportedEditKind: () => undefined,
createVideoJob: vi.fn(async () => ({
jobId: 'job-123',
diff --git a/testing/e2e/global-setup.ts b/testing/e2e/global-setup.ts
index 27204a5cd..87091e921 100644
--- a/testing/e2e/global-setup.ts
+++ b/testing/e2e/global-setup.ts
@@ -68,9 +68,18 @@ export default async function globalSetup() {
// GET /v1beta/interactions/{id} → inline base64 mp4). The adapter under
// test points its baseUrl at this dedicated prefix so aimock's native
// interactions handling stays untouched for the stateful-interactions
- // text tests.
+ // text tests. The mount also chains follow-up edits: a create carrying
+ // `previous_interaction_id` gets a distinct job id whose poll returns a
+ // different clip, so the video-edit spec can assert the edit round-trip.
mock.mount('/omni-video', geminiOmniVideoMount())
+ // Sora follow-up edits (`POST /v1/videos/{id}/remix`). aimock 1.29's native
+ // /v1/videos pipeline covers create/retrieve but not the remix endpoint,
+ // so this fall-through mount answers remix creates (and the poll of the
+ // remix job id) and returns false for everything else, which drops through
+ // to aimock's native handler.
+ mock.mount('/v1/videos', openaiVideoRemixMount())
+
// Anthropic server_tool_use bug reproduction (issue #604). aimock can't
// natively synthesize `server_tool_use` / `web_fetch_tool_result` content
// blocks, so this mount hand-crafts the raw SSE Claude would emit when a
@@ -434,9 +443,13 @@ function geminiVeoMount(): Mountable {
*/
function geminiOmniVideoMount(): Mountable {
const JOB_ID = 'v1_omni-video-e2e'
- // Minimal MP4-ish base64 payload — the spec only asserts the
- // element renders with the data: URL the adapter builds from it.
+ const EDIT_JOB_ID = 'v1_omni-video-edit-e2e'
+ // Minimal MP4-ish base64 payloads — the specs only assert the
+ // element renders with the data: URL the adapter builds from them. The
+ // edit payload differs from the create payload so the video-edit spec can
+ // prove the follow-up job produced a different clip.
const VIDEO_BASE64 = 'AAAAIGZ0eXBpc29tAAACAGlzb21pc28y'
+ const EDITED_VIDEO_BASE64 = 'AAAAIGZ0eXBpc29tAAACAGVkaXRlZA=='
return {
async handleRequest(
req: http.IncomingMessage,
@@ -447,12 +460,21 @@ function geminiOmniVideoMount(): Mountable {
pathname: string,
): Promise {
if (pathname === '/v1beta/interactions' && req.method === 'POST') {
- await drainBody(req)
+ const body = await readBody(req)
+ let isEdit = false
+ try {
+ const parsed = JSON.parse(body) as {
+ previous_interaction_id?: string
+ }
+ isEdit = typeof parsed.previous_interaction_id === 'string'
+ } catch {
+ // non-JSON body — treat as a plain create
+ }
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(
JSON.stringify({
- id: JOB_ID,
+ id: isEdit ? EDIT_JOB_ID : JOB_ID,
object: 'interaction',
status: 'in_progress',
model: 'gemini-omni-flash-preview',
@@ -463,6 +485,7 @@ function geminiOmniVideoMount(): Mountable {
const pollMatch = pathname.match(/^\/v1beta\/interactions\/([^/]+)$/)
if (pollMatch && req.method === 'GET') {
+ const isEdit = pollMatch[1] === EDIT_JOB_ID
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(
@@ -488,7 +511,7 @@ function geminiOmniVideoMount(): Mountable {
{
type: 'video',
mime_type: 'video/mp4',
- data: VIDEO_BASE64,
+ data: isEdit ? EDITED_VIDEO_BASE64 : VIDEO_BASE64,
},
],
},
@@ -503,6 +526,67 @@ function geminiOmniVideoMount(): Mountable {
}
}
+/**
+ * Fall-through mount for Sora's remix endpoint. Handles:
+ *
+ * - `POST /v1/videos/{id}/remix` — creates the follow-up job (returns the
+ * remix job id, already completed so the poll settles immediately).
+ * - `GET /v1/videos/video-job-remix-e2e` — the poll/URL fetch for that job,
+ * returning a distinct URL so the spec can assert the edit round-trip.
+ *
+ * Everything else returns false and falls through to aimock's native
+ * /v1/videos pipeline (create + retrieve backed by onVideo fixtures).
+ */
+function openaiVideoRemixMount(): Mountable {
+ const REMIX_JOB_ID = 'video-job-remix-e2e'
+ const REMIX_URL = 'https://example.com/guitar-store-remixed.mp4'
+ return {
+ async handleRequest(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ // aimock strips the mount prefix ('/v1/videos') and any query string,
+ // so pathname looks like '/{id}/remix' or '/{id}'.
+ pathname: string,
+ ): Promise {
+ const remixMatch = pathname.match(/^\/([^/]+)\/remix$/)
+ if (remixMatch && req.method === 'POST') {
+ await drainBody(req)
+ res.statusCode = 200
+ res.setHeader('Content-Type', 'application/json')
+ res.end(
+ JSON.stringify({
+ id: REMIX_JOB_ID,
+ object: 'video',
+ status: 'completed',
+ model: 'sora-2',
+ remixed_from_video_id: remixMatch[1],
+ url: REMIX_URL,
+ }),
+ )
+ return true
+ }
+
+ if (pathname === `/${REMIX_JOB_ID}` && req.method === 'GET') {
+ res.statusCode = 200
+ res.setHeader('Content-Type', 'application/json')
+ res.end(
+ JSON.stringify({
+ id: REMIX_JOB_ID,
+ object: 'video',
+ status: 'completed',
+ model: 'sora-2',
+ url: REMIX_URL,
+ }),
+ )
+ return true
+ }
+
+ // Not a remix path — fall through to aimock's native videos handler.
+ return false
+ },
+ }
+}
+
/**
* Mounts a Claude-shaped SSE response that includes a client `tool_use` block
* followed by a `web_fetch` `server_tool_use` block, plus its
diff --git a/testing/e2e/src/components/ImageGenUI.tsx b/testing/e2e/src/components/ImageGenUI.tsx
index 93cec8b7c..1a1b1352a 100644
--- a/testing/e2e/src/components/ImageGenUI.tsx
+++ b/testing/e2e/src/components/ImageGenUI.tsx
@@ -15,6 +15,8 @@ interface ImageGenUIProps {
aimockPort?: number
/** Show a file input and send the prompt as multimodal parts (image-to-image). */
withImageInput?: boolean
+ /** Show an edit box on the generated image (follow-up edit via previousImage). */
+ withEditInput?: boolean
}
function fileToBase64(file: File): Promise {
@@ -39,8 +41,10 @@ export function ImageGenUI({
testId,
aimockPort,
withImageInput,
+ withEditInput,
}: ImageGenUIProps) {
const [prompt, setPrompt] = useState('')
+ const [editPrompt, setEditPrompt] = useState('')
const [imageFile, setImageFile] = useState(null)
const connectionOptions = () => {
@@ -53,7 +57,10 @@ export function ImageGenUI({
return { connection: fetchHttpStream('/api/image/stream'), body }
}
return {
- fetcher: async (input: { prompt: MediaPrompt }) => {
+ fetcher: async (input: {
+ prompt: MediaPrompt
+ previousImage?: { url?: string; b64Json?: string }
+ }) => {
return generateImageFn({
data: {
prompt: input.prompt,
@@ -61,6 +68,9 @@ export function ImageGenUI({
numberOfImages: 1,
aimockPort,
testId,
+ ...(input.previousImage
+ ? { previousImage: input.previousImage }
+ : {}),
},
}) as Promise
},
@@ -70,6 +80,19 @@ export function ImageGenUI({
const { generate, result, isLoading, error, status } =
useGenerateImage(connectionOptions())
+ // Follow-up edit: the previously generated image rides previousImage and the
+ // server prepends it to the prompt as an image input (adapter edit path).
+ const handleEdit = async () => {
+ const image = result?.images[0]
+ if (!image) return
+ await generate({
+ prompt: editPrompt,
+ previousImage: image.url
+ ? { url: image.url }
+ : { b64Json: image.b64Json },
+ })
+ }
+
const handleGenerate = async () => {
if (!imageFile) {
await generate({ prompt })
@@ -145,6 +168,26 @@ export function ImageGenUI({
))}
)}
+ {withEditInput && result && result.images.length > 0 && (
+
+ setEditPrompt(e.target.value)}
+ placeholder="Describe an edit..."
+ className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm"
+ />
+
+ Edit
+
+
+ )}
)
}
diff --git a/testing/e2e/src/components/VideoGenUI.tsx b/testing/e2e/src/components/VideoGenUI.tsx
index 2136c3e34..d0c6f8f74 100644
--- a/testing/e2e/src/components/VideoGenUI.tsx
+++ b/testing/e2e/src/components/VideoGenUI.tsx
@@ -16,6 +16,8 @@ interface VideoGenUIProps {
aimockPort?: number
/** Show a file input and send the prompt as multimodal parts (image-to-video). */
withImageInput?: boolean
+ /** Show an edit box on the completed video (follow-up edit via previousJobId). */
+ withEditInput?: boolean
/** Video feature variant — selects the adapter server-side (e.g. 'interactions-video' → Gemini Omni Flash). */
feature?: Feature
}
@@ -47,9 +49,11 @@ export function VideoGenUI({
testId,
aimockPort,
withImageInput,
+ withEditInput,
feature,
}: VideoGenUIProps) {
const [prompt, setPrompt] = useState('')
+ const [editPrompt, setEditPrompt] = useState('')
const [imageFile, setImageFile] = useState(null)
const connectionOptions = () => {
@@ -62,9 +66,21 @@ export function VideoGenUI({
return { connection: fetchHttpStream('/api/video/stream'), body }
}
return {
- fetcher: async (input: { prompt: MediaPrompt }) => {
+ fetcher: async (input: {
+ prompt: MediaPrompt
+ previousJobId?: string
+ }) => {
return generateVideoFn({
- data: { prompt: input.prompt, provider, aimockPort, testId, feature },
+ data: {
+ prompt: input.prompt,
+ provider,
+ aimockPort,
+ testId,
+ feature,
+ ...(input.previousJobId
+ ? { previousJobId: input.previousJobId }
+ : {}),
+ },
}) as Promise
},
}
@@ -73,6 +89,16 @@ export function VideoGenUI({
const { generate, result, videoStatus, isLoading, error, status } =
useGenerateVideo(connectionOptions())
+ // Follow-up edit of the completed generation: always pass the prior jobId;
+ // adapters resolve a URL themselves when their provider needs one.
+ const handleEdit = async () => {
+ if (!result?.url) return
+ await generate({
+ prompt: editPrompt,
+ previousJobId: result.jobId,
+ })
+ }
+
const handleGenerate = async () => {
if (!imageFile) {
await generate({ prompt })
@@ -149,6 +175,26 @@ export function VideoGenUI({
className="rounded border border-gray-700 max-w-lg"
/>
)}
+ {withEditInput && result && result.url && (
+
+ setEditPrompt(e.target.value)}
+ placeholder="Describe an edit..."
+ className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm"
+ />
+
+ Edit
+
+
+ )}
)
}
diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts
index cd3e2203d..4eb49b2e9 100644
--- a/testing/e2e/src/lib/feature-support.ts
+++ b/testing/e2e/src/lib/feature-support.ts
@@ -251,6 +251,17 @@ export const matrix: Record> = {
// global-setup.ts) — aimock handles synchronous text interactions natively
// but not background video jobs (create → poll → inline base64 mp4).
'interactions-video': new Set(['gemini']),
+ // Follow-up video edits via generateVideo's `previousJobId`. OpenAI runs Sora's
+ // `POST /v1/videos/{id}/remix` through the openaiVideoRemixMount
+ // fall-through; Gemini chains Omni's `previous_interaction_id` through the
+ // geminiOmniVideoMount. Grok's `/v1/videos/edits` and fal's video_url
+ // endpoints remain unit-test-only (no aimock coverage yet).
+ 'video-edit': new Set(['openai', 'gemini']),
+ // Follow-up image edits via generateImage's `previousImage` (the prior image is
+ // prepended as an image part). OpenAI routes to the multipart
+ // `/v1/images/edits` aimock mocks natively; the other providers' edit
+ // endpoints are unit-test-only (same coverage note as image-to-image).
+ 'image-edit': new Set(['openai']),
// Only Gemini currently surfaces a first-class stateful conversation API via
// the adapter (geminiTextInteractions, behind @tanstack/ai-gemini/experimental).
'stateful-interactions': new Set(['gemini']),
diff --git a/testing/e2e/src/lib/features.ts b/testing/e2e/src/lib/features.ts
index f6a0b57d1..f3a9c1aaa 100644
--- a/testing/e2e/src/lib/features.ts
+++ b/testing/e2e/src/lib/features.ts
@@ -140,6 +140,14 @@ export const featureConfigs: Record = {
tools: [],
modelOptions: {},
},
+ 'video-edit': {
+ tools: [],
+ modelOptions: {},
+ },
+ 'image-edit': {
+ tools: [],
+ modelOptions: {},
+ },
'stateful-interactions': {
tools: [],
modelOptions: {},
diff --git a/testing/e2e/src/lib/media-providers.ts b/testing/e2e/src/lib/media-providers.ts
index b61829ca0..a9dd3a3b0 100644
--- a/testing/e2e/src/lib/media-providers.ts
+++ b/testing/e2e/src/lib/media-providers.ts
@@ -161,8 +161,14 @@ export function createVideoAdapter(
// Gemini Omni Flash only serves the Interactions API; its background
// video jobs run through a dedicated aimock mount (see geminiOmniVideoMount
// in global-setup.ts) addressed via a distinct baseUrl prefix so aimock's
- // native /v1beta/interactions text handling is untouched.
- if (feature === 'interactions-video') {
+ // native /v1beta/interactions text handling is untouched. The video-edit
+ // feature reuses the same mount for Gemini (previousJobId chains
+ // previous_interaction_id); OpenAI video-edit falls through to the Sora
+ // adapter below (remix rides the openaiVideoRemixMount).
+ if (
+ feature === 'interactions-video' ||
+ (feature === 'video-edit' && provider === 'gemini')
+ ) {
if (provider !== 'gemini') {
throw new Error(`No interactions-video adapter for provider: ${provider}`)
}
diff --git a/testing/e2e/src/lib/server-functions.ts b/testing/e2e/src/lib/server-functions.ts
index 562d0dc0e..077d90b4e 100644
--- a/testing/e2e/src/lib/server-functions.ts
+++ b/testing/e2e/src/lib/server-functions.ts
@@ -25,6 +25,7 @@ export const generateImageFn = createServerFn({ method: 'POST' })
numberOfImages?: number
aimockPort?: number
testId?: string
+ previousImage?: { url?: string; b64Json?: string }
}) => {
const isEmpty =
typeof data.prompt === 'string'
@@ -42,10 +43,17 @@ export const generateImageFn = createServerFn({ method: 'POST' })
data.aimockPort,
data.testId,
)
+ const previousImage =
+ data.previousImage?.url != null
+ ? { url: data.previousImage.url }
+ : data.previousImage?.b64Json != null
+ ? { b64Json: data.previousImage.b64Json }
+ : undefined
return generateImage({
adapter,
prompt: data.prompt,
numberOfImages: data.numberOfImages ?? 1,
+ ...(previousImage ? { previousImage } : {}),
})
})
@@ -152,6 +160,7 @@ export const generateVideoFn = createServerFn({ method: 'POST' })
aimockPort?: number
testId?: string
feature?: Feature
+ previousJobId?: string
}) => {
const isEmpty =
typeof data.prompt === 'string'
@@ -174,6 +183,7 @@ export const generateVideoFn = createServerFn({ method: 'POST' })
const { jobId } = await generateVideo({
adapter,
prompt: data.prompt,
+ ...(data.previousJobId ? { previousJobId: data.previousJobId } : {}),
})
// Poll for completion (aimock returns completed immediately)
const result = await getVideoJobStatus({ adapter, jobId })
diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts
index 0e362b5b1..d20ac00b5 100644
--- a/testing/e2e/src/lib/types.ts
+++ b/testing/e2e/src/lib/types.ts
@@ -43,6 +43,8 @@ export type Feature =
| 'video-gen'
| 'image-to-video'
| 'interactions-video'
+ | 'video-edit'
+ | 'image-edit'
| 'stateful-interactions'
export const ALL_PROVIDERS: Provider[] = [
@@ -89,5 +91,7 @@ export const ALL_FEATURES: Feature[] = [
'video-gen',
'image-to-video',
'interactions-video',
+ 'video-edit',
+ 'image-edit',
'stateful-interactions',
]
diff --git a/testing/e2e/src/routes/$provider/$feature.tsx b/testing/e2e/src/routes/$provider/$feature.tsx
index 8846161f6..c16ccf31d 100644
--- a/testing/e2e/src/routes/$provider/$feature.tsx
+++ b/testing/e2e/src/routes/$provider/$feature.tsx
@@ -43,12 +43,14 @@ export const Route = createFileRoute('/$provider/$feature')({
const MEDIA_FEATURES = new Set([
'image-gen',
'image-to-image',
+ 'image-edit',
'tts',
'transcription',
'transcription-diarization',
'video-gen',
'image-to-video',
'interactions-video',
+ 'video-edit',
'audio-gen',
'sound-effects',
])
@@ -146,6 +148,16 @@ function MediaFeature({
withImageInput
/>
)
+ case 'image-edit':
+ return (
+
+ )
case 'tts':
return (
)
+ case 'video-edit':
+ return (
+
+ )
case 'audio-gen':
case 'sound-effects':
return (
diff --git a/testing/e2e/src/routes/api.image.stream.ts b/testing/e2e/src/routes/api.image.stream.ts
index bd65b9756..a7955ec97 100644
--- a/testing/e2e/src/routes/api.image.stream.ts
+++ b/testing/e2e/src/routes/api.image.stream.ts
@@ -12,16 +12,31 @@ export const Route = createFileRoute('/api/image/stream')({
const abortController = new AbortController()
const body = await request.json()
const data = body.forwardedProps ?? body.data ?? body
- const { prompt, provider, numberOfImages, testId, aimockPort } =
- data as {
- prompt: MediaPrompt
- provider: Provider
- numberOfImages?: number
- testId?: string
- aimockPort?: number
- }
+ const {
+ prompt,
+ provider,
+ numberOfImages,
+ testId,
+ aimockPort,
+ previousImage,
+ } = data as {
+ prompt: MediaPrompt
+ provider: Provider
+ numberOfImages?: number
+ testId?: string
+ aimockPort?: number
+ previousImage?: { url?: string; b64Json?: string }
+ }
const adapter = createImageAdapter(provider, aimockPort, testId)
+ // The wire shape is a loose optional pair; generateImage's previousImage
+ // takes the strict GeneratedImage union, so narrow to one branch.
+ const editImage =
+ previousImage?.url != null
+ ? { url: previousImage.url }
+ : previousImage?.b64Json != null
+ ? { b64Json: previousImage.b64Json }
+ : undefined
try {
const stream = generateImage({
@@ -29,6 +44,7 @@ export const Route = createFileRoute('/api/image/stream')({
prompt,
numberOfImages: numberOfImages ?? 1,
stream: true,
+ ...(editImage ? { previousImage: editImage } : {}),
})
return toHttpResponse(stream, { abortController })
} catch (error: any) {
diff --git a/testing/e2e/src/routes/api.image.ts b/testing/e2e/src/routes/api.image.ts
index d8b455a63..b31a5d5a4 100644
--- a/testing/e2e/src/routes/api.image.ts
+++ b/testing/e2e/src/routes/api.image.ts
@@ -12,16 +12,31 @@ export const Route = createFileRoute('/api/image')({
const abortController = new AbortController()
const body = await request.json()
const data = body.forwardedProps ?? body.data ?? body
- const { prompt, provider, numberOfImages, testId, aimockPort } =
- data as {
- prompt: MediaPrompt
- provider: Provider
- numberOfImages?: number
- testId?: string
- aimockPort?: number
- }
+ const {
+ prompt,
+ provider,
+ numberOfImages,
+ testId,
+ aimockPort,
+ previousImage,
+ } = data as {
+ prompt: MediaPrompt
+ provider: Provider
+ numberOfImages?: number
+ testId?: string
+ aimockPort?: number
+ previousImage?: { url?: string; b64Json?: string }
+ }
const adapter = createImageAdapter(provider, aimockPort, testId)
+ // The wire shape is a loose optional pair; generateImage's previousImage
+ // takes the strict GeneratedImage union, so narrow to one branch.
+ const editImage =
+ previousImage?.url != null
+ ? { url: previousImage.url }
+ : previousImage?.b64Json != null
+ ? { b64Json: previousImage.b64Json }
+ : undefined
try {
const stream = generateImage({
@@ -29,6 +44,7 @@ export const Route = createFileRoute('/api/image')({
prompt,
numberOfImages: numberOfImages ?? 1,
stream: true,
+ ...(editImage ? { previousImage: editImage } : {}),
})
return toServerSentEventsResponse(stream, { abortController })
} catch (error: any) {
diff --git a/testing/e2e/src/routes/api.video.stream.ts b/testing/e2e/src/routes/api.video.stream.ts
index 05c5b74f1..e73fe05c1 100644
--- a/testing/e2e/src/routes/api.video.stream.ts
+++ b/testing/e2e/src/routes/api.video.stream.ts
@@ -12,13 +12,15 @@ export const Route = createFileRoute('/api/video/stream')({
const abortController = new AbortController()
const body = await request.json()
const data = body.forwardedProps ?? body.data ?? body
- const { prompt, provider, testId, aimockPort, feature } = data as {
- prompt: MediaPrompt
- provider: Provider
- testId?: string
- aimockPort?: number
- feature?: Feature
- }
+ const { prompt, provider, testId, aimockPort, feature, previousJobId } =
+ data as {
+ prompt: MediaPrompt
+ provider: Provider
+ testId?: string
+ aimockPort?: number
+ feature?: Feature
+ previousJobId?: string
+ }
const adapter = createVideoAdapter(
provider,
@@ -33,6 +35,7 @@ export const Route = createFileRoute('/api/video/stream')({
prompt,
stream: true,
pollingInterval: 500,
+ ...(previousJobId ? { previousJobId } : {}),
})
return toHttpResponse(stream, { abortController })
} catch (error: any) {
diff --git a/testing/e2e/src/routes/api.video.ts b/testing/e2e/src/routes/api.video.ts
index 83ceec707..ce8f85117 100644
--- a/testing/e2e/src/routes/api.video.ts
+++ b/testing/e2e/src/routes/api.video.ts
@@ -12,13 +12,15 @@ export const Route = createFileRoute('/api/video')({
const abortController = new AbortController()
const body = await request.json()
const data = body.forwardedProps ?? body.data ?? body
- const { prompt, provider, testId, aimockPort, feature } = data as {
- prompt: MediaPrompt
- provider: Provider
- testId?: string
- aimockPort?: number
- feature?: Feature
- }
+ const { prompt, provider, testId, aimockPort, feature, previousJobId } =
+ data as {
+ prompt: MediaPrompt
+ provider: Provider
+ testId?: string
+ aimockPort?: number
+ feature?: Feature
+ previousJobId?: string
+ }
const adapter = createVideoAdapter(
provider,
@@ -33,6 +35,7 @@ export const Route = createFileRoute('/api/video')({
prompt,
stream: true,
pollingInterval: 500,
+ ...(previousJobId ? { previousJobId } : {}),
})
return toServerSentEventsResponse(stream, { abortController })
} catch (error: any) {
diff --git a/testing/e2e/tests/image-edit.spec.ts b/testing/e2e/tests/image-edit.spec.ts
new file mode 100644
index 000000000..44e3a6eea
--- /dev/null
+++ b/testing/e2e/tests/image-edit.spec.ts
@@ -0,0 +1,92 @@
+import { test, expect } from './fixtures'
+import {
+ fillPrompt,
+ clickGenerate,
+ waitForGenerationComplete,
+ featureUrl,
+} from './helpers'
+import { providersFor } from './test-matrix'
+
+// Follow-up image edits via generateImage's `previousImage`: generate an image,
+// then submit an edit prompt with the completed image as the edit source.
+// The core prepends the prior image to the prompt as an image part, which
+// routes OpenAI's adapter to the multipart /v1/images/edits endpoint — the
+// same pipeline the image-to-image spec exercises, but fed from a previous
+// generation instead of a file upload.
+for (const provider of providersFor('image-edit')) {
+ test.describe(`${provider} -- image-edit`, () => {
+ test('sse -- edits a generated image via previousImage', async ({
+ page,
+ request,
+ testId,
+ aimockPort,
+ }) => {
+ await page.goto(
+ featureUrl(provider, 'image-edit', testId, aimockPort, 'sse'),
+ )
+ await page.waitForLoadState('networkidle')
+ await fillPrompt(page, 'a guitar in a music store')
+ await clickGenerate(page)
+ await waitForGenerationComplete(page)
+ const image = page.getByTestId('generated-image')
+ await expect(image).toHaveCount(1)
+ const originalSrc = await image.getAttribute('src')
+
+ const editInput = page.getByTestId('edit-prompt-input')
+ await editInput.click()
+ await editInput.fill('add a tree to this product photo')
+ await editInput.dispatchEvent('input', { bubbles: true })
+ await page.getByTestId('edit-button').click()
+
+ // The edit fixture returns a distinct 1x1 png data URL.
+ await expect(image).toHaveAttribute('src', /^data:image\/png;base64,/, {
+ timeout: 30_000,
+ })
+ expect(await image.getAttribute('src')).not.toBe(originalSrc)
+
+ // Prove the edit routed through the multipart edits endpoint with the
+ // prior generation attached (not /v1/images/generations).
+ const journalRes = await request.get(
+ `http://127.0.0.1:${aimockPort}/v1/_requests`,
+ )
+ const entries = (await journalRes.json()) as Array<{
+ path?: string
+ body?: unknown
+ }>
+ const editEntry = entries.find(
+ (e) =>
+ e.path === '/v1/images/edits' &&
+ JSON.stringify(e.body ?? '').includes(
+ 'add a tree to this product photo',
+ ),
+ )
+ expect(editEntry).toBeTruthy()
+ })
+
+ test('fetcher -- edits a generated image via server function', async ({
+ page,
+ testId,
+ aimockPort,
+ }) => {
+ await page.goto(
+ featureUrl(provider, 'image-edit', testId, aimockPort, 'fetcher'),
+ )
+ await page.waitForLoadState('networkidle')
+ await fillPrompt(page, 'a guitar in a music store')
+ await clickGenerate(page)
+ await waitForGenerationComplete(page)
+ const image = page.getByTestId('generated-image')
+ await expect(image).toHaveCount(1)
+
+ const editInput = page.getByTestId('edit-prompt-input')
+ await editInput.click()
+ await editInput.fill('add a tree to this product photo')
+ await editInput.dispatchEvent('input', { bubbles: true })
+ await page.getByTestId('edit-button').click()
+
+ await expect(image).toHaveAttribute('src', /^data:image\/png;base64,/, {
+ timeout: 30_000,
+ })
+ })
+ })
+}
diff --git a/testing/e2e/tests/video-edit.spec.ts b/testing/e2e/tests/video-edit.spec.ts
new file mode 100644
index 000000000..af861ad3a
--- /dev/null
+++ b/testing/e2e/tests/video-edit.spec.ts
@@ -0,0 +1,80 @@
+import { test, expect } from './fixtures'
+import {
+ fillPrompt,
+ clickGenerate,
+ waitForGenerationComplete,
+ featureUrl,
+} from './helpers'
+import { providersFor } from './test-matrix'
+
+// Follow-up video edits via generateVideo's `previousJobId`: generate a clip,
+// then chain an edit prompt onto the completed result. OpenAI runs Sora's
+// `POST /v1/videos/{id}/remix` (openaiVideoRemixMount answers the remix
+// create and its poll); Gemini chains Omni's `previous_interaction_id`
+// (geminiOmniVideoMount returns a distinct clip for the edit job). In both
+// cases the spec proves the round-trip by asserting the rendered video
+// source changes to the edit job's clip.
+const EXPECTED_EDITED_SRC: Record = {
+ openai: /guitar-store-remixed\.mp4$/,
+ gemini: /^data:video\/mp4;base64,AAAAIGZ0eXBpc29tAAACAGVkaXRlZA==$/,
+}
+
+for (const provider of providersFor('video-edit')) {
+ test.describe(`${provider} -- video-edit`, () => {
+ test('sse -- edits a completed generation via previousJobId', async ({
+ page,
+ testId,
+ aimockPort,
+ }) => {
+ await page.goto(
+ featureUrl(provider, 'video-edit', testId, aimockPort, 'sse'),
+ )
+ await fillPrompt(page, 'a guitar being played in a store')
+ await clickGenerate(page)
+ await waitForGenerationComplete(page, 60_000)
+ const video = page.getByTestId('generated-video')
+ await expect(video).toBeVisible()
+ const originalSrc = await video.getAttribute('src')
+
+ const editInput = page.getByTestId('edit-prompt-input')
+ await editInput.click()
+ await editInput.fill('make it nighttime')
+ await editInput.dispatchEvent('input', { bubbles: true })
+ await page.getByTestId('edit-button').click()
+
+ await expect(video).toHaveAttribute(
+ 'src',
+ EXPECTED_EDITED_SRC[provider]!,
+ { timeout: 60_000 },
+ )
+ expect(await video.getAttribute('src')).not.toBe(originalSrc)
+ })
+
+ test('fetcher -- edits a completed generation via server function', async ({
+ page,
+ testId,
+ aimockPort,
+ }) => {
+ await page.goto(
+ featureUrl(provider, 'video-edit', testId, aimockPort, 'fetcher'),
+ )
+ await fillPrompt(page, 'a guitar being played in a store')
+ await clickGenerate(page)
+ await waitForGenerationComplete(page, 60_000)
+ const video = page.getByTestId('generated-video')
+ await expect(video).toBeVisible()
+
+ const editInput = page.getByTestId('edit-prompt-input')
+ await editInput.click()
+ await editInput.fill('make it nighttime')
+ await editInput.dispatchEvent('input', { bubbles: true })
+ await page.getByTestId('edit-button').click()
+
+ await expect(video).toHaveAttribute(
+ 'src',
+ EXPECTED_EDITED_SRC[provider]!,
+ { timeout: 60_000 },
+ )
+ })
+ })
+}
From 49e9d026885c031ebae7a5d349c631cc1e79a027 Mon Sep 17 00:00:00 2001
From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com>
Date: Sat, 11 Jul 2026 03:07:08 +0000
Subject: [PATCH 5/5] ci: apply automated fixes
---
packages/ai-fal/src/model-meta.ts | 6 ++----
packages/ai/src/activities/generateVideo/adapter.ts | 4 +++-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/ai-fal/src/model-meta.ts b/packages/ai-fal/src/model-meta.ts
index 0aea8ba0e..9eaad60b5 100644
--- a/packages/ai-fal/src/model-meta.ts
+++ b/packages/ai-fal/src/model-meta.ts
@@ -191,10 +191,8 @@ export type FalVideoPromptModalitiesFor =
* then submits to the mapped edit endpoint.
*/
export const FAL_VIDEO_EDIT_BY_SOURCE = {
- 'xai/grok-imagine-video/text-to-video':
- 'xai/grok-imagine-video/edit-video',
- 'xai/grok-imagine-video/image-to-video':
- 'xai/grok-imagine-video/edit-video',
+ 'xai/grok-imagine-video/text-to-video': 'xai/grok-imagine-video/edit-video',
+ 'xai/grok-imagine-video/image-to-video': 'xai/grok-imagine-video/edit-video',
} as const
export type FalVideoEditSourceModel = keyof typeof FAL_VIDEO_EDIT_BY_SOURCE
diff --git a/packages/ai/src/activities/generateVideo/adapter.ts b/packages/ai/src/activities/generateVideo/adapter.ts
index 0e01a8ad5..ad91b9b93 100644
--- a/packages/ai/src/activities/generateVideo/adapter.ts
+++ b/packages/ai/src/activities/generateVideo/adapter.ts
@@ -239,7 +239,9 @@ export abstract class BaseVideoAdapter<
* generation's job id; media-kind adapters call this instead of
* requiring a URL.
*/
- protected async resolvePreviousJobUrl(previousJobId: string): Promise {
+ protected async resolvePreviousJobUrl(
+ previousJobId: string,
+ ): Promise {
const result = await this.getVideoUrl(previousJobId)
if (!result.url) {
throw new Error(