Spaces:
Build error
Build error
| import { Buffer } from 'node:buffer'; | |
| import fetch from 'node-fetch'; | |
| import express from 'express'; | |
| import { speak, languages } from 'google-translate-api-x'; | |
| import crypto from 'node:crypto'; | |
| import util from 'node:util'; | |
| import urlJoin from 'url-join'; | |
| import lodash from 'lodash'; | |
| import { readSecret, SECRET_KEYS } from './secrets.js'; | |
| import { GEMINI_SAFETY, VERTEX_SAFETY } from '../constants.js'; | |
| import { delay, getConfigValue, trimTrailingSlash } from '../util.js'; | |
| const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; | |
| const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com'; | |
| function createWavHeader(dataSize, sampleRate, numChannels = 1, bitsPerSample = 16) { | |
| const header = Buffer.alloc(44); | |
| header.write('RIFF', 0); | |
| header.writeUInt32LE(36 + dataSize, 4); | |
| header.write('WAVE', 8); | |
| header.write('fmt ', 12); | |
| header.writeUInt32LE(16, 16); | |
| header.writeUInt16LE(1, 20); | |
| header.writeUInt16LE(numChannels, 22); | |
| header.writeUInt32LE(sampleRate, 24); | |
| header.writeUInt32LE(sampleRate * numChannels * bitsPerSample / 8, 28); | |
| header.writeUInt16LE(numChannels * bitsPerSample / 8, 32); | |
| header.writeUInt16LE(bitsPerSample, 34); | |
| header.write('data', 36); | |
| header.writeUInt32LE(dataSize, 40); | |
| return header; | |
| } | |
| function createCompleteWavFile(pcmData, sampleRate) { | |
| const header = createWavHeader(pcmData.length, sampleRate); | |
| return Buffer.concat([header, pcmData]); | |
| } | |
| // Vertex AI authentication helper functions | |
| export async function getVertexAIAuth(request) { | |
| const authMode = request.body.vertexai_auth_mode || 'express'; | |
| if (request.body.reverse_proxy) { | |
| return { | |
| authHeader: `Bearer ${request.body.proxy_password}`, | |
| authType: 'proxy', | |
| }; | |
| } | |
| if (authMode === 'express') { | |
| const apiKey = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI); | |
| if (apiKey) { | |
| return { | |
| authHeader: `Bearer ${apiKey}`, | |
| authType: 'express', | |
| }; | |
| } | |
| throw new Error('API key is required for Vertex AI Express mode'); | |
| } else if (authMode === 'full') { | |
| // Get service account JSON from backend storage | |
| const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); | |
| if (serviceAccountJson) { | |
| try { | |
| const serviceAccount = JSON.parse(serviceAccountJson); | |
| const jwtToken = await generateJWTToken(serviceAccount); | |
| const accessToken = await getAccessToken(jwtToken); | |
| return { | |
| authHeader: `Bearer ${accessToken}`, | |
| authType: 'full', | |
| }; | |
| } catch (error) { | |
| console.error('Failed to authenticate with service account:', error); | |
| throw new Error(`Service account authentication failed: ${error.message}`); | |
| } | |
| } | |
| throw new Error('Service Account JSON is required for Vertex AI Full mode'); | |
| } | |
| throw new Error(`Unsupported Vertex AI authentication mode: ${authMode}`); | |
| } | |
| /** | |
| * Generates a JWT token for Google Cloud authentication using service account credentials. | |
| * @param {object} serviceAccount Service account JSON object | |
| * @returns {Promise<string>} JWT token | |
| */ | |
| export async function generateJWTToken(serviceAccount) { | |
| const now = Math.floor(Date.now() / 1000); | |
| const expiry = now + 3600; // 1 hour | |
| const header = { | |
| alg: 'RS256', | |
| typ: 'JWT', | |
| }; | |
| const payload = { | |
| iss: serviceAccount.client_email, | |
| scope: 'https://www.googleapis.com/auth/cloud-platform', | |
| aud: 'https://oauth2.googleapis.com/token', | |
| iat: now, | |
| exp: expiry, | |
| }; | |
| const headerBase64 = Buffer.from(JSON.stringify(header)).toString('base64url'); | |
| const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); | |
| const signatureInput = `${headerBase64}.${payloadBase64}`; | |
| // Create signature using private key | |
| const sign = crypto.createSign('RSA-SHA256'); | |
| sign.update(signatureInput); | |
| const signature = sign.sign(serviceAccount.private_key, 'base64url'); | |
| return `${signatureInput}.${signature}`; | |
| } | |
| export async function getAccessToken(jwtToken) { | |
| const response = await fetch('https://oauth2.googleapis.com/token', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| body: new URLSearchParams({ | |
| grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', | |
| assertion: jwtToken, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const error = await response.text(); | |
| throw new Error(`Failed to get access token: ${error}`); | |
| } | |
| /** @type {any} */ | |
| const data = await response.json(); | |
| return data.access_token; | |
| } | |
| /** | |
| * Extracts the project ID from a Service Account JSON object. | |
| * @param {object} serviceAccount Service account JSON object | |
| * @returns {string} Project ID | |
| * @throws {Error} If project ID is not found in the service account | |
| */ | |
| export function getProjectIdFromServiceAccount(serviceAccount) { | |
| if (!serviceAccount || typeof serviceAccount !== 'object') { | |
| throw new Error('Invalid service account object'); | |
| } | |
| const projectId = serviceAccount.project_id; | |
| if (!projectId || typeof projectId !== 'string') { | |
| throw new Error('Project ID not found in service account JSON'); | |
| } | |
| return projectId; | |
| } | |
| /** | |
| * Generates Google API URL and headers based on request configuration | |
| * @param {express.Request} request Express request object | |
| * @param {string} model Model name to use | |
| * @param {string} endpoint API endpoint (default: 'generateContent') | |
| * @returns {Promise<{url: string, headers: object, apiName: string, baseUrl: string, safetySettings: object[]}>} URL, headers, and API name | |
| */ | |
| export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') { | |
| const useVertexAi = request.body.api === 'vertexai'; | |
| const region = request.body.vertexai_region || 'us-central1'; | |
| const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio'; | |
| const safetySettings = [...GEMINI_SAFETY, ...(useVertexAi ? VERTEX_SAFETY : [])]; | |
| let url; | |
| let baseUrl; | |
| let headers = { | |
| 'Content-Type': 'application/json', | |
| }; | |
| if (useVertexAi) { | |
| // Get authentication for Vertex AI | |
| const { authHeader, authType } = await getVertexAIAuth(request); | |
| if (authType === 'express') { | |
| // Express mode: use API key parameter | |
| const keyParam = authHeader.replace('Bearer ', ''); | |
| const projectId = request.body.vertexai_express_project_id; | |
| baseUrl = region === 'global' | |
| ? 'https://aiplatform.googleapis.com/v1' | |
| : `https://${region}-aiplatform.googleapis.com/v1`; | |
| url = projectId | |
| ? `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}` | |
| : `${baseUrl}/publishers/google/models/${model}:${endpoint}`; | |
| headers['x-goog-api-key'] = keyParam; | |
| } else if (authType === 'full') { | |
| // Full mode: use project-specific URL with Authorization header | |
| // Get project ID from Service Account JSON | |
| const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); | |
| if (!serviceAccountJson) { | |
| throw new Error('Vertex AI Service Account JSON is missing.'); | |
| } | |
| let projectId; | |
| try { | |
| const serviceAccount = JSON.parse(serviceAccountJson); | |
| projectId = getProjectIdFromServiceAccount(serviceAccount); | |
| } catch (error) { | |
| throw new Error('Failed to extract project ID from Service Account JSON.'); | |
| } | |
| // Handle global region differently - no region prefix in hostname | |
| baseUrl = region === 'global' | |
| ? 'https://aiplatform.googleapis.com/v1' | |
| : `https://${region}-aiplatform.googleapis.com/v1`; | |
| url = `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`; | |
| headers['Authorization'] = authHeader; | |
| } else { | |
| // Proxy mode: use Authorization header | |
| const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI); | |
| baseUrl = `${apiUrl}/v1`; | |
| url = `${baseUrl}/publishers/google/models/${model}:${endpoint}`; | |
| headers['Authorization'] = authHeader; | |
| } | |
| } else { | |
| // Google AI Studio | |
| const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); | |
| const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE); | |
| const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta'); | |
| baseUrl = `${apiUrl}/${apiVersion}`; | |
| url = `${baseUrl}/models/${model}:${endpoint}`; | |
| headers['x-goog-api-key'] = apiKey; | |
| } | |
| return { url, headers, apiName, baseUrl, safetySettings }; | |
| } | |
| export const router = express.Router(); | |
| router.post('/caption-image', async (request, response) => { | |
| try { | |
| const mimeType = request.body.image.split(';')[0].split(':')[1]; | |
| const base64Data = request.body.image.split(',')[1]; | |
| const model = request.body.model || 'gemini-2.0-flash'; | |
| const { url, headers, apiName, safetySettings } = await getGoogleApiConfig(request, model); | |
| const body = { | |
| contents: [{ | |
| role: 'user', | |
| parts: [ | |
| { text: request.body.prompt }, | |
| { | |
| inlineData: { | |
| mimeType: mimeType, | |
| data: base64Data, | |
| }, | |
| }], | |
| }], | |
| safetySettings: safetySettings, | |
| }; | |
| console.debug(`${apiName} captioning request`, model, body); | |
| const result = await fetch(url, { | |
| body: JSON.stringify(body), | |
| method: 'POST', | |
| headers: headers, | |
| }); | |
| if (!result.ok) { | |
| const error = await result.json(); | |
| console.error(`${apiName} API returned error: ${result.status} ${result.statusText}`, error); | |
| return response.status(500).send({ error: true }); | |
| } | |
| /** @type {any} */ | |
| const data = await result.json(); | |
| console.info(`${apiName} captioning response`, data); | |
| const candidates = data?.candidates; | |
| if (!candidates) { | |
| return response.status(500).send('No candidates found, image was most likely filtered.'); | |
| } | |
| const caption = candidates[0].content.parts[0].text; | |
| if (!caption) { | |
| return response.status(500).send('No caption found'); | |
| } | |
| return response.json({ caption }); | |
| } catch (error) { | |
| console.error(error); | |
| response.status(500).send('Internal server error'); | |
| } | |
| }); | |
| router.post('/list-voices', (_, response) => { | |
| return response.json(languages); | |
| }); | |
| router.post('/generate-voice', async (request, response) => { | |
| try { | |
| const text = request.body.text; | |
| const voice = request.body.voice ?? 'en'; | |
| const result = await speak(text, { to: voice, forceBatch: false }); | |
| const buffer = Array.isArray(result) | |
| ? Buffer.concat(result.map(x => new Uint8Array(Buffer.from(x.toString(), 'base64')))) | |
| : Buffer.from(result.toString(), 'base64'); | |
| response.setHeader('Content-Type', 'audio/mpeg'); | |
| return response.send(buffer); | |
| } catch (error) { | |
| console.error('Google Translate TTS generation failed', error); | |
| response.status(500).send('Internal server error'); | |
| } | |
| }); | |
| router.post('/list-native-voices', async (_, response) => { | |
| try { | |
| // Hardcoded Gemini native TTS voices from official documentation | |
| // Source: https://ai.google.dev/gemini-api/docs/speech-generation#voices | |
| const voices = [ | |
| { name: 'Zephyr', voice_id: 'Zephyr', lang: 'en-US', description: 'Bright' }, | |
| { name: 'Puck', voice_id: 'Puck', lang: 'en-US', description: 'Upbeat' }, | |
| { name: 'Charon', voice_id: 'Charon', lang: 'en-US', description: 'Informative' }, | |
| { name: 'Kore', voice_id: 'Kore', lang: 'en-US', description: 'Firm' }, | |
| { name: 'Fenrir', voice_id: 'Fenrir', lang: 'en-US', description: 'Excitable' }, | |
| { name: 'Leda', voice_id: 'Leda', lang: 'en-US', description: 'Youthful' }, | |
| { name: 'Orus', voice_id: 'Orus', lang: 'en-US', description: 'Firm' }, | |
| { name: 'Aoede', voice_id: 'Aoede', lang: 'en-US', description: 'Breezy' }, | |
| { name: 'Callirhoe', voice_id: 'Callirhoe', lang: 'en-US', description: 'Easy-going' }, | |
| { name: 'Autonoe', voice_id: 'Autonoe', lang: 'en-US', description: 'Bright' }, | |
| { name: 'Enceladus', voice_id: 'Enceladus', lang: 'en-US', description: 'Breathy' }, | |
| { name: 'Iapetus', voice_id: 'Iapetus', lang: 'en-US', description: 'Clear' }, | |
| { name: 'Umbriel', voice_id: 'Umbriel', lang: 'en-US', description: 'Easy-going' }, | |
| { name: 'Algieba', voice_id: 'Algieba', lang: 'en-US', description: 'Smooth' }, | |
| { name: 'Despina', voice_id: 'Despina', lang: 'en-US', description: 'Smooth' }, | |
| { name: 'Erinome', voice_id: 'Erinome', lang: 'en-US', description: 'Clear' }, | |
| { name: 'Algenib', voice_id: 'Algenib', lang: 'en-US', description: 'Gravelly' }, | |
| { name: 'Rasalgethi', voice_id: 'Rasalgethi', lang: 'en-US', description: 'Informative' }, | |
| { name: 'Laomedeia', voice_id: 'Laomedeia', lang: 'en-US', description: 'Upbeat' }, | |
| { name: 'Achernar', voice_id: 'Achernar', lang: 'en-US', description: 'Soft' }, | |
| { name: 'Alnilam', voice_id: 'Alnilam', lang: 'en-US', description: 'Firm' }, | |
| { name: 'Schedar', voice_id: 'Schedar', lang: 'en-US', description: 'Even' }, | |
| { name: 'Gacrux', voice_id: 'Gacrux', lang: 'en-US', description: 'Mature' }, | |
| { name: 'Pulcherrima', voice_id: 'Pulcherrima', lang: 'en-US', description: 'Forward' }, | |
| { name: 'Achird', voice_id: 'Achird', lang: 'en-US', description: 'Friendly' }, | |
| { name: 'Zubenelgenubi', voice_id: 'Zubenelgenubi', lang: 'en-US', description: 'Casual' }, | |
| { name: 'Vindemiatrix', voice_id: 'Vindemiatrix', lang: 'en-US', description: 'Gentle' }, | |
| { name: 'Sadachbia', voice_id: 'Sadachbia', lang: 'en-US', description: 'Lively' }, | |
| { name: 'Sadaltager', voice_id: 'Sadaltager', lang: 'en-US', description: 'Knowledgeable' }, | |
| { name: 'Sulafat', voice_id: 'Sulafat', lang: 'en-US', description: 'Warm' }, | |
| ]; | |
| return response.json({ voices }); | |
| } catch (error) { | |
| console.error('Failed to return Google TTS voices:', error); | |
| response.sendStatus(500); | |
| } | |
| }); | |
| router.post('/generate-native-tts', async (request, response) => { | |
| try { | |
| const { text, voice, model } = request.body; | |
| const { url, headers, apiName, safetySettings } = await getGoogleApiConfig(request, model); | |
| console.debug(`${apiName} TTS request`, { model, text, voice }); | |
| const requestBody = { | |
| contents: [{ | |
| role: 'user', | |
| parts: [{ text: text }], | |
| }], | |
| generationConfig: { | |
| responseModalities: ['AUDIO'], | |
| speechConfig: { | |
| voiceConfig: { | |
| prebuiltVoiceConfig: { | |
| voiceName: voice, | |
| }, | |
| }, | |
| }, | |
| }, | |
| safetySettings: safetySettings, | |
| }; | |
| const result = await fetch(url, { | |
| method: 'POST', | |
| headers: headers, | |
| body: JSON.stringify(requestBody), | |
| }); | |
| if (!result.ok) { | |
| const errorText = await result.text(); | |
| console.error(`${apiName} TTS API error: ${result.status} ${result.statusText}`, errorText); | |
| const errorMessage = JSON.parse(errorText).error?.message || 'TTS generation failed.'; | |
| return response.status(result.status).json({ error: errorMessage }); | |
| } | |
| /** @type {any} */ | |
| const data = await result.json(); | |
| const audioPart = data?.candidates?.[0]?.content?.parts?.[0]; | |
| const audioData = audioPart?.inlineData?.data; | |
| const mimeType = audioPart?.inlineData?.mimeType; | |
| if (!audioData) { | |
| return response.status(500).json({ error: 'No audio data found in response' }); | |
| } | |
| const audioBuffer = Buffer.from(audioData, 'base64'); | |
| //If the audio is raw PCM, wrap it in a WAV header and send it. | |
| if (mimeType && mimeType.toLowerCase().includes('audio/l16')) { | |
| const rateMatch = mimeType.match(/rate=(\d+)/); | |
| const sampleRate = rateMatch ? parseInt(rateMatch[1], 10) : 24000; | |
| const pcmData = audioBuffer; | |
| // Create a complete, playable WAV file buffer. | |
| const wavBuffer = createCompleteWavFile(pcmData, sampleRate); | |
| // Send the WAV file directly to the browser. This is much faster. | |
| response.setHeader('Content-Type', 'audio/wav'); | |
| return response.send(wavBuffer); | |
| } | |
| // Fallback for any other audio format Google might send in the future. | |
| response.setHeader('Content-Type', mimeType || 'application/octet-stream'); | |
| response.send(audioBuffer); | |
| } catch (error) { | |
| console.error('Google TTS generation failed:', error); | |
| if (!response.headersSent) { | |
| return response.status(500).json({ error: 'Internal server error during TTS generation' }); | |
| } | |
| return response.end(); | |
| } | |
| }); | |
| router.post('/generate-image', async (request, response) => { | |
| try { | |
| const model = request.body.model || 'imagen-3.0-generate-002'; | |
| const { url, headers, apiName } = await getGoogleApiConfig(request, model, 'predict'); | |
| // AI Studio is stricter than Vertex AI. | |
| const isVertex = request.body.api === 'vertexai'; | |
| // Is it even worth it? | |
| const isDeprecated = model.startsWith('imagegeneration'); | |
| // Get person generation setting from config | |
| const personGeneration = getConfigValue('gemini.image.personGeneration', 'allow_adult'); | |
| const requestBody = { | |
| instances: [{ | |
| prompt: request.body.prompt || '', | |
| }], | |
| parameters: { | |
| sampleCount: 1, | |
| seed: isVertex ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined, | |
| enhancePrompt: isVertex ? Boolean(request.body.enhance ?? false) : undefined, | |
| negativePrompt: isVertex ? (request.body.negative_prompt || undefined) : undefined, | |
| aspectRatio: String(request.body.aspect_ratio || '1:1'), | |
| personGeneration: !isDeprecated && personGeneration ? personGeneration : undefined, | |
| language: isVertex ? 'auto' : undefined, | |
| safetySetting: !isDeprecated ? (isVertex ? 'block_only_high' : 'block_low_and_above') : undefined, | |
| addWatermark: isVertex ? false : undefined, | |
| outputOptions: { | |
| mimeType: 'image/jpeg', | |
| compressionQuality: 100, | |
| }, | |
| }, | |
| }; | |
| console.debug(`${apiName} image generation request:`, model, requestBody); | |
| const result = await fetch(url, { | |
| method: 'POST', | |
| headers: headers, | |
| body: JSON.stringify(requestBody), | |
| }); | |
| if (!result.ok) { | |
| const errorText = await result.text(); | |
| console.warn(`${apiName} image generation error: ${result.status} ${result.statusText}`, errorText); | |
| return response.status(500).send('Image generation request failed'); | |
| } | |
| /** @type {any} */ | |
| const data = await result.json(); | |
| const imagePart = data?.predictions?.[0]?.bytesBase64Encoded; | |
| if (!imagePart) { | |
| console.warn(`${apiName} image generation error: No image data found in response`); | |
| return response.status(500).send('No image data found in response'); | |
| } | |
| return response.send({ image: imagePart }); | |
| } catch (error) { | |
| console.error('Google Image generation failed:', error); | |
| if (!response.headersSent) { | |
| return response.sendStatus(500); | |
| } | |
| return response.end(); | |
| } | |
| }); | |
| router.post('/generate-video', async (request, response) => { | |
| try { | |
| const controller = new AbortController(); | |
| request.socket.removeAllListeners('close'); | |
| request.socket.on('close', function () { | |
| controller.abort(); | |
| }); | |
| const model = request.body.model || 'veo-3.1-generate-preview'; | |
| const { url, headers, apiName, baseUrl } = await getGoogleApiConfig(request, model, 'predictLongRunning'); | |
| const useVertexAi = request.body.api === 'vertexai'; | |
| const isVeo3 = /veo-3/.test(model); | |
| const lowerBound = isVeo3 ? 4 : 5; | |
| const upperBound = isVeo3 ? 8 : 8; | |
| const requestBody = { | |
| instances: [{ | |
| prompt: String(request.body.prompt || ''), | |
| }], | |
| parameters: { | |
| negativePrompt: String(request.body.negative_prompt || ''), | |
| durationSeconds: lodash.clamp(Number(request.body.seconds || 6), lowerBound, upperBound), | |
| aspectRatio: String(request.body.aspect_ratio || '16:9'), | |
| personGeneration: 'allow_all', | |
| seed: isVeo3 ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined, | |
| }, | |
| }; | |
| console.debug(`${apiName} video generation request:`, model, requestBody); | |
| const videoJobResponse = await fetch(url, { | |
| method: 'POST', | |
| headers: headers, | |
| body: JSON.stringify(requestBody), | |
| }); | |
| if (!videoJobResponse.ok) { | |
| const errorText = await videoJobResponse.text(); | |
| console.warn(`${apiName} video generation error: ${videoJobResponse.status} ${videoJobResponse.statusText}`, errorText); | |
| return response.status(500).send('Video generation request failed'); | |
| } | |
| /** @type {any} */ | |
| const videoJobData = await videoJobResponse.json(); | |
| const videoJobName = videoJobData?.name; | |
| if (!videoJobName) { | |
| console.warn(`${apiName} video generation error: No job name found in response`); | |
| return response.status(500).send('No video job name found in response'); | |
| } | |
| console.debug(`${apiName} video job name:`, videoJobName); | |
| for (let attempt = 0; attempt < 30; attempt++) { | |
| if (controller.signal.aborted) { | |
| console.info(`${apiName} video generation aborted by client`); | |
| return response.status(500).send('Video generation aborted by client'); | |
| } | |
| await delay(5000 + attempt * 1000); | |
| if (useVertexAi) { | |
| const { url: pollUrl, headers: pollHeaders } = await getGoogleApiConfig(request, model, 'fetchPredictOperation'); | |
| const pollResponse = await fetch(pollUrl, { | |
| method: 'POST', | |
| headers: pollHeaders, | |
| body: JSON.stringify({ operationName: videoJobName }), | |
| }); | |
| if (!pollResponse.ok) { | |
| const errorText = await pollResponse.text(); | |
| console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText); | |
| return response.status(500).send('Video job status request failed'); | |
| } | |
| /** @type {any} */ | |
| const pollData = await pollResponse.json(); | |
| const jobDone = pollData?.done; | |
| console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`); | |
| if (jobDone) { | |
| const videoData = pollData?.response?.videos?.[0]?.bytesBase64Encoded; | |
| if (!videoData) { | |
| const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 }); | |
| console.warn(`${apiName} video generation error: No video data found in response`, pollDataLog); | |
| return response.status(500).send('No video data found in response'); | |
| } | |
| return response.send({ video: videoData }); | |
| } | |
| } else { | |
| const pollUrl = urlJoin(baseUrl, videoJobName); | |
| const pollResponse = await fetch(pollUrl, { | |
| method: 'GET', | |
| headers: headers, | |
| }); | |
| if (!pollResponse.ok) { | |
| const errorText = await pollResponse.text(); | |
| console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText); | |
| return response.status(500).send('Video job status request failed'); | |
| } | |
| /** @type {any} */ | |
| const pollData = await pollResponse.json(); | |
| const jobDone = pollData?.done; | |
| console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`); | |
| if (jobDone) { | |
| const videoUri = pollData?.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri; | |
| console.debug(`${apiName} video URI:`, videoUri); | |
| if (!videoUri) { | |
| const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 }); | |
| console.warn(`${apiName} video generation error: No video URI found in response`, pollDataLog); | |
| return response.status(500).send('No video URI found in response'); | |
| } | |
| const videoResponse = await fetch(videoUri, { | |
| method: 'GET', | |
| headers: headers, | |
| }); | |
| if (!videoResponse.ok) { | |
| console.warn(`${apiName} video fetch error: ${videoResponse.status} ${videoResponse.statusText}`); | |
| return response.status(500).send('Video fetch request failed'); | |
| } | |
| const videoData = await videoResponse.arrayBuffer(); | |
| const videoBase64 = Buffer.from(videoData).toString('base64'); | |
| return response.send({ video: videoBase64 }); | |
| } | |
| } | |
| } | |
| console.warn(`${apiName} video generation error: Job timed out after multiple attempts`); | |
| return response.status(500).send('Video generation timed out'); | |
| } catch (error) { | |
| console.error('Google Video generation failed:', error); | |
| return response.sendStatus(500); | |
| } | |
| }); | |