263 lines
8.0 KiB
TypeScript
263 lines
8.0 KiB
TypeScript
/**
|
|
* Veo (Google) Video Generation Adapter
|
|
*
|
|
* Direct REST API calls for video generation with Google's Veo models.
|
|
* Async task pattern: submit → poll → return inline base64 video.
|
|
*
|
|
* REST endpoints (Gemini API):
|
|
* - Submit: POST /v1beta/models/{model}:predictLongRunning
|
|
* - Poll: POST /v1beta/models/{model}:fetchPredictOperation { operationName }
|
|
* Returns inline base64 video data in response.videos[]
|
|
*
|
|
* Supported models:
|
|
* - veo-3.1-fast-generate-001 (fast, $0.15/sec)
|
|
* - veo-3.1-generate-001 (quality, $0.40/sec)
|
|
* - veo-3.0-fast-generate-001 (fast, $0.15/sec)
|
|
* - veo-3.0-generate-001 (quality, $0.40/sec)
|
|
* - veo-2.0-generate-001 (legacy, $0.50/sec)
|
|
*
|
|
* Authentication: x-goog-api-key header
|
|
*
|
|
* Stateless: video content is returned as a base64 data URL.
|
|
* No files are saved on the server.
|
|
*/
|
|
|
|
import type {
|
|
VideoGenerationConfig,
|
|
VideoGenerationOptions,
|
|
VideoGenerationResult,
|
|
} from '../types';
|
|
import { runPolledTask, type TerminalResult } from '../polled-task';
|
|
|
|
const DEFAULT_MODEL = 'veo-3.0-generate-001';
|
|
const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com';
|
|
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 Veo API calls */
|
|
function apiHeaders(apiKey: string): Record<string, string> {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'x-goog-api-key': apiKey,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REST types (matches official Gemini API response format)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface VeoOperation {
|
|
name: string;
|
|
done?: boolean;
|
|
response?: {
|
|
/** fetchPredictOperation returns inline base64 video data */
|
|
videos?: Array<{
|
|
bytesBase64Encoded?: string; // base64-encoded video bytes
|
|
mimeType?: string; // e.g. "video/mp4"
|
|
}>;
|
|
};
|
|
error?: { code: number; message: string; status: string };
|
|
}
|
|
|
|
function resolveCompletedOperation(
|
|
operation: VeoOperation,
|
|
options: VideoGenerationOptions,
|
|
): TerminalResult<VideoGenerationResult> {
|
|
if (operation.error) {
|
|
return {
|
|
status: 'failed',
|
|
message: `Veo generation failed: ${operation.error.code} - ${operation.error.message}`,
|
|
};
|
|
}
|
|
|
|
const videos = operation.response?.videos;
|
|
if (!videos || videos.length === 0) {
|
|
throw new Error('Veo returned no generated videos');
|
|
}
|
|
|
|
const first = videos[0];
|
|
if (!first.bytesBase64Encoded) {
|
|
throw new Error('Veo returned video entry without data');
|
|
}
|
|
|
|
const mimeType = first.mimeType || 'video/mp4';
|
|
const { width, height } = getDimensions(options.aspectRatio);
|
|
|
|
return {
|
|
status: 'done',
|
|
result: {
|
|
url: `data:${mimeType};base64,${first.bytesBase64Encoded}`,
|
|
duration: options.duration || 8,
|
|
width,
|
|
height,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Submit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function submitVideoGeneration(
|
|
baseUrl: string,
|
|
apiKey: string,
|
|
model: string,
|
|
options: VideoGenerationOptions,
|
|
): Promise<VeoOperation> {
|
|
const url = `${baseUrl}/v1beta/models/${model}:predictLongRunning`;
|
|
|
|
const body: Record<string, unknown> = {
|
|
instances: [{ prompt: options.prompt }],
|
|
};
|
|
|
|
// Parameters are optional — only include if we have values
|
|
const parameters: Record<string, unknown> = {};
|
|
if (options.aspectRatio) parameters.aspectRatio = options.aspectRatio;
|
|
if (options.duration) parameters.durationSeconds = options.duration;
|
|
if (Object.keys(parameters).length > 0) {
|
|
body.parameters = parameters;
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: apiHeaders(apiKey),
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Veo submit failed (${response.status}): ${text}`);
|
|
}
|
|
|
|
return response.json() as Promise<VeoOperation>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Poll
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function pollOperation(
|
|
baseUrl: string,
|
|
apiKey: string,
|
|
model: string,
|
|
operationName: string,
|
|
): Promise<VeoOperation> {
|
|
const url = `${baseUrl}/v1beta/models/${model}:fetchPredictOperation`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: apiHeaders(apiKey),
|
|
body: JSON.stringify({ operationName }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Veo poll failed (${response.status}): ${text}`);
|
|
}
|
|
|
|
return response.json() as Promise<VeoOperation>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Lightweight connectivity test — validates API key by fetching model info.
|
|
* Uses GET /v1beta/models/{model} which does not trigger generation.
|
|
*/
|
|
export async function testVeoConnectivity(
|
|
config: VideoGenerationConfig,
|
|
): Promise<{ success: boolean; message: string }> {
|
|
const model = config.model || DEFAULT_MODEL;
|
|
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
const url = `${baseUrl}/v1beta/models`;
|
|
|
|
// Try ?key= query param first (direct Google API), fall back to x-goog-api-key header (proxy)
|
|
let response: Response | null = null;
|
|
try {
|
|
response = await fetch(`${url}?key=${config.apiKey}`, {
|
|
method: 'GET',
|
|
redirect: 'manual',
|
|
});
|
|
} catch {
|
|
// Direct API unreachable, try header auth
|
|
}
|
|
if (!response || !response.ok) {
|
|
try {
|
|
response = await fetch(url, {
|
|
method: 'GET',
|
|
redirect: 'manual',
|
|
headers: { 'x-goog-api-key': config.apiKey },
|
|
});
|
|
} catch (_err) {
|
|
return {
|
|
success: false,
|
|
message: `Network error: unable to reach ${baseUrl}. Check your Base URL and network connection.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (response.ok) {
|
|
return { success: true, message: `Connected to Veo (${model})` };
|
|
}
|
|
|
|
// Parse error body for user-friendly message
|
|
const text = await response.text().catch(() => '');
|
|
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
return {
|
|
success: false,
|
|
message: `Invalid API key or unauthorized (${response.status}). Check your API Key and Base URL match the same provider.`,
|
|
};
|
|
}
|
|
return {
|
|
success: false,
|
|
message: `Veo connectivity failed (${response.status}): ${text}`,
|
|
};
|
|
}
|
|
|
|
export async function generateWithVeo(
|
|
config: VideoGenerationConfig,
|
|
options: VideoGenerationOptions,
|
|
): Promise<VideoGenerationResult> {
|
|
const model = config.model || DEFAULT_MODEL;
|
|
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
|
|
return runPolledTask<VideoGenerationResult>({
|
|
submit: async () => {
|
|
const operation = await submitVideoGeneration(baseUrl, config.apiKey, model, options);
|
|
if (!operation.name) {
|
|
throw new Error('Veo returned operation without name');
|
|
}
|
|
return operation.done
|
|
? resolveCompletedOperation(operation, options)
|
|
: { status: 'submitted', taskId: operation.name };
|
|
},
|
|
poll: async (operationName) => {
|
|
const operation = await pollOperation(baseUrl, config.apiKey, model, operationName);
|
|
return operation.done ? resolveCompletedOperation(operation, options) : { status: 'pending' };
|
|
},
|
|
intervalMs: POLL_INTERVAL_MS,
|
|
maxAttempts: MAX_POLL_ATTEMPTS,
|
|
label: 'Veo video generation',
|
|
formatTimeout: () => 'Veo video generation timed out after 10 minutes',
|
|
});
|
|
}
|