# Video generation

URL: https://www.atmee.ai/docs/v2/api/generation/video

> Turn an image and a prompt, with optional audio, into an mp4 of up to 15 seconds. No avatar, persona or live session needed.

[`POST /v1/generation/clips`](/docs/v2/api/reference/generation/generateClip) renders a clip of the
person or character in your image, moving the way your prompt describes, and speaking your audio
if you send some. It runs on the Atmee V2 renderer, and you get a downloadable mp4 back in the
same request, or after a retry when the renderer is starting cold.

| Input                                 | Output                              | Price             | Plans                               |
| ------------------------------------- | ----------------------------------- | ----------------- | ----------------------------------- |
| An image and a prompt, audio optional | An mp4 of 1 to 15 seconds at 25 fps | 1 credit per clip | Growth and up, with Atmee V2 access |

Use it for UGC-style ads, product explainers, social posts, spokesperson intros, and character
shots for games and stories. Clips work best when a person or character is the subject of the
shot; a scene without one, such as a place or a product on its own, can work too, but results
vary. For the images that animate badly, see
[Avatar 101: Negative examples](/docs/v2/guides/negative-examples).

## Before you start

* **Atmee V2 access** on a plan with clips. Without V2 access the call answers
  `v2_access_required`; there is no Atmee V1 clip.
* **A key with `generation:write` and `avatars:write`.** See
  [what every generation call has in common](/docs/v2/api/generation).
* **A server.** Call the API from your backend only; the key never goes to a browser.
* **Permission.** If the image shows a real person other than you, get that person's permission
  first.

## Make a clip

### Get the image and the audio to Atmee

A public https link works: a jpg, png or webp of up to 10 MB, and audio as wav, mp3, webm or
another common format. Otherwise upload them first with
[`POST /v1/uploads`](/docs/v2/api/reference/avatars/createUpload) (`kind` `portrait` or `voice`) and
pass `upload:<uploadId>`. A portrait upload stays usable after the clip, and a generated portrait
works too.

### Send the request

```bash
curl -X POST https://api.atmanity.us/v1/generation/clips \
  -H "X-Api-Key: $ATMEE_API_KEY" \
  -H "Idempotency-Key: 6f1c2a9e-3b7d-4c11-9a52-0d8e4f7b2c61" \
  -H "Content-Type: application/json" \
  -d '{
    "image": "https://example.com/host.png",
    "prompt": "she holds up the bottle and smiles, the camera slowly pushing in",
    "audio": "https://example.com/line.mp3",
    "durationSeconds": 12
  }'
```

