Seedance 2.5 is live — 30-second cinematic video with native audio & real-person references
ゲーム向けAI画像生成:キャッシング戦略で効率化
2026/09/03

ゲーム向けAI画像生成:キャッシング戦略で効率化

セマンティックキーとキャッシュ優先フロー、単発リクエスト、非同期ポーリング、予算枠、耐久ストレージで、ゲーム画像生成パイプラインのコスト削減と実装を解説します。

キャッシングにより、プレイヤーアクション1回あたりの平均AI画像API費用を$0.002まで削減できますが、本当に新しい画像を生成する場合は安くなりません。Z-Imageの現在のreAPI単価$0.005/完了画像では、支配的なルールはキャッシュミス率×受理済み資産1件あたりの完了試行回数≤0.4です。40%のミス率に達する上限は、新規資産が初回試行で100%パスする場合のみです。$0.002以下を維持するには、この境界線を下回る必要があります。[1]

プロンプトだけでなく、アイテムの意味をキャッシュしましょう。同時発生のミスをマージし、確度の高いアイテムを事前生成し、API応答ではなく受理されたゲーム資産1件あたりのコストで測定してください。

TL;DR

  • Z-Image生成が$0.005/完了あたりで、ゲームが有効コスト$0.002以下を目指す場合、キャッシュミス率×受理済み資産1件あたりの完了試行回数≤0.4が必要です。試行回数が1の場合、40%が上限に達し、これより低いなら40%未満の必要があります。[1]
  • レシピキー(プレイヤーが結合した内容)とセマンティック資産キー(生成されたオブジェクトとアート版)を分けて管理してください。
  • 共有データベースのユニーク制約で1つの有効資産ジョブを強制します。同一プロセス内呼び出し者をマージするメモリ内Promiseマップもありますが、複数ワーカーを保護するロックではありません。
  • 画像生成は非同期です。1回だけ送信し、タスクIDを保持し、タスクエンドポイントをポーリングし、完了した出力をあなたが管理するストレージにコピーしてください。[2][3]
  • 3つのタスクスモークテストは初回送信で3つのオリジナル候補を完了し、15クレジット、合計$0.015かかりました。ビジュアルレビューなしなので、候補であって受理済み資産ではありません。[4]
  • キャッシング後に本当に新しいセマンティック資産が1日3,000件であれば、従来型キャッシュでは経済性を救えません。ゲームはより多くアートを再利用するか、生成対象を絞るか、より高いコスト を受け入れる必要があります。

本当の問題は「画像1件あたりのコスト」ではなく「クラフト1件あたりのコスト」

鉄と火のようなアイテムを組み合わせ、新しい結果とその画像を同時に生成する無限クラフトゲームを考えます。プレイヤーが 1 日 3,000 回クラフトし、その多くが未生成の組み合わせだった場合、メディア費用を 1 回あたり $0.002 未満に保つにはどうすればよいでしょうか。この問いが示す本質的な制約は、ゲームが収益で賄える速度を上回って新規リクエストを生み出し得ることです。

まず、次の式から考えます:

日次生成コスト =
  クラフトイベント数
  × キャッシュミス率
  × 受理済み資産1件あたりの完了試行回数
  × 完了生成1件あたりの単価

Z-Image生成が$0.005/完了あたりの場合、上限は:

$0.005 × キャッシュミス率 × 受理済み資産1件あたりの完了試行回数 <= $0.002
キャッシュミス率 × 受理済み資産1件あたりの完了試行回数 <= 0.4

30日間で1日3,000クラフトイベントの場合、試行回数1のシナリオは次のようになります。

キャッシュミス率新規生成/日30日間API費用
100%3,000$450
40%1,200$180
20%600$90
10%300$45

これらはシナリオであり予測ミスレートではなく、すべての行が受理済み資産1件あたり1回の完了試行を想定しています。例えば1.5試行の場合、同じ$0.002上限を維持するにはミス率は26.7%以下である必要があります。セマンティック重複排除後も3,000イベントがすべてユニークなままなら、最初の行が適用されます。

レシピキーとセマンティック資産キーは異なる問題を解決

レシピキーはプレイヤーの入力を表します。ルール内で順序が重要でない場合、正確な正規アイテムIDをソートしてからハッシュ化してください。ハッシュ化されていない識別子は検査しやすいままです。

レシピ識別子: v3 | fire | iron

これによりiron + firefire + ironは同じサーバー所有レシピ行を通じて解決されます。ルール版を含めてください。後のバランスパッチで結果が変わる可能性があるためです。キーのためにIDをスラグ化しないでください。損失のある正規化により2つの異なるアイテムがマージされる可能性があります。

セマンティック資産キーはゲームが最終的に表示する内容を表します。ハッシュ化されていない識別子は同じくらい明確なままでいることができます。

資産識別子: ember-shield | inventory-icon-v1 | z-image | 1:1 | filtered

複数のレシピがember-shieldに解決される場合があります。翻訳もそれを異なる名前で呼ぶ場合があります。それらのパスは依然として1つの承認済みビジュアルを再利用できます。

