> ## Documentation Index
> Fetch the complete documentation index at: https://www.tryleap.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Leap's API is at https://api.tryleap.ai. Send the key in the x-api-key header or as Authorization: Bearer; keys start with leap_.
> Take model IDs and input schemas from GET /v1/models/{creator}/{name}, or from https://api.tryleap.ai/v1/public/models without a key. Never guess an input field: unknown fields are a 400.
> Model inputs take uploaded file IDs (file_...), never URLs.
> To give a coding agent the whole API, install the Leap skill: curl -fsSL https://www.tryleap.ai/install.sh | sh
> The full OpenAPI 3.1 spec is at https://www.tryleap.ai/docs/openapi.json.

# Turn a photo into a video

> Upload a product photo, animate it into a 4-second vertical clip with Veo 3.1 Fast, wait for it, and save the video, in one Node.js or Python script.

This script uploads a product photo, starts `google/veo-3.1-fast` with the photo as the clip's first frame, long-polls until the clip is done and saves it as `product.mp4`. The clip is 4 seconds, vertical (9:16) and has sound. It costs \$0.66 and usually takes one to three minutes.

You need an API key in `LEAP_API_KEY` and a photo named `product.jpg`, at most 4 MB, in the folder you run the script from. Veo crops the photo to the video's shape, so a tall photo works best. To shrink a large photo, see [Upload an image](/docs/files#upload-an-image).

To run the Node.js version, save it as `photo-to-video.mjs` and run `node photo-to-video.mjs` (Node 18 or later). For the Python version, run `pip install requests`, save it as `photo_to_video.py` and run `python3 photo_to_video.py`.

<CodeGroup>
  ```js Node.js theme={"theme":"css-variables"}
  import { randomUUID } from "node:crypto";
  import { readFile, writeFile } from "node:fs/promises";

  const API = "https://api.tryleap.ai";
  const KEY = process.env.LEAP_API_KEY;
  if (!KEY) throw new Error("Set LEAP_API_KEY first.");

  // Sends a request with your key, and throws the API's message on an error.
  async function leap(path, init = {}) {
    const response = await fetch(`${API}${path}`, {
      ...init,
      headers: { "x-api-key": KEY, ...init.headers },
    });
    const body = await response.json();
    if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
    return body;
  }

  // 1. Upload the photo. The body is the image itself.
  const file = await leap("/v1/files?filename=product.jpg", {
    method: "POST",
    headers: { "content-type": "image/jpeg" },
    body: await readFile("product.jpg"),
  });
  console.log(`Uploaded ${file.id}, ${file.width}x${file.height}`);

  // 2. Start the clip from the photo, with a new idempotency key.
  let generation = await leap("/v1/generations", {
    method: "POST",
    headers: { "content-type": "application/json", "idempotency-key": randomUUID() },
    body: JSON.stringify({
      model: "google/veo-3.1-fast",
      input: {
        prompt: "A slow push in on the product as soft morning light moves across it",
        first_frame: file.id,
        duration: 4,
        aspect_ratio: "9:16",
      },
    }),
  });
  console.log(`Started ${generation.id}`);

  // 3. Long-poll until it's done, for up to 15 minutes: each read waits up to
  // 60 seconds for the clip to finish.
  const deadline = Date.now() + 15 * 60_000;
  while (!["succeeded", "failed", "canceled"].includes(generation.status)) {
    if (Date.now() > deadline) {
      throw new Error(`${generation.id} is still ${generation.status}. Read it again later.`);
    }
    await new Promise((resolve) => setTimeout(resolve, 2_000));
    generation = await leap(`/v1/generations/${generation.id}`, {
      headers: { prefer: "wait=60" },
      signal: AbortSignal.timeout(75_000),
    });
  }

  if (generation.status !== "succeeded") {
    throw new Error(`${generation.status}: ${generation.error?.message ?? "no message"}`);
  }

  // 4. Download the clip. The signed link needs no API key.
  const video = await fetch(generation.output[0].url);
  if (!video.ok) throw new Error(`Download failed: ${video.status}`);
  await writeFile("product.mp4", Buffer.from(await video.arrayBuffer()));
  console.log(`Saved product.mp4 for $${generation.usage.cost_usd}`);
  ```

  ```python Python theme={"theme":"css-variables"}
  import os
  import time
  import uuid

  import requests

  API = "https://api.tryleap.ai"
  HEADERS = {"x-api-key": os.environ["LEAP_API_KEY"]}


  def check(response):
      """Returns the answer's JSON, or raises the API's message on an error."""
      body = response.json()
      if not response.ok:
          raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
      return body


  # 1. Upload the photo. The body is the image itself.
  with open("product.jpg", "rb") as f:
      file = check(
          requests.post(
              f"{API}/v1/files",
              params={"filename": "product.jpg"},
              headers={**HEADERS, "content-type": "image/jpeg"},
              data=f.read(),
              timeout=60,
          )
      )
  print(f"Uploaded {file['id']}, {file['width']}x{file['height']}")

  # 2. Start the clip from the photo, with a new idempotency key.
  generation = check(
      requests.post(
          f"{API}/v1/generations",
          headers={**HEADERS, "idempotency-key": str(uuid.uuid4())},
          json={
              "model": "google/veo-3.1-fast",
              "input": {
                  "prompt": "A slow push in on the product as soft morning light moves across it",
                  "first_frame": file["id"],
                  "duration": 4,
                  "aspect_ratio": "9:16",
              },
          },
          timeout=60,
      )
  )
  print(f"Started {generation['id']}")

  # 3. Long-poll until it's done, for up to 15 minutes: each read waits up to
  # 60 seconds for the clip to finish.
  deadline = time.monotonic() + 15 * 60
  while generation["status"] not in {"succeeded", "failed", "canceled"}:
      if time.monotonic() > deadline:
          raise TimeoutError(f"{generation['id']} is still {generation['status']}. Read it again later.")
      time.sleep(2)
      generation = check(
          requests.get(
              f"{API}/v1/generations/{generation['id']}",
              headers={**HEADERS, "prefer": "wait=60"},
              timeout=75,
          )
      )

  if generation["status"] != "succeeded":
      message = (generation["error"] or {}).get("message", "no message")
      raise RuntimeError(f"{generation['status']}: {message}")

  # 4. Download the clip. The signed link needs no API key.
  video = requests.get(generation["output"][0]["url"], timeout=300)
  video.raise_for_status()
  with open("product.mp4", "wb") as f:
      f.write(video.content)
  print(f"Saved product.mp4 for ${generation['usage']['cost_usd']}")
  ```
