211 lines
5.9 KiB
TypeScript
211 lines
5.9 KiB
TypeScript
/**
|
|
* Grok (xAI) Video Generation Adapter
|
|
*
|
|
* Async task pattern: submit → poll → return video URL.
|
|
*
|
|
* REST endpoints:
|
|
* - Submit: POST /v1/videos/generations
|
|
* - Poll: GET /v1/videos/{request_id}
|
|
*
|
|
* Supported models:
|
|
* - grok-imagine-video ($0.05/sec)
|
|
*
|
|
* Authentication: Bearer token via Authorization header
|
|
*
|
|
* API docs: https://docs.x.ai/developers/rest-api-reference/inference/videos
|
|
*/
|
|
|
|
import type {
|
|
VideoGenerationConfig,
|
|
VideoGenerationOptions,
|
|
VideoGenerationResult,
|
|
} from '../types';
|
|
import { probeAuth } from '../probe-auth';
|
|
import { runPolledTask } from '../polled-task';
|
|
|
|
const DEFAULT_MODEL = 'grok-imagine-video';
|
|
const DEFAULT_BASE_URL = 'https://api.x.ai/v1';
|
|
const POLL_INTERVAL_MS = 10_000; // 10 seconds
|
|
const MAX_POLL_ATTEMPTS = 60; // 10 minutes max
|
|
|
|
/** Dimension defaults per aspect ratio */
|
|
function getDimensions(aspectRatio?: string): {
|
|
width: number;
|
|
height: number;
|
|
} {
|
|
switch (aspectRatio) {
|
|
case '9:16':
|
|
return { width: 720, height: 1280 };
|
|
case '1:1':
|
|
return { width: 1080, height: 1080 };
|
|
case '4:3':
|
|
return { width: 1024, height: 768 };
|
|
default:
|
|
return { width: 1280, height: 720 }; // 16:9
|
|
}
|
|
}
|
|
|
|
/** Common headers for all Grok Video API calls */
|
|
function apiHeaders(apiKey: string): Record<string, string> {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${apiKey}`,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REST types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface GrokVideoSubmitResponse {
|
|
request_id: string;
|
|
}
|
|
|
|
interface GrokVideoPollResponse {
|
|
status: string; // "pending" | "done" | "failed"
|
|
progress?: number; // 0-100
|
|
video?: {
|
|
url: string;
|
|
duration: number;
|
|
respect_moderation?: boolean;
|
|
};
|
|
model?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connectivity test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Lightweight connectivity test — validates API key by making a minimal
|
|
* request that triggers auth check. 401/403 means key invalid.
|
|
*/
|
|
export async function testGrokVideoConnectivity(
|
|
config: VideoGenerationConfig,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
return probeAuth({
|
|
providerName: 'Grok Video',
|
|
request: () =>
|
|
fetch(`${baseUrl}/videos/generations`, {
|
|
method: 'POST',
|
|
redirect: 'manual',
|
|
headers: apiHeaders(config.apiKey),
|
|
body: JSON.stringify({
|
|
model: config.model || DEFAULT_MODEL,
|
|
prompt: '',
|
|
}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Submit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function submitVideoGeneration(
|
|
baseUrl: string,
|
|
apiKey: string,
|
|
model: string,
|
|
options: VideoGenerationOptions,
|
|
): Promise<string> {
|
|
const body: Record<string, unknown> = {
|
|
model,
|
|
prompt: options.prompt,
|
|
};
|
|
|
|
if (options.duration) body.duration = options.duration;
|
|
|
|
const response = await fetch(`${baseUrl}/videos/generations`, {
|
|
method: 'POST',
|
|
headers: apiHeaders(apiKey),
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Grok video submit failed (${response.status}): ${text}`);
|
|
}
|
|
|
|
const data = (await response.json()) as GrokVideoSubmitResponse;
|
|
if (!data.request_id) {
|
|
throw new Error('Grok video returned empty request_id');
|
|
}
|
|
|
|
return data.request_id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Poll
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function pollVideoStatus(
|
|
baseUrl: string,
|
|
apiKey: string,
|
|
requestId: string,
|
|
): Promise<GrokVideoPollResponse> {
|
|
const response = await fetch(`${baseUrl}/videos/${requestId}`, {
|
|
method: 'GET',
|
|
headers: apiHeaders(apiKey),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Grok video poll failed (${response.status}): ${text}`);
|
|
}
|
|
|
|
return response.json() as Promise<GrokVideoPollResponse>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function generateWithGrokVideo(
|
|
config: VideoGenerationConfig,
|
|
options: VideoGenerationOptions,
|
|
): Promise<VideoGenerationResult> {
|
|
const model = config.model || DEFAULT_MODEL;
|
|
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
|
|
return runPolledTask<VideoGenerationResult>({
|
|
submit: async () => ({
|
|
status: 'submitted',
|
|
taskId: await submitVideoGeneration(baseUrl, config.apiKey, model, options),
|
|
}),
|
|
poll: async (requestId) => {
|
|
const result = await pollVideoStatus(baseUrl, config.apiKey, requestId);
|
|
|
|
if (result.status === 'done') {
|
|
if (!result.video?.url) {
|
|
throw new Error('Grok video task completed but no video URL returned');
|
|
}
|
|
const { width, height } = getDimensions(options.aspectRatio);
|
|
return {
|
|
status: 'done',
|
|
result: {
|
|
url: result.video.url,
|
|
duration: result.video.duration || options.duration || 6,
|
|
width,
|
|
height,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (result.status === 'failed') {
|
|
return {
|
|
status: 'failed',
|
|
message: `Grok video generation failed: ${JSON.stringify(result)}`,
|
|
};
|
|
}
|
|
|
|
return { status: 'pending' };
|
|
},
|
|
intervalMs: POLL_INTERVAL_MS,
|
|
maxAttempts: MAX_POLL_ATTEMPTS,
|
|
label: 'Grok video generation',
|
|
formatTimeout: ({ taskId, elapsedMs }) =>
|
|
`Grok video generation timed out after ${elapsedMs / 1000}s (request: ${taskId})`,
|
|
});
|
|
}
|