# Image / video / music APIs

> /v1/images/generations and /v1/images/edits billed per image · /v1/videos/generations async, billed per second · /v1/music/generations async, billed per generation · /v1/transcripts/extract async video-to-script, billed per call

来源：https://zhonkemodel.dflop.top/en/docs/reference/media-apis

Alongside the four chat protocol endpoints, the platform offers three families of media endpoints. Authentication is exactly as it is for chat — the same `sk-gpushare-*` key, in whichever of the four forms you like (`x-api-key` or `x-goog-api-key` header, `?key=` query, `Authorization: Bearer`); see [Authentication](./authentication.md). Everything bills against your account balance (shared by all keys), and an insufficient balance returns **402** `quota_exceeded`.

| Endpoint | Purpose | Billing |
|---|---|---|
| `POST /v1/images/generations` | Text-to-image and image-to-image (synchronous, or `"async": true` for a task) | **per image** |
| `POST /v1/images/edits` | Image-to-image, accepting JSON or a multipart file upload (synchronous, or `"async": true`) | **per image** (same price as above) |
| `POST /v1/videos/generations` | Text-to-video and image-to-video (async task) | **per second** |
| `GET /v1/videos/generations/{id}` | Poll a video task | free |
| `GET /v1/videos/generations` | List this account's video tasks (**all statuses by default**; `?limit=` default 30, max 100; `?status=` to filter) | free |
| `POST /v1/music/generations` | AI music generation (Suno, async task) | **per generation** (2 songs each) |
| `GET /v1/music/generations/{id}` | Poll a music task | free |
| `GET /v1/music/generations` | List this account's music tasks (same shape) | free |
| `POST /v1/audio/speech` | Speech synthesis (synchronous, or `"async": true` for a task) | **per character** |
| `GET /v1/audio/speech/{id}` | Poll a speech task | free |
| `GET /v1/audio/speech` | List this account's speech tasks (same shape) | free |
| `POST /v1/transcripts/extract` | Short-video link → spoken script (synchronous) | **per call** |

