ZvenoAI
Руководства

Видеогенерация

Асинхронный API для генерации видео по тексту и изображениям через единую точку входа ZvenoAI с автоматическим выбором провайдера и оплатой в рублях.

ZvenoAI предоставляет OpenAI-совместимый видео-API: один эндпоинт, одна авторизация, оплата в рублях. Если у выбранного провайдера сбой — запрос автоматически уходит к резервному, а с баланса списывается только фактическая стоимость.

Как это устроено

В отличие от чат-комплишена видеогенерация асинхронна: ни один провайдер в индустрии не отдаёт видео синхронно — генерация занимает от десятков секунд до нескольких минут. ZvenoAI унифицирует жизненный цикл задачи в три шага.

Отправка задачи

POST /v1/videos — отправляете промпт и параметры. В ответ приходит идентификатор vj_<uuid> и статус pending. На балансе резервируется оценочная стоимость по параметрам запроса (длительность, разрешение, наличие аудио) и тарифу выбранного провайдера; если средств не хватает ни на одного из подходящих кандидатов — запрос отклоняется с 402 insufficient_funds ещё до похода к провайдеру.

Опрос статуса

GET /v1/videos/{id} — опрашиваете задачу, пока статус не станет терминальным (completed или failed). На стороне ZvenoAI воркер сам ходит к провайдеру с ограничением частоты и экспоненциальной задержкой между попытками.

Скачивание

GET /v1/videos/{id}/content — стриминг MP4. Поддерживает HTTP Range для частичной загрузки и перемотки (если умеет провайдер).

Воркер ZvenoAI сам ходит к провайдеру с ограничением частоты и задержкой между попытками. Клиенту не нужно опрашивать /v1/videos/{id} чаще, чем раз в 5–10 секунд — это не ускорит генерацию.

Быстрый старт

import os, time, requests

BASE = "https://api.zveno.ai"
KEY = os.environ["ZVENO_API_KEY"]
H = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}

# 1. Отправка задачи
job = requests.post(f"{BASE}/v1/videos", headers=H, json={
    "model": "openai/sora-2",
    "prompt": "Wide shot of a child flying a red kite at sunset",
    "size": "1280x720",
    "duration": 8,
}).json()
print("отправлено:", job["id"], job["status"])

# 2. Опрос (терминальные статусы: completed | failed)
while job["status"] in ("pending", "in_progress"):
    time.sleep(5)
    job = requests.get(job["polling_url"], headers=H).json()
    print("статус:", job["status"])

if job["status"] != "completed":
    raise SystemExit(f"{job['status']}: {job.get('error') or '—'}")

# 3. Скачивание
video_url = job["unsigned_urls"][0]
with requests.get(video_url, headers=H, stream=True) as r:
    with open("output.mp4", "wb") as f:
        for chunk in r.iter_content(1 << 20):
            f.write(chunk)

print(f"готово. стоимость: {job['usage']['cost']} RUB")
const BASE = 'https://api.zveno.ai'
const KEY = process.env.ZVENO_API_KEY
const H = { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }

// 1. Отправка задачи
let job = await fetch(`${BASE}/v1/videos`, {
  method: 'POST',
  headers: H,
  body: JSON.stringify({
    model: 'openai/sora-2',
    prompt: 'Wide shot of a child flying a red kite at sunset',
    size: '1280x720',
    duration: 8,
  }),
}).then((r) => r.json())

// 2. Опрос (терминальные статусы: completed | failed)
while (['pending', 'in_progress'].includes(job.status)) {
  await new Promise((r) => setTimeout(r, 5000))
  job = await fetch(job.polling_url, { headers: H }).then((r) => r.json())
}
if (job.status !== 'completed') throw new Error(`${job.status}: ${job.error ?? '—'}`)

// 3. Скачивание
const res = await fetch(job.unsigned_urls[0], { headers: H })
const fs = await import('node:fs')
const { Readable } = await import('node:stream')
const { pipeline } = await import('node:stream/promises')
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream('output.mp4'))

console.log(`готово. стоимость: ${job.usage.cost} RUB`)
# 1. Отправка задачи
JOB=$(curl -sS https://api.zveno.ai/v1/videos \
  -H "Authorization: Bearer $ZVENO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"openai/sora-2","prompt":"a child flying a red kite","size":"1280x720","duration":8}')

JOB_ID=$(echo "$JOB" | jq -r .id)

# 2. Опрос (терминальные статусы: completed | failed)
while :; do
  STATUS=$(curl -sS "https://api.zveno.ai/v1/videos/$JOB_ID" \
    -H "Authorization: Bearer $ZVENO_API_KEY" | jq -r .status)
  echo "статус: $STATUS"
  case "$STATUS" in completed|failed) break ;; esac
  sleep 5
