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

Обновлено

От API-ключа до скачанных результатов, в том порядке, в каком это делает конвейер: проверить ключ, запустить, дождаться результата или принять вебхук, скачать. Все примеры написаны на обычном HTTP через fetch или requests, SDK ставить не нужно, а ключ читается из FACESWAP_API_KEY. Примеры опираются друг на друга: следующие используют API, headers и функции, объявленные в предыдущих.

Получите ключ и проверьте его

Создайте ключ в настройках, в разделе «Разработчикам», и выберите команду, из кошелька которой он будет тратить swapz. Ключ видит лица, задачи и апскейлы этой команды и больше ничего. Полный ключ показывается один раз, поэтому скопируйте его сразу.

Храните ключ в переменной окружения или в хранилище секретов. Все примеры здесь читают FACESWAP_API_KEY и передают ключ в заголовке x-api-key. Не кладите его во фронтенд-код, там его прочитает любой.

Перед первым запуском владелец аккаунта один раз открывает приложение и подтверждает, что он совершеннолетний и что все, чьи лица он использует, на это согласились. До этого любой запуск получает отказ с declaration_required.

Этот запрос бесплатный. Ответ 200 со страницей лиц значит, что ключ работает. 401 значит, что ключа нет, в нём опечатка или он отозван.

Проверить ключ

curl "https://faceswap.ing/api/v1/faces?pageSize=1" \
  -H "x-api-key: $FACESWAP_API_KEY"
const API = "https://faceswap.ing/api/v1";
const headers = { "x-api-key": process.env.FACESWAP_API_KEY };

const response = await fetch(`${API}/faces?pageSize=1`, { headers });

console.log(response.status, await response.json());
import os
import time

import requests

API = "https://faceswap.ing/api/v1"
HEADERS = {"x-api-key": os.environ["FACESWAP_API_KEY"]}

response = requests.get(f"{API}/faces", headers=HEADERS, params={"pageSize": 1}, timeout=30)

print(response.status_code, response.json())

Разместите файлы

Маршрута загрузки нет. Фото лица и все целевые файлы задаются публичными HTTPS URL, которые наш сервис скачивает во время замены, поэтому они должны открываться без ваших заголовков. Подписанный URL из S3, R2 или Google Cloud Storage тоже подойдёт, если он действует несколько часов.

Путь должен заканчиваться расширением файла: по нему API отличает фото от видео и считает цену. Для фото это .jpg, .jpeg, .png, .webp, .bmp, .heic, .heif, для видео .mp4, .mov, .avi, .mkv, .webm, .gif. Строка запроса после расширения, как у подписанных URL, не мешает.

Ограничения на размер, длину и кадр перечислены в руководстве, в разделе «Медиафайлы». Файл, который нарушает ограничение, отклоняется при запуске, ещё до удержания swapz.

Запустите замену

Отправьте POST /faceswap/tasks с лицом и до 200 целевых файлов. Фото и видео можно смешивать в одном запуске. Запуск проверяет каждый файл, который хостите вы, до того, как что-либо оценить, поэтому дайте запросу тайм-аут от минуты.

Запуск, который прошёл, отвечает 200, и в data лежат созданные задачи. Сохраните каждый taskId: большой или смешанный запуск делится на несколько задач, которые выполняются параллельно. Если запуск получил отказ до того, как что-то создал, приходит статус ошибки и тело application/problem+json: ветвитесь по его code, как показано ниже, в разделе «Ошибки». Если отказ пришёл на полпути, ответ всё равно 200: в data лежат задачи, которые успели создаться, а в refusal причина отказа. Когда запуск прошёл целиком, refusal равен null.

swapz за весь запуск удерживаются из кошелька команды в момент запуска. Когда задача завершается, списывается стоимость готовых файлов, а за каждый файл с ошибкой swapz возвращаются.

В конвейере отправляйте каждый запуск с Idempotency-Key, чтобы повтор не запустил его дважды. Как это сделать, показано ниже, в разделе «Повторы и дубликаты».

Запуск

curl -X POST "https://faceswap.ing/api/v1/faceswap/tasks" \
  -H "x-api-key: $FACESWAP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "faceImageUrl": "https://example.com/face.jpg",
    "targetFiles": [{ "url": "https://example.com/photo.jpg" }]
  }'
