Генерация и realtime¶
Сердце API. Клиент создаёт генерацию, получает 202 {id} и ждёт готовности
push'ем — через SSE-стрим или уведомление на устройство. Поллинг
возможен, но не нужен.
Прогнать поток без траты токенов
Для интеграции есть тестовые эффекты — проигрывают весь поток (успех / ошибка с возвратом / долгая / мгновенная) на mock-провайдере, без реального ИИ и бесплатно.
Жизненный цикл¶
┌──────────┐ воркер взял ┌────────────┐ ИИ вернул ок ┌───────────┐
создана ──▶│ pending │───────────────▶│ processing │───────────────▶│ succeeded │
└──────────┘ в работу └─────┬──────┘ └───────────┘
│ ошибка / таймаут
▼
┌──────────┐
│ failed │ (кредиты возвращаются)
└──────────┘
Статусы (status):
| Статус | Значение | Терминальный |
|---|---|---|
pending |
в очереди, ждёт свободный аккаунт провайдера | — |
processing |
отправлена провайдеру, ИИ считает | — |
succeeded |
готово, есть result_url |
✅ |
failed |
ошибка/таймаут; если кредиты были зарезервированы — возвращаются | ✅ |
На терминальном статусе задача больше не меняется, а SSE-стрим закрывается.
Причина отказа (error_code)¶
Заполняется только при status: failed; в остальных статусах null. При любом
failed зарезервированные кредиты возвращаются автоматически (детали) —
отдельного действия от клиента не нужно.
error_code |
Когда | Что показать пользователю |
|---|---|---|
bad_input_image |
фото не удалось обработать: превышен лимит разрешения (~100 МП) или битый файл | «Не удалось обработать фото, выберите другое»; ужать снимок |
safety |
модель отказала по контенту (safety-фильтр провайдера) | «Это фото не подходит, попробуйте другое»; не повторять с тем же фото |
provider_timeout |
генерация не уложилась в бюджет ожидания | «Не успели, попробуйте ещё раз» — повтор; кредиты вернулись |
pool_timeout |
задача так и не получила свободный аккаунт провайдера | то же — повтор позже |
provider_error |
провайдер вернул невосстановимую ошибку | «Что-то пошло не так, попробуйте позже» |
no_result_url |
провайдер отчитался об успехе, но не отдал результат | то же |
result_rejected |
результат провайдера не прошёл проверку безопасности | то же |
no_model · lost_account |
внутренняя ошибка конфигурации/инфраструктуры | то же; чинится на нашей стороне |
Провайдерские коды — открытый набор
Коды выше — закрытый список наших причин. Провайдер может вернуть собственный
error_type, который проходит насквозь как error_code (напр. на тест-эффектах).
Не завязывай UI жёстко на полное перечисление: ветвись по известным значениям,
а всё прочее трактуй как «общая ошибка, попробуйте позже» (кредиты в любом случае
возвращены).
Создать генерацию → 202¶
curl -X POST "$BASE_URL/v1/generations" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"effect_id":"<EFFECT_ID>","characters":[["<UPLOAD_ID>"]]}'
Фото идут полем characters: список персонажей в порядке characters эффекта из
каталога, у каждого - список upload_id его фото. Эффект на двоих, где второго
человека можно снять с двух ракурсов:
-
Повтор без второго списания. Добавь заголовок
Idempotency-Keyс ключом, который приложение создало на это нажатие:curl -X POST "$BASE_URL/v1/generations" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -H "Idempotency-Key: 4b1f0c7e-9d2a-4e55-8c31-6a7d2f9b0e14" \ -d '{"effect_id":"<EFFECT_ID>","characters":[["<UPLOAD_ID>"]]}'Если ответ не дошёл, повтори запрос с тем же ключом: придёт та же генерация с тем же
id, кредиты спишутся один раз. Тот же ключ с другим телом -409 idempotency_key_reused. Без заголовка каждый запрос создаёт новую генерацию и списывает заново. Правила ключа - Ошибки, «Повтор запроса». -costкредитов резервируется с баланса сразу (атомарно). Не хватает —402 insufficient_credits, задача не создаётся. - Эффекта/загрузки нет или они чужие (любое из фото) —404 not_found. - Загрузка не завершена (PUT в хранилище так и не выполнен, объекта нет) или файл превышает лимит размера (15 МБ) —422 validation_error. Проверка идёт до резерва кредитов, так что баланс не трогается. - Фото по персонажам. Каждому персонажу нужно отmin_photosдоmax_photosфото; необязательного (min_photos = 0) можно не присылать: пустой список[]или короче весьcharacters, если он последний. Первый персонаж обязателен. Порядок фото у персонажа сохраняется: первое - основное лицо, остальные - его ракурсы. Отказы, все422 validation_errorи до резерва кредитов: персонажей больше, чем у эффекта; у персонажа фото меньшеmin_photosили большеmax_photos; одно и то жеupload_idвстречается дважды; фото больше, чем принимает модель эффекта. Без единого фото, или вместе сupload_id, тело не проходит валидацию. Пределы схемы - до 4 персонажей и до 4 фото у каждого: это потолок самых вместительных моделей, сам эффект обычно берёт меньше. - Старый способ: плоский списокupload_id+additional_uploads: [<upload_id>, …](до 15 элементов). Он принимается, пока из него понятно, чьё фото чьё: у эффекта один персонаж, у каждого персонажа одно фото (тогда фото раздаются людям по порядку) или у каждого ровноmin_photos == max_photosфото. Иначе ответ422 characters_required: такой эффект нужно запускать полемcharacters. Число фото проверяется так же, как выше; повтор одногоupload_idв плоском списке не отклоняется.
Backpressure вместо отказа
Если пул провайдера насыщен, задача честно ждёт в очереди в статусе pending —
это не ошибка. 503 provider_unavailable возможен лишь в крайних случаях; реакция —
повторить позже.
Статус и история¶
# Одна генерация: статус + результат
curl "$BASE_URL/v1/generations/$GEN_ID" -H "Authorization: Bearer $TOKEN"
# История с фильтром и пагинацией
curl "$BASE_URL/v1/generations?status=succeeded&limit=20&offset=0" \
-H "Authorization: Bearer $TOKEN"
Деталь (GenerationDetail) содержит status, вложенный effect, result_url, preview,
output_type, error_code, тайминги. Полная схема — в референсе.
output_type—"image"или"video": по нему клиент решает, показать картинку или видеоплеер дляresult_url(берётся из модели эффекта, см. каталог). Видео сохраняется как.mp4.- Фото приходит в том формате, в котором его отдала модель: PNG, JPEG или WebP. Расширение в
result_urlсовпадает с форматом, но у результатов до 30.09.2026 оно.jpgпри любом формате, поэтому надёжнее брать формат изContent-Typeответа. - Фото может прийти с прозрачным фоном - PNG с альфа-каналом, так работают эффекты вроде «убрать фон». Показывай его без подложки, а «Сохранить» и «Поделиться» отдавай тем же PNG: перекодирование в JPEG закрасит фон.
Превью для галереи¶
result_url - полный файл: фото весит мегабайты, видео в сетке не покажет кадр, пока не
начнёт играть. Для галереи и истории есть preview, уменьшенная копия результата:
{
"id": "7c9e…",
"status": "succeeded",
"output_type": "video",
"result_url": "https://…/results/7c9e….mp4?X-Amz-Signature=…",
"preview": {
"thumb": "https://…/results/thumbs/480/7c9e….webp?X-Amz-Signature=…",
"poster": "https://…/results/thumbs/480/7c9e….webp?X-Amz-Signature=…",
"width": 1080,
"height": 1920,
"blurhash": "LDEeuz^H%g~VSetRTK0ePCD$s9XA"
}
}
| Поле | Что |
|---|---|
thumb |
WebP 480 по короткой стороне, не больше оригинала; у видео это его постер |
poster |
у видео кадр на полсекунды от начала (тот же файл, что thumb); у фото null |
width, height |
размер оригинала; копия сохраняет пропорции, по ним резервируй плитку |
blurhash |
заглушка, пока грузится thumb |
preview=null, пока генерация неsucceeded, у генераций, сделанных до появления превью, и в редком случае, когда копию сделать не удалось. Тогда показывайresult_url.- Копия готова до перехода в
succeeded: деталь, запрошенная по сигналу SSE или push, уже несётpreview. Отдельного события под превью нет. - Результаты личные, поэтому ссылки подписанные и живут столько же, сколько
result_url(около часа). Кэшируй по пути объекта без подписи: сам файл под ним не меняется. - Экран результата, «Сохранить» и «Поделиться» работают с
result_url, как раньше.
result_url протухает
result_url — presigned-ссылка на готовый результат (фото или видео) со сроком жизни
около часа. Не кешируй её дольше: если истекла — просто запроси деталь генерации заново,
придёт свежая ссылка. Сам файл при этом никуда не девается.
Realtime: SSE-стрим¶
Поток text/event-stream. Каждое событие — event: status с JSON в data:
event: status
data: {"id":"7c9e…","status":"processing","error_code":null}
event: status
data: {"id":"7c9e…","status":"succeeded","error_code":null}
Как читать на клиенте:
- Снимок сразу. Первое событие — текущий статус на момент подключения (даже если подписался поздно и всё уже готово).
- Терминал закрывает стрим. На
succeeded/failedсервер завершает поток — это сигнал остановиться. - За результатом — в деталь. В
dataнетresult_url(он короткоживущий). Увиделsucceeded→ запросиGET /v1/generations/{id}заresult_urlиpreview. - Переподключение. Оборвалась сеть — переоткрой стрим: снимок-первым событием вернёт актуальный статус, ничего не потеряешь.
Не копи открытые стримы
Число одновременно открытых стримов на пользователя ограничено — попытка открыть
лишний вернёт 429 too_many_streams. Держи один стрим на активную генерацию и закрывай его
на терминальном статусе (сервер закрывает сам). Лимит общий на все стримы, включая
стрим заявки маркетплейса (ниже), а не отдельный на каждый вид.
Тот же клиент — для заявок маркетплейса
GET /v1/user-effects/{id}/stream устроен ровно так же: event: status, снимок первым
событием, закрытие на терминальном статусе. Отличаются только поля в data
({id, status, effect_id}) и набор терминальных статусов
(approved / rejected / archived). Пиши SSE-клиент один раз — см.
Свои эффекты.
SSE или push
SSE удобен, пока экран открыт. Если приложение в фоне/закрыто — полагайся на push-уведомления: зарегистрируй FCM/APNs-токен, и готовность придёт системным пушем без открытого соединения.
Возврат кредитов¶
Если генерация перешла в failed по вине провайдера или не уложилась в таймаут,
зарезервированный cost возвращается на баланс автоматически. Статус при этом
остаётся failed (отдельного «возвращено» нет) — факт возврата виден в
истории кредитов записью refund. Отдельного действия от клиента не
требуется — достаточно отразить новый баланс (GET /v1/credits/balance или
GET /v1/me).
Скрыть из истории¶
curl -X POST "$BASE_URL/v1/generations/$GEN_ID/hide" -H "Authorization: Bearer $TOKEN"
# 204 — мягкое удаление: пропадает из выдачи истории, но не стирается физически
Внутренний pipeline (для разработчиков бэкенда)¶
Общая схема¶
sequenceDiagram
participant C as Клиент
participant API as FastAPI API
participant GQ as очередь gen
participant WG as worker-gen
participant PQ as очередь poll
participant WP as worker-poll
participant NQ as очередь notify
participant WN as worker-notify
participant P as AI-провайдер
participant R as Redis pub/sub
C->>API: POST /v1/generations
API->>API: reserve credits + INSERT generation(pending)
API->>GQ: enqueue_generation.delay(gen_id)
API-->>C: 202 { id, status: pending }
WG->>P: submit (select_account + build_inputs)
Note over WG: retry до 20 раз с countdown=5s<br/>при отсутствии слота пула
WG->>PQ: poll_generation.apply_async(countdown=2s)
loop backoff 2→5s, max ~30 мин
WP->>P: check_status(job_id)
alt ещё не готово
WP->>PQ: self.retry(countdown=5s)
else готово
WP->>WP: store_result_from_url → S3, копия 480 для preview
WP->>NQ: notify_ready.delay(gen_id)
end
end
WN->>R: publish(channel=gen:{id}, status)
WN->>WN: FCM/APNs push (если зарегистрировано устройство)
C->>API: GET /generations/{id}/stream (SSE)
API->>R: subscribe(channel=gen:{id})
R-->>API: event: status succeeded/failed
API-->>C: event: status succeeded
C->>API: GET /generations/{id}
API-->>C: { status: succeeded, result_url: …, preview: … }
Машина состояний генерации¶
stateDiagram-v2
[*] --> pending : POST /v1/generations
pending --> processing : worker-gen отправил провайдеру
processing --> succeeded : worker-poll получил результат
processing --> failed : ошибка провайдера / таймаут poll
processing --> failed : provider_timeout, провайдер не закончил за час с отправки
pending --> failed : не удалось отправить — негодное фото, отказ провайдера
pending --> failed : pool_timeout — beat-sweeper истёк лимит ожидания
succeeded --> [*]
failed --> [*]
Каждый переход делается под блокировкой строки и только из того статуса, который шаг ожидал
увидеть: если над одной генерацией оказались две цепочки (повторная доставка задачи, sweep
поверх живой цепочки), вторая отступает, не трогая ни провайдера, ни слот пула. Любой переход в
терминальный статус ставит notify_ready, из какого бы шага он ни случился, поэтому failed на
отправке приходит в стрим и пушем так же, как failed в опросе.
Backpressure в worker-gen¶
flowchart TD
A[submit_generation] --> B{select_account<br/>SKIP LOCKED}
B -- нет свободного слота --> C{pending дольше<br/>pending_expire_seconds?}
C -- нет --> D[retry countdown=5s\nотметка живой цепочки]
D --> A
C -- да --> G[failed\npool_timeout\nкредиты возвращаются]
B -- слот получен --> H[build_inputs\nresize + presign]
H --> I[adapter.submit → provider]
I --> J[poll queue\ncountdown=2s]
Beat-задачи¶
| Задача | Период | Действие |
|---|---|---|
sweep_orphaned |
60 с | Ставит заново шаг pending/processing, цепочка которого не отмечалась generation_orphan_seconds; прогон, вдвое переживший срок своего статуса, завершает провалом с возвратом |
reconcile_in_flight |
120 с | Лечит утечки пула аккаунтов (lost_account) |
reconcile_credits |
300 с | Выравнивает balance по сумме ledger (защита от drift) |
sweep_orphaned_uploads |
3600 с | Удаляет брошенные загрузки из БД и S3 |
sample_saturation |
15 с | Снимает снапшот pool/queue/pending → Redis для /metrics |