veo-adapter.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. /**
  2. * Veo (Google) Video Generation Adapter
  3. *
  4. * Direct REST API calls for video generation with Google's Veo models.
  5. * Async task pattern: submit → poll → return inline base64 video.
  6. *
  7. * REST endpoints (Gemini API):
  8. * - Submit: POST /v1beta/models/{model}:predictLongRunning
  9. * - Poll: POST /v1beta/models/{model}:fetchPredictOperation { operationName }
  10. * Returns inline base64 video data in response.videos[]
  11. *
  12. * Supported models:
  13. * - veo-3.1-fast-generate-001 (fast, $0.15/sec)
  14. * - veo-3.1-generate-001 (quality, $0.40/sec)
  15. * - veo-3.0-fast-generate-001 (fast, $0.15/sec)
  16. * - veo-3.0-generate-001 (quality, $0.40/sec)
  17. * - veo-2.0-generate-001 (legacy, $0.50/sec)
  18. *
  19. * Authentication: x-goog-api-key header
  20. *
  21. * Stateless: video content is returned as a base64 data URL.
  22. * No files are saved on the server.
  23. */
  24. import type {
  25. VideoGenerationConfig,
  26. VideoGenerationOptions,
  27. VideoGenerationResult,
  28. } from '../types';
  29. const DEFAULT_MODEL = 'veo-3.0-generate-001';
  30. const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com';
  31. const POLL_INTERVAL_MS = 10_000; // 10 seconds
  32. const MAX_POLL_ATTEMPTS = 60; // 10 minutes max
  33. function delay(ms: number): Promise<void> {
  34. return new Promise((resolve) => setTimeout(resolve, ms));
  35. }
  36. /** Dimension defaults per aspect ratio */
  37. function getDimensions(aspectRatio?: string): {
  38. width: number;
  39. height: number;
  40. } {
  41. switch (aspectRatio) {
  42. case '9:16':
  43. return { width: 720, height: 1280 };
  44. case '1:1':
  45. return { width: 1080, height: 1080 };
  46. case '4:3':
  47. return { width: 1024, height: 768 };
  48. default:
  49. return { width: 1280, height: 720 }; // 16:9
  50. }
  51. }
  52. /** Common headers for all Veo API calls */
  53. function apiHeaders(apiKey: string): Record<string, string> {
  54. return {
  55. 'Content-Type': 'application/json',
  56. 'x-goog-api-key': apiKey,
  57. };
  58. }
  59. // ---------------------------------------------------------------------------
  60. // REST types (matches official Gemini API response format)
  61. // ---------------------------------------------------------------------------
  62. interface VeoOperation {
  63. name: string;
  64. done?: boolean;
  65. response?: {
  66. /** fetchPredictOperation returns inline base64 video data */
  67. videos?: Array<{
  68. bytesBase64Encoded?: string; // base64-encoded video bytes
  69. mimeType?: string; // e.g. "video/mp4"
  70. }>;
  71. };
  72. error?: { code: number; message: string; status: string };
  73. }
  74. // ---------------------------------------------------------------------------
  75. // Submit
  76. // ---------------------------------------------------------------------------
  77. async function submitVideoGeneration(
  78. baseUrl: string,
  79. apiKey: string,
  80. model: string,
  81. options: VideoGenerationOptions,
  82. ): Promise<VeoOperation> {
  83. const url = `${baseUrl}/v1beta/models/${model}:predictLongRunning`;
  84. const body: Record<string, unknown> = {
  85. instances: [{ prompt: options.prompt }],
  86. };
  87. // Parameters are optional — only include if we have values
  88. const parameters: Record<string, unknown> = {};
  89. if (options.aspectRatio) parameters.aspectRatio = options.aspectRatio;
  90. if (options.duration) parameters.durationSeconds = options.duration;
  91. if (Object.keys(parameters).length > 0) {
  92. body.parameters = parameters;
  93. }
  94. const response = await fetch(url, {
  95. method: 'POST',
  96. headers: apiHeaders(apiKey),
  97. body: JSON.stringify(body),
  98. });
  99. if (!response.ok) {
  100. const text = await response.text();
  101. throw new Error(`Veo submit failed (${response.status}): ${text}`);
  102. }
  103. return response.json() as Promise<VeoOperation>;
  104. }
  105. // ---------------------------------------------------------------------------
  106. // Poll
  107. // ---------------------------------------------------------------------------
  108. async function pollOperation(
  109. baseUrl: string,
  110. apiKey: string,
  111. model: string,
  112. operationName: string,
  113. ): Promise<VeoOperation> {
  114. const url = `${baseUrl}/v1beta/models/${model}:fetchPredictOperation`;
  115. const response = await fetch(url, {
  116. method: 'POST',
  117. headers: apiHeaders(apiKey),
  118. body: JSON.stringify({ operationName }),
  119. });
  120. if (!response.ok) {
  121. const text = await response.text();
  122. throw new Error(`Veo poll failed (${response.status}): ${text}`);
  123. }
  124. return response.json() as Promise<VeoOperation>;
  125. }
  126. // ---------------------------------------------------------------------------
  127. // Public entry point
  128. // ---------------------------------------------------------------------------
  129. /**
  130. * Lightweight connectivity test — validates API key by fetching model info.
  131. * Uses GET /v1beta/models/{model} which does not trigger generation.
  132. */
  133. export async function testVeoConnectivity(
  134. config: VideoGenerationConfig,
  135. ): Promise<{ success: boolean; message: string }> {
  136. const model = config.model || DEFAULT_MODEL;
  137. const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
  138. const url = `${baseUrl}/v1beta/models`;
  139. // Try ?key= query param first (direct Google API), fall back to x-goog-api-key header (proxy)
  140. let response: Response | null = null;
  141. try {
  142. response = await fetch(`${url}?key=${config.apiKey}`, { method: 'GET' });
  143. } catch {
  144. // Direct API unreachable, try header auth
  145. }
  146. if (!response || !response.ok) {
  147. try {
  148. response = await fetch(url, {
  149. method: 'GET',
  150. headers: { 'x-goog-api-key': config.apiKey },
  151. });
  152. } catch (_err) {
  153. return {
  154. success: false,
  155. message: `Network error: unable to reach ${baseUrl}. Check your Base URL and network connection.`,
  156. };
  157. }
  158. }
  159. if (response.ok) {
  160. return { success: true, message: `Connected to Veo (${model})` };
  161. }
  162. // Parse error body for user-friendly message
  163. const text = await response.text().catch(() => '');
  164. if (response.status === 400 || response.status === 401 || response.status === 403) {
  165. return {
  166. success: false,
  167. message: `Invalid API key or unauthorized (${response.status}). Check your API Key and Base URL match the same provider.`,
  168. };
  169. }
  170. return {
  171. success: false,
  172. message: `Veo connectivity failed (${response.status}): ${text}`,
  173. };
  174. }
  175. export async function generateWithVeo(
  176. config: VideoGenerationConfig,
  177. options: VideoGenerationOptions,
  178. ): Promise<VideoGenerationResult> {
  179. const model = config.model || DEFAULT_MODEL;
  180. const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
  181. // 1. Submit
  182. const operation = await submitVideoGeneration(baseUrl, config.apiKey, model, options);
  183. if (!operation.name) {
  184. throw new Error('Veo returned operation without name');
  185. }
  186. // 2. Poll until done
  187. let current = operation;
  188. let pollCount = 0;
  189. while (!current.done) {
  190. if (pollCount >= MAX_POLL_ATTEMPTS) {
  191. throw new Error('Veo video generation timed out after 10 minutes');
  192. }
  193. await delay(POLL_INTERVAL_MS);
  194. current = await pollOperation(baseUrl, config.apiKey, model, current.name);
  195. pollCount++;
  196. }
  197. // 3. Check for errors
  198. if (current.error) {
  199. throw new Error(`Veo generation failed: ${current.error.code} - ${current.error.message}`);
  200. }
  201. // 4. Extract inline base64 video from response.videos[]
  202. const videos = current.response?.videos;
  203. if (!videos || videos.length === 0) {
  204. throw new Error('Veo returned no generated videos');
  205. }
  206. const first = videos[0];
  207. if (!first.bytesBase64Encoded) {
  208. throw new Error('Veo returned video entry without data');
  209. }
  210. const base64 = first.bytesBase64Encoded;
  211. const mimeType = first.mimeType || 'video/mp4';
  212. const { width, height } = getDimensions(options.aspectRatio);
  213. return {
  214. url: `data:${mimeType};base64,${base64}`,
  215. duration: options.duration || 8,
  216. width,
  217. height,
  218. };
  219. }