0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ClipDanceで作ったSeedance 2.0/2.5の画像→動画をreAPIへ移す方法:設定対応表とNode.js実装

0
Posted at

ClipDance で画像→動画を1本仕上げても、プロンプトだけをAPIに移したのでは同じ狙いを再現できない。画像の役割、尺、縦横比、解像度、音声設定、モデルIDまでが一組の入力条件になる。

目指すのはピクセル単位の一致ではなく、狙った構図や動きを再現可能な形にすることだ。5秒の画像→動画を例に、試作時の設定をJSONへ移し、生成完了まで追跡できるところまで実装する。

先に結論:プロンプトではなく「ショット仕様」を移す

最小構成は次の順番になる。

  1. 1枚の画像、5秒、720pでショットを試す
  2. 採用したプロンプトと設定を保存する
  3. 画像をAPIから取得できるHTTPS URLへ置く
  4. APIへは一度だけPOSTし、返されたタスクIDを保存する
  5. 同じタスクIDをGETして completed または failed まで待つ
  6. 完成した動画を自分のストレージへ回収する

POSTは新しい生成の依頼、GETは既存タスクの状態確認である。処理が遅いからとPOSTを繰り返すと、同じジョブを重複して作成しかねない。

Seedance 2.0と2.5は、必要な尺と参照素材で選ぶ

ByteDanceが2026年2月12日に公開した「Seedance 2.0 Official Launch」では、最大15秒、画像9枚、動画3本、音声3本までの参照入力が案内された。2026年7月31日の「One-take Creation, Flexible Referencing: Introducing Seedance 2.5」では、最大30秒、画像30枚、動画10本、音声10本へ拡張されている。

数字が大きい方を毎回選ぶ必要はない。短い商品カットやSNS用の1ショットなら、2.0の範囲で足りることが多い。長い一発撮り、多数の参照素材、既存動画の編集や延長が必要なら2.5を検討する。

以下は、公式発表の参照上限と、2026年9月4日時点で本稿が対象とするreAPI公開仕様を、移行判断に必要な項目だけ並べたものだ。

判断材料 Seedance 2.0 Seedance 2.5
出力尺 4〜15秒 4〜30秒
参照上限 画像9枚、動画3本、音声3本 画像30枚、動画10本、音声10本
解像度 最大4K。ただしモデルの種類で上限が変わる 最大1080p
使い分けの目安 短い単一ショット 長いショット、多数の参照、編集・延長
APIで注意する点 Fastモデルは480p/720pまで edit/extendはタスク種別と制約を明示する

表の上限は、すべてを詰め込む目標ではない。参照素材が増えるほど、「どの画像の服を残し、どの動画のカメラを借り、どの音声のテンポを使うのか」が曖昧になりやすい。最初の移行では1枚に絞った方が原因を追いやすい。

試作時に記録するもの

ClipDanceで画像→動画を試すときは、完成動画だけでなく、その動画を作った条件も残す。最低限、次の7項目が必要だ。

  • 使用したモデルの世代
  • プロンプトの全文
  • 元画像のファイル名と版
  • 画像を「通常の参照」と「開始フレーム」のどちらとして扱ったか
  • 尺と縦横比
  • 解像度
  • 音声を生成したか

例えば、次のようなメモを残す。

{
  "shot_id": "coffee-pour-01",
  "model_family": "Seedance 2.5",
  "prompt": "A barista slowly pours coffee into a white ceramic cup, close-up, locked camera, warm morning light",
  "source_file": "coffee-pack-v03.png",
  "source_role": "reference_image",
  "duration": 5,
  "aspect_ratio": "9:16",
  "resolution": "720p",
  "generate_audio": false
}

source_file は制作側で版を追うための名前であり、このままAPIへ送る値ではない。APIには、外部から直接取得できる画像URLを渡す。

Seedance 2.5で seed を使った場合は、その値も記録する。seed は再現性を高める条件の一つだが、出力を固定するものではない。移行時の合格基準は「完全一致」ではなく、商品形状、動作、構図、カメラ、尺など、守るべき項目で定義する。

画面の設定をAPIのJSONへ対応させる

次は、試作時に記録した値を reAPIのSeedance 2.5モデル へ渡す場合の対応である。このJSONはreAPIの公開インターフェースを対象としており、BytePlusのネイティブAPIとフィールド名を混ぜてはいけない。