Mint a new UUID for each new clip. The fields are in [Request fields](#request-fields).

### Retry while it renders

A warm renderer answers within the request. A cold one can take a few minutes, and the request
doesn't wait that long: past about 45 seconds it answers `409 request_in_progress` with a
`Retry-After` header, and the render keeps going. Wait that many seconds and send the **same
request with the same key** until the clip comes back. You're charged once.

Never mint a new key for a request that is still in progress: the first render may still finish
and charge, so a new key can charge twice.

### Download the clip

```json
{
  "clip": {
    "upload": "upload:3b2f6e1a-9c4d-4f8e-a1b2-7c6d5e4f3a2b",
    "url": "https://…/clip.mp4",
    "expiresAt": "2026-11-05T09:12:44Z",
    "durationSeconds": 12,
    "width": 512,
    "height": 512,
    "unavailable": false
  },
  "replayed": false
}
```

Download `clip.url` right away and store it yourself: the link is short-lived, and the clip
expires 24 hours after it was made. The answer also reports `microcreditsCharged`, what the clip cost. A retry with the
same key returns the same result with `replayed: true` and charges nothing; once the clip has expired, it comes back with
`unavailable: true` and no `url`.

## Request fields

| Field             | Notes                                                                                                                                                                                         |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `image`           | Required. Who or what the clip shows: a public https link or `upload:<uploadId>` of a portrait upload.                                                                                        |
| `prompt`          | Required. The shot and the motion, up to 2,000 characters, such as "she walks along the beach at sunset, the camera following".                                                               |
| `audio`           | Optional. Speech or sound: a public https link or `upload:<uploadId>` of a voice upload. It plays once from the start of the clip; without it the clip is silent.                             |
| `durationSeconds` | 1 to 15. Defaults to 8 for a silent clip. Required with `audio`, since the length isn't read from the file: longer audio is cut at the end of the clip, shorter audio is padded with silence. |
| `resolution`      | Optional. `{ "width", "height" }` in pixels, square 512 × 512 by default. See [Sizes](#sizes).                                                                                                |
| `seed`            | Optional. A whole number, 0 or more, default 0. Another seed gives other motion from the same inputs.                                                                                         |

## Sizes

Set the clip's size with `resolution`. Leave it out for a square clip.

| Shape     | `resolution`                      | Good for                      |
| --------- | --------------------------------- | ----------------------------- |
| Square    | `{ "width": 512, "height": 512 }` | The default, on every account |
| Portrait  | `{ "width": 384, "height": 672 }` | Stories, Reels and Shorts     |
| Landscape | `{ "width": 672, "height": 384 }` | Web pages and YouTube         |

Not every account can use every size yet. To see which ones yours can:

1. Call `GET /v1/account` with your key.
2. Look at `pricing.generation.clipResolutions`: it lists the sizes you can ask for. If it isn't
   there at all, every size is open to you.

Asking for a size that isn't on that list fails with `400 invalid_input`, and the error message
names the sizes you can use instead.

## Longer videos

A clip is at most 15 seconds. For a longer video, make several clips and join them:

1. Split the script or audio into parts of 15 seconds or less, at a pause between sentences so
   the joins don't land mid-word.
2. Make one clip per part, with the same image and size for each so the person looks the same
   throughout, and a new `Idempotency-Key` for each.
3. Join the mp4s in order. Every clip is 25 fps at the size you asked for, so they join without
   re-encoding:

```bash
# parts.txt lists the clips in order, one per line: file 'part1.mp4'
ffmpeg -f concat -safe 0 -i parts.txt -c copy video.mp4
```

Each part is billed as its own clip, so a 60-second video made of four 15-second clips costs 4 credits.

## Cost

**1 credit per clip**, whatever its length, with or without sound, on Growth and up. To check
what a call cost, read `microcreditsCharged` in its answer: it counts in hundredths of a credit,
so `100` is 1 credit, and a replay shows `0`.

You pay only for a clip you get: flagged, failed and refused calls cost nothing, and so does a
replay. Your balance and the key's spend cap are checked before the render starts.

## Errors

Clips answer the [errors every generation call shares](/docs/v2/api/generation#errors), plus:

| `error`                                                 | What to do                                                                                         |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `invalid_input` naming `input_failure image` or `audio` | The renderer couldn't use the file (no face, unreadable audio). Send another file, with a new key. |
| `invalid_input` naming `input_failure resolution`       | That size isn't being served right now. Pick another size, not another image.                      |
| `invalid_input` for a missing `durationSeconds`         | Set it whenever you send `audio`.                                                                  |
| `v2_access_required`                                    | The account has no Atmee V2 access yet.                                                            |

## Node example

Make a clip, retry while it renders, and save the mp4. Node 18 or later.

```js
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";

const BASE = "https://api.atmanity.us";

export async function makeClip(input) {
  const key = randomUUID(); // one key for this clip, reused on every retry
  for (;;) {
    const res = await fetch(`${BASE}/v1/generation/clips`, {
      method: "POST",
      headers: {
        "X-Api-Key": process.env.ATMEE_API_KEY,
        "Idempotency-Key": key,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(input),
    });
    // A wrong key answers 401 as plain text or no body, so never assume JSON.
    const text = await res.text();
    let body = {};
    try {
      body = text ? JSON.parse(text) : {};
    } catch {
      body = { message: text };
    }
    if (res.ok) return body.clip;
    const code =
      typeof body.error === "string" ? body.error : `http_${res.status}`;
    const retry = ["request_in_progress", "rate_limited"].includes(code);
    if (!retry) throw new Error(`${res.status} ${code}: ${body.message ?? ""}`);
    const wait = Number(res.headers.get("Retry-After") ?? 10);
    await new Promise((resolve) => setTimeout(resolve, wait * 1000));
  }
}

const clip = await makeClip({
  image: "https://example.com/host.png",
  prompt: "he waves at the camera and laughs",
  durationSeconds: 8,
});
const mp4 = await fetch(clip.url);
await writeFile("clip.mp4", Buffer.from(await mp4.arrayBuffer()));
```

- [Portraits and enhancement](/docs/v2/api/generation/images): Make or improve the image a clip starts from.
- [Create an avatar](/docs/v2/api/create-avatar): Want it to talk back? Make a live avatar.
