ClipDance で画像→動画を1本仕上げても、プロンプトだけをAPIに移したのでは同じ狙いを再現できない。画像の役割、尺、縦横比、解像度、音声設定、モデルIDまでが一組の入力条件になる。
目指すのはピクセル単位の一致ではなく、狙った構図や動きを再現可能な形にすることだ。5秒の画像→動画を例に、試作時の設定をJSONへ移し、生成完了まで追跡できるところまで実装する。
先に結論:プロンプトではなく「ショット仕様」を移す
最小構成は次の順番になる。
- 1枚の画像、5秒、720pでショットを試す
- 採用したプロンプトと設定を保存する
- 画像をAPIから取得できるHTTPS URLへ置く
- APIへは一度だけPOSTし、返されたタスクIDを保存する
- 同じタスクIDをGETして
completedまたはfailedまで待つ - 完成した動画を自分のストレージへ回収する
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本通ったら、尺、解像度、参照素材、音声の順に条件を一つずつ増やす。これなら失敗したときに、どの変更まで戻ればよいか判断できる。