done

# 3. Скачивание (только если completed)
[[ "$STATUS" == "completed" ]] && curl -fsS "https://api.zveno.ai/v1/videos/$JOB_ID/content" \
  -H "Authorization: Bearer $ZVENO_API_KEY" \
  -o output.mp4

Поиск моделей

Идентификатор модели — всегда в формате vendor/model (например, openai/sora-2, google/veo-3.1). Найти подходящую модель можно двумя способами:

Поля видеомодели

ПолеОписание
idSlug модели; используйте его в поле model при создании задачи
canonical_slugСтабильный идентификатор модели (не меняется при rename)
nameЧеловекочитаемое название
createdUnix timestamp добавления модели в каталог
supported_resolutionsРазрешения, например ["720p", "1080p"]
supported_aspect_ratiosСоотношения сторон, например ["16:9", "9:16"]
supported_sizesТочные пиксельные размеры WIDTHxHEIGHT
supported_durationsДопустимые длительности в секундах
supported_frame_imagesКакие позиции кадра поддерживает модель: ["first"], ["last"] или ["first","last"] (null — генерация из кадра не поддерживается)
generate_audioМожет ли модель генерировать аудиодорожку
seedПоддерживает ли модель параметр seed
hugging_face_idHugging Face model identifier, если применимо
allowed_passthrough_parametersИмена параметров, которые можно передать через provider.options.<slug>.parameters

Поля supported_frame_images отдают короткие лейблы ("first"/"last"), а в запросе frame_images[].frame_type ожидаются полные значения ("first_frame"/"last_frame") — следите за маппингом.

Параметры запроса

ПараметрТипОписание
modelstring, обязvendor/model, см. каталог
promptstring, обязТекстовое описание сцены
durationintДлительность в секундах, минимум 1. Допустимые значения — в supported_durations модели
resolutionstringЛогическое разрешение, см. таблицу ниже
aspect_ratiostringСоотношение сторон, см. таблицу ниже
sizestringТочные пиксели WIDTHxHEIGHT, например 1280x720 (взаимозаменяемо с resolution+aspect_ratio)
generate_audiobooleanСгенерировать ли звуковую дорожку (если модель умеет)
seedintДетерминированная генерация (поддержка зависит от модели)
frame_imagesarrayОпорные кадры для генерации из изображения (см. ниже)
input_referencesarrayИзображения-референсы для подсказки стилистики (см. ниже)
providerobjectПередача параметров провайдеру и предпочтения по выбору провайдера (см. ниже)

Разрешения

ЗначениеОписание
480pСтандартное низкое (640×480)
720pHD
1080pFull HD
1K~1024 по короткой стороне
2K~2048 по короткой стороне (QHD)
4KUHD (~3840×2160)

Соотношения сторон

ЗначениеОписание
16:9Широкий горизонтальный (стандарт)
9:16Вертикальный (Stories, Reels)
1:1Квадрат
4:3Классический горизонтальный
3:4Классический вертикальный
21:9Cinemascope, ультраширокий
9:21Сверхвысокий вертикальный

Допустимый набор для каждой модели — в её полях supported_resolutions, supported_aspect_ratios, supported_sizes, supported_durations. Если передать значение вне этого набора, ответ будет 400.

Использование изображений

Многие модели принимают изображения как часть подсказки. Поддерживаются два разных режима — у них разный смысл и не пересекающаяся семантика.

Генерация из изображения (frame_images)

Задаёт первый и/или последний кадр, провайдер достраивает движение между ними. Подходит для оживления статичной картинки или плавного перехода между двумя сценами.

{
  "model": "google/veo-3.1",
  "prompt": "transitioning from morning to night over the same valley",
  "frame_images": [
    {
      "type": "image_url",
      "image_url": { "url": "https://example.com/morning.png" },
      "frame_type": "first_frame"
    },
    {
      "type": "image_url",
      "image_url": { "url": "https://example.com/night.png" },
      "frame_type": "last_frame"
    }
  ],
  "resolution": "1080p"
}

Какие frame_type доступны для конкретной модели — в её поле supported_frame_images. Значение null означает, что генерация из кадра не поддерживается.

Стилевые референсы (input_references)

Задаёт референсы для подсказки общей стилистики (персонаж, палитра, художественное направление) — провайдер сам решит, как их использовать. Композиция кадра при этом определяется промптом.

{
  "model": "google/veo-3.1",
  "prompt": "a colossal solar flare beside a planet",
  "input_references": [
    {
      "type": "image_url",
      "image_url": { "url": "https://example.com/style-ref.png" }
    }
  ],
  "resolution": "1080p"
}