生のプロンプトは悪いキーになります。表現の変更がキャッシュを断片化し、同一プロンプトはアートディレクション変更後に陳腐化します。各レシピを安定した結果IDにマップしてから、結果ID、モデル、アスペクト比、安全モード、スタイル版でアートをキー化してください。新しいアートが必要なときにバージョンを上げてください。

キャッシュ優先リクエストフロー

プレイヤーが開始した場合でも、生成をバックグラウンド資産生成として扱ってください。実用的なフローは:

  1. 着信レシピをセマンティック結果IDに解決します。
  2. 耐久ストレージでセマンティック資産キーを調べます。
  3. ヒット時は、保存URLをすぐに返します。
  4. ミス時は、セマンティック資産をキーとする1つの共有ジョブ行をアトミックに挿入します。
  5. 同じデータベーストランザクション内で、共有日次送信予算に1スロット予約します。他のワーカーは既存ジョブを返します。
  6. 行をsubmittingとしてコミットしてから1つのPOSTを作成し、応答直後にタスクIDを保持します。
  7. 送信が不確実な場合はsubmit_uncertainと記してから停止します。既知のタスクIDをGETでポーリングしてcompletedまたはfailedになるまで待ちます。
  8. 終了ステータスと確定クレジットを記録します。完了画像を自分のストレージにコピーしてpending_reviewに移します。
  9. 受理チェックだけがpending_reviewreadyに移動できます。

ステップ6後は、プレースホルダーとpendingステータスを返します。クライアントがゲームエンドポイントを確認する間、ワーカーにポーリングさせます。reAPIキーをクライアントに決して公開しないでください。

ポーリングは無料で、進行中タスクエンドポイントは5秒間キャッシュされるため、約5秒ごとにポーリングしてください。[3] 429Retry-Afterを尊重してください。POSTを盲目的に再試行しないでください。reAPIはgeneration requestをIdempotency-Keyで重複排除しないため、失われた応答は受理されたタスクと二重課金を隠す可能性があります。[5]

共有ロック付きNode 20実行可能モジュール

複数ワーカーを保護する最小の例は共有状態が必要です。以下のモジュールは素の.mjsで、postgresパッケージを通じてPostgreSQLを使用し、送信後に返ります。

npm install postgres

テーブルを1度作成します。game_recipesをゲーム自体のルールデータから母集団化します。ブラウザは2つのアイテム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ではなく)は、2番目のワーカーが同じ資産を送信するのを防ぎます。POST中にダイのプロセスはsubmittingを残します。古い行をsubmit_uncertainとして扱い、再利用するのではなく調査してください。既知のタスクIDが存在した後、一時的なポーリング失敗はGETだけを再試行します。

共有カウンターは保守的な送信上限です。未確認または拒否されたPOSTでもスロットを消費します。これはサイレントに上限を超えるよりも安全です。ターミナルタスクが失敗した場合、そのステータスとusage.creditsは行に留まります。この例は失敗した資産や拒否された資産を自動再試行しません。再試行をサポートする前に、別の試行台帳と明示的な予算化されたオペレーター操作を追加してください。

ストレージワーカーはsource_urlをダウンロードし、オブジェクトストアにアップロードし、その耐久URLをstageForReviewに渡してください。reviewAsset(..., true)だけがキャッシュ可能なready資産を作成します。

3つのタスクスモークテストが証明すること

2026年9月3日、content_filter: trueで3つのオリジナル1:1インベントリアイテムプロンプトを送信しました。苔色のランタン、ガラスのコンパス、エンバーシールドです。各タスクは初回送信で完了し、5クレジットを報告しました。[4]

候補タスクID観測完了時間クレジット
Moss lanterntask_01a065490250739d893a1efcd83f4d1d25.1 s5
Glass compasstask_01a0654995df7265b44b132dce34a91425.0 s5
Ember shieldtask_01a0654995fd73e28e7c68217066407018.2 s5
合計3 tasks15 / $0.015

パブリックスモークテストレコードには、リクエスト設定、タスクID、経過時間、確定クレジットが含まれています。一時的な出力URLは意図的に省略されています。[4]

これはレイテンシベンチマークではなくスモークテストであり、p95、バースト、または長期信頼性については何も説明していません。プロトコルはタスク結果と決済を記録しましたが、アートレビューは含みませんでした。completedは3つの候補が到着したことを意味し、アートレビュアーが受理したことではありません。

ですから、有用なクリエイティブメトリクスは:

受理済み資産1件あたりのコスト =
  確定生成クレジット合計×$0.001/受理済み資産

3回の試行が1つの使用可能なアイコンを生成する場合、その有効コストは$0.015です。技術的なステータスとクリエイティブ受理を別々に記録してください。

レイテンシのための事前生成、架空の節約のためではなく

予測可能なリクエスト(プレイヤーのインベントリから到達可能なトップ20レシピなど)だけを事前生成します。同じセマンティックキャッシュと単発パスを使用し、別の日次上限を付けます。

