> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://openrouter.ai/docs/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://openrouter.ai/docs/_mcp/server.

# Turn an Image into a 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 have not chosen a model yet, read
[Choose a Video Generation Model](/docs/cookbook/video-generation/choose-video-model)
so you can select one based on your clip duration, output shape, input type,
audio, provider controls, and cost requirements.

Use the API reference pages as the source of truth for exact fields:

* [Create video generation request](/docs/api/api-reference/video-generation/create-videos)
* [List video generation models](/docs/api/api-reference/video-generation/list-videos-models)
* [TypeScript SDK video generation reference](/docs/client-sdks/typescript/api-reference/videogeneration)

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
curl -I "$FIRST_FRAME_URL"
```

Example output:

```text
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
curl https://openrouter.ai/api/v1/videos/models
```

Example model output excerpt:

```json
{
  "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, but the same request shape
belongs in whatever server route, queue, or worker owns video generation in your
app.

```js
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
{
  "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
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
[
  {
    "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. Keep the flow explicit: poll with a limit, stop on terminal failure,
then download the completed video.

Example polling and download helper:

```js
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
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.