| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { |
| VideoGenerationConfig, |
| VideoGenerationOptions, |
| VideoGenerationResult, |
| } from '../types'; |
|
|
| const DEFAULT_MODEL = 'doubao-seedance-1-5-pro-251215'; |
| const DEFAULT_BASE_URL = 'https://ark.cn-beijing.volces.com'; |
| const POLL_INTERVAL_MS = 5000; |
| const MAX_POLL_ATTEMPTS = 60; |
|
|
| |
| interface SeedanceSubmitResponse { |
| id: string; |
| } |
|
|
| |
| interface SeedancePollResponse { |
| id: string; |
| model: string; |
| status: 'queued' | 'running' | 'succeeded' | 'failed' | string; |
| content?: { |
| video_url?: string; |
| }; |
| resolution?: string; |
| ratio?: string; |
| duration?: number; |
| framespersecond?: number; |
| error?: { |
| message: string; |
| code?: string; |
| }; |
| } |
|
|
| |
| |
| |
| |
| function toSeedanceRatio(aspectRatio?: string): string | undefined { |
| if (!aspectRatio) return undefined; |
| return aspectRatio; |
| } |
|
|
| |
| |
| |
| |
| function toSeedanceResolution(resolution?: string): string | undefined { |
| if (!resolution) return undefined; |
| return resolution; |
| } |
|
|
| |
| |
| |
| function estimateDimensions( |
| ratio?: string, |
| resolution?: string, |
| ): { width: number; height: number } { |
| const resMap: Record<string, number> = { |
| '480p': 480, |
| '720p': 720, |
| '1080p': 1080, |
| }; |
| const h = resMap[resolution || '720p'] || 720; |
|
|
| if (!ratio) return { width: Math.round((h * 16) / 9), height: h }; |
| const [w, hRatio] = ratio.split(':').map(Number); |
| if (!w || !hRatio) return { width: Math.round((h * 16) / 9), height: h }; |
| return { width: Math.round((h * w) / hRatio), height: h }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function testSeedanceConnectivity( |
| config: VideoGenerationConfig, |
| ): Promise<{ success: boolean; message: string }> { |
| const baseUrl = config.baseUrl || DEFAULT_BASE_URL; |
| try { |
| const response = await fetch( |
| `${baseUrl}/api/v3/contents/generations/tasks/connectivity-test-nonexistent`, |
| { |
| method: 'GET', |
| headers: { Authorization: `Bearer ${config.apiKey}` }, |
| }, |
| ); |
| |
| if (response.status === 401 || response.status === 403) { |
| const text = await response.text(); |
| return { |
| success: false, |
| message: `Seedance auth failed (${response.status}): ${text}`, |
| }; |
| } |
| return { success: true, message: 'Connected to Seedance' }; |
| } catch (err) { |
| return { success: false, message: `Seedance connectivity error: ${err}` }; |
| } |
| } |
|
|
| export async function submitSeedanceTask( |
| config: VideoGenerationConfig, |
| options: VideoGenerationOptions, |
| ): Promise<string> { |
| const baseUrl = config.baseUrl || DEFAULT_BASE_URL; |
|
|
| const body: Record<string, unknown> = { |
| model: config.model || DEFAULT_MODEL, |
| content: [ |
| { |
| type: 'text', |
| text: options.prompt, |
| }, |
| ], |
| watermark: false, |
| }; |
|
|
| const ratio = toSeedanceRatio(options.aspectRatio); |
| if (ratio) body.ratio = ratio; |
|
|
| if (options.duration) body.duration = options.duration; |
|
|
| const resolution = toSeedanceResolution(options.resolution); |
| if (resolution) body.resolution = resolution; |
|
|
| const response = await fetch(`${baseUrl}/api/v3/contents/generations/tasks`, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${config.apiKey}`, |
| }, |
| body: JSON.stringify(body), |
| }); |
|
|
| if (!response.ok) { |
| const text = await response.text(); |
| throw new Error(`Seedance task submission failed (${response.status}): ${text}`); |
| } |
|
|
| const data = (await response.json()) as SeedanceSubmitResponse; |
| if (!data.id) { |
| throw new Error('Seedance returned empty task ID'); |
| } |
|
|
| return data.id; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function pollSeedanceTask( |
| config: VideoGenerationConfig, |
| taskId: string, |
| ): Promise<VideoGenerationResult | null> { |
| const baseUrl = config.baseUrl || DEFAULT_BASE_URL; |
|
|
| const response = await fetch(`${baseUrl}/api/v3/contents/generations/tasks/${taskId}`, { |
| method: 'GET', |
| headers: { |
| Authorization: `Bearer ${config.apiKey}`, |
| }, |
| }); |
|
|
| if (!response.ok) { |
| const text = await response.text(); |
| throw new Error(`Seedance poll failed (${response.status}): ${text}`); |
| } |
|
|
| const data = (await response.json()) as SeedancePollResponse; |
|
|
| if (data.status === 'succeeded') { |
| if (!data.content?.video_url) { |
| throw new Error('Seedance task succeeded but no video URL returned'); |
| } |
| const dims = estimateDimensions(data.ratio, data.resolution); |
| return { |
| url: data.content.video_url, |
| duration: data.duration || 5, |
| width: dims.width, |
| height: dims.height, |
| }; |
| } |
|
|
| if (data.status === 'failed') { |
| throw new Error(`Seedance video generation failed: ${data.error?.message || 'Unknown error'}`); |
| } |
|
|
| |
| return null; |
| } |
|
|
| |
| |
| |
| export async function generateWithSeedance( |
| config: VideoGenerationConfig, |
| options: VideoGenerationOptions, |
| ): Promise<VideoGenerationResult> { |
| const taskId = await submitSeedanceTask(config, options); |
|
|
| for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { |
| await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); |
| const result = await pollSeedanceTask(config, taskId); |
| if (result) return result; |
| } |
|
|
| throw new Error( |
| `Seedance video generation timed out after ${(MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1000}s (task: ${taskId})`, |
| ); |
| } |
|
|