diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 603afe4..01a42bb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -637,9 +637,9 @@ const videoCmd = defineCommand({ 'video-urls': { type: 'string', description: 'comma-separated public source video URLs (max 3)' }, operation: { type: 'string', default: 'generate', description: 'generate | edit' }, quality: { type: 'string', description: 'economy | balanced | quality (provider-neutral intent)' }, - ratio: { type: 'string', default: '16:9', description: '16:9 | 9:16 | 1:1 | 4:3 | 3:4 | 21:9' }, - duration: { type: 'string', default: '5', description: '4-15 seconds' }, - resolution: { type: 'string', default: '720p', description: '480p | 720p | 1080p' }, + ratio: { type: 'string', default: '16:9', description: '16:9 | 9:16 | 1:1 | 4:3 | 3:4 | 21:9 | adaptive (Atlas-only)' }, + duration: { type: 'string', default: '5', description: '4-15 seconds, or -1 (Atlas-only) for a provider-chosen length' }, + resolution: { type: 'string', default: '720p', description: '480p | 720p | 1080p; -SR variants and 4k are Atlas-only' }, 'generate-audio': { type: 'boolean', default: true }, }, async run({ args }) { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 7ef305a..b2faa71 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -254,7 +254,7 @@ server.tool( ); server.tool( 'video', - 'Generate a video clip via the configured BYO provider (Doubao Seedance), with exact Gate C settings.', + 'Generate a video clip via the configured BYO provider (Doubao Seedance or Atlas Cloud), with exact Gate C settings.', { prompt: z.string(), output: z.string(), @@ -264,9 +264,9 @@ server.tool( reference_video_urls: z.array(z.string()).max(3).optional(), operation: z.enum(['generate', 'edit']).optional(), quality: z.enum(['economy', 'balanced', 'quality']).optional(), - ratio: z.enum(['16:9', '9:16', '1:1', '4:3', '3:4', '21:9']).optional(), - duration: z.number().min(4).max(15).optional(), - resolution: z.enum(['480p', '720p', '1080p']).optional(), + ratio: z.enum(['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive']).optional().describe('adaptive is Atlas-only (follows the source/first frame)'), + duration: z.union([z.literal(-1), z.number().min(4).max(15)]).optional().describe('4-15 seconds; -1 (Atlas-only) lets the provider choose'), + resolution: z.enum(['480p', '720p', '1080p', '720p-SR', '1080p-SR', '1440p-SR', '4k']).optional().describe('the -SR variants and 4k are Atlas-only'), generate_audio: z.boolean().optional(), }, (a) => format(video.generateVideo(a)), diff --git a/packages/tools/src/video/video.ts b/packages/tools/src/video/video.ts index 6b53385..5eb6cb8 100644 --- a/packages/tools/src/video/video.ts +++ b/packages/tools/src/video/video.ts @@ -28,9 +28,13 @@ export interface VideoParams { reference_video_urls?: string[]; operation?: 'generate' | 'edit'; quality?: 'economy' | 'balanced' | 'quality'; - ratio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9'; + /** `adaptive` (Atlas: follow the source/first frame) is Atlas-only; other + * providers reject it with their own error. */ + ratio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive'; + /** 4-15 seconds; Atlas additionally accepts -1 (provider-chosen length). */ duration?: number; - resolution?: '480p' | '720p' | '1080p'; + /** The `-SR` super-resolution variants and `4k` are Atlas-only. */ + resolution?: '480p' | '720p' | '1080p' | '720p-SR' | '1080p-SR' | '1440p-SR' | '4k'; generate_audio?: boolean; } @@ -58,8 +62,8 @@ export function buildAtlasCreateRequest(cfg: VideoProviderConfig, p: VideoParams throw new Error('video: Atlas Cloud accepts a single first-frame image_url; additional references are not supported'); } const duration = p.duration ?? 5; - if (!Number.isFinite(duration) || duration < 4 || duration > 15) { - throw new Error('video: duration must be between 4 and 15 seconds'); + if (duration !== -1 && (!Number.isFinite(duration) || duration < 4 || duration > 15)) { + throw new Error('video: duration must be between 4 and 15 seconds, or -1 for a provider-chosen length'); } // Default the model by task type, and fail closed on an explicit mismatch: // a text-to-video model given a first frame would silently produce a video @@ -194,7 +198,7 @@ export function validateDownloadedVideo(buffer: Buffer): void { export async function generateVideo(params: VideoParams, config: OvsConfig = loadConfig(), opts: GenerateVideoOpts = {}): Promise { const cfg = config.video; if (!cfg?.api_key) { - throw new Error('No video provider configured. Set video.api_key (provider=doubao) in config, or OVS_VIDEO_* env vars.'); + throw new Error('No video provider configured. Set video.api_key (provider=doubao or provider=atlas) in config, or OVS_VIDEO_* env vars.'); } const now = opts.now ?? Date.now; const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS; @@ -241,7 +245,9 @@ export async function generateVideo(params: VideoParams, config: OvsConfig = loa return { output: resolve(params.output), bytes: buf.byteLength, task_id: id }; } if (status === 'failed' || status === 'canceled') { - throw new Error(`video: task ${id} ${status}`); + // The provider already said WHY; a refusal must carry that detail. + const detail = provider === 'atlas' ? atlasPoll?.error : doubaoPoll?.error?.message; + throw new Error(`video: task ${id} ${status}${detail ? `: ${detail}` : ''}`); } await sleep(interval); } diff --git a/packages/tools/test/gen.test.ts b/packages/tools/test/gen.test.ts index 70caa75..70619e3 100644 --- a/packages/tools/test/gen.test.ts +++ b/packages/tools/test/gen.test.ts @@ -323,6 +323,38 @@ describe('generateVideo (Doubao Seedance task + poll)', () => { }); }); +describe('generateVideo provider errors', () => { + it('names both providers when none is configured', async () => { + await expect(generateVideo({ prompt: 'x', output: join(dir, 'none.mp4') }, {})) + .rejects.toThrow(/provider=doubao or provider=atlas/); + }); + + it('carries Doubao\'s failure detail instead of a bare "failed"', async () => { + const srv = await startServer((req, res) => { + const url = req.url ?? ''; + if (req.method === 'POST' && url === '/contents/generations/tasks') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ id: 't9' })); + } else if (req.method === 'GET' && url === '/contents/generations/tasks/t9') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'failed', error: { message: 'quota exceeded' } })); + } else { + res.writeHead(404); + res.end(); + } + }); + try { + await expect(generateVideo( + { prompt: 'a dog', output: join(dir, 'never2.mp4') }, + { video: { provider: 'doubao', base_url: srv.baseUrl, api_key: 'sk' } }, + { pollIntervalMs: 1 }, + )).rejects.toThrow(/failed: quota exceeded/); + } finally { + await srv.close(); + } + }); +}); + describe('generateVideo (Atlas Cloud task + poll)', () => { it('defaults the model by task type: image-to-video when a first frame is passed', () => { const req = buildAtlasCreateRequest( @@ -352,6 +384,43 @@ describe('generateVideo (Atlas Cloud task + poll)', () => { expect(req.body).not.toHaveProperty('image'); }); + it('accepts the Atlas-only knobs: duration -1, adaptive ratio, SR resolutions', () => { + const req = buildAtlasCreateRequest( + { provider: 'atlas', api_key: 'atlas-key' }, + { prompt: 'a sunrise', output: 'out.mp4', duration: -1, ratio: 'adaptive', resolution: '1080p-SR' }, + ); + expect(req.body).toMatchObject({ duration: -1, ratio: 'adaptive', resolution: '1080p-SR' }); + expect(() => buildAtlasCreateRequest( + { provider: 'atlas', api_key: 'atlas-key' }, + { prompt: 'a sunrise', output: 'out.mp4', duration: 3 }, + )).toThrow(/between 4 and 15 seconds, or -1/); + }); + + it('carries the provider\'s failure detail instead of a bare "failed"', async () => { + const srv = await startServer((req, res) => { + const url = req.url ?? ''; + if (req.method === 'POST' && url === '/model/generateVideo') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ code: 200, data: { id: 'atlas-9', status: 'starting' } })); + } else if (req.method === 'GET' && url === '/model/prediction/atlas-9') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ code: 200, data: { id: 'atlas-9', status: 'failed', error: 'content policy violation' } })); + } else { + res.writeHead(404); + res.end(); + } + }); + try { + await expect(generateVideo( + { prompt: 'a sunrise', output: join(dir, 'never.mp4') }, + { video: { provider: 'atlas', base_url: srv.baseUrl, api_key: 'atlas-key' } }, + { pollIntervalMs: 1 }, + )).rejects.toThrow(/failed: content policy violation/); + } finally { + await srv.close(); + } + }); + it('fails closed on an explicit model/task mismatch instead of shipping the wrong video', () => { expect(() => buildAtlasCreateRequest( { provider: 'atlas', api_key: 'atlas-key', model: 'bytedance/seedance-2.0/text-to-video' },