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 ( + + ) +} +``` + ### 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 ( + + ) +} +``` + +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" + /> + +
+ )} )} 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" />
)} + {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" + /> + +
+ )} ) } 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" + /> + +
+ )} ) } 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(