試作時に記録する項目 APIフィールド 注意点
モデル model 自分のAPIキーで取得できる正確なモデルIDを使う
プロンプト prompt 要約せず、採用した文をそのままバージョン管理する
通常の参照画像 image_urls 公開HTTP(S) URLの配列
開始/終了フレーム image_with_roles first_frame、last_frame を明示する
尺 duration 2.0は4〜15秒、2.5は4〜30秒
縦横比 size 9:16、16:9、1:1 など
解像度 resolution 480p、720p、1080p など。モデルごとの上限を確認する
音声生成 generate_audio 参照音声を渡す audio_urls とは別の設定
Seedance 2.5のseed seed 同じ値でも出力の完全一致は保証されない。2.0の共通フィールドとしては送らない

image_urls と image_with_roles は同時に送らない。前者は通常の参照画像、後者は開始・終了フレームの役割を固定したい場合に使う。image_with_roles は文字列の配列ではなく、次のようにURLと役割を持つオブジェクトの配列である。

{
  "image_with_roles": [
    {
      "url": "https://your-cdn.example/start.png",
      "role": "first_frame"
    },
    {
      "url": "https://your-cdn.example/end.png",
      "role": "last_frame"
    }
  ],
  "size": "adaptive"
}

last_frame を使う場合は first_frame も必要になる。開始/終了フレームと通常の参照画像を同じ配列へ混在させず、入力画像の比率を引き継ぐため size は adaptive にする。

音声にも注意が要る。generate_audio: true は映像に合わせた音を生成する指定である。一方の audio_urls は参照音声であり、渡した音声ファイルが無加工で使われるという意味ではない。承認済みのナレーションを絶対に変えられない案件では、生成後の編集で原音を戻す設計にする。

ローカル画像は、そのままAPIへ渡せない

画面ではPC内の画像を選べても、サーバーから実行するAPIに /Users/name/Desktop/photo.png のようなローカルパスは渡せない。生成側からアクセスできる、直接取得可能なHTTP(S) URLが必要だ。

画像はS3やR2などに置き、次の4点を確認する。

  • URLを認証Cookieなしで取得できる
  • 画像本体が返り、HTMLの閲覧ページへ転送されない
  • APIの処理中にURLが失効しない
  • Content-Typeと実際のファイル形式が一致する

ブラウザのシークレットウィンドウでURLを開く確認だけでは不十分なことがある。次のように、実際のGETでステータスとContent-Typeも確認する。

curl --fail --location --output /dev/null \
  --write-out '%{http_code} %{content_type}\n' \
  "https://your-cdn.example/coffee-pack-v03.png"

HTTPステータスが 200 で画像の Content-Type が返れば、少なくとも入口の条件は満たしている。期限付きURLを使う場合は、生成待ち時間より十分長い有効期限を持たせる。

Node.jsで送信から完了確認まで実装する

以下は、二重送信を避け、待機時間に上限を設けたNode.js 18以降の実装例である。APIキー、モデルID、入力画像URLは環境変数から読み込む。

// seedance-i2v.mjs
const API_BASE = 'https://reapi.ai/api/v1';
const API_KEY = process.env.REAPI_API_KEY;
const SOURCE_IMAGE_URL = process.env.SOURCE_IMAGE_URL;
const MODEL = process.env.SEEDANCE_MODEL;
const RESUME_TASK_ID = process.env.TASK_ID;

if (!API_KEY) throw new Error('REAPI_API_KEY is required');
if (!MODEL && !RESUME_TASK_ID) {
  throw new Error('SEEDANCE_MODEL is required for a new task');
}
if (!SOURCE_IMAGE_URL && !RESUME_TASK_ID) {
  throw new Error('SOURCE_IMAGE_URL is required for a new task');
}

class HttpError extends Error {
  constructor(status, body, retryAfterMs = 0) {
    super(`HTTP ${status}: ${JSON.stringify(body)}`);
    this.status = status;
    this.body = body;
    this.retryAfterMs = retryAfterMs;
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function parseRetryAfter(value) {
  if (!value) return 0;

  const seconds = Number(value);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

  const date = Date.parse(value);
  return Number.isNaN(date) ? 0 : Math.max(0, date - Date.now());
}

async function fetchJson(url, options = {}, timeoutMs = 20_000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });
    const text = await response.text();