const API = "https://faceswap.ing/api/v1";
const headers = { "x-api-key": process.env.FACESWAP_API_KEY };

const response = await fetch(`${API}/faceswap/tasks`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    faceImageUrl: "https://example.com/face.jpg",
    targetFiles: [{ url: "https://example.com/photo.jpg" }],
  }),
  // Every file is screened before anything is priced: allow a minute.
  signal: AbortSignal.timeout(120_000),
});

const launch = await response.json();

// A refusal is an application/problem+json document: branch on its code.
if (!response.ok) throw new Error(`${response.status} ${launch.code}: ${launch.detail ?? launch.title}`);

// Set when the launch stopped part-way: data still lists the tasks it created.
if (launch.refusal) console.warn(launch.refusal.code, launch.refusal.detail);

const taskIds = launch.data.map((task) => task.taskId);
import os
import time

import requests

API = "https://faceswap.ing/api/v1"
HEADERS = {"x-api-key": os.environ["FACESWAP_API_KEY"]}

response = requests.post(
    f"{API}/faceswap/tasks",
    headers=HEADERS,
    json={
        "faceImageUrl": "https://example.com/face.jpg",
        "targetFiles": [{"url": "https://example.com/photo.jpg"}],
    },
    # Every file is screened before anything is priced: allow a minute.
    timeout=120,
)
launch = response.json()

# A refusal is an application/problem+json document: branch on its code.
if not response.ok:
    raise RuntimeError(f"{response.status_code} {launch['code']}: {launch.get('detail', launch['title'])}")

# Set when the launch stopped part-way: data still lists the tasks it created.
if launch["refusal"]:
    print(launch["refusal"]["code"], launch["refusal"].get("detail"))

task_ids = [task["taskId"] for task in launch["data"]]

Сокращённый ответ

json
{
  "data": [
    {
      "taskId": "01a08066-5d70-7000-a3db-ff3ef6b651ca",
      "status": "queued",
      "files": [
        {
          "id": "01a08066-5d70-7fff-9e92-493cc98e0da6",
          "status": "pending",
          "targetUrl": "https://example.com/photo.jpg",
          "outputUrl": null
        }
      ]
    }
  ],
  "refusal": null
}

Дождитесь результата

Читайте GET /faceswap/tasks/{taskId} каждые 5 или 10 секунд, пока status не станет одним из cancelled, completed, failed, partially_completed. Чтение бесплатное.

Ключ может сделать 120 запросов на чтение за 60 секунд. Если задач в работе больше нескольких, читайте вместо этого GET /faceswap/tasks?status=queued,processing: один запрос возвращает до 100 задач. Или обойдитесь без опроса и примите вебхук.

Дождаться завершения задачи

curl "https://faceswap.ing/api/v1/faceswap/tasks/01a08066-5d70-7000-a3db-ff3ef6b651ca" \
  -H "x-api-key: $FACESWAP_API_KEY"