理論的な結合空間を生成しないでください。使用されない資産はお金がかかり、オープンエンドのクラフトは強引なキューを超えます。観測需要でランク付けし、上限で停止します。

ハイブリッドは通常、最高に機能します。

  • 承認済みセマンティック資産は即座に返します。
  • 高確率の次のアイテムは事前生成されます。
  • ロングテールミスはプレースホルダーを表示し、1つのバックグラウンドタスクが実行されます。
  • 人気のない、または虐待的なリクエストは無限再試行ループの代わりにクォータにヒットします。

安全性、ストレージ、運用の境界

プレイヤー向け生成はcontent_filter: trueを保持してください。Z-Imageはデフォルトでその設定をオフのままにしておくため、デフォルトに依存するのは十分ではありません。[2]プレイヤーテキストをモデレートするか制約を加え、保護されたキャラクターや実在の人物を模倣する試みをブロックし、アカウントとデバイスでレート制限します。上流フィルターは1レイヤーであり、完全なゲームポリシーではありません。

キーをサーバー側に保持します。レシピキーと資産キー、タスクID、ステータス、クレジット、受理をログします。不要なプレイヤーデータは保持しません。

最後に、ゲームクライアントにURLを公開する前に、完了ファイルを耐久ストレージにコピーしてください。タスク出力URLは永続資産契約ではありません。[3]チェックサムとコンテンツタイプを資産レコードの横に保存して、再試行が承認済みアートを静かに置き換えられないようにしてください。

結論:キャッシングでは十分ではない場合

キャッシングはゲームが再利用を許可した場合のみ機能します。キャッシュミス率×受理済み資産1件あたりの完了試行回数が0.4を超える場合、$0.002上限は$0.005単価で保持できません。元の例の3,000日次リクエストが既にキャッシング後のユニークセマンティック資産の場合、キャッシュソフトウェアの変更は問題を解決しません。

その後、製品制約を変更してください。同様のアイテム間でアートを共有し、手続き型の色付けやオーバーレイを使用し、人気のあるアイテムのみを生成し、発見を制限し、ロングテール作成に料金を請求するか、より大きなメディア予算を受け入れます。

予算を設定する前にZ-Imageモデルページのライブ価格をチェックしてから、Z-Image APIガイドTasks APIリファレンスを使用して送信とポーリングを実装してください。価格とモデル動作は変わる可能性があります。自分の確定クレジットは本番請求の唯一の真実の情報源です。

FAQ

キャッシングにより新しいAI画像の価格は下がりますか?

いいえ。どの頻度で購入するかを減らします。現在のZ-Image請求は完了生成1件あたり5クレジットのままです。キャッシュヒットは新しい生成クレジットが必要ありません。

プロンプト自体がキャッシュキーであるべきですか?

通常はそうではありません。レシピを安定した結果IDに解決してから、アートを別にバージョン化します。

アイテム順序が重要な場合は?

レシピ入力をソートしないでください。water + firefire + waterはルールが異なる結果を与えるたびに異なるレシピキーを持つべきです。

ゲームは資産を事前生成すべきですか、それともリアルタイムで生成すべきですか?

両方を使用します。予算化された確度の高いセット を事前生成し、ヒットをすぐに提供し、予測不可能なロングテールアイテムをバックグラウンドで生成します。

reAPI画像タスクをどの頻度でポーリングすべきですか?

約5秒ごとが実用的な開始点です。より速いポーリングは生成をより速く終了させず、レート制限予算を浪費する可能性があります。

失敗した生成は課金されますか?

Z-Image文書は失敗リクエストと拒否リクエストは課金されないと述べています。それでもstatususage.creditsと一緒に検査してください。完了したが視覚的に使用不可な結果は失敗したタスクと異なり、クリエイティブコストにカウントされます。[2][3]

クラフトあたり$0.002以下の平均を約束できますか?

支払いミス率と受理済み資産1件あたりの完了試行回数を測定した後のみです。$0.005あたりの画像では、厳密に$0.002以下を維持するには、それらの積は0.4未満である必要があります。40%のミス率は、すべての資産が初回完了試行でパスするときに上限に達するだけです。

ゲームクライアントは画像エンドポイントを直接呼び出すことができますか?

そうすべきではありません。クライアント側の認証情報は抽出され、悪用される可能性があります。生成をバックエンドを通じてルーティングし、そこでクォータを強制し、クライアントがゲーム所有の保留中ジョブをクエリさせます。

References

  1. reAPI. Z-Image model page and live pricing. Retrieved September 3, 2026 from reapi.ai/models/z-image
  2. reAPI. Z-Image API documentation: inputs, asynchronous response, pricing, filters, and output retention. Retrieved September 3, 2026 from reapi.ai/docs/z-image
  3. reAPI. Tasks API: status, settled usage, polling, rate-limit behavior, and output storage. Retrieved September 3, 2026 from reapi.ai/docs/api/tasks
  4. reAPI. Three-task Z-Image smoke-test record. Run September 3, 2026. Temporary output URLs are omitted; no visual acceptance review was performed.
  5. reAPI. API overview: idempotency behavior. Retrieved September 3, 2026.