    let body = {};
    try {
      body = text ? JSON.parse(text) : {};
    } catch {
      if (!response.ok) {
        throw new HttpError(
          response.status,
          { raw: text.slice(0, 200) },
          parseRetryAfter(response.headers.get('retry-after')),
        );
      }
      throw new Error(`Expected JSON but received: ${text.slice(0, 200)}`);
    }

    if (!response.ok) {
      throw new HttpError(
        response.status,
        body,
        parseRetryAfter(response.headers.get('retry-after')),
      );
    }
    return body;
  } finally {
    clearTimeout(timer);
  }
}

async function submitVideo() {
  const body = {
    model: MODEL,
    prompt:
      'A barista slowly pours coffee into a white ceramic cup, ' +
      'close-up, locked camera, warm morning light',
    image_urls: [SOURCE_IMAGE_URL],
    duration: 5,
    size: '9:16',
    resolution: '720p',
    generate_audio: false,
  };

  // POSTは自動再試行しない。応答を失った場合に二重発注しないためである。
  const task = await fetchJson(`${API_BASE}/videos/generations`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });

  if (!task.id) {
    throw new Error(`Task id is missing: ${JSON.stringify(task)}`);
  }
  return task.id;
}

async function getTask(taskId, timeoutMs) {
  return fetchJson(
    `${API_BASE}/tasks/${encodeURIComponent(taskId)}`,
    { headers: { Authorization: `Bearer ${API_KEY}` } },
    timeoutMs,
  );
}

function canRetryPoll(error) {
  if (error instanceof HttpError) {
    return error.status === 429 || error.status >= 500;
  }
  return error.name === 'AbortError' || error instanceof TypeError;
}

async function waitForTask(taskId, maxWaitMs = 10 * 60_000) {
  const deadline = Date.now() + maxWaitMs;
  let consecutiveErrors = 0;

  while (Date.now() < deadline) {
    let task;

    try {
      const requestTimeoutMs = Math.max(
        1,
        Math.min(20_000, deadline - Date.now()),
      );
      task = await getTask(taskId, requestTimeoutMs);
      consecutiveErrors = 0;
    } catch (error) {
      if (!canRetryPoll(error)) throw error;
      consecutiveErrors += 1;

      const backoff = Math.min(
        15_000,
        3_000 * 2 ** Math.min(consecutiveErrors, 3),
      );
      const jitter = Math.floor(Math.random() * 500);
      const retryAfterMs =
        error instanceof HttpError ? error.retryAfterMs : 0;
      const delayMs = Math.max(backoff + jitter, retryAfterMs);
      const remainingMs = deadline - Date.now();
      if (remainingMs <= 0) break;
      await sleep(Math.min(delayMs, remainingMs));
      continue;
    }

    if (task.status === 'completed') return task;

    if (task.status === 'failed') {
      const code = task.error?.code ?? 'unknown';
      const message = task.error?.message ?? 'Generation failed';
      throw new Error(
        `task_id=${taskId}; code=${code}; message=${message}; ` +
          `settled_credits=${task.usage?.credits ?? 0}`,
      );
    }

    if (task.status !== 'processing') {
      throw new Error(`Unknown task status: ${task.status}`);
    }

    const remainingMs = deadline - Date.now();
    if (remainingMs <= 0) break;
    const nextPollMs = 3_000 + Math.floor(Math.random() * 500);
    await sleep(Math.min(nextPollMs, remainingMs));
  }

  throw new Error(
    `Stopped polling task ${taskId}. It may still be processing. ` +
      `Resume with TASK_ID=${taskId}; do not submit a new task.`,
  );
}

const taskId = RESUME_TASK_ID || (await submitVideo());

// 本番では、この時点でtaskId、model、shot_idをDBへ保存する。
console.log(`task_id=${taskId}`);

const task = await waitForTask(taskId);
const videoUrl = task.output?.video_urls?.[0];

if (!videoUrl) {
  throw new Error(`Completed task has no video URL: ${JSON.stringify(task)}`);
}

console.log(`video_url=${videoUrl}`);
console.log(`settled_credits=${task.usage?.credits ?? 0}`);

新規実行は次のようになる。

ここで使っている doubao-seedance-2.5-face は公開モデルページに掲載されている例である。本番では、自分のAPIキーで取得できるモデル一覧を確認し、返されたIDを変更せず SEEDANCE_MODEL に設定する。

