seedance-adapter.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /**
  2. * Seedance (ByteDance / Doubao / Ark) Video Generation Adapter
  3. *
  4. * Uses async task pattern: submit task → poll until succeeded → get video URL.
  5. * Endpoint: https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks
  6. *
  7. * Request format (text-to-video):
  8. * POST /api/v3/contents/generations/tasks
  9. * {
  10. * "model": "doubao-seedance-1-5-pro-251215",
  11. * "content": [{ "type": "text", "text": "prompt here" }],
  12. * "ratio": "16:9",
  13. * "duration": 5,
  14. * "resolution": "1080p",
  15. * "watermark": false
  16. * }
  17. *
  18. * Supported models:
  19. * - doubao-seedance-1-5-pro-251215 (latest, 4~12s)
  20. * - doubao-seedance-1-0-pro-250528 (stable, 2~12s)
  21. * - doubao-seedance-1-0-pro-fast-251015 (faster, 2~12s)
  22. * - doubao-seedance-1-0-lite-t2v-250428 (lightweight, 2~12s)
  23. *
  24. * API docs: https://www.volcengine.com/docs/6492/2165104
  25. */
  26. import type {
  27. VideoGenerationConfig,
  28. VideoGenerationOptions,
  29. VideoGenerationResult,
  30. } from '../types';
  31. const DEFAULT_MODEL = 'doubao-seedance-1-5-pro-251215';
  32. const DEFAULT_BASE_URL = 'https://ark.cn-beijing.volces.com';
  33. const POLL_INTERVAL_MS = 5000;
  34. const MAX_POLL_ATTEMPTS = 60; // 5 minutes max
  35. /** Response shape for task creation (only returns id) */
  36. interface SeedanceSubmitResponse {
  37. id: string;
  38. }
  39. /** Response shape for task polling */
  40. interface SeedancePollResponse {
  41. id: string;
  42. model: string;
  43. status: 'queued' | 'running' | 'succeeded' | 'failed' | string;
  44. content?: {
  45. video_url?: string;
  46. };
  47. resolution?: string;
  48. ratio?: string;
  49. duration?: number;
  50. framespersecond?: number;
  51. error?: {
  52. message: string;
  53. code?: string;
  54. };
  55. }
  56. /**
  57. * Map aspect ratio to Seedance ratio format.
  58. * Seedance uses the same "W:H" format we already have.
  59. */
  60. function toSeedanceRatio(aspectRatio?: string): string | undefined {
  61. if (!aspectRatio) return undefined;
  62. return aspectRatio; // Already in "16:9" format
  63. }
  64. /**
  65. * Map resolution to Seedance format.
  66. * Seedance expects "480p", "720p", "1080p".
  67. */
  68. function toSeedanceResolution(resolution?: string): string | undefined {
  69. if (!resolution) return undefined;
  70. return resolution; // Already in "720p" format
  71. }
  72. /**
  73. * Estimate video dimensions from ratio and resolution for the result.
  74. */
  75. function estimateDimensions(
  76. ratio?: string,
  77. resolution?: string,
  78. ): { width: number; height: number } {
  79. const resMap: Record<string, number> = {
  80. '480p': 480,
  81. '720p': 720,
  82. '1080p': 1080,
  83. };
  84. const h = resMap[resolution || '720p'] || 720;
  85. if (!ratio) return { width: Math.round((h * 16) / 9), height: h };
  86. const [w, hRatio] = ratio.split(':').map(Number);
  87. if (!w || !hRatio) return { width: Math.round((h * 16) / 9), height: h };
  88. return { width: Math.round((h * w) / hRatio), height: h };
  89. }
  90. /**
  91. * Submit a video generation task to Seedance API.
  92. * Returns the task ID for polling.
  93. */
  94. /**
  95. * Lightweight connectivity test — validates API key by making a GET request
  96. * to poll a non-existent task. If auth fails we get 401/403; if auth succeeds
  97. * we get 404 (task not found), confirming the key is valid.
  98. */
  99. export async function testSeedanceConnectivity(
  100. config: VideoGenerationConfig,
  101. ): Promise<{ success: boolean; message: string }> {
  102. const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
  103. try {
  104. const response = await fetch(
  105. `${baseUrl}/api/v3/contents/generations/tasks/connectivity-test-nonexistent`,
  106. {
  107. method: 'GET',
  108. headers: { Authorization: `Bearer ${config.apiKey}` },
  109. },
  110. );
  111. // 401/403 means key invalid; anything else (404, 400, 200) means key works
  112. if (response.status === 401 || response.status === 403) {
  113. const text = await response.text();
  114. return {
  115. success: false,
  116. message: `Seedance auth failed (${response.status}): ${text}`,
  117. };
  118. }
  119. return { success: true, message: 'Connected to Seedance' };
  120. } catch (err) {
  121. return { success: false, message: `Seedance connectivity error: ${err}` };
  122. }
  123. }
  124. export async function submitSeedanceTask(
  125. config: VideoGenerationConfig,
  126. options: VideoGenerationOptions,
  127. ): Promise<string> {
  128. const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
  129. const body: Record<string, unknown> = {
  130. model: config.model || DEFAULT_MODEL,
  131. content: [
  132. {
  133. type: 'text',
  134. text: options.prompt,
  135. },
  136. ],
  137. watermark: false,
  138. };
  139. const ratio = toSeedanceRatio(options.aspectRatio);
  140. if (ratio) body.ratio = ratio;
  141. if (options.duration) body.duration = options.duration;
  142. const resolution = toSeedanceResolution(options.resolution);
  143. if (resolution) body.resolution = resolution;
  144. const response = await fetch(`${baseUrl}/api/v3/contents/generations/tasks`, {
  145. method: 'POST',
  146. headers: {
  147. 'Content-Type': 'application/json',
  148. Authorization: `Bearer ${config.apiKey}`,
  149. },
  150. body: JSON.stringify(body),
  151. });
  152. if (!response.ok) {
  153. const text = await response.text();
  154. throw new Error(`Seedance task submission failed (${response.status}): ${text}`);
  155. }
  156. const data = (await response.json()) as SeedanceSubmitResponse;
  157. if (!data.id) {
  158. throw new Error('Seedance returned empty task ID');
  159. }
  160. return data.id;
  161. }
  162. /**
  163. * Poll the status of a Seedance video generation task.
  164. * Returns the result if complete, null if still running.
  165. * Throws on failure.
  166. */
  167. export async function pollSeedanceTask(
  168. config: VideoGenerationConfig,
  169. taskId: string,
  170. ): Promise<VideoGenerationResult | null> {
  171. const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
  172. const response = await fetch(`${baseUrl}/api/v3/contents/generations/tasks/${taskId}`, {
  173. method: 'GET',
  174. headers: {
  175. Authorization: `Bearer ${config.apiKey}`,
  176. },
  177. });
  178. if (!response.ok) {
  179. const text = await response.text();
  180. throw new Error(`Seedance poll failed (${response.status}): ${text}`);
  181. }
  182. const data = (await response.json()) as SeedancePollResponse;
  183. if (data.status === 'succeeded') {
  184. if (!data.content?.video_url) {
  185. throw new Error('Seedance task succeeded but no video URL returned');
  186. }
  187. const dims = estimateDimensions(data.ratio, data.resolution);
  188. return {
  189. url: data.content.video_url,
  190. duration: data.duration || 5,
  191. width: dims.width,
  192. height: dims.height,
  193. };
  194. }
  195. if (data.status === 'failed') {
  196. throw new Error(`Seedance video generation failed: ${data.error?.message || 'Unknown error'}`);
  197. }
  198. // queued or running
  199. return null;
  200. }
  201. /**
  202. * Generate a video using Seedance: submit task + poll until complete.
  203. */
  204. export async function generateWithSeedance(
  205. config: VideoGenerationConfig,
  206. options: VideoGenerationOptions,
  207. ): Promise<VideoGenerationResult> {
  208. const taskId = await submitSeedanceTask(config, options);
  209. for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
  210. await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
  211. const result = await pollSeedanceTask(config, taskId);
  212. if (result) return result;
  213. }
  214. throw new Error(
  215. `Seedance video generation timed out after ${(MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1000}s (task: ${taskId})`,
  216. );
  217. }