Передача параметров провайдеру

Каждый провайдер поддерживает свой набор расширенных параметров — например, negativePrompt, personGeneration, enhancePrompt у Google Veo. ZvenoAI готов пробрасывать их через provider.options.<provider-slug>.parameters, но допускает только параметры из белого списка модели — поля allowed_passthrough_parameters в каталоге.

{
  "model": "google/veo-3.1",
  "prompt": "A time-lapse of a flower blooming",
  "provider": {
    "options": {
      "openrouter": {
        "parameters": {
          "personGeneration": "allow",
          "negativePrompt": "blurry, low quality"
        }
      }
    }
  }
}

Сейчас allowed_passthrough_parameters пустой для всех моделей в каталоге — passthrough-параметры молча отбрасываются на стороне ZvenoAI. Список будет заполняться по мере подключения провайдер-специфичных опций.

Выбор провайдера

Если одна и та же модель доступна у нескольких провайдеров, ZvenoAI по умолчанию выбирает одного из них случайно с весом, обратно пропорциональным квадрату цены — в среднем дешевле, но не «всегда самый дешёвый», чтобы распределять нагрузку.

Поведение настраивается блоком provider.preferences (совместим с OpenRouter):

{
  "model": "openai/sora-2-pro",
  "prompt": "...",
  "provider": {
    "preferences": {
      "order": ["openrouter", "together"],
      "only": ["openrouter"],
      "ignore": ["together"],
      "sort": "price",
      "max_price": { "video": 500.0 },
      "allow_fallbacks": true
    }
  }
}
ПолеЧто делает
orderСтрогий приоритет. Подходящие провайдеры вне списка идут в конец (если allow_fallbacks: true)
onlyБелый список. Всё, чего нет в нём, исключается
ignoreЧёрный список
sort"price" — детерминированный выбор по возрастанию цены за видео в RUB
max_price.videoЖёсткий фильтр в RUB. Если ни один провайдер не вписывается — 503 service_unavailable (без тихого отказа)
allow_fallbacksПо умолчанию true. Если false — после order/only маршрутизатор не пробует никого больше

Реакция на сбой провайдера

Если провайдер только что вернул ошибку, ZvenoAI понижает его приоритет (но не блокирует) примерно на 30 секунд и пробует следующего. Это уменьшает p99-задержку без жёстких health-проверок.

Жизненный цикл задачи

отправка → pending → in_progress → completed  ─→  готово к скачиванию
                                 └→ failed    ─→  возврат средств
СтатусЧто значит
pendingЗадача принята, ждёт первого опроса провайдера
in_progressПровайдер генерирует видео
completedВидео готово, unsigned_urls и usage.cost заполнены
failedПровайдер вернул ошибку или истекли retry-попытки опроса; средства возвращаются

Контракт перечисляет шесть значений статуса (включая cancelled и expired — для совместимости с OpenRouter), но ZvenoAI отдаёт только эти четыре. «Истечение» ссылки на видео выражается через 404 с кодом video_expired на /content, а не через статус задачи.

Формат ответа

Ответ на отправку (202 Accepted)

{
  "id": "vj_5f3a2c8e",
  "polling_url": "https://api.zveno.ai/v1/videos/vj_5f3a2c8e",
  "status": "pending"
}

Ответ опроса (200 OK, статус completed)

{
  "id": "vj_5f3a2c8e",
  "polling_url": "https://api.zveno.ai/v1/videos/vj_5f3a2c8e",
  "status": "completed",
  "generation_id": "vj_5f3a2c8e",
  "unsigned_urls": ["https://api.zveno.ai/v1/videos/vj_5f3a2c8e/content"],
  "usage": {
    "cost": 87.5,
    "is_byok": false
  }
}

Ответ опроса при ошибке

{
  "id": "vj_8a1b...",
  "polling_url": "https://api.zveno.ai/v1/videos/vj_8a1b...",
  "status": "failed",
  "error": "content policy violation: requested content is not allowed"
}

URL внутри unsigned_urls — это наш прокси-эндпоинт GET /v1/videos/{id}/content, а не подписанная ссылка провайдера. Несмотря на название поля, запрос требует обычного Authorization: Bearer <api-key>. Сам URL не истекает, но истекает ссылка провайдера, к которой мы за вас пробрасываем поток — после её истечения вернётся 404 с кодом video_expired.

Скачивание контента

GET /v1/videos/{id}/content стримит MP4. Заголовок Range пробрасывается к провайдеру:

# скачать первый мегабайт (например, чтобы прочитать заголовок видео)
curl -sS https://api.zveno.ai/v1/videos/vj_5f3a2c8e/content \
  -H "Authorization: Bearer $ZVENO_API_KEY" \
  -H "Range: bytes=0-1048575" \
  -o head.mp4

Если провайдер поддерживает Range, сервер ответит 206 Partial Content с Content-Range — HTML5-плеер сможет перематывать видео по таймлайну и возобновлять прерванные загрузки. Если провайдер Range не поддерживает, придёт обычный 200 OK с полным телом.

ПараметрГдеОписание
indexqueryДля моделей с несколькими выходными видео. По умолчанию 0. Пока поддерживается только 0
RangeheaderСтандартный HTTP Range — bytes=START-END или bytes=START-

Биллинг и стоимость

Стоимость видео в рублях зависит от параметров генерации: длительности, разрешения и наличия аудио. Цена не за токены — это явное отличие от чата. Конкретные ставки за секунду по моделям и провайдерам — в каталоге /models (фильтр «Видео»).

Каждой паре «модель + провайдер» сопоставлено правило биллинга в одном из двух режимов:

  • Native. Стоимость считается по фактической цене провайдера за выполненную задачу. Источник цены — поле usage.cost из ответа провайдера.
  • Manual. Стоимость считается по фиксированной ставке в рублях за секунду, умноженной на длительность. Используется, когда провайдер не возвращает фактическую цену, либо для нормализации по моделям.

Биллинг двухфазный:

  1. Резерв. При отправке задачи маршрутизатор выбирает первого подходящего провайдера и резервирует на счёте оценочную стоимость по параметрам запроса (duration, resolution, generate_audio) и применённому правилу. Если средств не хватает — пробуется следующий по приоритету. Когда ни один из кандидатов не проходит проверку баланса, ответ 402 insufficient_funds ещё до похода к провайдеру.
  2. Списание / возврат. После completed резерв заменяется итоговой стоимостью, рассчитанной по тому же правилу с фактическими параметрами и usage.cost провайдера; при failed сумма возвращается полностью.

Контракт VideoUsage.cost совместим с OpenRouter по форме, но ZvenoAI возвращает значение в рублях (валюте биллинга платформы), а не в долларах. Источник правды для финансовой отчётности — таблица транзакций, доступная через GET /v1/credits и GET /v1/activity.

Обработка ошибок

Видео-эндпоинты возвращают тот же envelope, что и остальной API — см. Обработку ошибок. Тело:

{
  "error": {
    "code": 404,
    "message": "video is not ready yet"
  }
}

При upstream-ошибках провайдера (502/503) в error.metadata приходит provider_name и raw — формат описан в разделе Provider error.

ЭндпоинтКодКогда возникает
POST /v1/videos400Невалидное тело запроса, неизвестный resolution/size, отсутствует prompt/model. Также — submit-time moderation reject провайдера (фото реальных людей в input_references и т.п.); код в error.metadata.raw.error.code — см. ниже Provider moderation. Moderation после 202 (например, копирайт на сгенерированный ролик) приходит позже как status: "failed" в опросе задачи.
402Недостаточно средств для резервирования стоимости
404Идентификатор модели не найден или модель не относится к видеогенерации
429Превышен лимит частоты запросов на стороне ZvenoAI
503Нет провайдера, удовлетворяющего provider.preferences (например, по max_price), либо у выбранного провайдера нет активного правила биллинга для запрошенной комбинации параметров
GET /v1/videos/{id}404Задача не существует или принадлежит другому пользователю
GET /v1/videos/{id}/content400index > 0 (несколько выходных видео пока не поддержаны)
404Задача ещё не completed или ссылка истекла (см. error.message)
502Провайдер недоступен в момент стриминга — error.metadata.provider_name укажет, кто упал

Provider moderation

Провайдеры видеогенерации могут отказать в обработке запроса по своим content policy. Чаще всего это:

  • фото реальных людей в input_references или frame_images (privacy);
  • сцены, защищённые авторским правом (узнаваемые персонажи, кадры из фильмов);
  • запрещённый контент в промпте (sexual, violence и пр.).