REAPI_API_KEY="..." \
SOURCE_IMAGE_URL="https://your-cdn.example/coffee-pack-v03.png" \
SEEDANCE_MODEL="doubao-seedance-2.5-face" \
node seedance-i2v.mjs

途中でプロセスが止まっても、保存したタスクIDから再開できる。

REAPI_API_KEY="..." \
TASK_ID="task_xxx" \
node seedance-i2v.mjs

再開時はPOSTしないため、新しい生成を増やさずに同じタスクを追跡できる。実運用では、コンソールだけでなく、タスクID、モデルID、ショットID、送信時刻、リクエスト内容のハッシュをデータベースへ保存する。

この例の10分はクライアント側の待機上限であり、生成タスクの失敗判定ではない。タイムアウト後もタスクが processing の可能性があるため、表示されたIDで再開する。タイムアウトを理由に同じ内容をPOSTし直してはいけない。

失敗を一括で再試行しない

エラーは原因によって扱いを変える。

状況 対応
POSTが400 フィールド、形式、組み合わせを直す。同じ内容を再送しない
POSTが401 APIキーと送信先を確認する
POSTが402 残高や予約額を確認する
POST後に通信が切れ、IDを受け取れない 自動で再POSTしない。送信ログや保存済みIDを先に照合する
GETが429、5xx、または一時的な通信エラー Retry-After と待機間隔に従い、同じタスクIDを再確認する
タスクが failed error と usage.credits を保存し、原因を直してから新規送信する
タスクが completed output.video_urls を確認し、必要な動画を自分の保存先へ回収する

processing は失敗ではない。待ち時間を理由に別のPOSTを増やさない。反対に、400の入力不備は待っても直らないため、同じJSONを再送してはいけない。

2.5で既存動画を編集する場合は、omni_reference_task_type: "edit" と元動画の video_urls を渡し、size を adaptive にする。尺は duration: -1 を指定するか、edit を明示したうえで省略する。延長では omni_reference_task_type: "extend" と video_urls を使い、size を adaptive にするが、編集と同じ duration: -1 が必須というわけではない。

タスク種別の明示は、送信前に対応するフィールド制約を検査しやすくするためのものだ。プロンプトが別の処理を指示していれば、種別不一致で失敗することはある。画像→動画用のJSONにこのフィールドだけを足して流用してはいけない。

返却URLを最終保存先にしない

生成APIが返すURLをデータベースの最終納品URLにすると、後で取得できなくなる可能性がある。completed を受け取ったら動画を取得し、Content-Type、ファイルサイズ、再生時間を確認して、自分が管理する保存先へ移す。

少なくとも次を記録しておくと、後から追跡しやすい。

  • 元のタスクID
  • 使用したモデルID
  • リクエストのハッシュ
  • 返却された元URL
  • 管理下のストレージのURL
  • ファイルサイズとContent-Type
  • 決済後の usage.credits
  • 人が採用したか、却下したか

API料金を評価するときは、生成1回の単価だけでなく「使える1本あたりの実コスト」を見る。5本中2本しか使えなかった場合、実質コストは5本分の消費クレジットを2で割った値になる。

API化する前のチェックリスト

  • 元画像の版を固定した
  • 画像の役割を通常参照か開始フレームかで決めた
  • プロンプト、尺、比率、解像度、音声設定を保存した
  • 自分のAPIキーで利用可能なモデルIDを確認した
  • 素材URLが認証なしで直接取得できる
  • image_urls と image_with_roles を混在させていない
  • APIキーをクライアント側へ露出していない
  • POST直後にタスクIDを保存する
  • ポーリングに総待ち時間の上限がある
  • 429、5xx、一時的な通信エラーだけを有界に再試行する
  • 完成動画を自分のストレージへ回収する
  • usage.credits と採否を保存する

最初の移行は、5秒・720p・1枚でよい

いきなり30秒、多数の参照素材、音声、編集指定を一つのリクエストへ入れると、失敗しても原因を切り分けにくい。まずは採用済みの1枚を使い、5秒、720p、音声なしで送る。タスクIDを保存し、完了を待ち、動画と決済クレジットを記録する。

1本通ったら、尺、解像度、参照素材、音声の順に条件を一つずつ増やす。これなら失敗したときに、どの変更まで戻ればよいか判断できる。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?