</CodeGroup>

## How it works

1. `POST /v1/files` takes the photo itself as the request body, with no multipart encoding, and answers with a file whose `id` starts with `file_`. A PNG, JPEG or WebP image up to 4 MB works.
2. `POST /v1/generations` starts the clip. `first_frame` takes the file's ID; models take files by ID, never by URL. The [schema](/docs/models#read-an-input-schema) of `google/veo-3.1-fast` allows a `duration` of 4, 6 or 8 seconds and an `aspect_ratio` of `16:9` or `9:16`. The idempotency key makes a retry of this one request safe: send the same key again and you get the same generation back. See [Retry safely](/docs/generations#retry-safely).
3. The script long-polls the generation: each read with `prefer: wait=60` answers as soon as the status is `succeeded`, `failed` or `canceled`, or after 60 seconds. If it gives up, the run carries on, and you can read it again by its ID. For a loop that also rides out dropped connections and `429`s, see [Long-poll](/docs/generations#long-poll).
4. `output[0].url` is a signed link that works for 24 hours without a key. A large file redirects to storage; `fetch` and `requests` follow the redirect on their own.

A failed run isn't charged, and `error.message` says why. If the model's safety filter turned the photo down, try another photo. See [When a run fails](/docs/generations#when-a-run-fails).

## Change it

| Change | Price |
| - | - |
| As written: 4 s with sound | 4 × \$0.165 = \$0.66 |
| `"duration": 8` | 8 × \$0.165 = \$1.32 |
| `"audio": false` | 4 × \$0.11 = \$0.44 |
| `"aspect_ratio": "16:9"` | The same; Veo crops the photo to a landscape frame |

[Get a quote](/docs/pricing#get-a-quote) to check any input and its price before you run it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.