Отказы приходят в одной из двух форм — в зависимости от того, когда провайдер обнаружил проблему:

  • Submit-time — провайдер отверг запрос ещё до старта генерации (как правило, по входным данным: privacy на изображениях, запрещённый промпт). POST /v1/videos сразу возвращает HTTP 400 с заполненным error.metadata.provider_name и error.metadata.raw, где провайдер отдаёт свой код в raw.error.code:

    400 — privacy moderation на input_references
    {
      "error": {
        "code": 400,
        "message": "Provider rejected input: image may contain a real person",
        "metadata": {
          "provider_name": "openrouter",
          "raw": {
            "error": {
              "code": "InputImageSensitiveContentDetected.PrivacyInformation",
              "message": "The request failed because the input image may contain a real person"
            }
          }
        }
      }
    }
  • Async — провайдер обнаружил нарушение уже после 202 Accepted (например, фильтр авторских прав на сгенерированный видеоряд). POST отвечает успешно, а узнаёте о проблеме при опросе: GET /v1/videos/{id} отдаёт status: "failed" и причину в строковом поле error ответа задачи (это строка, а не envelope — см. пример ответа при ошибке). Структурированный metadata.raw для пост-202 отказов пока не заполняется — ориентируйтесь на текст и перезапускайте с изменённым промптом/входом.

Известные классы кодов (формат и имя зависят от провайдера — не закладывайтесь на конкретные значения, используйте для логов и подбора реакции):

КогдаКод провайдера (пример)ПричинаРекомендация
submit-time, 400InputImageSensitiveContentDetected.PrivacyInformationФото реального человека в frame_images / input_referencesИспользуйте stylized / AI-generated / non-human изображения
submit-time, 400InputImageSensitiveContentDetected.* (другие категории)Изображение содержит запрещённый контент (violence, sexual и пр.)Замените изображение
submit-time, 400content_policy_violationТекст промпта flagged провайдеромПереформулируйте промпт; уберите имена реальных персонажей и защищённые сцены
async, status: failedoutput_video_copyrightСгенерированное видео срезано фильтром авторских прав после генерацииПерефразируйте промпт без отсылок к защищённым сценам/персонажам

Парсинг submit-time ошибки в клиенте (учитывает, что формат raw зависит от провайдера и не гарантирован):

resp = requests.post(f"{BASE}/v1/videos", headers=H, json=body)
if resp.status_code == 400:
    err = resp.json().get("error") or {}
    meta = err.get("metadata") or {}
    raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
    raw_err = raw.get("error") if isinstance(raw, dict) else None
    if not isinstance(raw_err, dict):
        raw_err = {}
    code = str(raw_err.get("code") or "")  # провайдер может отдать int — нормализуем
    if code.startswith("InputImageSensitiveContent"):
        raise SystemExit("замените изображение — провайдер заблокировал по privacy/контенту")
    print(f"{meta.get('provider_name')} вернул {code or '?'}: "
          f"{raw_err.get('message') or err.get('message')}")

По биллингу: к моменту провайдер-ошибки резерв на счёте уже создан (provisional debit_usage). При submit-time 400 он отменяется тут же, при async failed — реверс происходит при финализации задачи. Нетто-списания в обоих случаях нет: баланс возвращается к исходному. Публичные эндпоинты /v1/credits и /v1/activity показывают только итоговую агрегацию, поэтому пара «дебет + сторно» в них не видна — финальный эффект на баланс нулевой.

Хорошие практики

  • Подробный промпт. Описывайте сцену, ракурс, освещение, движение камеры. Видео-модели чувствительнее к деталям, чем чат-модели.
  • Адекватное разрешение. Чем выше — тем дольше генерация и дороже. Для предпросмотра запускайте 720p, для финального результата — 1080p/4K.
  • Интервал опроса. 5–10 секунд обычно достаточно: воркер ZvenoAI всё равно ходит к провайдеру с собственным rate-limit'ом, более частые запросы клиента не ускорят результат.
  • Обработка ошибок. Терминальные статусы — только completed и failed. При failed поле error несёт причину; резерв возвращается автоматически — повторная попытка списывает заново.
  • Фиксация провайдера на повторных запусках. Для воспроизводимости в проде используйте provider.preferences.only, чтобы seed имел смысл (между провайдерами он не переносится).
  • Контроль стоимости. Если у проекта есть ценовой потолок на видео — задавайте provider.preferences.max_price.video, чтобы маршрутизатор отказался дороже лимита (503), а не списал больше ожидаемого.

Решение типичных проблем

Чего пока нет

  • Webhook-уведомления — только опрос через GET /v1/videos/{id}.
  • Несколько видео на одну задачу — index > 0 вернёт 400.
  • Specialized-эндпоинты провайдеров (remix, extend, lipsync) пока не проксируются.
  • Удаление задач — DELETE /v1/videos/{id} пока не реализован.

SDK

Видео-API не входит в официальный OpenAI SDK (openai.videos ходит только к OpenAI). Используйте обычный HTTP-клиент: на большинстве языков ~30 строк кода покрывают отправку, опрос статуса и скачивание (см. быстрый старт).

Что дальше

On this page