Blame Raw
· · · 641 lines (27.0 KB)
0 contributors
1import { Buffer } from 'node:buffer';
2import fetch from 'node-fetch';
3import express from 'express';
4import { speak, languages } from 'google-translate-api-x';
5import crypto from 'node:crypto';
6import util from 'node:util';
7import urlJoin from 'url-join';
8import lodash from 'lodash';
9
10import { readSecret, SECRET_KEYS } from './secrets.js';
11import { GEMINI_SAFETY, VERTEX_SAFETY } from '../constants.js';
12import { delay, getConfigValue, trimTrailingSlash } from '../util.js';
13
14const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
15const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
16
17function createWavHeader(dataSize, sampleRate, numChannels = 1, bitsPerSample = 16) {
18 const header = Buffer.alloc(44);
19 header.write('RIFF', 0);
20 header.writeUInt32LE(36 + dataSize, 4);
21 header.write('WAVE', 8);
22 header.write('fmt ', 12);
23 header.writeUInt32LE(16, 16);
24 header.writeUInt16LE(1, 20);
25 header.writeUInt16LE(numChannels, 22);
26 header.writeUInt32LE(sampleRate, 24);
27 header.writeUInt32LE(sampleRate * numChannels * bitsPerSample / 8, 28);
28 header.writeUInt16LE(numChannels * bitsPerSample / 8, 32);
29 header.writeUInt16LE(bitsPerSample, 34);
30 header.write('data', 36);
31 header.writeUInt32LE(dataSize, 40);
32 return header;
33}
34
35function createCompleteWavFile(pcmData, sampleRate) {
36 const header = createWavHeader(pcmData.length, sampleRate);
37 return Buffer.concat([header, pcmData]);
38}
39
40// Vertex AI authentication helper functions
41export async function getVertexAIAuth(request) {
42 const authMode = request.body.vertexai_auth_mode || 'express';
43
44 if (request.body.reverse_proxy) {
45 return {
46 authHeader: `Bearer ${request.body.proxy_password}`,
47 authType: 'proxy',
48 };
49 }
50
51 if (authMode === 'express') {
52 const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI);
53 if (apiKey) {
54 return {
55 authHeader: `Bearer ${apiKey}`,
56 authType: 'express',
57 };
58 }
59 throw new Error('API key is required for Vertex AI Express mode');
60 } else if (authMode === 'full') {
61 // Get service account JSON from backend storage
62 const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
63
64 if (serviceAccountJson) {
65 try {
66 const serviceAccount = JSON.parse(serviceAccountJson);
67 const jwtToken = await generateJWTToken(serviceAccount);
68 const accessToken = await getAccessToken(jwtToken);
69 return {
70 authHeader: `Bearer ${accessToken}`,
71 authType: 'full',
72 };
73 } catch (error) {
74 console.error('Failed to authenticate with service account:', error);
75 throw new Error(`Service account authentication failed: ${error.message}`);
76 }
77 }
78 throw new Error('Service Account JSON is required for Vertex AI Full mode');
79 }
80
81 throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`);
82}
83
84/**
85 * Generates a JWT token for Google Cloud authentication using service account credentials.
86 * @param {object} serviceAccount Service account JSON object
87 * @returns {Promise<string>} JWT token
88 */
89export async function generateJWTToken(serviceAccount) {
90 const now = Math.floor(Date.now() / 1000);
91 const expiry = now + 3600; // 1 hour
92
93 const header = {
94 alg: 'RS256',
95 typ: 'JWT',
96 };
97
98 const payload = {
99 iss: serviceAccount.client_email,
100 scope: 'https://www.googleapis.com/auth/cloud-platform',
101 aud: 'https://oauth2.googleapis.com/token',
102 iat: now,
103 exp: expiry,
104 };
105
106 const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url');
107 const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
108 const signatureInput = `${headerBase64}.${payloadBase64}`;
109
110 // Create signature using private key
111 const sign = crypto.createSign('RSA-SHA256');
112 sign.update(signatureInput);
113 const signature = sign.sign(serviceAccount.private_key, 'base64url');
114
115 return `${signatureInput}.${signature}`;
116}
117
118export async function getAccessToken(jwtToken) {
119 const response = await fetch('https://oauth2.googleapis.com/token', {
120 method: 'POST',
121 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
122 body: new URLSearchParams({
123 grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
124 assertion: jwtToken,
125 }),
126 });
127
128 if (!response.ok) {
129 const error = await response.text();
130 throw new Error(`Failed to get access token: ${error}`);
131 }
132
133 /** @type {any} */
134 const data = await response.json();
135 return data.access_token;
136}
137
138/**
139 * Extracts the project ID from a Service Account JSON object.
140 * @param {object} serviceAccount Service account JSON object
141 * @returns {string} Project ID
142 * @throws {Error} If project ID is not found in the service account
143 */
144export function getProjectIdFromServiceAccount(serviceAccount) {
145 if (!serviceAccount || typeof serviceAccount !== 'object') {
146 throw new Error('Invalid service account object');
147 }
148
149 const projectId = serviceAccount.project_id;
150 if (!projectId || typeof projectId !== 'string') {
151 throw new Error('Project ID not found in service account JSON');
152 }
153
154 return projectId;
155}
156
157/**
158 * Generates Google API URL and headers based on request configuration
159 * @param {express.Request} request Express request object
160 * @param {string} model Model name to use
161 * @param {string} endpoint API endpoint (default: 'generateContent')
162 * @returns {Promise<{url: string, headers: object, apiName: string, baseUrl: string, safetySettings: object[]}>} URL, headers, and API name
163 */
164export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') {
165 const useVertexAi = request.body.api === 'vertexai';
166 const region = request.body.vertexai_region || 'us-central1';
167 const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';
168 const safetySettings = [...GEMINI_SAFETY, ...(useVertexAi ? VERTEX_SAFETY : [])];
169
170 let url;
171 let baseUrl;
172 let headers = {
173 'Content-Type': 'application/json',
174 };
175
176 if (useVertexAi) {
177 // Get authentication for Vertex AI
178 const { authHeader, authType } = await getVertexAIAuth(request);
179
180 if (authType === 'express') {
181 // Express mode: use API key parameter
182 const keyParam = authHeader.replace('Bearer ', '');
183 const projectId = request.body.vertexai_express_project_id;
184 baseUrl = region === 'global'
185 ? 'https://aiplatform.googleapis.com/v1'
186 : `https://${region}-aiplatform.googleapis.com/v1`;
187 url = projectId
188 ? `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`
189 : `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
190 headers['x-goog-api-key'] = keyParam;
191 } else if (authType === 'full') {
192 // Full mode: use project-specific URL with Authorization header
193 // Get project ID from Service Account JSON
194 const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
195 if (!serviceAccountJson) {
196 throw new Error('Vertex AI Service Account JSON is missing.');
197 }
198
199 let projectId;
200 try {
201 const serviceAccount = JSON.parse(serviceAccountJson);
202 projectId = getProjectIdFromServiceAccount(serviceAccount);
203 } catch (error) {
204 throw new Error('Failed to extract project ID from Service Account JSON.');
205 }
206 // Handle global region differently - no region prefix in hostname
207 baseUrl = region === 'global'
208 ? 'https://aiplatform.googleapis.com/v1'
209 : `https://${region}-aiplatform.googleapis.com/v1`;
210 url = `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`;
211 headers['Authorization'] = authHeader;
212 } else {
213 // Proxy mode: use Authorization header
214 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI);
215 baseUrl = `${apiUrl}/v1`;
216 url = `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
217 headers['Authorization'] = authHeader;
218 }
219 } else {
220 // Google AI Studio
221 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
222 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE);
223 const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta');
224 baseUrl = `${apiUrl}/${apiVersion}`;
225 url = `${baseUrl}/models/${model}:${endpoint}`;
226 headers['x-goog-api-key'] = apiKey;
227 }
228
229 return { url, headers, apiName, baseUrl, safetySettings };
230}
231
232export const router = express.Router();
233
234router.post('/caption-image', async (request, response) => {
235 try {
236 const mimeType = request.body.image.split(';')[0].split(':')[1];
237 const base64Data = request.body.image.split(',')[1];
238 const model = request.body.model || 'gemini-2.0-flash';
239 const { url, headers, apiName, safetySettings } = await getGoogleApiConfig(request, model);
240
241 const body = {
242 contents: [{
243 role: 'user',
244 parts: [
245 { text: request.body.prompt },
246 {
247 inlineData: {
248 mimeType: mimeType,
249 data: base64Data,
250 },
251 }],
252 }],
253 safetySettings: safetySettings,
254 };
255
256 console.debug(`${apiName} captioning request`, model, body);
257
258 const result = await fetch(url, {
259 body: JSON.stringify(body),
260 method: 'POST',
261 headers: headers,
262 });
263
264 if (!result.ok) {
265 const error = await result.json();
266 console.error(`${apiName} API returned error: ${result.status} ${result.statusText}`, error);
267 return response.status(500).send({ error: true });
268 }
269
270 /** @type {any} */
271 const data = await result.json();
272 console.info(`${apiName} captioning response`, data);
273
274 const candidates = data?.candidates;
275 if (!candidates) {
276 return response.status(500).send('No candidates found, image was most likely filtered.');
277 }
278
279 const caption = candidates[0].content.parts[0].text;
280 if (!caption) {
281 return response.status(500).send('No caption found');
282 }
283
284 return response.json({ caption });
285 } catch (error) {
286 console.error(error);
287 response.status(500).send('Internal server error');
288 }
289});
290
291router.post('/list-voices', (_, response) => {
292 return response.json(languages);
293});
294
295router.post('/generate-voice', async (request, response) => {
296 try {
297 const text = request.body.text;
298 const voice = request.body.voice ?? 'en';
299
300 const result = await speak(text, { to: voice, forceBatch: false });
301 const buffer = Array.isArray(result)
302 ? Buffer.concat(result.map(x => new Uint8Array(Buffer.from(x.toString(), 'base64'))))
303 : Buffer.from(result.toString(), 'base64');
304
305 response.setHeader('Content-Type', 'audio/mpeg');
306 return response.send(buffer);
307 } catch (error) {
308 console.error('Google Translate TTS generation failed', error);
309 response.status(500).send('Internal server error');
310 }
311});
312
313router.post('/list-native-voices', async (_, response) => {
314 try {
315 // Hardcoded Gemini native TTS voices from official documentation
316 // Source: https://ai.google.dev/gemini-api/docs/speech-generation#voices
317 const voices = [
318 { name: 'Zephyr', voice_id: 'Zephyr', lang: 'en-US', description: 'Bright' },
319 { name: 'Puck', voice_id: 'Puck', lang: 'en-US', description: 'Upbeat' },
320 { name: 'Charon', voice_id: 'Charon', lang: 'en-US', description: 'Informative' },
321 { name: 'Kore', voice_id: 'Kore', lang: 'en-US', description: 'Firm' },
322 { name: 'Fenrir', voice_id: 'Fenrir', lang: 'en-US', description: 'Excitable' },
323 { name: 'Leda', voice_id: 'Leda', lang: 'en-US', description: 'Youthful' },
324 { name: 'Orus', voice_id: 'Orus', lang: 'en-US', description: 'Firm' },
325 { name: 'Aoede', voice_id: 'Aoede', lang: 'en-US', description: 'Breezy' },
326 { name: 'Callirhoe', voice_id: 'Callirhoe', lang: 'en-US', description: 'Easy-going' },
327 { name: 'Autonoe', voice_id: 'Autonoe', lang: 'en-US', description: 'Bright' },
328 { name: 'Enceladus', voice_id: 'Enceladus', lang: 'en-US', description: 'Breathy' },
329 { name: 'Iapetus', voice_id: 'Iapetus', lang: 'en-US', description: 'Clear' },
330 { name: 'Umbriel', voice_id: 'Umbriel', lang: 'en-US', description: 'Easy-going' },
331 { name: 'Algieba', voice_id: 'Algieba', lang: 'en-US', description: 'Smooth' },
332 { name: 'Despina', voice_id: 'Despina', lang: 'en-US', description: 'Smooth' },
333 { name: 'Erinome', voice_id: 'Erinome', lang: 'en-US', description: 'Clear' },
334 { name: 'Algenib', voice_id: 'Algenib', lang: 'en-US', description: 'Gravelly' },
335 { name: 'Rasalgethi', voice_id: 'Rasalgethi', lang: 'en-US', description: 'Informative' },
336 { name: 'Laomedeia', voice_id: 'Laomedeia', lang: 'en-US', description: 'Upbeat' },
337 { name: 'Achernar', voice_id: 'Achernar', lang: 'en-US', description: 'Soft' },
338 { name: 'Alnilam', voice_id: 'Alnilam', lang: 'en-US', description: 'Firm' },
339 { name: 'Schedar', voice_id: 'Schedar', lang: 'en-US', description: 'Even' },
340 { name: 'Gacrux', voice_id: 'Gacrux', lang: 'en-US', description: 'Mature' },
341 { name: 'Pulcherrima', voice_id: 'Pulcherrima', lang: 'en-US', description: 'Forward' },
342 { name: 'Achird', voice_id: 'Achird', lang: 'en-US', description: 'Friendly' },
343 { name: 'Zubenelgenubi', voice_id: 'Zubenelgenubi', lang: 'en-US', description: 'Casual' },
344 { name: 'Vindemiatrix', voice_id: 'Vindemiatrix', lang: 'en-US', description: 'Gentle' },
345 { name: 'Sadachbia', voice_id: 'Sadachbia', lang: 'en-US', description: 'Lively' },
346 { name: 'Sadaltager', voice_id: 'Sadaltager', lang: 'en-US', description: 'Knowledgeable' },
347 { name: 'Sulafat', voice_id: 'Sulafat', lang: 'en-US', description: 'Warm' },
348 ];
349 return response.json({ voices });
350 } catch (error) {
351 console.error('Failed to return Google TTS voices:', error);
352 response.sendStatus(500);
353 }
354});
355
356router.post('/generate-native-tts', async (request, response) => {
357 try {
358 const { text, voice, model } = request.body;
359 const { url, headers, apiName, safetySettings } = await getGoogleApiConfig(request, model);
360
361 console.debug(`${apiName} TTS request`, { model, text, voice });
362
363 const requestBody = {
364 contents: [{
365 role: 'user',
366 parts: [{ text: text }],
367 }],
368 generationConfig: {
369 responseModalities: ['AUDIO'],
370 speechConfig: {
371 voiceConfig: {
372 prebuiltVoiceConfig: {
373 voiceName: voice,
374 },
375 },
376 },
377 },
378 safetySettings: safetySettings,
379 };
380
381 const result = await fetch(url, {
382 method: 'POST',
383 headers: headers,
384 body: JSON.stringify(requestBody),
385 });
386
387 if (!result.ok) {
388 const errorText = await result.text();
389 console.error(`${apiName} TTS API error: ${result.status} ${result.statusText}`, errorText);
390 const errorMessage = JSON.parse(errorText).error?.message || 'TTS generation failed.';
391 return response.status(result.status).json({ error: errorMessage });
392 }
393
394 /** @type {any} */
395 const data = await result.json();
396 const audioPart = data?.candidates?.[0]?.content?.parts?.[0];
397 const audioData = audioPart?.inlineData?.data;
398 const mimeType = audioPart?.inlineData?.mimeType;
399
400 if (!audioData) {
401 return response.status(500).json({ error: 'No audio data found in response' });
402 }
403
404 const audioBuffer = Buffer.from(audioData, 'base64');
405
406 //If the audio is raw PCM, wrap it in a WAV header and send it.
407 if (mimeType && mimeType.toLowerCase().includes('audio/l16')) {
408 const rateMatch = mimeType.match(/rate=(\d+)/);
409 const sampleRate = rateMatch ? parseInt(rateMatch[1], 10) : 24000;
410 const pcmData = audioBuffer;
411
412 // Create a complete, playable WAV file buffer.
413 const wavBuffer = createCompleteWavFile(pcmData, sampleRate);
414
415 // Send the WAV file directly to the browser. This is much faster.
416 response.setHeader('Content-Type', 'audio/wav');
417 return response.send(wavBuffer);
418 }
419
420 // Fallback for any other audio format Google might send in the future.
421 response.setHeader('Content-Type', mimeType || 'application/octet-stream');
422 response.send(audioBuffer);
423 } catch (error) {
424 console.error('Google TTS generation failed:', error);
425 if (!response.headersSent) {
426 return response.status(500).json({ error: 'Internal server error during TTS generation' });
427 }
428 return response.end();
429 }
430});
431
432router.post('/generate-image', async (request, response) => {
433 try {
434 const model = request.body.model || 'imagen-3.0-generate-002';
435 const { url, headers, apiName } = await getGoogleApiConfig(request, model, 'predict');
436
437 // AI Studio is stricter than Vertex AI.
438 const isVertex = request.body.api === 'vertexai';
439 // Is it even worth it?
440 const isDeprecated = model.startsWith('imagegeneration');
441 // Get person generation setting from config
442 const personGeneration = getConfigValue('gemini.image.personGeneration', 'allow_adult');
443
444 const requestBody = {
445 instances: [{
446 prompt: request.body.prompt || '',
447 }],
448 parameters: {
449 sampleCount: 1,
450 seed: isVertex ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined,
451 enhancePrompt: isVertex ? Boolean(request.body.enhance ?? false) : undefined,
452 negativePrompt: isVertex ? (request.body.negative_prompt || undefined) : undefined,
453 aspectRatio: String(request.body.aspect_ratio || '1:1'),
454 personGeneration: !isDeprecated && personGeneration ? personGeneration : undefined,
455 language: isVertex ? 'auto' : undefined,
456 safetySetting: !isDeprecated ? (isVertex ? 'block_only_high' : 'block_low_and_above') : undefined,
457 addWatermark: isVertex ? false : undefined,
458 outputOptions: {
459 mimeType: 'image/jpeg',
460 compressionQuality: 100,
461 },
462 },
463 };
464
465 console.debug(`${apiName} image generation request:`, model, requestBody);
466
467 const result = await fetch(url, {
468 method: 'POST',
469 headers: headers,
470 body: JSON.stringify(requestBody),
471 });
472
473 if (!result.ok) {
474 const errorText = await result.text();
475 console.warn(`${apiName} image generation error: ${result.status} ${result.statusText}`, errorText);
476 return response.status(500).send('Image generation request failed');
477 }
478
479 /** @type {any} */
480 const data = await result.json();
481 const imagePart = data?.predictions?.[0]?.bytesBase64Encoded;
482
483 if (!imagePart) {
484 console.warn(`${apiName} image generation error: No image data found in response`);
485 return response.status(500).send('No image data found in response');
486 }
487
488 return response.send({ image: imagePart });
489 } catch (error) {
490 console.error('Google Image generation failed:', error);
491 if (!response.headersSent) {
492 return response.sendStatus(500);
493 }
494 return response.end();
495 }
496});
497
498router.post('/generate-video', async (request, response) => {
499 try {
500 const controller = new AbortController();
501 request.socket.removeAllListeners('close');
502 request.socket.on('close', function () {
503 controller.abort();
504 });
505
506 const model = request.body.model || 'veo-3.1-generate-preview';
507 const { url, headers, apiName, baseUrl } = await getGoogleApiConfig(request, model, 'predictLongRunning');
508 const useVertexAi = request.body.api === 'vertexai';
509
510 const isVeo3 = /veo-3/.test(model);
511 const lowerBound = isVeo3 ? 4 : 5;
512 const upperBound = isVeo3 ? 8 : 8;
513
514 const requestBody = {
515 instances: [{
516 prompt: String(request.body.prompt || ''),
517 }],
518 parameters: {
519 negativePrompt: String(request.body.negative_prompt || ''),
520 durationSeconds: lodash.clamp(Number(request.body.seconds || 6), lowerBound, upperBound),
521 aspectRatio: String(request.body.aspect_ratio || '16:9'),
522 personGeneration: 'allow_all',
523 seed: isVeo3 ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined,
524 },
525 };
526
527 console.debug(`${apiName} video generation request:`, model, requestBody);
528 const videoJobResponse = await fetch(url, {
529 method: 'POST',
530 headers: headers,
531 body: JSON.stringify(requestBody),
532 });
533
534 if (!videoJobResponse.ok) {
535 const errorText = await videoJobResponse.text();
536 console.warn(`${apiName} video generation error: ${videoJobResponse.status} ${videoJobResponse.statusText}`, errorText);
537 return response.status(500).send('Video generation request failed');
538 }
539
540 /** @type {any} */
541 const videoJobData = await videoJobResponse.json();
542 const videoJobName = videoJobData?.name;
543
544 if (!videoJobName) {
545 console.warn(`${apiName} video generation error: No job name found in response`);
546 return response.status(500).send('No video job name found in response');
547 }
548
549 console.debug(`${apiName} video job name:`, videoJobName);
550
551 for (let attempt = 0; attempt < 30; attempt++) {
552 if (controller.signal.aborted) {
553 console.info(`${apiName} video generation aborted by client`);
554 return response.status(500).send('Video generation aborted by client');
555 }
556
557 await delay(5000 + attempt * 1000);
558
559 if (useVertexAi) {
560 const { url: pollUrl, headers: pollHeaders } = await getGoogleApiConfig(request, model, 'fetchPredictOperation');
561
562 const pollResponse = await fetch(pollUrl, {
563 method: 'POST',
564 headers: pollHeaders,
565 body: JSON.stringify({ operationName: videoJobName }),
566 });
567
568 if (!pollResponse.ok) {
569 const errorText = await pollResponse.text();
570 console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText);
571 return response.status(500).send('Video job status request failed');
572 }
573
574 /** @type {any} */
575 const pollData = await pollResponse.json();
576 const jobDone = pollData?.done;
577 console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`);
578
579 if (jobDone) {
580 const videoData = pollData?.response?.videos?.[0]?.bytesBase64Encoded;
581 if (!videoData) {
582 const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 });
583 console.warn(`${apiName} video generation error: No video data found in response`, pollDataLog);
584 return response.status(500).send('No video data found in response');
585 }
586
587 return response.send({ video: videoData });
588 }
589 } else {
590 const pollUrl = urlJoin(baseUrl, videoJobName);
591 const pollResponse = await fetch(pollUrl, {
592 method: 'GET',
593 headers: headers,
594 });
595
596 if (!pollResponse.ok) {
597 const errorText = await pollResponse.text();
598 console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText);
599 return response.status(500).send('Video job status request failed');
600 }
601
602 /** @type {any} */
603 const pollData = await pollResponse.json();
604 const jobDone = pollData?.done;
605 console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`);
606
607 if (jobDone) {
608 const videoUri = pollData?.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri;
609 console.debug(`${apiName} video URI:`, videoUri);
610
611 if (!videoUri) {
612 const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 });
613 console.warn(`${apiName} video generation error: No video URI found in response`, pollDataLog);
614 return response.status(500).send('No video URI found in response');
615 }
616
617 const videoResponse = await fetch(videoUri, {
618 method: 'GET',
619 headers: headers,
620 });
621
622 if (!videoResponse.ok) {
623 console.warn(`${apiName} video fetch error: ${videoResponse.status} ${videoResponse.statusText}`);
624 return response.status(500).send('Video fetch request failed');
625 }
626
627 const videoData = await videoResponse.arrayBuffer();
628 const videoBase64 = Buffer.from(videoData).toString('base64');
629
630 return response.send({ video: videoBase64 });
631 }
632 }
633 }
634
635 console.warn(`${apiName} video generation error: Job timed out after multiple attempts`);
636 return response.status(500).send('Video generation timed out');
637 } catch (error) {
638 console.error('Google Video generation failed:', error);
639 return response.sendStatus(500);
640 }
641});