const TERMINAL = new Set(["cancelled", "completed", "failed", "partially_completed"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function waitFor(taskId) {
  for (;;) {
    const response = await fetch(`${API}/faceswap/tasks/${taskId}`, { headers });

    if (response.status === 429) {
      await sleep(Number(response.headers.get("retry-after") ?? 5) * 1000);
      continue;
    }

    if (!response.ok) throw new Error(`Task read answered ${response.status}`);

    const task = await response.json();

    if (TERMINAL.has(task.status)) return task;

    await sleep(5000);
  }
}
TERMINAL = {"cancelled", "completed", "failed", "partially_completed"}


def wait_for(task_id: str) -> dict:
    while True:
        response = requests.get(f"{API}/faceswap/tasks/{task_id}", headers=HEADERS, timeout=30)

        if response.status_code == 429:
            time.sleep(int(response.headers.get("Retry-After", "5")))
            continue

        response.raise_for_status()
        task = response.json()

        if task["status"] in TERMINAL:
            return task

        time.sleep(5)

Или примите вебхук

Зарегистрируйте один HTTPS-эндпоинт на ключ в настройках, в разделе «Разработчикам», в меню ключа. При сохранении один раз показывается секрет подписи. Когда задача, запущенная этим ключом, завершается, мы отправляем на эндпоинт POST с событием, внутри которого лежит задача в той же форме, что возвращает GET /faceswap/tasks/{taskId}.

Доставки подписываются по стандарту Standard Webhooks (standardwebhooks.com), поэтому проверить их может любая его библиотека с вашим секретом в том виде, в каком он показан: например, standardwebhooks для JavaScript и Python или библиотеки Svix. Чтобы проверить вручную, как в примере ниже, сверяйте подпись по сырому телу до разбора JSON. webhook-signature содержит v1, запятую и HMAC-SHA256 в base64 от webhook-id, точки, webhook-timestamp, точки и тела. Ключом служат байты, которые кодирует ваш секрет после whsec_. Подписей в заголовке может быть несколько через пробел: доставку принимайте, если совпала хотя бы одна. Отклоняйте метку времени, которая расходится с вашими часами больше чем на 5 минут.

Ответьте 2xx в течение 10 секунд, а работу делайте потом, из своей очереди. Любой другой ответ приводит к повтору, всего 6 попыток. Доставка гарантируется как минимум один раз, поэтому сохраняйте webhook-id и пропускайте id, который уже видели.

После 10 неудач подряд эндпоинт отключается, пока его не включат в настройках. События за это время повторно не отправляются, поэтому конвейеру, который полагается на вебхуки, стоит раз в день читать список недавних задач.

Проверить доставку

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody is the request body exactly as received, before any JSON parsing.
// secret is the signing secret as shown, whsec_...
export function verify(rawBody, headers, secret) {
  const id = headers["webhook-id"] ?? "";
  const timestamp = Number(headers["webhook-timestamp"]);
  const signatures = (headers["webhook-signature"] ?? "").split(" ");

  // Refuse a delivery older than 5 minutes: a replay carries an old timestamp.
  if (!(Math.abs(Date.now() / 1000 - timestamp) <= 300)) return false;

  // The key is the bytes the secret encodes, after its prefix.
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = Buffer.from(
    createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"),
  );

  // The header may list more than one signature, as "v1,<base64>" each.
  return signatures.some((one) => {
    const [version, value = ""] = one.split(",");
    const given = Buffer.from(value);

    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
import base64
import hashlib
import hmac
import time

# raw_body is the request body exactly as received, as bytes.
# secret is the signing secret as shown, whsec_...
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    webhook_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    signatures = headers.get("webhook-signature", "").split(" ")

    # Refuse a delivery older than 5 minutes: a replay carries an old timestamp.
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False

    # The key is the bytes the secret encodes, after its prefix.
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = webhook_id.encode() + b"." + timestamp.encode() + b"." + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # The header may list more than one signature, as "v1,<base64>" each.
    return any(
        version == "v1" and hmac.compare_digest(value, expected)
        for version, _, value in (one.partition(",") for one in signatures)
    )

Принять событие

import express from "express";

const app = express();

// The raw bytes: the signature covers the body exactly as it was sent.
app.post("/faceswap-events", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");

  if (!verify(rawBody, req.headers, process.env.FACESWAP_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(rawBody);

  // Store event.id first: a retry carries the same one.
  // Then queue the work and answer at once.
  res.sendStatus(200);
});
import json
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/faceswap-events")
def faceswap_events():
    raw_body = request.get_data()  # the bytes the signature covers

    if not verify(raw_body, request.headers, os.environ["FACESWAP_WEBHOOK_SECRET"]):
        return "", 400

    event = json.loads(raw_body)

    # Store event["id"] first: a retry carries the same one.
    # Then queue the work and answer at once.
    return "", 200

Скачайте результаты

У каждого готового файла есть outputUrl, обычная HTTPS-ссылка, для которой ключ не нужен. У файла, который не готов, есть статус и errorCode с причиной, и он ничего не стоит.

Копируйте результаты в своё хранилище. Генерации и файлы, из которых они сделаны, удаляются через 30 дней после завершения задачи. Когда это произошло, показывает purgedAt у задачи.

Скачать готовые файлы

# outputUrl needs no key.
curl -L -o result.jpg "OUTPUT_URL_FROM_THE_TASK"
import { writeFile } from "node:fs/promises";

const task = await waitFor(taskId);

for (const file of task.files) {
  if (file.status !== "completed" || !file.outputUrl) {
    console.warn(file.targetUrl, file.status, file.errorCode);
    continue;
  }

  // outputUrl needs no key.
  const output = await fetch(file.outputUrl);
  const name = new URL(file.outputUrl).pathname.split("/").pop();

  await writeFile(name, Buffer.from(await output.arrayBuffer()));
}
task = wait_for(task_id)

for file in task["files"]:
    if file["status"] != "completed" or not file["outputUrl"]:
        print(file["targetUrl"], file["status"], file["errorCode"])
        continue

    # outputUrl needs no key.
    output = requests.get(file["outputUrl"], timeout=300)
    output.raise_for_status()

    with open(file["outputUrl"].split("?")[0].rsplit("/", 1)[-1], "wb") as out:
        out.write(output.content)

Ошибки и что с ними делать

Любая ошибка приходит документом application/problem+json. Ветвитесь сначала по HTTP-статусу, затем по code. detail является фразой для лога, и её формулировка может измениться.

Все коды отказа в запуске и все errorCode, которые может получить файл с ошибкой, перечислены в руководстве.

ОтветЧто случилосьЧто делать
401 api_key_missing, api_key_invalidКлюча нет, в нём опечатка, он отозван или истёк.Исправьте ключ. Повтор не поможет.
403 organization_suspendedРабочее пространство ключа приостановлено.Не отправляйте запросы, пока ограничение не снимут.
404 not_foundЗадача не принадлежит команде этого ключа.Проверьте id и ключ.
422 validationЗапрос не совпадает со схемой. property указывает на первое поле с ошибкой.Исправьте запрос по справочнику.
429 rate_limitedСлишком много запросов с этого ключа в текущем окне.Подождите Retry-After секунд и отправьте тот же запрос.
409 idempotency_key_in_progressПервый запуск с этим Idempotency-Key ещё не ответил.Подождите Retry-After секунд и отправьте тот же запрос с тем же ключом идемпотентности.
422 idempotency_key_reusedЭтот Idempotency-Key уже присылали с другим телом. Ничего не запущено.Для другого запуска нужен новый ключ идемпотентности.
503 moderation_unavailable, media_checking, service_busyЗапуск пока отклонён, ничего не удержано.Подождите Retry-After секунд (30) и отправьте тот же запуск снова.
402 insufficient_swapzУ команды не хватает swapz на запуск. Оба числа есть в shortfall.Пополните кошелёк и отправьте запуск снова.
403 declaration_requiredВладелец аккаунта не сделал подтверждение.Откройте приложение и подтвердите.
400 moderation_refused, file_too_large, video_too_long и другие ограниченияФайл отклонён и будет отклонён снова.Замените этот файл или уберите его.
502 launch_failedСбой на нашей стороне. Ничего не удержано.Отправьте запуск позже.

Лимиты запросов

Каждый ключ может сделать 120 запросов на чтение за 60 секунд и 30 запусков за 60 секунд, и считаются они отдельно. Окна фиксированы и выровнены по часам. Сверх лимита приходит 429 с заголовком Retry-After, а отклонённый запрос в счёт не идёт.

Один запуск несёт до 200 файлов, так что пакет из нескольких сотен файлов укладывается в несколько запусков. Отправляйте их один за другим и пережидайте 429, если он придёт. Два ключа никогда не делят счётчик.

Повторы и дубликаты

Любой GET можно безопасно повторять, как и любой запрос, получивший 429: такой запрос отклоняется до того, как его прочитали.

Отправляйте каждый запуск с заголовком Idempotency-Key. В нём передаётся ключ идемпотентности: новый UUID на каждый запуск, который вы сохраняете и без изменений отправляете с каждым повтором этого запуска. Если ответ потерялся из-за тайм-аута или обрыва соединения, отправьте тот же запрос с тем же ключом. Если первый запрос создал задачи, повтор получит сохранённый ответ и заголовок Idempotent-Replayed: true, и ничего не запустится и не удержится дважды. Ключ идемпотентности хранится 24 часа и привязан к API-ключу, который его отправил.

Сохраняется только запуск, который создал задачи. Запуск, отклонённый до удержания, например с insufficient_swapz, moderation_unavailable, service_busy или launch_failed, освобождает ключ. Когда вы устраните причину, тот же запрос с тем же ключом выполнится. Запуск, отклонённый на полпути, с задачами в data, сохраняется как успешный.

Пока первый запрос с этим ключом идемпотентности ещё выполняется, повтор получает 409 с idempotency_key_in_progress и заголовком Retry-After. Подождите столько и отправьте снова. Ключ идемпотентности, отправленный повторно с другим телом, получает 422 с idempotency_key_reused и ничего не запускает, потому что другому запуску нужен новый ключ. Каждый повтор расходует лимит на запуски.

Если повтор получает 409 несколько минут подряд, первый запрос с этим ключом идемпотентности оборвался на полпути, и неизвестно, запустился ли он. Прежде чем запускать снова под новым ключом, прочитайте список последних задач. Запуск без заголовка не дедуплицируется никогда, и перед его повторной отправкой нужна та же проверка.

Вебхук может прийти дважды, а события разных задач могут прийти не по порядку. Отсеивайте дубликаты по webhook-id и считайте конечный статус задачи окончательным: он больше не меняется.

Один запуск при любом числе повторов

# One key per launch. Sending this same command again, with the same
# KEY, is answered with the first launch's response instead of a second swap.
KEY=$(uuidgen)

curl -X POST "https://faceswap.ing/api/v1/faceswap/tasks" \
  -H "x-api-key: $FACESWAP_API_KEY" \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" \
  --retry 5 --retry-connrefused --max-time 120 \
  -d '{
    "faceImageUrl": "https://example.com/face.jpg",
    "targetFiles": [{ "url": "https://example.com/photo.jpg" }]
  }'
async function launchOnce(body) {
  // One key per launch, reused by every retry of it.
  const key = crypto.randomUUID();

  for (let attempt = 1; attempt <= 5; attempt += 1) {
    let response;

    try {
      response = await fetch(`${API}/faceswap/tasks`, {
        method: "POST",
        headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": key },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(120_000),
      });
    } catch {
      // No answer: it may or may not have launched. The same key makes it run once.
      await sleep(2000 * attempt);
      continue;
    }

    // 409: the first try is still running. 429: throttled. 503: not yet. 5xx: not stored.
    if (response.status === 409 || response.status === 429 || response.status >= 500) {
      await sleep(Number(response.headers.get("retry-after") ?? 5) * 1000);
      continue;
    }

    // A refusal that will repeat, as a problem document: 400, 402, 403, or 422 for a body
    // that does not match the schema or this key reused for another body.
    if (!response.ok) {
      const problem = await response.json();

      throw new Error(`Launch answered ${response.status} ${problem.code}: ${problem.detail ?? problem.title}`);
    }

    // "Idempotent-Replayed: true" when this is the first try's answer again.
    return response.json();
  }

  throw new Error(`No answer after 5 attempts; list recent tasks before launching again.`);
}

const result = await launchOnce({
  faceImageUrl: "https://example.com/face.jpg",
  targetFiles: [{ url: "https://example.com/photo.jpg" }],
});
import uuid


def launch_once(body: dict) -> dict:
    # One key per launch, reused by every retry of it.
    key = str(uuid.uuid4())

    for attempt in range(1, 6):
        try:
            response = requests.post(
                f"{API}/faceswap/tasks",
                headers={**HEADERS, "Idempotency-Key": key},
                json=body,
                timeout=120,
            )
        except requests.RequestException:
            # No answer: it may or may not have launched. The same key makes it run once.
            time.sleep(2 * attempt)
            continue

        # 409: the first try is still running. 429: throttled. 503: not yet. 5xx: not stored.
        if response.status_code in (409, 429) or response.status_code >= 500:
            time.sleep(int(response.headers.get("Retry-After", "5")))
            continue

        # A refusal that will repeat, as a problem document: 400, 402, 403, or 422 for a body
        # that does not match the schema or this key reused for another body.
        if not response.ok:
            problem = response.json()
            raise RuntimeError(f"Launch answered {response.status_code} {problem['code']}: {problem.get('detail')}")

        # "Idempotent-Replayed: true" when this is the first try's answer again.
        return response.json()

    raise RuntimeError("No answer after 5 attempts; list recent tasks before launching again.")


result = launch_once({
    "faceImageUrl": "https://example.com/face.jpg",
    "targetFiles": [{"url": "https://example.com/photo.jpg"}],
})

Встройте API в свой конвейер

Пакетная обработка. Сгруппируйте целевые файлы по лицу и отправляйте до 200 файлов за запуск, а затем ждите по id задач или по вебхукам. Пример ниже использует post, waitFor и wait_for из предыдущих шагов.

Обработчики очередей. Одно сообщение, один запуск: сохраните taskIds рядом с сообщением и отмечайте задание выполненным, когда придёт вебхук по каждой задаче. Ежедневный список последних задач подберёт то, что пропустил отключённый эндпоинт.

n8n, Make, Zapier и другие HTTP-шаги. Используйте шаг HTTP-запроса: метод POST, URL https://faceswap.ing/api/v1/faceswap/tasks, заголовок x-api-key с ключом из хранилища учётных данных инструмента и JSON-тело, как в запуске выше. Дальше либо крутите цикл из паузы и GET задачи, либо запускайте второй сценарий от триггера вебхука. Чтобы проверить там подпись, триггер должен отдавать сырое тело.

Типизированные клиенты. Документ OpenAPI по адресу https://faceswap.ing/api/v1/openapi.json описывает каждый маршрут и поле, поэтому генератор вроде openapi-typescript или OpenAPI Generator соберёт клиент для вашего языка.

Пакет: одно лицо на много файлов

const FACE = "https://example.com/face.jpg";
const TARGETS = ["https://example.com/shots/001.jpg", "https://example.com/shots/002.mp4"];
const PER_LAUNCH = 200;

async function post(path, body) {
  for (;;) {
    const response = await fetch(`${API}${path}`, {
      method: "POST",
      headers: { ...headers, "Content-Type": "application/json" },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(120_000),
    });

    // 429 is throttled and 503 is "not yet": nothing ran, so wait and send it again.
    if (response.status !== 429 && response.status !== 503) return response;

    await sleep(Number(response.headers.get("retry-after") ?? 5) * 1000);
  }
}

const taskIds = [];

for (let start = 0; start < TARGETS.length; start += PER_LAUNCH) {
  const chunk = TARGETS.slice(start, start + PER_LAUNCH);
  const response = await post("/faceswap/tasks", {
    faceImageUrl: FACE,
    targetFiles: chunk.map((url) => ({ url })),
  });
  const launch = await response.json();

  // Refused before it created anything: nothing runs and nothing is held.
  if (!response.ok) {
    console.error(response.status, launch.code, launch.detail);
    continue;
  }

  // A launch refused part-way still lists the tasks it created.
  taskIds.push(...launch.data.map((task) => task.taskId));

  if (launch.refusal) console.error(launch.refusal.code, launch.refusal.detail);
}

const tasks = await Promise.all(taskIds.map(waitFor));
FACE = "https://example.com/face.jpg"
TARGETS = ["https://example.com/shots/001.jpg", "https://example.com/shots/002.mp4"]
PER_LAUNCH = 200


def post(path: str, body: dict) -> requests.Response:
    while True:
        response = requests.post(f"{API}{path}", headers=HEADERS, json=body, timeout=120)

        # 429 is throttled and 503 is "not yet": nothing ran, so wait and send it again.
        if response.status_code not in (429, 503):
            return response

        time.sleep(int(response.headers.get("Retry-After", "5")))


task_ids = []

for start in range(0, len(TARGETS), PER_LAUNCH):
    chunk = TARGETS[start:start + PER_LAUNCH]
    response = post("/faceswap/tasks", {
        "faceImageUrl": FACE,
        "targetFiles": [{"url": url} for url in chunk],
    })
    launch = response.json()

    # Refused before it created anything: nothing runs and nothing is held.
    if not response.ok:
        print(response.status_code, launch["code"], launch.get("detail"))
        continue

    # A launch refused part-way still lists the tasks it created.
    task_ids += [task["taskId"] for task in launch["data"]]

    if launch["refusal"]:
        print(launch["refusal"]["code"], launch["refusal"].get("detail"))

tasks = [wait_for(task_id) for task_id in task_ids]

Типы для TypeScript из документа OpenAPI

bash
npx openapi-typescript https://faceswap.ing/api/v1/openapi.json -o faceswap-api.d.ts