> ## Documentation Index > Fetch the complete documentation index at: https://openrouter.ai/docs/llms.txt > Use this file to discover all available pages before exploring further. # Turn an Image into a Video > Use frame images to control the first or last frame of an OpenRouter video Use this guide when you need to add image-to-video generation where an image becomes the first or last frame of a generated video. By the end, your implementation should submit an image-to-video job with `frame_images` and download the finished clip. For reusable agent knowledge across projects, install the [openrouter-video skill](https://github.com/OpenRouterTeam/skills/tree/main/skills/openrouter-video). ## Before you start You need: * An OpenRouter API key available as `OPENROUTER_API_KEY` * Node.js 20 or newer * A public HTTPS image URL available as `FIRST_FRAME_URL` * A model that supports `frame_images`, confirmed with `GET /api/v1/videos/models` If you haven't chosen a model yet, read [Choose a Video Generation Model](/docs/cookbook/video-generation/choose-video-model) first. It helps you pick one based on clip duration, output shape, input type, audio, provider controls, and cost. Use the API reference pages as the source of truth for exact fields: * [Create video generation request](/docs/api/api-reference/video-generation/submit-a-video-generation-request) * [List video generation models](/docs/api/api-reference/video-generation/list-all-video-generation-models) * [TypeScript SDK video generation reference](/docs/client-sdks/typescript/sdks/videogeneration/README) Submitting `POST /api/v1/videos` starts a real video generation job and may spend OpenRouter credits. `frame_images` is for exact frame control. If you provide both `frame_images` and `input_references`, OpenRouter treats the request as image-to-video. Use a stable, directly downloadable image URL. Some providers cannot fetch image URLs that require cookies, redirects through HTML pages, bot checks, or unusual headers. Before submitting, check that your image URL returns `200` with an image content type: ```bash lines theme={null} curl -I "$FIRST_FRAME_URL" ``` Example output: ```text lines theme={null} HTTP/2 200 content-type: image/jpeg ``` ## Step 1: Choose a model with frame-image support Fetch the model list and choose a model whose `supported_frame_images` includes the frame type you want: ```bash lines theme={null} curl https://openrouter.ai/api/v1/videos/models ``` Example model output excerpt: ```json lines theme={null} { "id": "google/veo-3.1-lite", "supported_durations": [8, 4, 6], "supported_resolutions": ["720p", "1080p"], "supported_aspect_ratios": ["16:9", "9:16"], "supported_frame_images": ["first_frame", "last_frame"] } ``` For first-frame and last-frame control, look for `supported_frame_images` containing `first_frame` and `last_frame`. ## Step 2: Submit the image-to-video job Build the video request with `frame_images` when the image should anchor an exact frame. This example uses a first frame. Put the same request shape in whatever server route, queue, or worker owns video generation in your app. Validate `duration`, `resolution`, and `aspect_ratio` against the selected model's supported fields from the [video models API](/docs/api/api-reference/video-generation/list-all-video-generation-models) before submitting. Unsupported values return a 400 with the supported values. ```js expandable lines theme={null} const apiKey = process.env.OPENROUTER_API_KEY; const firstFrameUrl = process.env.FIRST_FRAME_URL; if (!apiKey) { throw new Error("Set OPENROUTER_API_KEY first."); } if (!firstFrameUrl) { throw new Error("Set FIRST_FRAME_URL to a directly downloadable image URL."); } const response = await fetch("https://openrouter.ai/api/v1/videos", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "google/veo-3.1-lite", prompt: "The camera slowly pushes in as the subject turns toward warm window light, cinematic, realistic motion", duration: 4, resolution: "720p", aspect_ratio: "16:9", generate_audio: false, frame_images: [ { type: "image_url", image_url: { url: firstFrameUrl, }, frame_type: "first_frame", }, ], }), }); if (!response.ok) { throw new Error(await response.text()); } const job = await response.json(); console.log(job); ``` The submit call returns the job fields immediately. In the QA run, the submitted job later completed and downloaded with this final summary: ```json lines theme={null} { "id": "kBJZL5kI6gO33dfKN76A", "status": "completed", "output_path": "image-video.mp4", "bytes": 1515304 } ``` ## Step 3: Use a last frame when you need a transition If the selected model supports `last_frame`, add both frames so the model can move from a known starting composition to a known ending composition: ```js lines theme={null} const lastFrameUrl = process.env.LAST_FRAME_URL; if (!lastFrameUrl) { throw new Error("Set LAST_FRAME_URL to a directly downloadable image URL."); } // Before submitting, confirm this URL returns 200 with an image content type: // curl -I "$LAST_FRAME_URL" const frameImages = [ { type: "image_url", image_url: { url: firstFrameUrl }, frame_type: "first_frame", }, { type: "image_url", image_url: { url: lastFrameUrl }, frame_type: "last_frame", }, ]; ``` Then set `frame_images` in the request body to `frameImages`. Request shape for the optional last-frame path: ```json lines theme={null} [ { "type": "image_url", "image_url": { "url": "https://your-domain.example/first-frame.jpg" }, "frame_type": "first_frame" }, { "type": "image_url", "image_url": { "url": "https://your-domain.example/last-frame.jpg" }, "frame_type": "last_frame" } ] ``` This is useful when you want the video to move from a known starting composition to a known ending composition. ## Step 4: Poll and download After submission, poll from a server route, worker, or job runner instead of the browser. Poll with a limit, stop on terminal failure, then download the completed video. Example polling and download helper: ```js expandable lines theme={null} import { writeFile } from "node:fs/promises"; async function waitForVideo(job) { let current = job; for (let attempt = 1; attempt <= 60; attempt += 1) { if (current.status === "completed") { return current; } if (current.status === "failed") { throw new Error(current.error ?? "Video generation failed."); } if (["cancelled", "expired"].includes(current.status)) { throw new Error(current.error ?? `Video generation ${current.status}.`); } await new Promise((resolve) => setTimeout(resolve, 30_000)); if (!current.polling_url) { throw new Error("Video job did not include a polling_url."); } const pollingUrl = new URL(current.polling_url, "https://openrouter.ai"); const response = await fetch(pollingUrl, { headers: { Authorization: `Bearer ${apiKey}`, }, }); if (!response.ok) { throw new Error(await response.text()); } current = await response.json(); } throw new Error("Video generation did not complete after 60 attempts."); } async function downloadVideo(job) { const videoUrl = job.unsigned_urls?.[0] ?? `https://openrouter.ai/api/v1/videos/${job.id}/content?index=0`; const response = await fetch(videoUrl, { headers: videoUrl.startsWith("https://openrouter.ai/api/") ? { Authorization: `Bearer ${apiKey}` } : undefined, }); if (!response.ok) { throw new Error(await response.text()); } return Buffer.from(await response.arrayBuffer()); } const completedJob = await waitForVideo(job); const videoBuffer = await downloadVideo(completedJob); await writeFile("image-video.mp4", videoBuffer); console.log("Saved image-video.mp4"); ``` The QA run saved the finished video after polling completed: ```text lines theme={null} Saved image-video.mp4 ``` ## Check your work The first frame of the resulting video should closely match the image you provided as `first_frame`. If you also supplied `last_frame`, the clip should resolve toward that image. The implementation should produce a playable MP4 from the completed job.