# Video generation

Generate video from text, images or video. The endpoints have the shape of the OpenAI videos API, so `client.videos.create_and_poll` and `client.videos.download_content` work. To move code that called Sora, see [Move from the Sora API](https://inference.ai.exchange/docs/move-from-sora.md).

A render takes up to several minutes, so a video job is asynchronous. You create the job, then poll it or ask the exchange to call you, then download the file.

## Quickstart

```bash
curl https://router.inference.ai.exchange/v1/videos \
  -H "Authorization: Bearer sk-liquid-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "seedance-1-5-pro",
    "prompt": "a red paper boat on a stream",
    "seconds": "5",
    "size": "1280x720"
  }'
# {"id": "job_...", "object": "video", "status": "in_progress", ...}

curl https://router.inference.ai.exchange/v1/videos/job_... \
  -H "Authorization: Bearer sk-liquid-..."
# repeat until "status" is not "in_progress"

curl https://router.inference.ai.exchange/v1/videos/job_.../content \
  -H "Authorization: Bearer sk-liquid-..." --output video.mp4
```

```python
from openai import OpenAI

client = OpenAI(base_url="https://router.inference.ai.exchange/v1", api_key="sk-liquid-...")

video = client.videos.create_and_poll(
    model="seedance-1-5-pro",
    prompt="a red paper boat on a stream",
    seconds="5",
    size="1280x720",
)
if video.status == "completed":
    client.videos.download_content(video.id).write_to_file("video.mp4")
```

## Examples

## How it works

1. **Create the job.** `POST /v1/videos` returns the job with the status `in_progress`. The exchange fixes the price cap and holds it.
2. **Wait.** Poll `GET /v1/videos/{id}`, or send `callback_url` and let the exchange call you.
3. **Read the end state.** The job ends as `completed`, `failed`, `cancelled` or `expired`.
4. **Download.** `GET /v1/videos/{id}/content` returns the file of a `completed` job.

You can cancel a job in step 2. Only a `completed` job costs money.

## Find a model

A control works only when an offer for the model declares it. Read what a model supports before you send a control.

| Source                               | Use it for                                                                                                           |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/models?kind=video`          | One card for each model: `parameters`, `video_seconds` and `pricing`.                                                |
| `GET /v1/exchange/models?kind=video` | The market view, with no key: `sizes`, `seconds`, `formats`, `max_duration_seconds` and `rate_per_video_second_usd`. |
| The model page in the console        | The same data, with example videos and a Playground.                                                                 |

On the card of a video model:

- `kind` is `video_generation` and `modality` is `video`.
- `parameters` lists each control that at least one offer supports. `seconds` is a `range` or an `enum`. `size` and `resolution` are an `enum`. `generate_audio` is a `boolean` when the model makes both kinds of video. `seed`, `creativity`, `upscale_factor` and `previous_job_id` are listed when an offer declared them. A name that is not a key is not supported, so omit it. [Modalities](https://inference.ai.exchange/docs/modalities.md#read-a-model-card) describes each descriptor.
- `video_seconds` gives the lengths. `pricing` gives the price for each second, with a `variant` when the price depends on the sound or the resolution.
- On the market view, if `default_size_only` is true, omit `size`.

If a request sets a control that no offer declared, the answer is `400 unsupported_parameter`. `param` names the feature, such as `video.continue`, or the field, such as `resolution`. The exchange holds nothing. It never drops a control, because you would pay for a video made without it.

## Size, shape, resolution and sound

Send `size` when you know the pixel size. Otherwise describe the video, and the exchange picks a size that an offer declared.

```json
{
  "model": "<model>",
  "prompt": "a red paper boat",
  "seconds": 5,
  "aspect_ratio": "16:9",
  "resolution": "720p"
}
```

| You send                        | The exchange does                                                                          |
| ------------------------------- | ------------------------------------------------------------------------------------------ |
| `size`                          | Matches offers that declared that size.                                                    |
| `aspect_ratio`                  | Picks the smallest declared size that has the shape.                                       |
| `aspect_ratio` and `resolution` | Keeps only the sizes whose short side has the pixels of the label, such as 720 for `720p`. |
| `resolution` alone              | Picks no size. It limits the offers to those that make that resolution.                    |
| `generate_audio`                | Matches offers that make that mode. `true` buys sound, `false` buys a silent video.        |
| None of these                   | Lets any offer win. It makes its default.                                                  |

- If no declared size fits, the answer is `400 unsupported_parameter`.
- If `size` has another shape than `aspect_ratio`, or another short side than `resolution`, the answer is `400 conflicting_parameters` and names both fields.
- A silent video or a lower resolution can cost less. The cap and the charge use the price of the options that you asked for.

## Controls

`seed`, `creativity`, `upscale_factor` and `previous_job_id` are offer features: `video.seed`, `video.creativity`, `video.upscale_factor` and `video.continue`. Only an offer that declared the feature can win a request that sets the field.

A negative `seed`, a `creativity` outside 0 to 1 and an `upscale_factor` below 2 get `400 invalid_request` with the name of the field.

### Seed

`seed` repeats a render on a best-effort basis. An upstream can change its model.

```json
{ "model": "<model>", "prompt": "a red paper boat", "seconds": 5, "seed": 7 }
```

### Upscale

`upscale_factor` and `creativity` apply to a model that upscales a video. Send the source as a reference.

```json
{
  "model": "<upscaling model>",
  "prompt": "the same video, sharper",
  "input_references": [{ "type": "video", "url": "https://example.com/clip.mp4" }],
  "upscale_factor": 2,
  "creativity": 0.3
}
```

### Continue a video

`previous_job_id` must name a `completed` video job of your workspace. Each job is charged for its own seconds. The earlier job is not charged again.

```json
{ "model": "<model>", "prompt": "the boat reaches the sea", "previous_job_id": "job_..." }
```

- An id that is no video job of your workspace, including a job of another workspace, gets `400 invalid_request` with `param` `previous_job_id`.
- A job that has not completed gets `400 conflicting_parameters` with `param` `previous_job_id`.
- The status response repeats `previous_job_id`.

### Start image and frames

A video can start from an image. The exchange picks it in this order: the entry with the role `first_frame`, then the first image with no role in `input_references`. An entry with the role `last_frame` fixes the last frame. See [Reference inputs](https://inference.ai.exchange/docs/reference-inputs.md).

An upstream crops a start image that does not have the shape of the video, so the exchange refuses one:

- The shape of the video is the shape of `size`, or the default size of each offer without `size`. The two must agree within 1%, so a 1280x720 image fits a 1920x1080 video.
- The shape is what a viewer sees: an EXIF turn swaps the width and height of a JPEG.
- With `size`, an image of another shape gets `400 invalid_request`. No money is held.
- Without `size`, an offer whose default size has another shape cannot win. If none is left, the answer is `400 no_eligible_offer`, and the message names a `size` that fits.
- Crop or pad a refused image, or send a `size` of its shape.

The exchange reads a linked (`https:`) start image by header only, with `Range` requests. The frame header must start in the first 256 KiB. The link must use `https`, name a public host, use the default port and hold no login, and the host must answer within 3 seconds. If the exchange cannot read the link, it refuses the request and states the reason.

## Job status

| `status`      | Meaning                                                                                                     | Cost    |
| ------------- | ----------------------------------------------------------------------------------------------------------- | ------- |
| `in_progress` | A provider accepted the job and is rendering it.                                                            | Held    |
| `completed`   | The file is ready to download.                                                                              | Charged |
| `failed`      | The render failed. `error` states why.                                                                      | None    |
| `cancelled`   | You cancelled the job.                                                                                      | None    |
| `expired`     | The job did not finish within the video job limit of the exchange. The default is 10 minutes from dispatch. | None    |

- `has_audio` is a field of the answer. It states if the video has sound: the mode that you asked for in `generate_audio`, else the default of the offer that won. It is absent when the exchange does not know.
- When the job ends, the status response holds the `exchange` block with `video_seconds`, the seconds that you paid for.

## Cancel a job

`POST /v1/videos/{id}/cancel` ends a job that is `in_progress`.

```bash
curl -X POST https://router.inference.ai.exchange/v1/videos/job_.../cancel \
  -H "Authorization: Bearer sk-liquid-..."
```

- The exchange stops the provider and releases the hold. The job costs nothing. The answer is the job with the status `cancelled`.
- The provider stops within a few seconds. If the job finishes first, the answer is `409 video_not_cancellable`, and the job keeps the status `completed` and its charge.
- A job that is `completed`, `failed` or `expired` also gets `409 video_not_cancellable`.
- A repeat cancel of a cancelled job returns the job.
- A key that can only read cannot cancel.

## Callback

Add `callback_url` to `POST /v1/videos` to get a call instead of polling. The response then holds a `callback` block:

```json
{
  "id": "job_...",
  "status": "in_progress",
  "callback": { "url": "https://example.com/hooks/video", "signing_secret": "whsec_..." }
}
```

The `callback` block is only in the response that places the job. Keep the `signing_secret`: the exchange does not state it again. With no `callback` block, poll the job.

The address must be an `https` URL with a public host name, the default port, no login and no IP address. Otherwise the exchange refuses the request and places no job.

### Payload

When the job ends, the exchange sends a `POST` with a JSON body:

```json
{
  "id": "job_....video.generation.completed",
  "type": "video.generation.completed",
  "created_at": 1790000000,
  "data": { "id": "job_...", "object": "video", "status": "completed", "exchange": {} }
}
```

| `type`                       | Sent when               |
| ---------------------------- | ----------------------- |
| `video.generation.completed` | The job is `completed`. |
| `video.generation.failed`    | The job is `failed`.    |
| `video.generation.cancelled` | The job is `cancelled`. |
| `video.generation.expired`   | The job is `expired`.   |

`data` is the object that `GET /v1/videos/{id}` returns, with no file. Download the file with `GET /v1/videos/{id}/content`.

### Verify a delivery

| Header                     | Value                                                                          |
| -------------------------- | ------------------------------------------------------------------------------ |
| `X-Liquid-Signature`       | `t=<unix seconds>,v1=<hex>`: HMAC-SHA256 of `<t>.<body>` with `signing_secret` |
| `X-Liquid-Event`           | The same as `type`                                                             |
| `X-Liquid-Idempotency-Key` | The same for each attempt of one delivery. Drop a repeat                       |

Compute the HMAC over the `t` value, a dot and the raw body. Compare it with `v1` in constant time. Refuse a `t` that is more than five minutes old.

### Answer and retries

Answer with a `2xx` status within 10 seconds. The exchange does not follow a redirect.

- Another status, or no answer, causes a retry after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. Then it stops. An answer of `410` stops it at once.
- Delivery is at least once, so your handler must accept a repeat. Polling always works.

## Download

`GET /v1/videos/{id}/content` returns the file. The provider that rendered the video serves the download. You can download again until `expires_at`, if the response states it.

- A job that is not `completed` gets `404 video_not_ready`.
- After `expires_at`, the answer is `410 video_expired`.

## Billing

The price is for each second of video that you ask for. The create response returns `x-liquid-cap-usd` and `x-liquid-job-id`. The cap is exactly the charge, and the exchange settles when the render is complete. A `failed`, `cancelled` or `expired` job costs nothing.

## Request parameters

The body can be JSON or `multipart/form-data`.

| Field              | Type             | Required | Description                                                                                                |
| ------------------ | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `model`            | string           | Yes      | The model to buy, or a preset of the video kind.                                                           |
| `prompt`           | string           | Yes      | The text that describes the video. At most 32,000 characters.                                              |
| `input_references` | array            | No       | Reference inputs in one list, with a `role` for the frames. See [Reference inputs](https://inference.ai.exchange/docs/reference-inputs.md). |
| `seconds`          | number or string | No       | The length, from 1 to 30. `duration` is another name for it.                                               |
| `size`             | string           | No       | A pixel size such as `1280x720`.                                                                           |
| `aspect_ratio`     | string           | No       | `auto`, or a shape such as `16:9`.                                                                         |
| `resolution`       | string           | No       | A label such as `720p` or `1080p`.                                                                         |
| `generate_audio`   | boolean          | No       | `true` for a video with sound, `false` for a silent video.                                                 |
| `seed`             | integer          | No       | A whole number, 0 or more, that makes the render repeat.                                                   |
| `creativity`       | number           | No       | From 0 to 1: how far an upscaling model may depart from the source.                                        |
| `upscale_factor`   | integer          | No       | 2 or more: how many times larger than the source the output is.                                            |
| `previous_job_id`  | string           | No       | The id of an earlier completed video job of your workspace that this video continues.                      |
| `callback_url`     | string           | No       | An `https` address that the exchange calls when the job ends. See [Callback](#callback).                   |
| `exchange`         | object           | No       | Purchasing rules, such as `cap_usd`. See [Purchasing rules](https://inference.ai.exchange/docs/rules.md).                                   |

Without `seconds`, the exchange uses the shortest length that an offer declares, or 4 if none does. The `seconds` field of the response states the length used.

## Errors

| Error                                                                                        | Cause and fix                                                                        |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `400 unsupported_model`                                                                      | Not a video model. Read `/v1/models` for `kind: video_generation`.                   |
| `400 unsupported_parameter` with `param` `size`, `seconds`, `resolution` or `generate_audio` | No offer makes it. The message or `eligibility.excluded` lists what the offers make. |
| `400 unsupported_parameter` with `param` `video.seed` and the like                           | No offer declared the control. Read `parameters` on the model card.                  |
| `400 conflicting_parameters` with `param` `size`                                             | `size` has another shape or short side than `aspect_ratio` or `resolution`.          |
| `400 invalid_request` with `param` `input_references`                                        | The start image has the wrong shape or is unreadable. Crop, pad or change `size`.    |
| `400 no_eligible_offer` with `param` `input_references`                                      | The image fits no default size. Send the `size` that the message names.              |
| `400` with `param` `previous_job_id`                                                         | The id is no video job of your workspace, or the job has not completed.              |
| `409 video_not_cancellable`                                                                  | The job has ended and cannot be cancelled.                                           |
| `404`                                                                                        | The job belongs to another account. Use the key that made the job.                   |

See [Errors](https://inference.ai.exchange/docs/errors.md) for each code and exclusion reason.

## Next steps

- [Reference inputs](https://inference.ai.exchange/docs/reference-inputs.md): frames, guidance and voice samples.
- [Image generation](https://inference.ai.exchange/docs/image-generation.md): the same job for images.
- [Purchasing rules](https://inference.ai.exchange/docs/rules.md): set a cap and limits.
