Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface ImageProviderConfig {
model?: string;
}
export interface VideoProviderConfig {
provider?: 'doubao';
provider?: 'doubao' | 'atlas';
base_url?: string;
api_key?: string;
model?: string;
Expand Down
75 changes: 65 additions & 10 deletions packages/tools/src/video/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { OvsConfig, VideoProviderConfig } from '@orkas/video-studio-core';

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

function atlasBase(cfg: VideoProviderConfig): string {
return (cfg.base_url ?? ATLAS_DEFAULT_BASE).replace(/\/+$/, '');
}

/** Build an Atlas Cloud media task request (`POST {base}/model/generateVideo`). */
export function buildAtlasCreateRequest(cfg: VideoProviderConfig, p: VideoParams): ProviderRequest {
if (!cfg.api_key) throw new Error('video: no api_key configured');
if (p.operation !== undefined && p.operation !== 'generate') {
throw new Error('video: Atlas Cloud currently supports the generate operation');
}
if (p.reference_video_urls?.length || p.reference_image_urls?.length) {
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');
}
return {
url: `${atlasBase(cfg)}/model/generateVideo`,
headers: { authorization: `Bearer ${cfg.api_key}`, 'content-type': 'application/json' },
body: {
model: p.model ?? cfg.model ?? ATLAS_DEFAULT_MODEL,
prompt: p.prompt,
duration,
resolution: p.resolution ?? '720p',
ratio: p.ratio ?? '16:9',
generate_audio: p.generate_audio !== false,
...(p.image_url ? { image: p.image_url } : {}),
},
};
}

/** Build the Doubao Seedance task-create request (`POST {base}/contents/generations/tasks`). */
export function buildSeedanceCreateRequest(cfg: VideoProviderConfig, p: VideoParams): ProviderRequest {
if (!cfg.api_key) throw new Error('video: no api_key configured');
Expand Down Expand Up @@ -88,6 +122,16 @@ interface PollResp {
content?: { video_url?: string };
error?: { message?: string };
}
interface AtlasResp {
code?: number;
data?: {
id?: string;
status?: string;
outputs?: string[];
output?: string | string[];
error?: string;
};
}

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

Expand Down Expand Up @@ -129,7 +173,7 @@ export function validateDownloadedVideo(buffer: Buffer): void {
}

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

const req = buildSeedanceCreateRequest(cfg, params);
const created = (await postJson(req.url, req.body, req.headers, POLL_TIMEOUT_MS)) as CreateResp;
const id = created.id;
const provider = cfg.provider ?? 'doubao';
const req = provider === 'atlas' ? buildAtlasCreateRequest(cfg, params) : buildSeedanceCreateRequest(cfg, params);
const created = (await postJson(req.url, req.body, req.headers, POLL_TIMEOUT_MS)) as CreateResp & AtlasResp;
const id = provider === 'atlas' ? created.data?.id : created.id;
if (!id) throw new Error('video: task create returned no id');

const base = arkBase(cfg);
const base = provider === 'atlas' ? atlasBase(cfg) : arkBase(cfg);
const authHeaders = { authorization: `Bearer ${cfg.api_key}` };
const start = now();

for (;;) {
if (now() - start > TASK_TIMEOUT_MS) throw new Error(`video: task ${id} timed out after ${TASK_TIMEOUT_MS}ms`);
const poll = (await getJson(`${base}/contents/generations/tasks/${id}`, authHeaders, POLL_TIMEOUT_MS)) as PollResp;
if (poll.status === 'succeeded') {
const url = poll.content?.video_url;
const pollUrl = provider === 'atlas'
? `${base}/model/prediction/${id}`
: `${base}/contents/generations/tasks/${id}`;
const response = (await getJson(pollUrl, authHeaders, POLL_TIMEOUT_MS)) as PollResp & AtlasResp;
const atlasPoll = provider === 'atlas' ? response.data : undefined;
const doubaoPoll = provider === 'atlas' ? undefined : response;
const status = atlasPoll?.status ?? doubaoPoll?.status;
const succeeded = status === 'succeeded' || status === 'completed';
if (succeeded) {
const atlasOutput = provider === 'atlas'
? (Array.isArray(atlasPoll?.output) ? atlasPoll.output[0] : atlasPoll?.output) ?? atlasPoll?.outputs?.[0]
: undefined;
const url = provider === 'atlas' ? atlasOutput : doubaoPoll?.content?.video_url;
if (!url) throw new Error(`video: task ${id} succeeded but returned no video_url`);
const dl = await fetchWithTimeout(url, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
if (!dl.ok) throw new Error(`video download failed with HTTP ${dl.status}`);
Expand All @@ -171,8 +226,8 @@ export async function generateVideo(params: VideoParams, config: OvsConfig = loa
}
return { output: resolve(params.output), bytes: buf.byteLength, task_id: id };
}
if (poll.status === 'failed' || poll.status === 'canceled') {
throw new Error(`video: task ${id} ${poll.status}`);
if (status === 'failed' || status === 'canceled') {
throw new Error(`video: task ${id} ${status}`);
}
await sleep(interval);
}
Expand Down
51 changes: 50 additions & 1 deletion packages/tools/test/gen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
compileImagePromptContract,
normalizeImageReferenceBindings,
} from '../src/image/image';
import { generateVideo, buildSeedanceCreateRequest, validateDownloadedVideo } from '../src/video/video';
import { generateVideo, buildAtlasCreateRequest, buildSeedanceCreateRequest, validateDownloadedVideo } from '../src/video/video';

const VALID_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
Expand Down Expand Up @@ -323,6 +323,55 @@ describe('generateVideo (Doubao Seedance task + poll)', () => {
});
});

describe('generateVideo (Atlas Cloud task + poll)', () => {
it('builds the Atlas Cloud media request', () => {
const req = buildAtlasCreateRequest(
{ provider: 'atlas', api_key: 'atlas-key' },
{ prompt: 'a sunrise', output: 'out.mp4', image_url: 'https://example.com/first.png' },
);
expect(req.url).toBe('https://api.atlascloud.ai/api/v1/model/generateVideo');
expect(req.headers.authorization).toBe('Bearer atlas-key');
expect(req.body).toMatchObject({
model: 'bytedance/seedance-2.0/text-to-video',
prompt: 'a sunrise',
image: 'https://example.com/first.png',
duration: 5,
resolution: '720p',
ratio: '16:9',
});
});

it('creates, polls, and downloads an Atlas Cloud result', 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-1', status: 'starting' } }));
} else if (req.method === 'GET' && url === '/model/prediction/atlas-1') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ code: 200, data: { id: 'atlas-1', status: 'completed', outputs: [`${srv.baseUrl}/atlas.mp4`] } }));
} else if (req.method === 'GET' && url === '/atlas.mp4') {
res.writeHead(200, { 'content-type': 'video/mp4' });
res.end(VALID_MP4);
} else {
res.writeHead(404);
res.end();
}
});
try {
const result = await generateVideo(
{ prompt: 'a sunrise', output: join(dir, 'atlas.mp4') },
{ video: { provider: 'atlas', base_url: srv.baseUrl, api_key: 'atlas-key' } },
{ pollIntervalMs: 1 },
);
expect(result.task_id).toBe('atlas-1');
expect(readFileSync(result.output)).toEqual(VALID_MP4);
} finally {
await srv.close();
}
});
});

// --- config env overlay ----------------------------------------------------

describe('config env overlay', () => {
Expand Down