Видеогенерация
Асинхронный 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). Найти подходящую модель можно двумя способами:
Каталог моделей на сайте
Интерактивная фильтрация по модальности и вендору, карточка модели с разрешениями,
длительностями, поддержкой аудио и seed.
GET /v1/videos/models
Программный доступ к каталогу. Эндпоинт публичный — авторизация не требуется. Возвращает массив моделей с их возможностями и ценами.
Поля видеомодели
| Поле | Описание |
|---|---|
id | Slug модели; используйте его в поле model при создании задачи |
canonical_slug | Стабильный идентификатор модели (не меняется при rename) |
name | Человекочитаемое название |
created | Unix 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_id | Hugging Face model identifier, если применимо |
allowed_passthrough_parameters | Имена параметров, которые можно передать через provider.options.<slug>.parameters |
Поля supported_frame_images отдают короткие лейблы ("first"/"last"), а в запросе
frame_images[].frame_type ожидаются полные значения ("first_frame"/"last_frame") — следите
за маппингом.
Параметры запроса
| Параметр | Тип | Описание |
|---|---|---|
model | string, обяз | vendor/model, см. каталог |
prompt | string, обяз | Текстовое описание сцены |
duration | int | Длительность в секундах, минимум 1. Допустимые значения — в supported_durations модели |
resolution | string | Логическое разрешение, см. таблицу ниже |
aspect_ratio | string | Соотношение сторон, см. таблицу ниже |
size | string | Точные пиксели WIDTHxHEIGHT, например 1280x720 (взаимозаменяемо с resolution+aspect_ratio) |
generate_audio | boolean | Сгенерировать ли звуковую дорожку (если модель умеет) |
seed | int | Детерминированная генерация (поддержка зависит от модели) |
frame_images | array | Опорные кадры для генерации из изображения (см. ниже) |
input_references | array | Изображения-референсы для подсказки стилистики (см. ниже) |
provider | object | Передача параметров провайдеру и предпочтения по выбору провайдера (см. ниже) |
Разрешения
| Значение | Описание |
|---|---|
480p | Стандартное низкое (640×480) |
720p | HD |
1080p | Full HD |
1K | ~1024 по короткой стороне |
2K | ~2048 по короткой стороне (QHD) |
4K | UHD (~3840×2160) |
Соотношения сторон
| Значение | Описание |
|---|---|
16:9 | Широкий горизонтальный (стандарт) |
9:16 | Вертикальный (Stories, Reels) |
1:1 | Квадрат |
4:3 | Классический горизонтальный |
3:4 | Классический вертикальный |
21:9 | Cinemascope, ультраширокий |
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 с полным телом.
| Параметр | Где | Описание |
|---|---|---|
index | query | Для моделей с несколькими выходными видео. По умолчанию 0. Пока поддерживается только 0 |
Range | header | Стандартный HTTP Range — bytes=START-END или bytes=START- |
Биллинг и стоимость
Стоимость видео в рублях зависит от параметров генерации: длительности, разрешения и наличия аудио. Цена не за токены — это явное отличие от чата. Конкретные ставки за секунду по моделям и провайдерам — в каталоге /models (фильтр «Видео»).
Каждой паре «модель + провайдер» сопоставлено правило биллинга в одном из двух режимов:
- Native. Стоимость считается по фактической цене провайдера за выполненную задачу. Источник цены — поле
usage.costиз ответа провайдера. - Manual. Стоимость считается по фиксированной ставке в рублях за секунду, умноженной на длительность. Используется, когда провайдер не возвращает фактическую цену, либо для нормализации по моделям.
Биллинг двухфазный:
- Резерв. При отправке задачи маршрутизатор выбирает первого подходящего провайдера и резервирует на счёте оценочную стоимость по параметрам запроса (
duration,resolution,generate_audio) и применённому правилу. Если средств не хватает — пробуется следующий по приоритету. Когда ни один из кандидатов не проходит проверку баланса, ответ402 insufficient_fundsещё до похода к провайдеру. - Списание / возврат. После
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/videos | 400 | Невалидное тело запроса, неизвестный 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}/content | 400 | index > 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, 400 | InputImageSensitiveContentDetected.PrivacyInformation | Фото реального человека в frame_images / input_references | Используйте stylized / AI-generated / non-human изображения |
submit-time, 400 | InputImageSensitiveContentDetected.* (другие категории) | Изображение содержит запрещённый контент (violence, sexual и пр.) | Замените изображение |
submit-time, 400 | content_policy_violation | Текст промпта flagged провайдером | Переформулируйте промпт; уберите имена реальных персонажей и защищённые сцены |
async, status: failed | output_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 строк кода покрывают отправку, опрос статуса и скачивание (см. быстрый старт).