| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- /**
- * Seedance (ByteDance / Doubao / Ark) Video Generation Adapter
- *
- * Uses async task pattern: submit task → poll until succeeded → get video URL.
- * Endpoint: https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks
- *
- * Request format (text-to-video):
- * POST /api/v3/contents/generations/tasks
- * {
- * "model": "doubao-seedance-1-5-pro-251215",
- * "content": [{ "type": "text", "text": "prompt here" }],
- * "ratio": "16:9",
- * "duration": 5,
- * "resolution": "1080p",
- * "watermark": false
- * }
- *
- * Supported models:
- * - doubao-seedance-1-5-pro-251215 (latest, 4~12s)
- * - doubao-seedance-1-0-pro-250528 (stable, 2~12s)
- * - doubao-seedance-1-0-pro-fast-251015 (faster, 2~12s)
- * - doubao-seedance-1-0-lite-t2v-250428 (lightweight, 2~12s)
- *
- * API docs: https://www.volcengine.com/docs/6492/2165104
- */
- 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; // 5 minutes max
- /** Response shape for task creation (only returns id) */
- interface SeedanceSubmitResponse {
- id: string;
- }
- /** Response shape for task polling */
- 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;
- };
- }
- /**
- * Map aspect ratio to Seedance ratio format.
- * Seedance uses the same "W:H" format we already have.
- */
- function toSeedanceRatio(aspectRatio?: string): string | undefined {
- if (!aspectRatio) return undefined;
- return aspectRatio; // Already in "16:9" format
- }
- /**
- * Map resolution to Seedance format.
- * Seedance expects "480p", "720p", "1080p".
- */
- function toSeedanceResolution(resolution?: string): string | undefined {
- if (!resolution) return undefined;
- return resolution; // Already in "720p" format
- }
- /**
- * Estimate video dimensions from ratio and resolution for the result.
- */
- 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 };
- }
- /**
- * Submit a video generation task to Seedance API.
- * Returns the task ID for polling.
- */
- /**
- * Lightweight connectivity test — validates API key by making a GET request
- * to poll a non-existent task. If auth fails we get 401/403; if auth succeeds
- * we get 404 (task not found), confirming the key is valid.
- */
- 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}` },
- },
- );
- // 401/403 means key invalid; anything else (404, 400, 200) means key works
- 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;
- }
- /**
- * Poll the status of a Seedance video generation task.
- * Returns the result if complete, null if still running.
- * Throws on failure.
- */
- 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'}`);
- }
- // queued or running
- return null;
- }
- /**
- * Generate a video using Seedance: submit task + poll until complete.
- */
- 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})`,
- );
- }
|