
Генерация изображений для игр: кэширование для экономии
Постройте конвейер генерации изображений с кэшем, семантическими ключами, одиночными запросами, асинхронным опросом, лимитами бюджета и долговечным хранилищем.
Кэширование может снизить среднюю стоимость вызова API генерации изображений на действие игрока до $0.002, но оно не может удешевить действительно новое изображение. При текущей ставке Z-Image на reAPI $0.005 за завершённую генерацию действует правило доля платных промахов × завершённых попыток на принятый ассет ≤ 0.4. Доля промахов 40% достигает потолка только при условии, что каждый новый ассет проходит с первой попытки; оставаться ниже $0.002 требует результата ниже этого предела.[1]
Кэшируйте смысл элемента, а не просто его подсказку. Объединяйте одновременные промахи, предварительно генерируйте только вероятные элементы и измеряйте стоимость на принятый игровой ассет, а не на один ответ API.
TL;DR
- При $0.005 за завершённую генерацию Z-Image игра, нацеленная на эффективную стоимость не более $0.002 на создание, нуждается в
доля платных промахов × завершённых попыток на принятый ассет ≤ 0.4. Ровно одна попытка, 40% соответствует потолку; строго ниже означает менее 40%.[1] - Ведите ключ рецепта для того, что комбинировал игрок, и ключ семантического ассета для объекта результата и версии арта.
- Обеспечьте одно активное задание ассета с ограничением уникальности в общей базе данных. Карта Promise в памяти процесса может объединить вызовы, но это не блокировка, защищающая несколько рабочих.
- Генерация изображений асинхронна. Отправьте один раз, сохраните ID задачи, опрашивайте конечную точку задачи и скопируйте завершённый вывод в контролируемое вами хранилище. [2][3]
- Дымовой тест из трёх задач завершил три исходных кандидата с первой отправки за 15 кредитов, или $0.015 всего. Они не были визуально проверены, поэтому это кандидаты, а не принятые ассеты.[4]
- Если 3000 ежедневных изображений уже действительно новые семантические ассеты после кэширования, обычный кэш не спасёт экономику. Игра должна переиспользовать арт, сузить генеративную поверхность или поддержать более высокую стоимость.
Реальная проблема в стоимости создания, не в стоимости изображения
Представим игру с бесконечным крафтом: она объединяет предметы вроде железа и огня, а затем создаёт новый результат и его изображение. Как удержать расходы на медиа ниже $0.002 за один крафт, если игроки совершают 3000 действий в день и многие комбинации появляются впервые? Здесь проявляется главное ограничение: игра способна создавать новые запросы быстрее, чем выручка позволяет их оплачивать.
Начните с этой формулы:
дневная стоимость генерации =
события создания
× доля платных промахов
× завершённые попытки на принятый ассет
× цена за завершённую генерациюПри $0.005 за завершённую задачу Z-Image потолок составляет:
$0.005 × доля платных промахов × завершённые попытки на принятый ассет <= $0.002
доля платных промахов × завершённые попытки на принятый ассет <= 0.4Для 3000 событий создания в день за 30 дней сценарии с одной попыткой выглядят так:
| Доля платных промахов | Новых генераций/день | Стоимость API за 30 дней |
|---|---|---|
| 100% | 3,000 | $450 |
| 40% | 1,200 | $180 |
| 20% | 600 | $90 |
| 10% | 300 | $45 |
Это сценарии, а не предсказанные коэффициенты попадания, и каждая строка предполагает одну завершённую попытку на принятый ассет. При 1.5 попытках, например, доля промахов должна быть не более 26.7%, чтобы сохранить тот же потолок $0.002. Если все 3000 событий остаются уникальными после семантической дедупликации, применяется первая строка.
Ключи рецептов и ключи семантических ассетов решают разные проблемы
Ключ рецепта представляет ввод игрока. Если порядок не имеет значения в ваших правилах, отсортируйте точные канонические ID элементов перед хэшированием. Нехэшированная идентичность легко проверяется:
идентичность рецепта: v3 | fire | ironЭто делает так, что iron + fire и fire + iron разрешают через одну строку рецепта, принадлежащую серверу. Включите версию правил, потому что более позднее изменение баланса может изменить результат. Не ленивьте ID для ключа: потеря нормализации может объединить два разных элемента.
Ключ семантического ассета представляет то, что в итоге отображает игра. Его нехэшированная идентичность может оставаться столь же ясной:
идентичность ассета: ember-shield | inventory-icon-v1 | z-image | 1:1 | filteredНесколько рецептов могут разрешить в ember-shield; переводы также могут называть его по-другому. Эти пути всё ещё могут переиспользовать одно одобренное визуальное.
Необработанные подсказки делают плохие ключи: изменения формулировки фрагментируют кэш, и идентичные подсказки устаревают после изменения направления арта. Сопоставьте каждый рецепт со стабильным ID результата, затем ключируйте арт по ID результата, модели, соотношению сторон, режиму безопасности и версии стиля. Увеличьте версию, когда хотите нового арта.
Поток запроса, ориентированный на кэш
Рассматривайте генерацию как фоновое производство ассетов, даже если игрок её инициирует. Практичный поток:
- Разрешите входящий рецепт в семантический ID результата.
- Найдите ключ семантического ассета в долговечном хранилище.
- При попадании верните немедленно сохранённый URL.
- При промахе атомарно вставьте одну строку общего задания, ключированную семантическим ассетом.
- В той же транзакции базы данных зарезервируйте один слот в общем дневном бюджете подачи. Другие рабочие возвращают существующее задание.
- Зафиксируйте строку как
submittingперед одним POST, затем сохраните ID задачи сразу же после ответа. - Если подача неоднозначна, отметьте
submit_uncertainи остановитесь. Опрашивайте известный ID задачи с GET, пока он не станетcompletedилиfailed. - Запишите статус терминала и урегулированные кредиты. Скопируйте завершённое изображение в собственное хранилище и переместите его в
pending_review. - Только проверка приёма может переместить
pending_reviewвready.
После шага шесть верните заполнитель и состояние pending. Позвольте рабочему опрашивать, пока клиент проверяет конечную точку вашей игры. Никогда не предоставляйте ключ reAPI клиенту.
Опрос бесплатен, и конечная точка задачи в полёте кэшируется пять секунд, поэтому опрашивайте примерно каждые пять секунд.[3] Уважайте Retry-After на 429. Не слепо переотправляйте POST: reAPI не дедупликирует запросы генерации по Idempotency-Key, поэтому потерянный ответ может скрыть принятую задачу и второй платёж.[5]
Запускаемый модуль Node 20 с общей блокировкой
Наименьший пример, защищающий несколько рабочих, нуждается в общем состоянии. Модуль ниже — чистый .mjs, использует PostgreSQL через пакет postgres и возвращается после подачи вместо того, чтобы держать запрос игрока открытым.
npm install postgresСоздайте таблицы один раз. Заполните game_recipes из собственных данных правил игры; браузер отправляет два ID элемента, никогда не ID результата или отображаемое имя.
CREATE TABLE game_recipes (
recipe_key text PRIMARY KEY,
result_item_id text NOT NULL,
result_display_name text NOT NULL
);
CREATE TABLE game_asset_jobs (
asset_key text PRIMARY KEY,
origin_recipe_key text NOT NULL REFERENCES game_recipes(recipe_key),
result_item_id text NOT NULL,
state text NOT NULL CHECK (state IN (
'submitting', 'submit_uncertain', 'processing', 'completed',
'pending_review', 'ready', 'failed', 'rejected'
)),
submit_owner uuid NOT NULL,
prompt text NOT NULL,
task_id text UNIQUE,
source_url text,
durable_url text,
usage_credits integer,
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE game_generation_budgets (
budget_day date PRIMARY KEY,
submissions integer NOT NULL CHECK (submissions >= 0),
submission_limit integer NOT NULL CHECK (submission_limit > 0)
);Сохраните это как cache-first.mjs. Полные ключи SHA-256 хэшируют точные канонические ID; нет шага ленивизации, который может свернуть два разных ID.
import { createHash, randomUUID } from 'node:crypto';
import postgres from 'postgres';
const API_BASE = 'https://reapi.ai/api/v1';
const apiKey = process.env.REAPI_API_KEY;
const databaseUrl = process.env.DATABASE_URL;
const dailyLimit = Number(process.env.DAILY_NEW_ASSET_LIMIT ?? '100');
if (!apiKey) throw new Error('Set REAPI_API_KEY on the server');
if (!databaseUrl) throw new Error('Set DATABASE_URL on the server');
if (!Number.isInteger(dailyLimit) || dailyLimit < 1) {
throw new Error('DAILY_NEW_ASSET_LIMIT must be a positive integer');
}
const sql = postgres(databaseUrl);
const localFlights = new Map(); // L1 only; PostgreSQL owns correctness.
function hashIdentity(value) {
return createHash('sha256')
.update(JSON.stringify(value), 'utf8')
.digest('hex');
}
function canonicalId(value) {
if (typeof value !== 'string' || value.length === 0 || value.length > 200) {
throw new Error('Item IDs must be non-empty canonical server IDs');
}
return value; // Preserve the exact string; do not trim, slugify, or case-fold.
}
export function recipeKey(leftItemId, rightItemId) {
const pair = [canonicalId(leftItemId), canonicalId(rightItemId)].sort(
(a, b) => (a < b ? -1 : a > b ? 1 : 0),
);
return `recipe:v3:${hashIdentity({ rulesVersion: 'v3', itemIds: pair })}`;
}
function assetKey(resultItemId) {
return `asset:${hashIdentity({
resultItemId,
styleVersion: 'inventory-icon-v1',
model: 'z-image',
aspectRatio: '1:1',
contentFilter: true,
})}`;
}
async function resolveRecipe(leftItemId, rightItemId) {
const key = recipeKey(leftItemId, rightItemId);
const [recipe] = await sql`
SELECT result_item_id, result_display_name
FROM game_recipes
WHERE recipe_key = ${key}
`;
if (!recipe) throw new Error('Unknown recipe');
if (recipe.result_display_name.length > 200) {
throw new Error('Canonical display name is too long');
}
return { ...recipe, recipeKey: key };
}
function publicJob(job) {
return {
assetKey: job.asset_key,
state: job.state,
taskId: job.task_id ?? null,
url: job.state === 'ready' ? job.durable_url : null,
usageCredits: job.usage_credits ?? null,
};
}
async function claimSharedJob(recipe, key, prompt) {
const owner = randomUUID();
return sql.begin(async (tx) => {
const created = await tx`
INSERT INTO game_asset_jobs (
asset_key, origin_recipe_key, result_item_id,
state, submit_owner, prompt
) VALUES (
${key}, ${recipe.recipeKey}, ${recipe.result_item_id},
'submitting', ${owner}, ${prompt}
)
ON CONFLICT (asset_key) DO NOTHING
RETURNING *
`;
if (created.length === 0) {
const [existing] = await tx`
SELECT * FROM game_asset_jobs WHERE asset_key = ${key}
`;
if (!existing) throw new Error('Concurrent job was not readable');
return { ownsSubmit: false, job: existing };
}
const budget = await tx`
INSERT INTO game_generation_budgets (
budget_day, submissions, submission_limit
) VALUES (
(now() AT TIME ZONE 'UTC')::date, 1, ${dailyLimit}
)
ON CONFLICT (budget_day) DO UPDATE SET
submissions = game_generation_budgets.submissions + 1,
submission_limit = EXCLUDED.submission_limit
WHERE game_generation_budgets.submissions < EXCLUDED.submission_limit
RETURNING submissions
`;
if (budget.length === 0) throw new Error('Shared daily budget reached');
return { ownsSubmit: true, owner, job: created[0] };
});
}
async function markSubmitUncertain(key, owner, message) {
const [job] = await sql`
UPDATE game_asset_jobs
SET state = 'submit_uncertain', error_message = ${message},
updated_at = now()
WHERE asset_key = ${key} AND submit_owner = ${owner}
AND state = 'submitting'
RETURNING *
`;
return job;
}
async function submitClaimedJob(claim, key, prompt) {
let response;
let body = '';
try {
response = await fetch(`${API_BASE}/images/generations`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'z-image',
prompt,
aspect_ratio: '1:1',
content_filter: true,
}),
});
body = await response.text();
} catch (error) {
const job = await markSubmitUncertain(
key,
claim.owner,
error instanceof Error ? error.message : String(error),
);
return publicJob(job ?? claim.job);
}
let task;
try {
task = JSON.parse(body);
} catch {
task = null;
}
if (!response.ok || !task?.id) {
const message = `Unconfirmed submit response: HTTP ${response.status}`;
const job = await markSubmitUncertain(key, claim.owner, message);
return publicJob(job ?? claim.job);
}
const [saved] = await sql`
UPDATE game_asset_jobs
SET state = 'processing', task_id = ${task.id}, updated_at = now()
WHERE asset_key = ${key} AND submit_owner = ${claim.owner}
AND state = 'submitting'
RETURNING *
`;
if (!saved) {
throw new Error(`Task ${task.id} was accepted but its ID was not persisted; do not resubmit`);
}
return publicJob(saved);
}
async function startOrRead(recipe, key) {
const prompt = [
`A clean 3D inventory icon of ${recipe.result_display_name}.`,
'One centered object, readable silhouette, plain warm background,',
'soft studio light, no text, no logo, no existing game character.',
].join(' ');
const claim = await claimSharedJob(recipe, key, prompt);
if (!claim.ownsSubmit) return publicJob(claim.job);
return submitClaimedJob(claim, key, prompt);
}
export async function getOrCreateCraftAsset({ leftItemId, rightItemId }) {
const recipe = await resolveRecipe(leftItemId, rightItemId);
const key = assetKey(recipe.result_item_id);
const existing = localFlights.get(key);
if (existing) return existing;
const work = startOrRead(recipe, key).finally(() => {
if (localFlights.get(key) === work) localFlights.delete(key);
});
localFlights.set(key, work);
return work;
}
export async function pollAsset(key) {
const [job] = await sql`
SELECT * FROM game_asset_jobs WHERE asset_key = ${key}
`;
if (!job) throw new Error('Unknown asset job');
if (job.state !== 'processing' || !job.task_id) return publicJob(job);
const response = await fetch(`${API_BASE}/tasks/${job.task_id}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const parsed = Number.parseInt(response.headers.get('retry-after') ?? '', 10);
return {
...publicJob(job),
retryAfterSeconds: Number.isFinite(parsed) ? Math.max(parsed, 1) : 5,
};
}
if (!response.ok) {
throw new Error(`GET task failed with HTTP ${response.status}; retry this GET, not the POST`);
}
const task = await response.json();
if (task.status === 'processing') return publicJob(job);
const credits = Number.isInteger(task.usage?.credits)
? task.usage.credits
: null;
if (task.status === 'failed') {
const [failed] = await sql`
UPDATE game_asset_jobs
SET state = 'failed', usage_credits = ${credits},
error_message = ${task.error?.message ?? 'Generation failed'},
updated_at = now()
WHERE asset_key = ${key} AND task_id = ${job.task_id}
RETURNING *
`;
return publicJob(failed);
}
if (task.status !== 'completed') {
throw new Error(`Unexpected task status: ${task.status}`);
}
const sourceUrl = task.output?.image_urls?.[0] ?? null;
const [completed] = await sql`
UPDATE game_asset_jobs
SET state = 'completed', source_url = ${sourceUrl},
usage_credits = ${credits},
error_message = ${sourceUrl ? null : 'Completed task returned no image URL'},
updated_at = now()
WHERE asset_key = ${key} AND task_id = ${job.task_id}
RETURNING *
`;
return publicJob(completed);
}
export async function stageForReview(key, durableUrl) {
const [job] = await sql`
UPDATE game_asset_jobs
SET state = 'pending_review', durable_url = ${durableUrl},
updated_at = now()
WHERE asset_key = ${key} AND state = 'completed'
AND source_url IS NOT NULL
RETURNING *
`;
if (!job) throw new Error('Only an archived completed asset can enter review');
return publicJob(job);
}
export async function reviewAsset(key, accepted, reason = null) {
const nextState = accepted ? 'ready' : 'rejected';
const [job] = await sql`
UPDATE game_asset_jobs
SET state = ${nextState}, error_message = ${reason}, updated_at = now()
WHERE asset_key = ${key} AND state = 'pending_review'
RETURNING *
`;
if (!job) throw new Error('Asset is not pending review');
return publicJob(job);
}Строка базы данных, а не localFlights, предотвращает отправку одного ассета вторым рабочим. Процесс, который умирает во время POST, оставляет submitting; рассматривайте устаревшую строку как submit_uncertain и исследуйте её, а не переиспользуйте. После существования известного ID задачи переходящие сбои при опросе переиспользуют только GET.
Общий счётчик — консервативный лимит подачи. Неподтвёрждённая или отклонённая POST всё равно потребляет свой слот, что безопаснее, чем молчаливо превышать лимит. Если терминальная задача не удаётся, её статус и usage.credits остаются в строке. Этот пример не повторно пытается неудачные или отклонённые ассеты; добавьте отдельный реестр попыток и явное, бюджетируемое действие оператора перед поддержкой повторных попыток.
Рабочий вашего хранилища должен загрузить source_url, загрузить его в ваше хранилище объектов и передать этот долговечный URL в stageForReview. Только reviewAsset(..., true) создаёт кэшируемый ready ассет.
Что доказывает дымовой тест из трёх задач
3 сентября 2026 г. мы отправили три исходных подсказки значка инвентаря 1:1 с content_filter: true: мшистый фонарь, компас из стекла и щит углей. Каждая задача завершилась с первой отправки и сообщила 5 кредитов.
[4]
| Кандидат | ID задачи | Наблюдаемое время завершения | Кредиты |
|---|---|---|---|
| Мшистый фонарь | task_01a065490250739d893a1efcd83f4d1d | 25.1 s | 5 |
| Компас из стекла | task_01a0654995df7265b44b132dce34a914 | 25.0 s | 5 |
| Щит углей | task_01a0654995fd73e28e7c682170664070 | 18.2 s | 5 |
| Всего | 3 задачи | — | 15 / $0.015 |
Общественная запись дымового теста содержит параметры запроса, ID задач, прошедшее время и урегулированные кредиты. Она намеренно опускает временные URL вывода.[4]
Это дымовой тест, а не тест задержки; он ничего не говорит о p95, всплесках или долговременной надёжности. Протокол записал результаты задач и урегулирование, но не включал проверку арта. completed означает, что прибыли три кандидата, а не то, что рецензент арта их принял.
Поэтому полезная метрика создания:
стоимость на принятый ассет =
общие урегулированные кредиты генерации × $0.001 / принятые ассетыЕсли три попытки дают один полезный значок, его эффективная стоимость составляет $0.015. Записывайте технический статус и творческую приёмку отдельно.
Предварительно генерируйте для задержки, а не воображаемой экономии
Предварительно генерируйте только предсказуемые запросы, например 20 лучших рецептов, достижимых из инвентаря игрока. Используйте тот же семантический кэш и путь одиночного полёта с отдельным дневным лимитом.
Не генерируйте теоретическое пространство комбинаций. Неиспользуемые ассеты стоят деньги, и открытое создание перерастает грубую очередь. Ранжируйте по наблюдаемому спросу и остановитесь на лимите.
Гибрид обычно ощущается лучше всего:
- одобренные семантические ассеты возвращаются немедленно;
- вероятные следующие элементы предварительно генерируются;
- долгохвостные промахи показывают заполнитель, пока работает одна фоновая задача;
- непопулярные или оскорбительные запросы попадают в квоту вместо бесконечного цикла повторных попыток.
Безопасность, хранилище и операционные границы
Сохраняйте content_filter: true для генерации, ориентированной на игроков. Z-Image оставляет этот параметр отключённым по умолчанию, поэтому полагаться на значение по умолчанию недостаточно.
[2] Модерируйте или ограничивайте текст игроков перед тем, как он станет подсказкой, блокируйте попытки подражать защищённым персонажам или реальным людям и ограничивайте скорость по учётной записи и устройству. Вышестоящий фильтр — это один уровень, а не полная политика вашей игры.
Сохраняйте ключ на сервере. Логируйте ключи рецепта и ассета, ID задачи, статус, кредиты и приём без сохранения ненужных данных игроков.
Наконец, скопируйте завершённые файлы в долговечное хранилище перед публикацией их URL для игровых клиентов. URL вывода задачи не являются постоянным контрактом ассета. [3] Сохраняйте контрольную сумму и тип контента рядом с записью ассета, чтобы повторная попытка не смогла тихо заменить одобренный арт.
Заключение: когда кэширования недостаточно
Кэширование работает только тогда, когда игра допускает переиспользование. Если доля платных промахов × завершённые попытки на принятый ассет превышает 0.4, потолок $0.002 не может удерживаться при цене за единицу $0.005. Если 3000 ежедневных запросов в исходном примере уже уникальные семантические ассеты после кэширования, изменение программного обеспечения кэша не решит проблему.
Затем измените ограничение продукта: поделитесь артом между похожими элементами, используйте процедурное перекрашивание или наложения, генерируйте только популярные элементы, ограничьте открытия, взимайте плату за создание долгохвостя или примите более крупный медиа-бюджет.
Проверьте живую страницу модели Z-Image перед установкой бюджета, затем используйте руководство API Z-Image и справочник API Tasks для реализации отправки и опроса. Цены и поведение модели могут меняться; ваши собственные урегулированные кредиты остаются источником истины для счёта продакшена.
FAQ
Снижает ли кэширование цену нового AI-изображения?
Нет. Оно снижает, как часто вы его покупаете. Текущий платёж Z-Image остаётся пять кредитов за завершённую генерацию; попадание в кэш не стоит новых кредитов генерации.
Должна ли сама подсказка быть ключом кэша?
Обычно нет. Разрешите рецепты в стабильный ID результата, затем версируйте его арт отдельно.
Что если порядок элементов имеет значение?
Не сортируйте входы рецепта. water + fire и fire + water должны иметь разные ключи рецепта всякий раз, когда ваши правила дают им разные результаты.
Должна ли игра предварительно генерировать ассеты или генерировать их в реальном времени?
Используйте оба: предварительно генерируйте бюджетируемый вероятный набор, обслуживайте попадания немедленно и генерируйте непредсказуемые долгохвостые элементы в фоне.
Как часто я должен опрашивать задачу изображения reAPI?
Примерно каждые пять секунд — практичное начальное место. Более частый опрос не заставляет генерацию завершаться быстрее и может тратить бюджет лимита скорости.
Взимается ли плата за неудачные генерации?
Документация Z-Image говорит, что неудачные и отклонённые запросы не взимаются. Всё же осмотрите status вместе с usage.credits: завершённый, но визуально неиспользуемый результат отличается от неудачной задачи и учитывается в творческую стоимость.[2][3]
Могу ли я пообещать среднее значение ниже $0.002 на создание?
Только после измерения вашей доли платных промахов и завершённых попыток на принятый ассет. При $0.005 за изображение, оставаться строго ниже $0.002 требует их произведения оставаться ниже 0.4. Доля промахов 40% просто встречает потолок, когда каждый ассет проходит с первой завершённой попытки.
Может ли игровой клиент вызывать конечную точку изображения напрямую?
Он не должен. Учётные данные на стороне клиента могут быть извлечены и злоупотреблены. Маршрутизируйте генерацию через ваш серверную часть, обеспечивайте там квоты и позвольте клиентам запрашивать задание ожидающей работы, принадлежащей игре.
Справочные материалы
- reAPI. Z-Image model page and live pricing. Retrieved September 3, 2026 from reapi.ai/models/z-image
- reAPI. Z-Image API documentation: inputs, asynchronous response, pricing, filters, and output retention. Retrieved September 3, 2026 from reapi.ai/docs/z-image
- reAPI. Tasks API: status, settled usage, polling, rate-limit behavior, and output storage. Retrieved September 3, 2026 from reapi.ai/docs/api/tasks
- reAPI. Three-task Z-Image smoke-test record. Run September 3, 2026. Temporary output URLs are omitted; no visual acceptance review was performed.
- reAPI. API overview: idempotency behavior. Retrieved September 3, 2026.
Автор

Категории
Ещё статьи

Seedream 5.0 Lite vs Pro: цена, редактирование и качество
Сравнение Seedream 5.0 Lite и Pro: различия в цене, разрешении, редактировании, ссылках на изображения, пакетной выдаче и использовании через API.


Статус Seedance 2.5: API, цены и ограничения
Seedance 2.5 уже доступен. Модельный ID reAPI, цены 480p/720p, 30-секундный вывод, пределы референсов и тарификация исходного видео.


API видео для взрослых: Seedream, Seedance, Wan, H3
Надёжный рабочий процесс NSFW видео: создайте ключевые кадры в Seedream 5.0 Pro, анимируйте через Seedance, Wan или H3, добавьте проверку согласия.
