Перейти к содержанию

Генерация и 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>"]]}'
{ "id": "7c9e…", "status": "pending", "cost": 5 }

Фото идут полем characters: список персонажей в порядке characters эффекта из каталога, у каждого - список upload_id его фото. Эффект на двоих, где второго человека можно снять с двух ракурсов:

{
  "effect_id": "1a2b3c4d-…",
  "characters": [
    ["b3f1c2a4-…"],
    ["c4a2d3b5-…", "d5b3e4c6-…"]
  ]
}
  • Повтор без второго списания. Добавь заголовок 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-стрим

curl -N "$BASE_URL/v1/generations/$GEN_ID/stream" -H "Authorization: Bearer $TOKEN"

Поток 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}

Как читать на клиенте:

  1. Снимок сразу. Первое событие — текущий статус на момент подключения (даже если подписался поздно и всё уже готово).
  2. Терминал закрывает стрим. На succeeded/failed сервер завершает поток — это сигнал остановиться.
  3. За результатом — в деталь. В data нет result_url (он короткоживущий). Увидел succeeded → запроси GET /v1/generations/{id} за result_url и preview.
  4. Переподключение. Оборвалась сеть — переоткрой стрим: снимок-первым событием вернёт актуальный статус, ничего не потеряешь.

Не копи открытые стримы

Число одновременно открытых стримов на пользователя ограничено — попытка открыть лишний вернёт 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