---
title: Video shot endpoints
description: Every /v1/videos/shots, /v1/videos/joins and /v1/videos/projects endpoint — render shots from a prompt with tagged characters and scenes, join finished shots into one video for free, and keep storyboards as projects.
layout: reference
---

A shot is a few seconds of video made from a prompt and pictures: tag your [saved characters](/docs/api/characters) and [scenes](/docs/api/characters#scenes) in the prompt — `@Mia_Wong`, `@[Night Café]` or `@chr_…` — and each tagged one becomes a reference picture. The people keep their faces, hair and clothes from their pictures, the place its look from its picture, and the shot comes with its own sound and spoken dialogue. Render each shot on its own, then join finished shots into one video at no charge.

The endpoints live under `https://lovelyrouter.com/v1/videos`. Authenticate with your API key as `Authorization: Bearer $LOVELY_API_KEY` ([Authentication](/docs/authentication)). The model is `minimax-h3-video`, powered by **MiniMax H3** and under the [MiniMax H3 terms](/docs/models/minimax-h3-license): every shot sets `consent: true`. Try it without code in the [playground](/app/playground?mode=videos).

## Tags and pictures {#tags}

The prompt picks the pictures. List the characters and scenes the shot may use in `characters` and `scenes` (a project's whole library is fine); every one the prompt tags becomes a picture, in the order it is first mentioned.

- `@handle` — the name with spaces as `_`: `Mia Wong` → `@Mia_Wong`. Case doesn't matter.
- `@[Full Name]` — any name as written: `@[Night Café]`.
- `@chr_…` / `@scn_…` — the id, when two have the same name.

Words in double quotes are dialogue, spoken by the character tagged last before them: `@Leo looks up: "You came."`. Don't describe clothes or faces — the pictures carry them. A shot takes 1 to 9 pictures; a prompt without tags uses the only listed character (and the only listed scene), if there is exactly one.

## Voices {#voices}

A tagged character with a [voice](/docs/api/characters#voices) brings it: their lines are spoken in that voice (its timbre, pitch and manner; not its words or background sound). The first **3** voiced characters of a shot, in picture order, bring their voices — the model takes at most 3 audio references; the others are voiced by the model. An inline character can carry `voice` too: a saved voice id (`voice_…`) or an `http(s)` / `data:` URL of a sample. Voices don't change the price.

## Who is who: the prompt sweep {#sweep}

The video model needs to know who every word is about, so pronouns — he, she, his, her, they (and 他, 她, 彼, 彼女) — outside the dialogue never reach it. Before a shot renders:

- **One character**: every pronoun becomes that character's tag (`her coat` → `@Mia's coat`), at once and free.
- **Two or more**: a chat model (`sweep_model`, default `gemini-3.8-flash`) rewrites the prompt so every person is named by their tag every time. The answer is checked — the same tags, the same dialogue word for word, no pronoun left — and tried once more if it fails the check; if it still does, the prompt renders as you sent it. The shot never fails because of the sweep.

The prompt rendered is the shot's `prompt_rendered` (`null` when nothing changed); `prompt` stays as you sent it. The chat call is billed as an ordinary chat request on the same key and shown as `other_usd`; it is not part of the shot's price. Send `sweep: false` to render your prompt exactly as written. Words inside double quotes are never changed.

## Pricing {#pricing}

A shot is billed per **rendered** second: the model renders on a grid of 17k+5 frames at 24 fps (at least 124 frames), so a 5 s shot renders 5.17 s and a 10 s shot 10.13 s — `rendered_seconds` in the shot object. The price is $0.13 per rendered second with one picture, plus $0.06 per second for each extra picture: a 5 s shot with two characters and a scene costs ($0.13 + 2 × $0.06) × 5.17 s. The price is reserved when you submit and charged when the shot completes; failed and cancelled shots cost nothing. Joins and projects are free.

## The shot object {#shot-object}

:::params Attributes
- `id` string
  `shot_` followed by 24 characters.
- `object` string
  Always `video.shot`.
- `status` string
  `queued`, `in_progress`, `completed`, `failed` or `cancelled`.
- `progress` integer
  0–100, estimated while rendering.
- `prompt` string
  The prompt as you sent it.
- `prompt_rendered` string | null
  The prompt after the [sweep](#sweep), when it changed anything.
- `seconds` number
  The length you asked for, 5–15.
- `rendered_seconds` number
  The length rendered and billed.
- `size` string
  `16:9`, `9:16` or `1:1`; `width` and `height` are the pixels once it is done.
- `references` array
  The tagged characters and scenes in picture order: `{picture, kind, name, id}` (`id` is `null` for inline ones).
  Then the voices that came with them, in order: `{audio, kind, name, id}` (`audio` = 1–3).
- `project_id` string | null
  The project it belongs to.
- `url` string | null
  The mp4, once `completed`. Kept for 7 days.
- `hold_usd`, `charge_usd` number
  The amount reserved, and the charge once settled.
- `other_usd` number
  The [sweep](#sweep)'s chat calls, billed as chat on the same key (not part of `hold_usd` / `charge_usd`).
- `error` object | null
  `{code, message}` when it failed.
:::

```json title="The shot object" aside
{
  "id": "shot_8c1f0a7d3b9e4c2a6f5d1e0b",
  "object": "video.shot",
  "model": "minimax-h3-video",
  "status": "completed",
  "progress": 100,
  "project_id": "prj_2a9e7c1b5d3f4e6a8c0b1d2e",
  "prompt": "In @[Night Café], @Leo looks up as @Mia_Wong sits down: \"You came.\"",
  "prompt_rendered": null,
  "seconds": 5,
  "rendered_seconds": 5.167,
  "size": "16:9",
  "width": 1344,
  "height": 768,
  "seed": null,
  "language": "en",
  "style": null,
  "references": [
    { "picture": 1, "kind": "scene", "name": "Night Café", "id": "scn_5b2d9e0c1a7f4b3e8d6c2a1f" },
    { "picture": 2, "kind": "character", "name": "Leo", "id": "chr_1e4b7d0a9c3f2e5b8a6d0c1f" },
    { "picture": 3, "kind": "character", "name": "Mia Wong", "id": "chr_3b9e1f0c7a2d4e58b6c1a9f2" },
    { "audio": 1, "kind": "character", "name": "Mia Wong", "id": "chr_3b9e1f0c7a2d4e58b6c1a9f2" }
  ],
  "url": "https://lovelyrouter.com/v1/videos/shots/shot_8c1f0a7d3b9e4c2a6f5d1e0b/content",
  "hold_usd": 1.291667,
  "charge_usd": 1.291667,
  "other_usd": 0,
  "error": null,
  "powered_by": "MiniMax H3"
}
```

## Create a shot {#create-a-shot}

::endpoint POST /v1/videos/shots

Starts a shot and returns `202` with the shot object (`200` if it finished within `wait`). Send JSON, or multipart with `characters` / `scenes` as JSON text fields.

:::params Body parameters
- `model` string, required
  `minimax-h3-video`.
- `prompt` string, required
  What happens, with characters and scenes tagged. Up to 4,000 characters.
- `consent` boolean, required
  `true` accepts the [MiniMax H3 terms](/docs/models/minimax-h3-license).
- `characters` array
  Saved character ids (`chr_…`), or inline ones: `{name, image, description?}` with `image` an `http(s)` or `data:` URL — or, in multipart, the name of a file part.  Inline characters may add `voice`: a saved voice id or an `http(s)` / `data:` URL of a sample (see [Voices](#voices)).
- `scenes` array
  Saved scene ids (`scn_…`), or inline `{name, image, description?}`.
- `seconds` number, default `5`
  5–15.
- `size` string, default `16:9`
  `16:9`, `9:16` or `1:1`.
- `language` string
  The language the dialogue is spoken in, e.g. `en`, `zh`, `ja`. Default English.
- `style` string
  The look, e.g. `warm 35mm film`. Default live action.
- `seed` integer
  0–2147483647, for repeatable results.
- `sweep` boolean, default `true`
  `false` renders the prompt as written, without the [sweep](#sweep).
- `sweep_model` string, default `gemini-3.8-flash`
  The chat model that names everyone when two or more characters are in the shot. Any chat model your key can use.
- `project_id` string
  A [project](#projects) of yours to file it under.
- `wait` integer
  0–300 seconds to hold the request until it finishes (also `?wait=`).
:::

```bash title="cURL" aside
curl https://lovelyrouter.com/v1/videos/shots \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "model": "minimax-h3-video",
    "consent": true,
    "prompt": "In @[Night Café], @Leo looks up as @Mia_Wong sits down: \"You came.\"",
    "characters": ["chr_3b9e1f0c7a2d4e58b6c1a9f2", "chr_1e4b7d0a9c3f2e5b8a6d0c1f"],
    "scenes": ["scn_5b2d9e0c1a7f4b3e8d6c2a1f"],
    "seconds": 5
  }'
```

```python title="Python" aside
import os, requests

shot = requests.post(
    "https://lovelyrouter.com/v1/videos/shots",
    headers={"Authorization": f"Bearer {os.environ['LOVELY_API_KEY']}"},
    json={
        "model": "minimax-h3-video",
        "consent": True,
        "prompt": "@Ana walks along the beach at sunset and says \"Finally.\"",
        "characters": [{"name": "Ana", "image": "https://example.com/ana-sheet.png"}],
        "seconds": 8,
    },
).json()
```

```js title="Node.js" aside
const res = await fetch('https://lovelyrouter.com/v1/videos/shots?wait=120', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.LOVELY_API_KEY}`, 'content-type': 'application/json' },
  body: JSON.stringify({ model: 'minimax-h3-video', consent: true, prompt: '@Mia_Wong waves at the camera.', characters: [characterId] }),
});
const shot = await res.json();
```

## Retrieve, list and delete shots {#shots}

::endpoint GET /v1/videos/shots/{id}

The shot object. `?wait=1–300` holds until it finishes or the time runs out. `GET /v1/videos/shots/{id}/content` is the mp4 (`Range` requests work). `GET /v1/videos/shots` lists your shots, newest first: `?limit=1–100`, `?before=<shot id>` for the next page, `?project_id=` for one project's.

`DELETE /v1/videos/shots/{id}` removes a shot from your list and deletes its file. A queued shot is cancelled for free; one already rendering still finishes and is charged.

```bash title="cURL" aside
curl "https://lovelyrouter.com/v1/videos/shots/SHOT_ID?wait=60" \
  -H "Authorization: Bearer $LOVELY_API_KEY"

curl https://lovelyrouter.com/v1/videos/shots/SHOT_ID/content \
  -H "Authorization: Bearer $LOVELY_API_KEY" -o shot.mp4
```

## Join shots {#joins}

::endpoint POST /v1/videos/joins

Joins your completed shots into one video, in the order given, each with its own sound. Free. The shots must share one size and be less than 7 days old. Returns `202` with the join object: `{id: "join_…", object: "video.join", status, shots, size, seconds, width, height, url, charge_usd: 0}`. `GET /v1/videos/joins/{id}` (with `?wait=`), `/content`, `GET /v1/videos/joins` and `DELETE` work like shots.

:::params Body parameters
- `shots` array, required
  2–50 shot ids, in the order they play.
- `project_id` string
  A project of yours to file it under.
:::

```bash title="cURL" aside
curl https://lovelyrouter.com/v1/videos/joins \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"shots": ["shot_8c1f0a7d3b9e4c2a6f5d1e0b", "shot_0d2e4f6a8b1c3e5d7f9a0b2c"]}'
```

## Projects {#projects}

::endpoint POST /v1/videos/projects

A project keeps a storyboard: a name and a JSON document of your own (`doc`) — the playground stores its size, style, language, shots, characters and scenes there. Free; up to 100 per account. `POST` takes `{name?, doc?}` and returns `201` with `{id: "prj_…", object: "video.project", name, doc, created_at, updated_at}`.

| Request | What it does |
| --- | --- |
| `GET /v1/videos/projects` | Your projects, last changed first, without `doc` (`?limit`, `?after`). |
| `GET /v1/videos/projects/{id}` | One project, with `doc`. |
| `PATCH /v1/videos/projects/{id}` | `{name?, doc?}`; `doc` is replaced whole, up to 200 KB. |
| `DELETE /v1/videos/projects/{id}` | Deletes the project; its shots stay. |

```bash title="cURL" aside
curl https://lovelyrouter.com/v1/videos/projects \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"name": "Night café", "doc": {"size": "16:9", "shots": []}}'
```

## Errors {#errors}

Errors use the [standard error body](/docs/api/errors). Prompt errors carry a `code` and the `tags` concerned.

| Status | `code` / `type` | Why |
| --- | --- | --- |
| `400` | `unknown_tags` | The prompt tags a name that isn't in `characters` or `scenes`; `error.tags` lists them. |
| `400` | `ambiguous_tag` | Two listed characters or scenes share the tagged name: tag one by its id. |
| `400` | `no_pictures` / `too_many_pictures` | No picture, or more than 9. |
| `400` | `consent_required` | `consent` wasn't `true`. |
| `400` | `mixed_sizes` | A join of shots of different sizes. |
| `400` | `invalid_request_error` | A field out of range, an unreadable picture, pictures over 24 MB in total, a join of shots that haven't completed or have expired. |
| `402` | `insufficient_quota` | The balance doesn't cover the reserve. |
| `404` | `not_found_error` | An unknown model, shot, join, project, character or scene, or one of another account. |
| `429` | `rate_limit_exceeded` | Five shots or joins are already queued or rendering on your account. |
