Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
8 changes: 4 additions & 4 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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)),
Expand Down
18 changes: 12 additions & 6 deletions packages/tools/src/video/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -194,7 +198,7 @@ export function validateDownloadedVideo(buffer: Buffer): void {
export async function generateVideo(params: VideoParams, config: OvsConfig = loadConfig(), opts: GenerateVideoOpts = {}): Promise<VideoResult> {
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;
Expand Down Expand Up @@ -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);
}
Expand Down
69 changes: 69 additions & 0 deletions packages/tools/test/gen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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' },
Expand Down
Loading