> Every response from these endpoints carries an `x-gateway-trace` header — **including 4xx/5xx errors**. Include it with the timestamp, the model and the full error body when reporting a problem, and paste it into [logs.dflop.top](https://logs.dflop.top) to look the call up.

---

## Retries don't double-charge: `Idempotency-Key`

Every **billed** POST endpoint (image, video, music, speech, voice clone, digital-human avatar, transcript) accepts an `Idempotency-Key` request header. With it, the same request can be sent any number of times and is executed exactly **once**:

```bash
IDEM=$(uuidgen)   # one key per submit intent, reused by every retry of it

curl https://zhonkezhonkeapi.dflop.top/v1/videos/generations \
  -H "Authorization: Bearer $PLATFORM_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  -d '{"model":"doubao-seedance-2-0-260128","prompt":"sunset over the sea","duration":5}'
```

- **Same key + same request body** → the **original response** is returned verbatim (same task `id`), with an extra `Idempotency-Replayed: true` header. No second task, no second charge.
- This is also **the easiest way to recover a task id**: forgot to save the `id`? Re-run the exact same curl (same key, same body) and you get the original task back.
- Keys are retained for **7 days**; after that the same key counts as a new request.
- Key format: 1–200 printable ASCII characters (a UUID is the obvious choice). One key per submit intent — do **not** reuse it across different requests.
- **Scoped to the account**, not to a single API key: replaying the same `Idempotency-Key` from a different `sk-gpushare-*` key on the same account still hits the replay. (The wallet is shared account-wide, so anything narrower would leak duplicate charges.)
- **Concurrency**: when two requests carrying the same key arrive at once, exactly one executes and the other immediately gets **409** `idempotency_in_flight` — they never both run.

```python
import uuid, requests

idem = str(uuid.uuid4())           # one key per submit intent
body = {
    "model": "doubao-seedance-2-0-260128",
    "content": [{"type": "text", "text": "sunset over the sea, drone shot"}],
    "duration": 5,
}

def submit():
    r = requests.post(
        "https://zhonkezhonkeapi.dflop.top/v1/videos/generations",
        headers={
            "Authorization": f"Bearer {PLATFORM_API_KEY}",
            "Idempotency-Key": idem,          # same key on every retry
        },
        json=body,
        timeout=60,
    )
    r.raise_for_status()
    # "true" on a replay: you got the first call's result, with no second charge
    replayed = r.headers.get("Idempotency-Replayed") == "true"
    return r.json()["id"], replayed

task_id, _ = submit()
task_id_again, replayed = submit()   # lost the id? just call it again
assert task_id == task_id_again and replayed
```

```typescript
const idem = crypto.randomUUID();          // one key per submit intent

async function submit() {
  const res = await fetch("https://zhonkezhonkeapi.dflop.top/v1/videos/generations", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${PLATFORM_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idem,             // same key on every retry
    },
    body: JSON.stringify({
      model: "doubao-seedance-2-0-260128",
      content: [{ type: "text", text: "sunset over the sea, drone shot" }],
      duration: 5,
    }),
  });
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  return {
    id: (await res.json()).id,
    replayed: res.headers.get("Idempotency-Replayed") === "true",
  };
}
```

| Case | Response |
|---|---|
| Header absent | Behaves exactly as before (no deduplication at all) |
| Malformed key | **400** `invalid_idempotency_key` |
| Same key, different body | **409** `idempotency_key_reuse` — use a fresh key |
| Same key, previous call still running | **409** `idempotency_in_flight` — wait and retry with the **same** key (a new key really would submit again). Usually a few seconds; if the previous call was cut off client-side, the key is held for up to 10 minutes before it frees itself |
| Same key, original response too large to retain | **409** `idempotency_response_not_cached` — only reachable for image calls that explicitly ask for `response_format:"b64_json"` (the bytes are too large to retain). Use the default `response_format:"url"` and replay works normally; the call that already happened can only be re-sent under a fresh key (and is billed again) |

> Only **2xx** responses are remembered. Upstream errors and validation failures do **not** consume the key — retry with the same one.

---

## Recovering a task id

Async tasks (video, music, speech) return a task `id` on submit. If you didn't save it, there are three ways back, easiest first:

1. **Re-send the exact same curl** (same `Idempotency-Key`) → you get the original task id straight back. See the section above.
2. **List your tasks**: `GET /v1/videos/generations`, `GET /v1/music/generations`, `GET /v1/audio/speech`. **All statuses are returned by default** (including queued, running and failed), newest first; `?limit=` defaults to 30, max 100.
   ```bash
   curl "https://zhonkezhonkeapi.dflop.top/v1/videos/generations?limit=10" \
     -H "Authorization: Bearer $PLATFORM_API_KEY"
   ```
   `?status=` filters, comma-separated for multiple: video accepts `queued,running,succeeded,failed,expired,cancelled,all`; music `processing,succeeded,failed,expired,cancelled,all`; speech `pending,succeeded,failed,all`. Pass `?status=succeeded` for the pre-2026-07-31 default.
3. **The call log site** [logs.dflop.top](https://logs.dflop.top): the search box matches both **task ID** and **request ID** (paste it in — you don't have to know which kind of id you're holding). Async-task records (image with `"async": true`, video, music, speech, avatar, voice) carry a task ID; calls with no task row behind them (chat, synchronous image, transcript) carry only a request ID.
   - The request ID is the value of the `x-gateway-trace` response header (present on every response, including 4xx/5xx).
   - ⚠️ An async task only becomes a ledger row once it reaches a terminal state and settles; a task still generating shows in the **In progress** strip at the top of the log page (with its task id and the reserved credits), or via the list endpoints in point 2. The log defaults to the last 30 days. See [Reconciliation and the call log](#reconciliation-and-the-call-log) below for the exact semantics.

---

## Reconciliation and the call log

[logs.dflop.top](https://logs.dflop.top) (sign in with your zhonkemodel.dflop.top account) is the gateway's per-call ledger and the reference for reconciling against your own records. The semantics:

- **One row = the terminal outcome of one external call.** Failed attempts produced by the gateway's automatic failover across upstream lanes are **not separate rows, not billed and not counted as requests**; they are folded into the terminal row's "internal attempts (not billed)" timeline (the row is tagged `换道 ×N`).
- **Four statuses**: `success` / `error` / `interrupted` (client disconnected or gateway timeout) / `rejected`. Rejected = refused by the gateway at submit time (bad parameters, unavailable model, insufficient balance, content block); **cost is always 0**, but the row is recorded so you can account for requests that were sent but never ran. `total_requests` on `GET /api/v1/usage` includes rejected rows and excludes internal attempts.
- **In progress**: async tasks (image / video / music / speech) that have not reached a terminal state appear in the **In progress** strip at the top of the log page, with the task id, submit time and the **reserved** credits; on settlement the reservation is trued up to actual output and the task becomes a normal row.
- **Submitted / completed time**: async tasks record both; synchronous calls record the completion time and derive the submit time from latency.
- **Result links**: successful image / video / music / speech rows carry `result_urls` and `result_expires_at` (platform-hosted images are swept after **24 hours**; video / music / speech links are long-lived or carry their own expiry). After expiry the log keeps only the link text.
- **Lookup by id**: the search box matches both the task id and the request id (= the `x-gateway-trace` response header).
- **CSV export** columns: `id, created_at, key_name, key_prefix, model, status, error_code, error_message, input_tokens, output_tokens, cached_tokens, unit_count, unit_type, cost_usd, latency_ms, request_id, task_id, submitted_at, attempts_count, result_urls, result_expires_at`. Multiple `result_urls` are joined with `|`; the `cost_usd` column name is historical — the value is in **credits**. One export is capped at 10,000 rows; past that the response carries `x-truncated: true`, so narrow the date range and export in slices.

---

## POST /v1/images/generations

OpenAI Images API-compatible shape, returned synchronously by default; add `"async": true` for submit + poll on long generations (see "Async mode" below).

### Available models

| Model ID | Display name | Price (per image) | Notes |
|---|---|---|---|
| `doubao-seedream-4-0-250828` | Seedream 4.0 | 11.73 | size ≥ 960×960 |
| `doubao-seedream-4-5-251128` | Seedream 4.5 | 14.96 | **size must be ≥ 1920×1920**, or upstream returns 400 |
| `doubao-seedream-5-0-260128` | Seedream 5.0 | 12.94 | **size must be ≥ 1920×1920**, or upstream returns 400 |
| `doubao-seedream-5-0-pro-260628` | Seedream 5.0 Pro | 17.79 for output ≤ 2.36 MP, **35.59 above that** | size ≥ 960×960. **Omitting `size` means upstream defaults to 2048×2048 and you pay the 35.59 tier** — to stay on the lower tier, pass a size ≤ 2.36 MP explicitly (e.g. `1536x1536`). Each `image[]` reference adds 1.21 of input (on the same billing line) |
| `grok-imagine-image` | Grok Imagine (Image) | 28.31 | standard tier |
| `grok-imagine-image-quality` | Grok Imagine (Quality) | 28.31 | high-quality tier |
| `gpt-image-2` | GPT-Image 2 | 23.86 | text-to-image and image-to-image (references are not charged). **The upstream ignores `size`** — put the aspect ratio in the `prompt`; measured latency 30–215 seconds. The older ids `gpt-image-2-low/medium/high` and `tvod-gpt-image2-*` resolve here as aliases |
| `tvod-midjourney-v8.1` | Midjourney v8.1 | 40.44 | **Every request returns exactly 4 images** (a 2×2 grid); `n` does not control the count ⇒ **161.76 per request**. `size` only sets the aspect ratio, never the pixel dimensions. Pick the output tier by appending `--sd` / `--hd` to the `prompt` |
| `tvod-midjourney-v7` | Midjourney v7 | 32.35 | Same — fixed 4 images ⇒ **129.41 per request** |

> `size` is passed straight through and never rewritten by the gateway — asking Seedream 4.5/5.0 for less than 1920×1920 gets you the upstream's 400 directly. Seedream 5.0 Pro is tiered on the **requested output pixel area** (the threshold is 2.36 MP ≈ 1536×1536). **`gpt-image-2` is the exception: its upstream never reads `size`** — to control the frame, write "Aspect ratio: 16:9 (landscape)" into the `prompt`, which overrides `size`.

> **The two Midjourney entries differ from every other model on this endpoint in two ways — integrate against the table notes above**:
> 1. **`n` does not control the image count.** The upstream produces a 2×2 grid, so a request always returns **4 images** (measured 2026-09-01: both `n=1` and `n=2` came back with 4). Pass `n: 1`. Billing settles on the number **actually returned**, so one v8.1 request costs `40.44 × 4 = 161.76` credits and your balance must cover that. The gateway's pre-flight check also gates on 4 images, so an insufficient balance is a 402 at submit time rather than an overdraft afterwards.
> 2. **`size` expresses an aspect ratio, not pixels.** The gateway maps `"WxH"` onto the nearest supported frame (`1:1`, `16:9`, `9:16`, `4:3`, `3:4`) and hands that to the upstream; the actual pixel dimensions follow from the model and the output tier. Measured: `2048x2048` → four `1024x1024`; `2048x1152` → four `1456x816`; the same `2048x1152` with `--hd` → four `2944x1648`. **Do not treat `size` as a promise of exact resolution.** A ratio that doesn't reduce to one of those five falls back to the model's default frame.
>
> The output tier goes at the end of the `prompt` (`--sd` for the standard tier, `--hd` for the higher one), like any other Midjourney flag — the prompt is forwarded verbatim. Reference images and the advanced flags (`--iw`, `--sref`, …) have **not been regression-tested on this channel** and are not advertised as supported. Because a request takes a while, prefer the **async mode** below (`"async": true` plus polling).

### The request

```json
{
  "model": "doubao-seedream-4-5-251128",
  "prompt": "A panda drinking tea in a bamboo grove, watercolour style",
  "size": "2048x2048",
  "n": 1
}
```

| Field | Required | Notes |
|---|---|---|
| `model` | ✓ | a Model ID from the table above |
| `prompt` | ✓ | the description |
| `size` | | `"WxH"`, passed through upstream (mind each SKU's minimum) |
| `n` | | how many images, default 1, **max 10** (more returns 400 `invalid_request`). `unit price × n` is held on submit and **settled against the number actually returned**. ⚠️ **Fixed-grid SKUs ignore `n`** (Midjourney always returns 4): those gate on `unit price × max(n, 4)`, so pass `n: 1` — see the model-table notes above |
| `image` | | reference image(s) for image-to-image: a string or an array, each item a **public https URL** or `data:image/...;base64,...`. Omit it for text-to-image. `gpt-image-2`, Seedream and `nano-banana-2` take data URIs; `nano-banana` and `nano-banana-pro` take **public URLs only** |
| `image_urls` | | equivalent spelling (array); both are merged and wire order is preserved (order carries meaning for multi-image composition) |

### Image-to-image (references)

Same endpoint — pass `image` and it's image-to-image. Full examples, SDK usage, aspect-ratio control and the common pitfalls are in [Image-to-image editing](../guides/image-editing.md).

```bash
curl https://zhonkezhonkeapi.dflop.top/v1/images/generations \
  -H "Authorization: Bearer $PLATFORM_API_KEY" \
  -H "Content-Type: application/json" \
  --max-time 300 \
  -d '{
    "model": "gpt-image-2",
    "prompt": "Make the sofa blue, leave everything else unchanged. Aspect ratio: 3:2 (landscape)",
    "image": ["https://your-host.com/original.png"]
  }'
```

⚠️ When a reference is a URL, **the server fetching it is the upstream's, not this gateway's** — object storage inside China, intranet addresses and image hosts behind auth or bot protection come back as an upstream 400, `Unable to download content from the provided URL`. Send a data URI instead, or upload the raw file through `/v1/images/edits` below.

### The response

```json
{
  "model": "doubao-seedream-4-5-251128",
  "created": 1765432100,
  "expires_at": 1765518500,
  "data": [{ "url": "https://...", "size": "2048x2048" }],
  "usage": { "generated_images": 1, "output_tokens": 4096, "total_tokens": 4096 }
}
```

The response is the upstream's own (OpenAI Images shape); the `usage` fields are whatever upstream actually returns. `expires_at` (a gateway extension) is when `data[].url` expires (Unix seconds); **it appears only when the image is hosted by this platform (`r2.dflop.top`)** — upstream pre-signed links carry no such field.

### curl

```bash
curl https://zhonkezhonkeapi.dflop.top/v1/images/generations \
  -H "Authorization: Bearer $PLATFORM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "doubao-seedream-4-5-251128",
    "prompt": "A panda drinking tea in a bamboo grove, watercolour style",
    "size": "2048x2048"
  }'
```

### Limitations

- **Returned image URLs are always temporary** — most SKUs return an upstream pre-signed link (about 24 hours), while `gpt-image-2` measurably returns a platform-hosted `https://r2.dflop.top/gateway/images/ephemeral/<uuid>.png` (**swept after 24 hours**; the top-level `expires_at` in the body is the exact expiry; images served through a relay lane are re-hosted on the same domain). Neither is permanent hosting — download and store them promptly
- Synchronous by default. The gateway's **per-attempt** upstream timeout is **240 seconds** (`IMAGES_UPSTREAM_TIMEOUT_SECS`) and the whole channel ladder is capped at **280 seconds** (`IMAGES_LADDER_DEADLINE_SECS`). Most SKUs finish in 5–20 seconds, but `gpt-image-2` measurably takes 30–215 seconds — **set your client timeout to ≥ 300 seconds**, or your own timeout will cut off a request the gateway is still legitimately waiting on (you are still billed, you just never receive the result)
- Errors use the OpenAI shape `{"error": {"code", "message", "param", "type"}}`; upstream 4xx/5xx pass through with their original status and body (and aren't billed)

### Async mode (use it for long generations)

Synchronous calls are bounded by a CDN non-streaming response limit of roughly 125 seconds. The gateway keeps the connection alive (past 90 seconds it commits the response headers and drips whitespace while it waits, so **synchronous calls are no longer cut off by the CDN**). The cost of that trick: **a turn that fails *after* the 90-second mark cannot return a truthful HTTP status code** — it comes back as `200` with an `{"error": ...}` body.

For clean failure semantics — or simply to avoid holding an HTTP connection open for minutes — add `"async": true` to the request body to switch to submit + poll:

```bash
# 1. Submit — returns immediately, does not wait for the image
curl https://zhonkezhonkeapi.dflop.top/v1/images/generations \
  -H "Authorization: Bearer $PLATFORM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt-image-2", "prompt": "a panda drinking tea in a bamboo forest", "async": true}'
# → {"id": "9f1e...", "status": "queued", "model": "gpt-image-2", "created_at": 1786000000}

# 2. Poll
curl https://zhonkezhonkeapi.dflop.top/v1/images/generations/9f1e... \
  -H "Authorization: Bearer $PLATFORM_API_KEY"
```

The polling contract is a single rule:

| Response | Meaning |
|---|---|
| **`202`** + `{"id","status","model","created_at"}` | Still running — keep polling (every 2–3 seconds is fine) |
| **Any other status code** | This is the answer — **byte-for-byte the same response** a synchronous call would have produced (`200` + `data[]` on success; the upstream's own status code and body on failure) |

- Bad parameters, unavailable models, insufficient balance and content blocks are **still returned synchronously as truthful 4xx at submit time** — you never get a task id for a request that was doomed
- A task runs for at most **280 seconds** (the whole channel-ladder budget), so budget **≥ 5 minutes** of polling; **a client-side timeout is not a failure** — the task keeps running and settles as usual, so recover the result by id from the list endpoint below instead of resubmitting
- Balance is reserved at submit for the worst case (`per-image price × n` plus reference-image cost) and settled against actual output; zero output is refunded in full
- `GET /v1/images/generations` lists your own tasks (**all statuses** by default, newest first) — if you lose a task id, recover it here instead of resubmitting; parameters and fields are under "List tasks" below
- `/v1/images/edits` supports it too: add `"async": true` to a JSON body, or an extra `-F "async=true"` field to multipart
- Tasks interrupted by a deploy are **refunded in full** and marked `failed` — you are never charged twice

#### List tasks

`GET /v1/images/generations` filters and paginates (since 2026-10-16); for reconciliation, pull the full range by submit time:

| Parameter | Meaning |
|---|---|
| `status` | `queued` / `running` / `succeeded` / `failed` / `in_flight` (queued + running); default all |
| `from` / `to` | Filter by **submit time**; accepts unix seconds, RFC 3339 or `YYYY-MM-DD` (`to` is exclusive) |
| `limit` | 1–100, default 30 |
| `cursor` | The previous page's `next_cursor`; `null` means the end |

Each item: `id` / `status` / `model` / `endpoint` (`generations` or `edits`) / `created_at` (submit) / `started_at` / `completed_at` (unix seconds) / `unit_count` (images actually produced) / `cost` (settled **credits**, `0` on failure) / `error_code` / `request_id` (= `x-gateway-trace`). `succeeded` items also carry `result: {"urls": [...], "expires_at": <unix seconds|null>}` — `urls` are byte-identical to the polling response's `data[].url`, and `expires_at` appears only on platform-hosted links (24 hours).

```bash
curl "https://zhonkezhonkeapi.dflop.top/v1/images/generations?status=succeeded&from=2026-08-30&to=2026-08-31&limit=100" \
  -H "Authorization: Bearer $PLATFORM_API_KEY"
```

> The Nano Banana family (`nano-banana` 15.77, `nano-banana-pro` 54.19, `nano-banana-2` 16.18 per image) also runs through this endpoint and bills per image. `nano-banana-2` returns `b64_json` (its first hop is our own pool); the others usually return URLs. The older ids (`gemini-2.5-flash-image`, `gemini-3-pro-image-preview`, `gemini-3.1-flash-image(-preview)`, `tvod-nano-*`) remain supported as aliases.

---

## POST /v1/images/edits

The other way into image-to-image. It exists so you can use **the call shape your OpenAI SDK already speaks** (`client.images.edit(image=...)` sends multipart), or upload raw local file bytes without base64-encoding them yourself.

Models, pricing, the channel ladder, `Idempotency-Key`, the response and the error shape are **identical to `/v1/images/generations`** — multipart is normalised into the same JSON envelope and continues down the same path. The one behavioural difference: **a request with no reference is rejected with 400 here** (on this endpoint a missing reference is a caller bug, and it will never silently degrade into text-to-image).

### Two request shapes

**JSON** (same shape as generations):

```bash
curl https://zhonkezhonkeapi.dflop.top/v1/images/edits \
  -H "Authorization: Bearer $PLATFORM_API_KEY" \
  -H "Content-Type: application/json" \
  --max-time 300 \
  -d '{
    "model": "gpt-image-2",
    "prompt": "Make the sofa blue, leave everything else unchanged",
    "image": ["data:image/png;base64,iVBORw0KGgo..."]
  }'
```

**multipart/form-data** (file field `image`, or `image[]` for several; other text fields pass through verbatim):

```bash
curl https://zhonkezhonkeapi.dflop.top/v1/images/edits \
  -H "Authorization: Bearer $PLATFORM_API_KEY" \
  --max-time 300 \
  -F "model=gpt-image-2" \
  -F "prompt=Make the sofa blue, leave everything else unchanged" \
  -F "image[]=@original.png" \
  -F "image[]=@style-reference.png"
```

### With the OpenAI SDK

```python
from openai import OpenAI

client = OpenAI(
    api_key=PLATFORM_API_KEY,
    base_url="https://zhonkezhonkeapi.dflop.top/v1",
    timeout=300.0,                       # gpt-image-2 measurably takes 30–215s
)

result = client.images.edit(
    model="gpt-image-2",
    image=open("original.png", "rb"),
    prompt="Make the sofa blue, leave everything else unchanged. Aspect ratio: 3:2 (landscape)",
)
print(result.data[0].url)
```

### Limitations

- No `image` → **400** (the error tells you to use `/v1/images/generations` for text-to-image)
- Request body limit **95 MB**; base64 inflates size by roughly 33%, so real phone photos (~3 MB each) cap out around 20 images
- Everything else (24h URL expiry, timeout budgets, error shape) matches `/v1/images/generations`
- For fuller usage and how to choose a route, see [Image-to-image editing](../guides/image-editing.md)

---

## POST /v1/videos/generations

**An async task**: submitting returns a task id immediately, and you poll until `succeeded`.

### Available models

| Model ID | Display name | Price (per second) | Notes |
|---|---|---|---|
| `doubao-seedance-1-0-pro-fast-251015` | Seedance 1.0 Pro Fast | 32.35 | |
| `doubao-seedance-1-0-pro-250528` | Seedance 1.0 Pro | 60.66 | |
| `doubao-seedance-1-5-pro-251215` | Seedance 1.5 Pro | 72.79 | |
| `doubao-seedance-2-0-fast-260128` | Seedance 2.0 Fast | 48.53 | |
| `doubao-seedance-2-0-260128` | Seedance 2.0 | 88.97 | |
| `doubao-seedance-2.0` | Seedance 2.0 | by resolution: 480p 33.16 / 720p 59.45 / 1080p 147.61 / 2k 291.17 / 4k 355.87 | supports real-person photos on camera; reference images are moderated and registered automatically. **Turns carrying a reference video are billed by token** (usage includes the reference video duration; `resolution` becomes required — omitting it returns 400. See the billing section below) |
| `doubao-seedance-2.0-fast` | Seedance 2.0 Fast | by resolution: 480p 23.86 / 720p 47.72 / 1080p 117.28 / 2k 141.54 / 4k 169.85 | **Turns carrying a reference video are billed by token** (usage includes the reference video duration; `resolution` becomes required — omitting it returns 400. See the billing section below) |
| `doubao-seedance-2.0-mini` | Seedance 2.0 Mini | by resolution: 480p 14.96 / 720p 29.93 | lightweight tier; 480p/720p only, 4–15 seconds. **Turns carrying a reference video are billed by token** (usage includes the reference video duration; `resolution` becomes required — omitting it returns 400. See the billing section below) |
| `doubao-seedance-2.5` | Seedance 2.5 | by resolution: 480p 42.06 / 720p 90.59 / 1080p 226 | multimodal references from images (≤30), video and audio; can generate sound; 4–30 seconds. **Requests carrying reference video bill by token** (2499.19/1M; 4165.32/1M without video input; the **1080p tier** bills at 2790.12/1M and 4650.21/1M — see the billing section below) |
| `doubao-seedance-2.0-lite` | Seedance 2.0 Lite | by resolution: 720p 29.93 / 1080p 64.7 | value tier; **`resolution` must be passed explicitly** (720p/1080p only). **Turns carrying a reference video are billed by token** (usage includes the reference video duration; `resolution` becomes required — omitting it returns 400. See the billing section below) |
| `doubao-seedance-2.0-fast-lite` | Seedance 2.0 Fast Lite | by resolution: 720p 24.67 / 1080p 52.57 | as above, lower latency. **Turns carrying a reference video are billed by token** (usage includes the reference video duration; `resolution` becomes required — omitting it returns 400. See the billing section below) |
| `doubao-seedance-2.0-mini-lite` | Seedance 2.0 Mini Lite | by resolution: 720p 16.18 / 1080p 34.78 | as above, cheapest for lightweight work. **Turns carrying a reference video are billed by token** (usage includes the reference video duration; `resolution` becomes required — omitting it returns 400. See the billing section below) |
| `doubao-seedance-2.5-lite` | Seedance 2.5 Lite | by resolution: 720p 44.48 / 1080p 95.84 | as above; same capabilities as `doubao-seedance-2.5` (4–30 seconds, multimodal references, sound). Its 1080p tier is far cheaper than the full-price card (95.84 vs 226) — same delivered resolution, different cost structure. Requests carrying reference video bill by token (2758.01/1M; 4424.14/1M without video input) |
| `grok-imagine-video` | Grok Imagine Video | 283.08 | text-to-video and image-to-video |
| `grok-imagine-video-1.5-preview` | Grok Imagine Video 1.5 | 586.38 | **image-to-video only** (upstream 400s without a reference image) |
| `dh-avatar` | Digital human video | flat per video second (see the in-app catalogue for pricing) | requires a reusable `avatar` first (created from a photo or video); avatar + driving audio (or text + voice) → a talking video |
| `clip-realman` | Smart edit · talking head | 4.04/second (of finished video) | a talking-head source video + a template → a finished cut with title, subtitles, name bar and music |
| `clip-mixcut` | Smart edit · asset mixcut | 4.04/second (of finished video) | narration audio + image/video assets + a template → a subtitled, packaged cut |
| `clip-news` | Smart edit · news brief | 2.43/second (of finished video) | a headline + image/video assets + a template → a news-style short, 5–300 seconds |

### Submitting

```json
{
  "model": "doubao-seedance-1-0-pro-fast-251015",
  "content": [
    { "type": "text", "text": "Sunset over the sea, aerial drone view --ratio 16:9" }
  ],
  "duration": 5
}
```

| Field | Required | Notes |
|---|---|---|
| `model` | ✓ | a Model ID from the table above (grok SKUs use the same shape; the gateway translates) |
| `content` | ✓ | an array: `{type:"text", text}` is mandatory; for **image-to-video** append `{type:"image_url", image_url:{url:"https://..."}}` |
| `duration` | | seconds. **Omitted, we hold 12 seconds' worth** (Seedance's ceiling) and settle on the real length — pass it explicitly |
| `ratio` / `resolution` / `watermark` | | passed through upstream (Seedance's own semantics). ⚠️ **`resolution` is required on the four Lite (`-lite`) SKUs**: the whole card prices on delivered resolution, so omitting it returns **400**; the vocabulary is `720p` / `1080p` only, and anything else 400s too |
| `video_mode` | | only meaningful on grok SKUs with a reference video: `"extend"` continues it, anything else (or omitted) rewrites it (see below) |

> **How grok video is translated**: grok SKUs run on an xAI upstream, and the gateway converts the Seedance shape above into xAI's. Appending `{type:"video_url", video_url:{url:"https://..."}}` routes the task to the video-to-video endpoint, which **rewrites** by default (redrawing the whole clip from your prompt, keeping the source aspect ratio and resolution, ignoring a custom `duration`); `video_mode: "extend"` switches to **continuation** (carrying on from the last frame for `duration` seconds, 2–10). For image-to-video the gateway deliberately withholds `ratio` so the source image's native aspect ratio is preserved and nothing gets stretched.

### Real people on camera

`doubao-seedance-2.0`, `doubao-seedance-2.0-fast` and `doubao-seedance-2.0-mini` support **uploading a photo of a real person and generating video of them on camera**. The compliance path runs entirely inside the gateway and needs nothing special from you — submit the ordinary image-to-video shape and the gateway moderates and registers the reference image (swapping in a compliant asset handle) before generating. If the preferred channel refuses a real-person image, the gateway transparently switches to one that supports registering real-person assets.

```json
{
  "model": "doubao-seedance-2.0",
  "resolution": "720p",
  "duration": 5,
  "content": [
    { "type": "text", "text": "The person in the photo smiles and waves at the camera, background unchanged" },
    { "type": "image_url", "image_url": { "url": "https://your-cdn.com/face.jpg" } }
  ],
  "portrait_auth": true
}
```

| Field | Notes |
|---|---|
| `image_url.url` | **must be a publicly fetchable http(s) URL** (the upstream pulls it from the open internet). **base64 / `data:` inline images are not supported** and are rejected with 400. The image must be reachable from mainland China (our own `r2.dflop.top` links work; some overseas hosts can't be fetched). |
| `portrait_auth` | Optional boolean asserting "I have the portrait rights for the real person shown", recorded for audit. It **does not affect whether video is produced** (real-person routing follows the SKU), but passing `true` explicitly is recommended whenever real people are involved, as a statement of responsibility. |
| `resolution` | The real-person vocabulary: `doubao-seedance-2.0` takes `480p`/`720p`/`1080p`/`2k`/`4k`, `-fast` takes `480p`/`720p`/`1080p`, `-mini` only `480p`/`720p`. Omitted, upstream picks its default and the flat rate applies. |

> **Multimodal references (Seedance 2.0 only)**: beyond a single first-frame image, `content[]` can carry reference media items tagged with a `role` — `{type:"image_url", role:"reference_image", image_url:{url}}`, `{type:"video_url", role:"reference_video", video_url:{url}}` and `{type:"audio_url", role:"reference_audio", audio_url:{url}}` (up to 10 reference images). Every external image follows the same public-URL and automatic-moderation rules above.

Response:

```json
{ "id": "9f2c...", "status": "queued", "model": "doubao-seedance-1-0-pro-fast-251015", "created_at": 1765432100 }
```

Submission itself is a synchronous HTTP call (with a 60-second gateway-to-upstream timeout); generation then proceeds in the background and doesn't hold the request open.

### Digital-human fields (dh-avatar)

`dh-avatar` reuses the same video submission endpoint, adding the fields below at the **top level** of the body (`duration` is **required** — the estimated seconds of the driving audio or script; omitting it returns 400). ⚠️ Digital human is a **two-stage** process: you need a reusable `avatar` first, created in the app under "Clone avatar" from a photo or video (the platform's shared avatars have been withdrawn from the UI because upstream stopped returning previews). Calling with an sk-key, just pass an existing avatar id.

> **The video is as long as its driving audio or script** (upstream measures the real length; there's no fixed cap). `duration` only sizes the **billing hold**, and settlement uses upstream's actual seconds.

| Field | Required | Notes |
|---|---|---|
| `avatar` | ✓ | the avatar id (created once under "Clone avatar" in the app, then reusable) |
| `audio_url` | one of the two | driving audio (public URL, mp3/wav) — makes the avatar speak it |
| `voice` + `text` | one of the two | text-driven: `voice` is a voice id (shared or cloned) and `text` the script (≤10,000 characters); upstream synthesises it and drives the avatar in one step |
| `title` | | a name for the piece (≤20 characters) |

Things to note:

- input media URLs must be publicly reachable (anything you've uploaded to our `r2.dflop.top` works directly);
- finished videos carry an automatic "AI generated" mark, as China's AIGC labelling rules require.

### Smart-edit fields (clip-realman / clip-mixcut / clip-news)

The three smart-edit SKUs reuse the same video submission endpoint, adding the fields below at the **top level**. All three share `style_id` (the template id), `title`, `language`, `materials[]`, `bgm` and `cover_url`, each with its own extra requirements. **The length of the finished video comes from the source media** (realman from the source video, mixcut from the narration audio, news from `duration`); for realman and mixcut, `duration` is only a billing hint and is never sent upstream.

```json
{
  "model": "clip-realman",
  "style_id": "tpl_xxx",
  "title": "Today's headlines",
  "source_video_url": "https://your-cdn.com/talk.mp4",
  "materials": [
    { "type": "image", "file_url": "https://your-cdn.com/a.jpg" },
    { "type": "video", "file_url": "https://your-cdn.com/b.mp4", "sound_switch": false }
  ],
  "bgm": { "mode": "auto" }
}
```

| Field | Applies to | Notes |
|---|---|---|
| `style_id` | all ✓ | template id (from the platform's smart-edit template library) |
| `source_video_url` | realman ✓ | the talking-head source video (public URL) |
| `audio_url` | mixcut ✓ | the narration audio (public URL) |
| `materials` | mixcut/news ✓, optional for realman | an array of `{type:"image"\|"video", file_url, sound_switch?}`, at most 10 |
| `title` | news ✓, optional elsewhere | the piece's or story's headline |
| `duration` | news | target length in seconds, `5–300` (clamped if out of range); a billing hint only for realman/mixcut |
| `material_composition` | news | `random` or `order`; random by default |
| `preprocess` | realman | asset pre-processing: `roughCut` or `sliceMerge` |
| `bgm` | all | `{mode:"auto"\|"none"\|"custom", url?, volume?}`; follows the template by default |
| `cover_url` | all | a custom first-frame cover (public image URL) |
| `introduce_card` | all | the name bar, `{name, description}` |
| `language` | all | subtitle language |

> **Billing**: charged on the **actual length of the finished video** (the real seconds reported by polling). Submissions with an external sk-key hold the clip ceiling (300 seconds) and settle down to the real length on success; passing a longer `duration` explicitly (for a long source video, say) holds that instead. Insufficient balance returns 402, and failures or expiry are refunded in full.
>
> **Template ids**: `style_id` comes from the platform's smart-edit template library. Template discovery currently exists only inside the Digital Human studio in the app, so calling directly with an sk-key means using a template id you already know.

#### Asset and media requirements (upstream hard limits)

Every URL must be publicly fetchable. The limits below are the upstream's own, and anything outside them is rejected there (the in-app Digital Human studio validates format, resolution, length and size at upload time).

| Medium | Format | Size | Resolution | Length |
|---|---|---|---|---|
| Talking-head source video `source_video_url` | mp4 / mov (h264 or HEVC, 10–60fps, 25 recommended) | < 500MB | < 2000px per side | < 5 minutes |
| Asset image `materials[].file_url` (image) | static jpg / png / webp | — | < 2000px per side | counts as 2s each |
| Asset video `materials[].file_url` (video) | mp4 / mov | < 500MB | < 2000px per side | ≤ 60s each |
| Narration audio `audio_url` (mixcut) | mp3 / wav / m4a | ≤ 120MB | — | ≤ 5 minutes, must be transcribable |
| Background music `bgm.url` | mp3 / wav / m4a | ≤ 120MB | — | ≤ 5 minutes |
| First-frame cover `cover_url` | jpg / jpeg / png | ≤ 10MB | < 2000px per side | — |

- **Total asset length ≤ 5 minutes**: images count as 2s each and videos at their real length; upstream rejects anything longer.
- The talking-head source video's **audio must be transcribable** (it drives the automatic subtitles); without clear speech the task fails.
- `clip-news` output length is set by `duration` (5–300s); `clip-realman` and `clip-mixcut` follow the source video and the narration audio respectively.

### Polling

```bash
curl https://zhonkezhonkeapi.dflop.top/v1/videos/generations/$TASK_ID \
  -H "Authorization: Bearer $PLATFORM_API_KEY"
```

`status` runs `queued` → `running` → `succeeded` / `failed` / `expired` / `cancelled`.

In-flight tasks (`queued` / `running`) may carry `progress` (an integer 0–100 from upstream). It appears only when upstream reports progress — today just the Seedance 2.0 real-person tiers — and its absence means that model has no progress data, not 0%.

On success:

```json
{
  "id": "9f2c...",
  "status": "succeeded",
  "model": "doubao-seedance-1-0-pro-fast-251015",
  "created_at": 1765432100,
  "updated_at": 1765432460,
  "ratio": "16:9",
  "resolution": "1080p",
  "duration": 5,
  "generate_audio": true,
  "seed": 33608,
  "framespersecond": 24,
  "content": {
    "video_url": "https://...",
    "last_frame_url": "https://..."
  },
  "usage": { "completion_tokens": 411300, "total_tokens": 411300 },
  "video_url": "https://...",
  "expires_at": 1765435700,
  "duration_sec": 5.0
}
```

Failures carry `error: {code, message}`. Generation usually takes 1–5 minutes; poll every 5–10 seconds.

Task ids are visible only to their own account — polling a nonexistent task or someone else's returns the same 404 (`code: "not_found"`), with no distinction drawn.

#### Response fields

| Field | Present when | Notes |
|---|---|---|
| `id` | always | Task id |
| `status` | always | `queued` / `running` / `succeeded` / `failed` / `expired` / `cancelled` |
| `model` | always | The submitted Model ID (canonicalised) |
| `created_at` | always | Task creation time, Unix seconds |
| `updated_at` | always | When the status last changed, Unix seconds. For terminal rows this is when the task settled; while in flight it falls back to `created_at` (we don't stamp the intermediate queued/running transitions). Always ≥ `created_at` and never in the future |
| `ratio` / `resolution` / `frames` / `seed` / `generate_audio` / `output_format` / `service_tier` / `safety_identifier` / `execution_expires_after` / `draft` | only if you sent them | The value you specified in the create request, echoed back. **Keys you didn't send are absent** — we don't invent upstream defaults on your behalf (upstream applies its own, and we can't observe them). Request-side "you decide" sentinels are likewise not echoed: `seed: -1` (random) and `ratio: "adaptive"` (auto-fit) would read as a concrete answer in a response, and we don't know what upstream actually picked |
| `duration` | `succeeded` only | Delivered length in **whole seconds**. This is the *delivered* duration (same source as `duration_sec`, rounded), not the `duration` you submitted. Equal for Seedance; they differ on SKUs that estimate length from a script (digital human text-driven), where this field is authoritative |
| `content` | `succeeded` only | Output block. `video_url` = the finished asset; `last_frame_url` = the last frame (see below) |
| `content.last_frame_url` | sent `return_last_frame: true` on create, and succeeded | PNG of the video's **final frame**, same dimensions as the video, no watermark. Use it to **chain long videos**: feed it as the first frame of the next task. Already copied into our object storage, so the link is permanent (the upstream original expires in 24h) |
| `framespersecond` | only when upstream reports it | Frame rate of the finished video. **Absent** when upstream didn't report one (it does not mean 24) |
| `usage` | `succeeded`, and only when upstream reports token usage | `{completion_tokens, total_tokens}`. Video models don't count input tokens, so the two are equal. **This is upstream's usage figure, not this platform's billing basis** — see below |
| `video_url` | `succeeded` only | Same value as `content.video_url`, flattened |
| `expires_at` | `succeeded` only | When `video_url` expires, Unix seconds |
| `duration_sec` | `succeeded` only | Same as `duration` but keeps fractional precision. A gateway extension; Ark has no such field |
| `output_files` | `succeeded`, subtitle SKUs only | One download link per language |
| `progress` | `queued`/`running`, and only when upstream reports it | Integer 0–100. **An absent field means the model has no progress data — not 0%** |
| `error` | `failed`/`expired`/`cancelled` only | `{code, message}` |

#### How `usage` relates to billing

`usage.completion_tokens` is passed through verbatim from upstream: if upstream reports it, we emit it, regardless of how that SKU is priced here. **Seeing a token count does not mean the SKU is billed per token.**

Each model's pricing basis is listed in [Models & pricing](./models.md) and is one of two things:

- **Per second** (most video SKUs): charge = unit price × delivered seconds; reconcile against `duration_sec`. `usage` is informational here and plays no part in pricing.
- **Per token** (the whole Volcano Seedance family — both the 2.0 and 2.5 series): charge = unit price × `usage.completion_tokens`; reconcile against that.

##### When Seedance bills per second and when it bills per token

Upstream meters `(reference video duration + output video duration) × width × height × fps / 1024`,
so **cost grows with the reference video while the output length does not move at all** — per-second
arithmetic cannot express that. The test is a single thing: whether `content[]` carries a `video_url` item.

| This request | Billing | Hold at submit |
|---|---|---|
| No reference video (text-to-video, image-to-video, first/last frame, reference images, reference audio) | **Per second** × the tier rate — byte-for-byte what it always was | unit price × the `duration` you sent |
| Carries a reference video (`{type:"video_url", …}`) | **Per token**, rates per delivery tier in [Models & pricing](./models.md) | unit price × (`duration` + **30 s**) |

- **Reference images and the text prompt cost no tokens** (upstream reports `prompt_tokens: 0`); extra reference images are free.
- **Reference audio does not switch you to token billing** — the official formula has no audio term, so those turns stay per second.
- ⚠️ **Budget for `duration + 30`, not `duration`.** The server cannot measure your reference video (and must not simply
  trust a self-reported value), so it freezes the worst case allowed upstream — 30 s of total input. Settlement then uses the
  real `usage.completion_tokens` reported by upstream and **refunds the rest**. Insufficient balance returns 402 at submit
  time with nothing charged.
- ⚠️ **`resolution` becomes required when a reference video is present**; omitting it returns 400. Token rates are graded by
  delivery tier, so without a tier there is no price. Requests without a reference video are unaffected and still fall back to
  the flat rate when `resolution` is omitted (see the `resolution` notes above).

Under either basis, the authoritative charge is the record for that request on the console's usage page.

#### Alignment with Volcengine Ark

For the Seedance SKUs, request and response fields follow Volcengine Ark's [create](https://docs.volcengine.com/docs/82379/1520757) / [query video generation task](https://docs.volcengine.com/docs/82379/1521309) contract, so code written against Ark's docs or official SDK can be pointed here unchanged. Five differences to know about:

- **The asset URL appears twice**: `content.video_url` (Ark's shape) and top-level `video_url` (this gateway's original shape) are always emitted together with the same value. Read either.
- **Task ids are this platform's own UUIDs** (e.g. `9f2c8a1e-…`), not Ark's `cgt-`-prefixed format. Store the id as an opaque string; don't validate its prefix or length.
- **Parameter fields are "what you sent", not "what upstream picked"**. Ark defines `ratio` / `resolution` / `seed` and friends as properties of the finished video; this gateway echoes the value you specified on create. The two agree whenever you specified a concrete value, so we only emit these keys when you actually did, and we skip "upstream decides" sentinels like `adaptive` / `-1` — a missing key beats reporting a number we never observed.
- **Gateway extensions**: `expires_at` / `duration_sec` / `output_files` / `progress` don't exist upstream. The extra keys are harmless to an Ark-shaped parser.
- **Ark fields not yet provided**: `tools` (tools actually used), `usage.tool_usage` (tool call counts), and `priority` (this platform has no priority scheduling). The request side forwards them verbatim, but you won't see them in the response.
- **Not applicable on this platform**: `draft` / `draft_task_id` (draft mode). Ark restricts these to `Seedance 1.5 Pro`, which this platform does not carry, so they are never returned.

### List tasks

`GET /v1/videos/generations` (no id) — this account's video tasks, newest first. Free.
Use it to recover a task id you didn't save, or just as a generation history.

| Query param | Default | Notes |
|---|---|---|
| `limit` | 30 | 1–100; anything larger is clamped to 100 |
| `status` | *(all)* | `queued` / `running` / `succeeded` / `failed` / `expired` / `cancelled` / `all`, **comma-separated for multiple** (e.g. `?status=queued,running`). An unrecognised value returns 400 `invalid_request` |

> **All statuses are returned by default as of 2026-07-31.** The previous default returned only `succeeded`, which made the list look empty while a task was still running. Pass `?status=succeeded` for the old behaviour.

```json
{
  "data": [
    {
      "id": "9a31d5c2-5c13-4caf-ad8b-1ee70ff5887f",
      "status": "running",
      "model": "doubao-seedance-2.0-fast-lite",
      "created_at": 1785495460,
      "progress": 42
    },
    {
      "id": "ed2ab10b-ceb1-4d0c-8c25-84ade94c95ac",
      "status": "succeeded",
      "model": "doubao-seedance-1-0-pro-fast-251015",
      "created_at": 1785490000,
      "content": { "video_url": "https://..." },
      "video_url": "https://...",
      "expires_at": 1786094800
    }
  ]
}
```

| Field | Present when | Notes |
|---|---|---|
| `id` | always | The task id — the same value `GET /v1/videos/generations/{id}` takes |
| `status` | always | Same vocabulary as the polling endpoint |
| `model` | always | The submitted Model ID (canonicalised) |
| `created_at` | always | Submit time, Unix seconds |
| `progress` | `queued`/`running`, and only when upstream reports it | Integer 0–100. **An absent field means the model has no progress data — not 0%** |
| `content` | `succeeded` only | `{video_url}`, the **same shape** as the polling endpoint. ⚠️ Not necessarily the same URL though: this endpoint prefers a permanent public URL while the polling endpoint returns a presigned one. Both point at the same asset — don't compare or de-duplicate them as strings |
| `video_url` | `succeeded` only | Same value as `content.video_url`. A presigned link valid for 7 days; calling this endpoint again re-signs an expired one |
| `expires_at` | `succeeded` only | When `video_url` expires, Unix seconds |
| `output_files` | `succeeded`, subtitle SKUs only | One download link per language |
| `error` | `failed`/`expired`/`cancelled` only | `{code, message}` |

> This endpoint is a gateway extension (Ark has no list endpoint). Each entry also carries card fields — `mode` (`t2v`/`i2v`), `prompt`, `ratio`, `resolution`, `duration` — so a "generation history" UI can render straight from it. Note these are reconstructed from the **create request** (`duration` is the length you ordered), which is a different basis from the delivered duration on the polling endpoint.

```python
import requests
r = requests.get(
    "https://zhonkezhonkeapi.dflop.top/v1/videos/generations",
    headers={"Authorization": f"Bearer {PLATFORM_API_KEY}"},
    params={"limit": 20},                    # in-flight only: {"status": "queued,running"}
    timeout=30,
)
for t in r.json()["data"]:
    print(t["id"], t["status"], t.get("video_url", ""))
```

### How billing works

- On submit, `unit price × duration` is **held** against the balance (12 seconds if `duration` is missing); an insufficient balance returns 402
- Settlement on a final state: success charges the **actual length** (falling back to the requested seconds when upstream doesn't report one), and failure or expiry is **refunded in full**; any failure during submission (an upstream error, a failed insert) releases the hold immediately
- `GET /v1/videos/generations` (with no id) lists this account's tasks, **all statuses by default** (`?limit=` default 30, max 100; `?status=succeeded` for successes only) — useful both as a "generation history" and to recover a lost task id (see [Recovering a task id](#recovering-a-task-id))

### Limitations

- Successful videos are copied into our own object storage, and `video_url` is a pre-signed link **valid for 7 days** (`expires_at` is when it dies); calling the list endpoint again after expiry re-signs a fresh link
- Upstream moderation can intercept after generation finishes (error codes like `OutputVideoSensitiveContentDetected`); that counts as a failure and isn't billed

---

## POST /v1/music/generations

Suno AI music generation, with the same async task shape as video: submit for a task id, then poll to a final state. **One generation yields 2 complete songs** (with lyrics and cover art).

### Available models

| Model ID | Display name | Price (per generation) |
|---|---|---|
| `suno-v3.5` | Suno V3.5 | 19.41 |
| `suno-v4` | Suno V4 | 19.41 |
| `suno-v4.5` | Suno V4.5 | 19.41 |
| `suno-v5` | Suno V5 | 19.41 |
| `suno-v5.5` | Suno V5.5 (latest) | 19.41 |

### The request

```bash
curl https://zhonkezhonkeapi.dflop.top/v1/music/generations \
  -H "Authorization: Bearer $GPUSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "suno-v5.5",
    "prompt": "An upbeat Mandarin pop song about a summer walk by the sea"
  }'
```

| Field | Type | Notes |
|---|---|---|
| `model` | string | required; any id from the table above |
| `prompt` | string | inspiration mode (≤200 characters): the AI writes the lyrics, picks a title and sings |
| `lyrics` | string | your own lyrics (≤3000 characters); passing them switches to custom mode and `prompt` is ignored |
| `title` | string | song title (custom mode) |
| `tags` | string | style, e.g. `"synthwave, female vocal"` |
| `negative_tags` | string | styles to avoid |
| `instrumental` | bool | instrumental only (lyrics ignored) |

Pass at least one of `prompt` and `lyrics` (both may be omitted when `instrumental: true`). Response: `{"id": "<task_id>", "status": "queued", "model": "...", "created_at": ...}`.

### Polling the task

```bash
curl https://zhonkezhonkeapi.dflop.top/v1/music/generations/$TASK_ID \
  -H "Authorization: Bearer $GPUSHARE_API_KEY"
```

`status` is one of `processing | succeeded | failed | expired`. On success the `tracks` array gives each song:

```json
{
  "id": "…",
  "status": "succeeded",
  "tracks": [
    {
      "clip_id": "…",
      "title": "Slow Sea Breeze",
      "duration_sec": 192.0,
      "audio_url": "https://…mp3",
      "image_url": "https://…jpeg",
      "lyrics": "[Verse]…"
    }
  ]
}
```

Generation usually takes 2–4 minutes; poll every 10–20 seconds. Task ids are private to their account, and someone else's or a nonexistent one always returns 404.

### List tasks

`GET /v1/music/generations` (no id) — this account's music tasks, newest first. Free.
Use it to recover a task id you didn't save.

| Query param | Default | Notes |
|---|---|---|
| `limit` | 30 | 1–100 |
| `status` | *(all)* | `processing` / `succeeded` / `failed` / `expired` / `cancelled` / `all`, comma-separated for multiple. ⚠️ The music family has **no** `queued`/`running` — everything between submit and terminal is `processing` (same vocabulary as the polling endpoint). An unrecognised value returns 400 |

The response is `{"data": [ … ]}`, and each item is **field-for-field identical** to the single-task polling response above (`id` / `model` / `status` / `upstream_status` / `tracks[]` / `error_code` / `error_message` / `created_at` / `completed_at`), so a list item can be consumed exactly like a poll result — no second parser needed.

```bash
curl "https://zhonkezhonkeapi.dflop.top/v1/music/generations?limit=10&status=processing" \
  -H "Authorization: Bearer $PLATFORM_API_KEY"
```

### How billing works

- The **flat unit price** is held on submit (one generation = 2 songs, already included); an insufficient balance returns 402
- Settlement: **one successful song is enough to charge full price**; both failing, or 30 minutes without completion (expiry), is **refunded in full**, and any failure during submission releases the hold immediately
- Audio and cover art are copied into our own object storage, with `audio_url` / `image_url` as 7-day pre-signed links; if the copy fails we fall back to the upstream's original links

---

## POST /v1/audio/speech

Speech synthesis: text (≤5000 characters) → MP3, with cloned voices and speed adjustment (speed applies to cloned voices only). Billed per input character (`voice-tts-pro`, **125.36 per 1,000 characters**).

> ⚠️ **Use async mode for long scripts.** Synthesis runs on an upstream async queue (seconds for short text, minutes for long), while a synchronous call is bound by the CDN's roughly 100-second ceiling on non-streaming responses — and when that cuts you off, the audio still renders and you're still charged, but you get nothing back. Add `"async": true` to the body to switch to submit-and-poll.

### The request

```json
{
  "model": "voice-tts-pro",
  "input": "Hello, and welcome to speech synthesis.",
  "voice": "<optional: a platform preset voice id, or a cloned voice id from /v1/audio/voices; omitted means the default voice>",
  "speed": 1.0,
  "async": false
}
```

### The response (synchronous, `async` omitted or `false`)

```json
{
  "model": "voice-tts-pro",
  "audio_url": "https://r2.dflop.top/audio-speech/…/xxx.mp3",
  "characters": 12,
  "cost_usd": "0.0037"
}
```

`audio_url` is a **permanent public link** in our object storage, and can be fed straight into a digital human's (`dh-avatar`) `audio_url`.

### The response(`"async": true`)

Returns a task id immediately, without blocking:

```json
{ "id": "3a8e…", "model": "voice-tts-pro", "status": "pending", "characters": 1200, "created_at": "…" }
```

Then poll **`GET /v1/audio/speech/{id}`** (free):

```json
{
  "id": "3a8e…", "model": "voice-tts-pro", "status": "succeeded",
  "characters": 1200, "duration_sec": "86.40",
  "audio_url": "https://r2.dflop.top/audio-speech/…/xxx.mp3", "created_at": "…"
}
```

`status` has three states: `pending` / `succeeded` / `failed`. **Failures are refunded in full automatically**; you're only charged on success, and the audio again lands on a permanent link.

### List tasks

`GET /v1/audio/speech` (no id) — this account's synthesis tasks, newest first. Free.
Use it to recover the task id from an `"async": true` submit you didn't save.

| Query param | Default | Notes |
|---|---|---|
| `limit` | 30 | 1–100 |
| `status` | *(all)* | `pending` / `succeeded` / `failed` / `all`, comma-separated for multiple. An unrecognised value returns 400 |

The response is `{"data": [ … ]}`, and each item is **field-for-field identical** to `GET /v1/audio/speech/{id}` (`id` / `model` / `status` / `characters` / `created_at`, plus `audio_url` / `duration_sec` on success and `error.message` on failure).

```bash
curl "https://zhonkezhonkeapi.dflop.top/v1/audio/speech?limit=10" \
  -H "Authorization: Bearer $PLATFORM_API_KEY"
```

## /v1/audio/voices — voice cloning and management

| Method | Path | Notes |
|---|---|---|
| POST | `/v1/audio/voices` | Clone a voice: `{name, audio_url, async?}` (a public reference-audio URL, 5 seconds to 3 minutes of clear speech). Billed per call (`voice-clone-pro`, **40.44 each**) |
| GET | `/v1/audio/voices` | List this account's cloned voices plus the platform presets: `{voices:[…], presets:[{id, name}]}` |
| GET | `/v1/audio/voices/{id}` | Check one voice's status (`pending` / `ready` / `failed`) |
| DELETE | `/v1/audio/voices/{id}` | Delete a voice (our record of it) |

Cloning also supports **`"async": true`**: it returns `{id, status:"pending"}` immediately and you poll `GET /v1/audio/voices/{id}` until `ready`. By default it blocks until ready (tens of seconds to minutes) — which, as above, runs into the CDN's ~100-second ceiling, so **new integrations should always use async**. Failures are refunded in full automatically.

Pass the resulting voice id as `voice` on `/v1/audio/speech` to synthesise with it, or as the `voice` for a text-driven digital human (`dh-avatar`; see [Digital human API](./digital-human-apis.md)). Before cloning a real person's voice, make sure you have their permission.

The platform also offers a set of **shared voices** (the `presets` in the `GET /v1/audio/voices` response) — pass one of their `id`s as `voice` with no cloning required.

---

## POST /v1/transcripts/extract

Short-video link → spoken script: paste a share link or share token from a short video and get back the video's spoken script plus metadata (title, cover, platform, length). The upstream **detects the platform itself** (Douyin, Kuaishou, Xiaohongshu, Bilibili, WeChat Channels and other major sites), so you don't specify a source.

> The server **blocks synchronously** until extraction finishes (internally: create a task, then poll upstream — usually 5–40 seconds, at most about 55). Set a generous client read timeout (**≥ 90 seconds** recommended). The upstream's concurrency ceiling is low, so heavy parallel use queues up and slows down.

### The request

```json
{
  "url": "https://v.douyin.com/xxxxxx/   — or just paste the raw share token"
}
```

| Field | Notes |
|---|---|
| `url` | **Required.** A short-video share link or raw share token (≤ 2000 characters). `input` is an equivalent alias. |

### The response

```json
{
  "model": "video-transcript",
  "content": "the extracted spoken script…",
  "title": "the original video title",
  "cover": "https://… cover image URL",
  "platform": "douyin",
  "duration_sec": 42,
  "origin_link": "https://… the original link echoed by upstream"
}
```

`platform` is the platform upstream identified (`douyin`, `kuaishou` and so on). `content` is the script itself; `title`, `cover` and `duration_sec` are supporting metadata and may be empty when the video has no such field.

### curl

```bash
curl -X POST https://zhonkezhonkeapi.dflop.top/v1/transcripts/extract \
  -H "Authorization: Bearer $GPUSHARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://v.douyin.com/xxxxxx/"}'
```

### How billing works

- A flat per-call charge (`video-transcript`, **20.22 each**) against the account balance (shared by all keys).
- **Only successes are billed**: an unparseable link, an unsupported video, an extraction timeout, or a script blocked by content safety are **all free**; only a clean, successful script costs you one call.
- Each call is recorded in usage and the call log as `unit_type=transcript` (the response carries `x-gateway-trace`; include it with the timestamp when reporting a problem).

### Limits and errors

- The `cover` URL may be **time-limited** (roughly 24 hours) — download and store it yourself if you need it long-term.
- Input is capped at 2000 characters, and the upstream's low concurrency ceiling means heavy parallel use queues.
- Errors use the normalised shape shared with every other endpoint: an invalid link or unsupported video → **400**, an extraction timeout → **504**, service quota temporarily exhausted → **503**, an upstream connection failure → **502**, insufficient balance → **402**.
- To restrict a key to **this capability only**, put `video-transcript` in its `allowed_models` (no `allowed_models` means every model the account can use).
