Atmee.aiDocs

Video generation

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

InputOutputPricePlans
An image and a prompt, audio optionalAn mp4 of 1 to 15 seconds at 25 fps1 credit per clipGrowth 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.

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.
  • 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 (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

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.

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

{
  "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

FieldNotes
imageRequired. Who or what the clip shows: a public https link or upload:<uploadId> of a portrait upload.
promptRequired. The shot and the motion, up to 2,000 characters, such as "she walks along the beach at sunset, the camera following".
audioOptional. 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.
durationSeconds1 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.
resolutionOptional. { "width", "height" } in pixels, square 512 × 512 by default. See Sizes.
seedOptional. 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.

ShaperesolutionGood 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:
# 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, plus:

errorWhat to do
invalid_input naming input_failure image or audioThe renderer couldn't use the file (no face, unreadable audio). Send another file, with a new key.
invalid_input naming input_failure resolutionThat size isn't being served right now. Pick another size, not another image.
invalid_input for a missing durationSecondsSet it whenever you send audio.
v2_access_requiredThe account has no Atmee V2 access yet.

Node example

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

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()));

Next steps

Video generation — Atmee Docs