# Text-to-video

> Turn a text prompt into a downloaded MP4 with MirAPI: a complete Python script that submits, polls, and downloads an async video task, with timeout and failure branches, billed per second of output.

This tutorial builds a complete Python script that turns a text prompt into a downloaded `output.mp4` using MirAPI's asynchronous video API. It covers the full submit → poll → download loop, plus timeout, failure, and error handling.

:::tip[Pick the right model]
Video endpoints only accept models with video output. Replace `deepseek-chat` in the examples with a video model's exact `id` from your catalogue — filter `GET /v1/models` for entries whose `output_modalities` includes `video`.
:::

## Prerequisites

- A MirAPI API key available as `MIRAPI_API_KEY`.
- Python 3 with the `requests` library (`pip install requests`).
- A video-capable model enabled on your key, and enough prepaid balance to cover the clip.

## How it works

Video generation is **asynchronous**: a clip takes longer than a chat reply, so the API returns a task that you submit, poll, and download instead of blocking.

1. `POST /v1/video/generations` — submit the prompt and receive a `task_id`.
2. `GET /v1/video/generations/{task_id}` — poll until `status` is `completed` or `failed`.
3. Download the MP4 from the `url` on the completed task.

`status` is one of `queued`, `in_progress`, `completed`, `failed`. Poll every 10 seconds or so — a job can take minutes, depending on the model and resolution. A completed task carries the video `url`, `format` (e.g. `mp4`), and a `metadata` object with the actual `duration`, `width`, `height`, and `fps`:

```json
{
  "task_id": "abcd1234efgh",
  "status": "completed",
  "url": "https://…",
  "format": "mp4",
  "metadata": { "duration": 5, "width": 1280, "height": 720 }
}
```

This tutorial uses the unified route. MirAPI also exposes an OpenAI/Sora route (`POST /v1/videos` → `GET /v1/videos/{task_id}` → `GET /v1/videos/{task_id}/content`) and Kling/Jimeng compatibility routes; see the [video generation guide](/docs/guides/video) for the full matrix.

## Request parameters

The unified route takes a JSON body with these fields:

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Video-capable model `id` from the catalogue. |
| `prompt` | string | yes | Text description of the clip. |
| `image` | string | no | First-frame image for image-to-video: HTTP(S) URL or base64 data URI. |
| `duration` | number | no | Target length in seconds. |
| `width` | integer | no | Output width in pixels. |
| `height` | integer | no | Output height in pixels. |
| `fps` | integer | no | Frame rate. |
| `seed` | integer | no | Seed for reproducible output (not guaranteed by every model). |
| `n` | integer | no | Number of videos to generate. |
| `response_format` | string | no | Response format (e.g. `url`). |
| `user` | string | no | End-user identifier for abuse tracking. |
| `metadata` | object | no | Model-specific extras (`negative_prompt`, `style`, `quality_level`, …) where the model accepts them. |

`image` is for image-to-video; this tutorial sends text only. To control the first frame, see the [image-to-video tutorial](/docs/tutorials/image-to-video).

## The script

```python
import os
import time
import requests

BASE = "https://api.mirapi.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['MIRAPI_API_KEY']}"}
POLL_INTERVAL = 10
DEADLINE_SECONDS = 600


def submit(prompt: str, duration: int) -> str:
    resp = requests.post(
        f"{BASE}/video/generations",
        headers=HEADERS,
        # Replace deepseek-chat with a video model id from GET /v1/models.
        json={"model": "deepseek-chat", "prompt": prompt, "duration": duration},
        timeout=60,
    )
    resp.raise_for_status()
    return resp.json()["task_id"]


def poll(task_id: str) -> dict:
    deadline = time.time() + DEADLINE_SECONDS
    while time.time() < deadline:
        resp = requests.get(f"{BASE}/video/generations/{task_id}", headers=HEADERS, timeout=30)
        resp.raise_for_status()
        task = resp.json()
        print(f"status={task['status']}")
        if task["status"] == "completed":
            return task
        if task["status"] == "failed":
            raise SystemExit(f"Video failed: {task.get('error')}")
        time.sleep(POLL_INTERVAL)
    raise TimeoutError(f"Task {task_id} did not finish within {DEADLINE_SECONDS}s")


task_id = submit("A golden retriever playing fetch on a sunny beach", 5)
task = poll(task_id)

video = requests.get(task["url"], timeout=60)
video.raise_for_status()
with open("output.mp4", "wb") as f:
    f.write(video.content)
print("Saved output.mp4")
```

## What each branch handles

- **Timeout:** after 10 minutes the script raises `TimeoutError` instead of polling forever. Raise `DEADLINE_SECONDS` for longer clips.
- **Failure:** a `failed` status exits immediately with the gateway's `error` message.
- **HTTP errors:** `raise_for_status` surfaces 401 (bad key), 403 (whitelist or balance — top up), and 429 (slow down and back off).
- **Download:** the completed task's `url` is a temporary link — download it right away.

## Verify

```bash
python text_to_video.py
file output.mp4
curl https://api.mirapi.ai/api/log/token -H "Authorization: Bearer $MIRAPI_API_KEY"
```

## Cost

Video is billed **per second of generated output**, settled when the job completes. Failed jobs produce no output and are not charged. A 5-second clip costs five times the model's per-second rate; check the console billing log at https://console.mirapi.ai or `GET /api/log/token` for the exact figure.

## Troubleshooting

| Status | Meaning | What to do |
| --- | --- | --- |
| `400` | Invalid parameters | Check the model `id`, and that `duration`/`width`/`height` are numeric. |
| `401` | Missing or invalid key | Verify `Authorization: Bearer`. |
| `403` | Key valid but rejected | Top up (empty balance), or check the model whitelist, quota, and IP allowlist. |
| `404` | Task or content not found | The task does not exist, or the video is not `completed` yet. |
| `429` | Rate limited | Back off exponentially (1s → 2s → 4s, cap ~30s); there is no `Retry-After` header. |
| `500` | Gateway or upstream failure | Retry idempotent requests safely. |

Every error message ends with a request ID — include it in support tickets.

- **Task stays `queued` or `in_progress`** — normal; jobs can take minutes. Keep polling at a steady interval.
- **Generation `failed`** — read the `error` field, confirm the model has video output, and check that any reference image is reachable and in a supported format.
- **`404` on the content endpoint** — the task is not `completed` yet; poll the status endpoint first.

## Related links

- [Video generation guide](/docs/guides/video) — the full route matrix, parameters, and billing
- [Image-to-video tutorial](/docs/tutorials/image-to-video) — first-frame control and compatibility routes
- [Models & pricing](/docs/models) — find video-capable models and per-second pricing
- [Billing & top-ups](/docs/billing) — balance, top-ups, and reconciliation