Skip to content

Commit e6ccab6

Browse files
authored
Merge pull request #13 from binyangzhu000-sudo/codex/atlas-cloud-video-provider
feat(video): add Atlas Cloud provider
2 parents dd4a0f4 + d563d62 commit e6ccab6

3 files changed

Lines changed: 116 additions & 12 deletions

File tree

packages/core/src/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface ImageProviderConfig {
2121
model?: string;
2222
}
2323
export interface VideoProviderConfig {
24-
provider?: 'doubao';
24+
provider?: 'doubao' | 'atlas';
2525
base_url?: string;
2626
api_key?: string;
2727
model?: string;

packages/tools/src/video/video.ts

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { OvsConfig, VideoProviderConfig } from '@orkas/video-studio-core';
55

66
const ARK_DEFAULT_BASE = 'https://ark.cn-beijing.volces.com/api/v3';
77
const DEFAULT_MODEL = 'doubao-seedance-2-0-260128';
8+
const ATLAS_DEFAULT_BASE = 'https://api.atlascloud.ai/api/v1';
9+
const ATLAS_DEFAULT_MODEL = 'bytedance/seedance-2.0/text-to-video';
810
const POLL_INTERVAL_MS = 10_000;
911
const POLL_TIMEOUT_MS = 30_000; // per-poll request timeout — one slow poll must not fail the task
1012
const TASK_TIMEOUT_MS = 60 * 60 * 1000;
@@ -38,6 +40,38 @@ function arkBase(cfg: VideoProviderConfig): string {
3840
return (cfg.base_url ?? ARK_DEFAULT_BASE).replace(/\/+$/, '');
3941
}
4042

43+
function atlasBase(cfg: VideoProviderConfig): string {
44+
return (cfg.base_url ?? ATLAS_DEFAULT_BASE).replace(/\/+$/, '');
45+
}
46+
47+
/** Build an Atlas Cloud media task request (`POST {base}/model/generateVideo`). */
48+
export function buildAtlasCreateRequest(cfg: VideoProviderConfig, p: VideoParams): ProviderRequest {
49+
if (!cfg.api_key) throw new Error('video: no api_key configured');
50+
if (p.operation !== undefined && p.operation !== 'generate') {
51+
throw new Error('video: Atlas Cloud currently supports the generate operation');
52+
}
53+
if (p.reference_video_urls?.length || p.reference_image_urls?.length) {
54+
throw new Error('video: Atlas Cloud accepts a single first-frame image_url; additional references are not supported');
55+
}
56+
const duration = p.duration ?? 5;
57+
if (!Number.isFinite(duration) || duration < 4 || duration > 15) {
58+
throw new Error('video: duration must be between 4 and 15 seconds');
59+
}
60+
return {
61+
url: `${atlasBase(cfg)}/model/generateVideo`,
62+
headers: { authorization: `Bearer ${cfg.api_key}`, 'content-type': 'application/json' },
63+
body: {
64+
model: p.model ?? cfg.model ?? ATLAS_DEFAULT_MODEL,
65+
prompt: p.prompt,
66+
duration,
67+
resolution: p.resolution ?? '720p',
68+
ratio: p.ratio ?? '16:9',
69+
generate_audio: p.generate_audio !== false,
70+
...(p.image_url ? { image: p.image_url } : {}),
71+
},
72+
};
73+
}
74+
4175
/** Build the Doubao Seedance task-create request (`POST {base}/contents/generations/tasks`). */
4276
export function buildSeedanceCreateRequest(cfg: VideoProviderConfig, p: VideoParams): ProviderRequest {
4377
if (!cfg.api_key) throw new Error('video: no api_key configured');
@@ -88,6 +122,16 @@ interface PollResp {
88122
content?: { video_url?: string };
89123
error?: { message?: string };
90124
}
125+
interface AtlasResp {
126+
code?: number;
127+
data?: {
128+
id?: string;
129+
status?: string;
130+
outputs?: string[];
131+
output?: string | string[];
132+
error?: string;
133+
};
134+
}
91135

92136
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
93137

@@ -129,7 +173,7 @@ export function validateDownloadedVideo(buffer: Buffer): void {
129173
}
130174

131175
/**
132-
* Generate a video with the configured BYO provider (Doubao Seedance): create an
176+
* Generate a video with the configured BYO provider (Doubao or Atlas Cloud): create an
133177
* async task, poll until it succeeds, then download the result. Text-to-video by
134178
* default; pass a PUBLIC `image_url` for image-to-video.
135179
*/
@@ -141,20 +185,31 @@ export async function generateVideo(params: VideoParams, config: OvsConfig = loa
141185
const now = opts.now ?? Date.now;
142186
const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
143187

144-
const req = buildSeedanceCreateRequest(cfg, params);
145-
const created = (await postJson(req.url, req.body, req.headers, POLL_TIMEOUT_MS)) as CreateResp;
146-
const id = created.id;
188+
const provider = cfg.provider ?? 'doubao';
189+
const req = provider === 'atlas' ? buildAtlasCreateRequest(cfg, params) : buildSeedanceCreateRequest(cfg, params);
190+
const created = (await postJson(req.url, req.body, req.headers, POLL_TIMEOUT_MS)) as CreateResp & AtlasResp;
191+
const id = provider === 'atlas' ? created.data?.id : created.id;
147192
if (!id) throw new Error('video: task create returned no id');
148193

149-
const base = arkBase(cfg);
194+
const base = provider === 'atlas' ? atlasBase(cfg) : arkBase(cfg);
150195
const authHeaders = { authorization: `Bearer ${cfg.api_key}` };
151196
const start = now();
152197

153198
for (;;) {
154199
if (now() - start > TASK_TIMEOUT_MS) throw new Error(`video: task ${id} timed out after ${TASK_TIMEOUT_MS}ms`);
155-
const poll = (await getJson(`${base}/contents/generations/tasks/${id}`, authHeaders, POLL_TIMEOUT_MS)) as PollResp;
156-
if (poll.status === 'succeeded') {
157-
const url = poll.content?.video_url;
200+
const pollUrl = provider === 'atlas'
201+
? `${base}/model/prediction/${id}`
202+
: `${base}/contents/generations/tasks/${id}`;
203+
const response = (await getJson(pollUrl, authHeaders, POLL_TIMEOUT_MS)) as PollResp & AtlasResp;
204+
const atlasPoll = provider === 'atlas' ? response.data : undefined;
205+
const doubaoPoll = provider === 'atlas' ? undefined : response;
206+
const status = atlasPoll?.status ?? doubaoPoll?.status;
207+
const succeeded = status === 'succeeded' || status === 'completed';
208+
if (succeeded) {
209+
const atlasOutput = provider === 'atlas'
210+
? (Array.isArray(atlasPoll?.output) ? atlasPoll.output[0] : atlasPoll?.output) ?? atlasPoll?.outputs?.[0]
211+
: undefined;
212+
const url = provider === 'atlas' ? atlasOutput : doubaoPoll?.content?.video_url;
158213
if (!url) throw new Error(`video: task ${id} succeeded but returned no video_url`);
159214
const dl = await fetchWithTimeout(url, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
160215
if (!dl.ok) throw new Error(`video download failed with HTTP ${dl.status}`);
@@ -171,8 +226,8 @@ export async function generateVideo(params: VideoParams, config: OvsConfig = loa
171226
}
172227
return { output: resolve(params.output), bytes: buf.byteLength, task_id: id };
173228
}
174-
if (poll.status === 'failed' || poll.status === 'canceled') {
175-
throw new Error(`video: task ${id} ${poll.status}`);
229+
if (status === 'failed' || status === 'canceled') {
230+
throw new Error(`video: task ${id} ${status}`);
176231
}
177232
await sleep(interval);
178233
}

packages/tools/test/gen.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
compileImagePromptContract,
1414
normalizeImageReferenceBindings,
1515
} from '../src/image/image';
16-
import { generateVideo, buildSeedanceCreateRequest, validateDownloadedVideo } from '../src/video/video';
16+
import { generateVideo, buildAtlasCreateRequest, buildSeedanceCreateRequest, validateDownloadedVideo } from '../src/video/video';
1717

1818
const VALID_PNG = Buffer.from(
1919
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@@ -323,6 +323,55 @@ describe('generateVideo (Doubao Seedance task + poll)', () => {
323323
});
324324
});
325325

326+
describe('generateVideo (Atlas Cloud task + poll)', () => {
327+
it('builds the Atlas Cloud media request', () => {
328+
const req = buildAtlasCreateRequest(
329+
{ provider: 'atlas', api_key: 'atlas-key' },
330+
{ prompt: 'a sunrise', output: 'out.mp4', image_url: 'https://example.com/first.png' },
331+
);
332+
expect(req.url).toBe('https://api.atlascloud.ai/api/v1/model/generateVideo');
333+
expect(req.headers.authorization).toBe('Bearer atlas-key');
334+
expect(req.body).toMatchObject({
335+
model: 'bytedance/seedance-2.0/text-to-video',
336+
prompt: 'a sunrise',
337+
image: 'https://example.com/first.png',
338+
duration: 5,
339+
resolution: '720p',
340+
ratio: '16:9',
341+
});
342+
});
343+
344+
it('creates, polls, and downloads an Atlas Cloud result', async () => {
345+
const srv = await startServer((req, res) => {
346+
const url = req.url ?? '';
347+
if (req.method === 'POST' && url === '/model/generateVideo') {
348+
res.writeHead(200, { 'content-type': 'application/json' });
349+
res.end(JSON.stringify({ code: 200, data: { id: 'atlas-1', status: 'starting' } }));
350+
} else if (req.method === 'GET' && url === '/model/prediction/atlas-1') {
351+
res.writeHead(200, { 'content-type': 'application/json' });
352+
res.end(JSON.stringify({ code: 200, data: { id: 'atlas-1', status: 'completed', outputs: [`${srv.baseUrl}/atlas.mp4`] } }));
353+
} else if (req.method === 'GET' && url === '/atlas.mp4') {
354+
res.writeHead(200, { 'content-type': 'video/mp4' });
355+
res.end(VALID_MP4);
356+
} else {
357+
res.writeHead(404);
358+
res.end();
359+
}
360+
});
361+
try {
362+
const result = await generateVideo(
363+
{ prompt: 'a sunrise', output: join(dir, 'atlas.mp4') },
364+
{ video: { provider: 'atlas', base_url: srv.baseUrl, api_key: 'atlas-key' } },
365+
{ pollIntervalMs: 1 },
366+
);
367+
expect(result.task_id).toBe('atlas-1');
368+
expect(readFileSync(result.output)).toEqual(VALID_MP4);
369+
} finally {
370+
await srv.close();
371+
}
372+
});
373+
});
374+
326375
// --- config env overlay ----------------------------------------------------
327376

328377
describe('config env overlay', () => {

0 commit comments

Comments
 (0)