Google: Add Veo models (#4742) * Google: Add Veo models * Combine retry into a singe loop * Improve error logging and reporting * Add client abort handling for video generation endpoint
Signed| @@ -337,6 +337,7 @@ const defaultSettings = { | ||
| 337 | 337 | // Google settings |
| 338 | 338 | google_api: 'makersuite', |
| 339 | 339 | google_enhance: true, |
| 340 | + google_duration: 6, | |
| 340 | 341 | }; |
| 341 | 342 | |
| 342 | 343 | const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed); |
| @@ -526,6 +527,7 @@ async function loadSettings() { | ||
| 526 | 527 | $('#sd_bfl_upsampling').prop('checked', extension_settings.sd.bfl_upsampling); |
| 527 | 528 | $('#sd_google_api').val(extension_settings.sd.google_api); |
| 528 | 529 | $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance); |
| 530 | + $('#sd_google_duration').val(extension_settings.sd.google_duration); | |
| 529 | 531 | |
| 530 | 532 | for (const style of extension_settings.sd.styles) { |
| 531 | 533 | const option = document.createElement('option'); |
| @@ -2149,6 +2151,9 @@ async function loadNovelModels() { | ||
| 2149 | 2151 | |
| 2150 | 2152 | async function loadGoogleModels() { |
| 2151 | 2153 | return [ |
| 2154 | + 'imagen-4.0-generate-001', | |
| 2155 | + 'imagen-4.0-ultra-generate-001', | |
| 2156 | + 'imagen-4.0-fast-generate-001', | |
| 2152 | 2157 | 'imagen-4.0-generate-preview-06-06', |
| 2153 | 2158 | 'imagen-4.0-fast-generate-preview-06-06', |
| 2154 | 2159 | 'imagen-4.0-ultra-generate-preview-06-06', |
| @@ -2159,6 +2164,13 @@ async function loadGoogleModels() { | ||
| 2159 | 2164 | 'imagegeneration@006', |
| 2160 | 2165 | 'imagegeneration@005', |
| 2161 | 2166 | 'imagegeneration@002', |
| 2167 | + 'veo-3.1-generate-preview', | |
| 2168 | + 'veo-3.1-fast-generate-preview', | |
| 2169 | + 'veo-3.0-generate-001', | |
| 2170 | + 'veo-3.0-fast-generate-001', | |
| 2171 | + 'veo-2.0-generate-001', | |
| 2172 | + 'veo-2.0-generate-exp', | |
| 2173 | + 'veo-2.0-generate-preview', | |
| 2162 | 2174 | ].map(name => ({ value: name, text: name })); |
| 2163 | 2175 | } |
| 2164 | 2176 | |
| @@ -3889,6 +3901,38 @@ async function generateFalaiImage(prompt, negativePrompt, signal) { | ||
| 3889 | 3901 | * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete. |
| 3890 | 3902 | */ |
| 3891 | 3903 | async function generateGoogleImage(prompt, negativePrompt, signal) { |
| 3904 | + const isVeo = /^veo-/.test(extension_settings.sd.model); | |
| 3905 | + | |
| 3906 | + if (isVeo) { | |
| 3907 | + const aspectRatio = extension_settings.sd.width / extension_settings.sd.height; | |
| 3908 | + const maxPromptLength = 3000; // 1024 tokens approx. | |
| 3909 | + const videoResult = await fetch('/api/google/generate-video', { | |
| 3910 | + method: 'POST', | |
| 3911 | + headers: getRequestHeaders(), | |
| 3912 | + signal: signal, | |
| 3913 | + body: JSON.stringify({ | |
| 3914 | + prompt: prompt.slice(0, maxPromptLength), | |
| 3915 | + aspect_ratio: aspectRatio >= 1 ? '16:9' : '9:16', | |
| 3916 | + seconds: extension_settings.sd.google_duration, | |
| 3917 | + negative_prompt: negativePrompt, | |
| 3918 | + model: extension_settings.sd.model, | |
| 3919 | + api: extension_settings.sd.google_api || 'makersuite', | |
| 3920 | + seed: extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : undefined, | |
| 3921 | + vertexai_auth_mode: oai_settings.vertexai_auth_mode, | |
| 3922 | + vertexai_region: oai_settings.vertexai_region, | |
| 3923 | + vertexai_express_project_id: oai_settings.vertexai_express_project_id, | |
| 3924 | + }), | |
| 3925 | + }); | |
| 3926 | + | |
| 3927 | + if (!videoResult.ok) { | |
| 3928 | + const text = await videoResult.text(); | |
| 3929 | + throw new Error(text); | |
| 3930 | + } | |
| 3931 | + | |
| 3932 | + const data = await videoResult.json(); | |
| 3933 | + return { format: 'mp4', data: data.video }; | |
| 3934 | + } | |
| 3935 | + | |
| 3892 | 3936 | const result = await fetch('/api/google/generate-image', { |
| 3893 | 3937 | method: 'POST', |
| 3894 | 3938 | headers: getRequestHeaders(), |
| @@ -4837,6 +4881,10 @@ jQuery(async () => { | ||
| 4837 | 4881 | extension_settings.sd.google_enhance = $(this).prop('checked'); |
| 4838 | 4882 | saveSettingsDebounced(); |
| 4839 | 4883 | }); |
| 4884 | + $('#sd_google_duration').on('input', function () { | |
| 4885 | + extension_settings.sd.google_duration = Number($(this).val()); | |
| 4886 | + saveSettingsDebounced(); | |
| 4887 | + }); | |
| 4840 | 4888 | $('#sd_electronhub_quality').on('change', function () { |
| 4841 | 4889 | extension_settings.sd.electronhub_quality = String($(this).val()); |
| 4842 | 4890 | saveSettingsDebounced(); |
| @@ -302,13 +302,21 @@ | ||
| 302 | 302 | </select> |
| 303 | 303 | </div> |
| 304 | 304 | </div> |
| 305 | 305 | <div class="flex-container alignItemsCenter"> |
| 306 | 306 | <label class="flex1 checkbox_label" for="sd_google_enhance" data-i18n="[title]Enables prompt enhancing (passes prompts through an LLM to add detail)." title="Enables prompt enhancing (passes prompts through an LLM to add detail)."> |
| 307 | 307 | <input id="sd_google_enhance" type="checkbox" /> |
| 308 | 308 | <span data-i18n="Enhance"> |
| 309 | 309 | Enhance |
| 310 | 310 | </span> |
| 311 | 311 | </label> |
| 312 | + <div class="flex1"> | |
| 313 | + <label for="sd_google_duration" data-i18n="Duration (Veo)">Duration (Veo)</label> | |
| 314 | + <select id="sd_google_duration"> | |
| 315 | + <option value="4">Short (4 seconds)</option> | |
| 316 | + <option value="6">Medium (6 seconds)</option> | |
| 317 | + <option value="8">Long (8 seconds)</option> | |
| 318 | + </select> | |
| 319 | + </div> | |
| 312 | 320 | </div> |
| 313 | 321 | </div> |
| 314 | 322 | |
| @@ -3,10 +3,13 @@ import fetch from 'node-fetch'; | ||
| 3 | 3 | import express from 'express'; |
| 4 | 4 | import { speak, languages } from 'google-translate-api-x'; |
| 5 | 5 | import crypto from 'node:crypto'; |
| 6 | +import util from 'node:util'; | |
| 7 | +import urlJoin from 'url-join'; | |
| 8 | +import lodash from 'lodash'; | |
| 6 | 9 | |
| 7 | 10 | import { readSecret, SECRET_KEYS } from './secrets.js'; |
| 8 | 11 | import { GEMINI_SAFETY } from '../constants.js'; |
| 9 | 12 | import { delay, getConfigValue, trimTrailingSlash } from '../util.js'; |
| 10 | 13 | |
| 11 | 14 | const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; |
| 12 | 15 | const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com'; |
| @@ -156,7 +159,7 @@ export function getProjectIdFromServiceAccount(serviceAccount) { | ||
| 156 | 159 | * @param {express.Request} request Express request object |
| 157 | 160 | * @param {string} model Model name to use |
| 158 | 161 | * @param {string} endpoint API endpoint (default: 'generateContent') |
| 159 | 162 | * @returns {Promise<{url: string, headers: object, apiName: string, baseUrl: string}>} URL, headers, and API name |
| 160 | 163 | */ |
| 161 | 164 | export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') { |
| 162 | 165 | const useVertexAi = request.body.api === 'vertexai'; |
| @@ -164,6 +167,7 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon | ||
| 164 | 167 | const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio'; |
| 165 | 168 | |
| 166 | 169 | let url; |
| 170 | + let baseUrl; | |
| 167 | 171 | let headers = { |
| 168 | 172 | 'Content-Type': 'application/json', |
| 169 | 173 | }; |
| @@ -176,12 +180,13 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon | ||
| 176 | 180 | // Express mode: use API key parameter |
| 177 | 181 | const keyParam = authHeader.replace('Bearer ', ''); |
| 178 | 182 | const projectId = request.body.vertexai_express_project_id; |
| 179 | 183 | const baseUrl = region === 'global' |
| 180 | 184 | ? 'https://aiplatform.googleapis.com/v1' |
| 181 | 185 | : `https://${region}-aiplatform.googleapis.com/v1`; |
| 182 | 186 | url = projectId |
| 183 | 187 | ? `https://aiplatform.googleapis.com/v1${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}?key=${keyParam}` |
| 184 | 188 | : `${baseUrl}/v1/publishers/google/models/${model}:${endpoint}?key=${keyParam}`; |
| 189 | + headers['x-goog-api-key'] = keyParam; | |
| 185 | 190 | } else if (authType === 'full') { |
| 186 | 191 | // Full mode: use project-specific URL with Authorization header |
| 187 | 192 | // Get project ID from Service Account JSON |
| @@ -198,14 +203,16 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon | ||
| 198 | 203 | throw new Error('Failed to extract project ID from Service Account JSON.'); |
| 199 | 204 | } |
| 200 | 205 | // Handle global region differently - no region prefix in hostname |
| 201 | 206 | urlbaseUrl = region === 'global' |
| 202 | - ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}` | |
| 207 | + ? 'https://aiplatform.googleapis.com/v1' | |
| 203 | 208 | : `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`; |
| 209 | + url = `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`; | |
| 204 | 210 | headers['Authorization'] = authHeader; |
| 205 | 211 | } else { |
| 206 | 212 | // Proxy mode: use Authorization header |
| 207 | 213 | const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI); |
| 208 | 214 | urlbaseUrl = `${apiUrl}/v1/publishers/google/models/${model}:${endpoint}`; |
| 215 | + url = `${baseUrl}/publishers/google/models/${model}:${endpoint}`; | |
| 209 | 216 | headers['Authorization'] = authHeader; |
| 210 | 217 | } |
| 211 | 218 | } else { |
| @@ -213,10 +220,12 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon | ||
| 213 | 220 | const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); |
| 214 | 221 | const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE); |
| 215 | 222 | const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta'); |
| 216 | 223 | urlbaseUrl = `${apiUrl}/${apiVersion}/models/${model}:${endpoint}?key=${apiKey}`; |
| 224 | + url = `${baseUrl}/models/${model}:${endpoint}`; | |
| 225 | + headers['x-goog-api-key'] = apiKey; | |
| 217 | 226 | } |
| 218 | 227 | |
| 219 | 228 | return { url, headers, apiName, baseUrl }; |
| 220 | 229 | } |
| 221 | 230 | |
| 222 | 231 | export const router = express.Router(); |
| @@ -461,7 +470,7 @@ router.post('/generate-image', async (request, response) => { | ||
| 461 | 470 | if (!result.ok) { |
| 462 | 471 | const errorText = await result.text(); |
| 463 | 472 | console.warn(`${apiName} image generation error: ${result.status} ${result.statusText}`, errorText); |
| 464 | 473 | return response.sendStatusstatus(500).send('Image generation request failed'); |
| 465 | 474 | } |
| 466 | 475 | |
| 467 | 476 | /** @type {any} */ |
| @@ -470,7 +479,7 @@ router.post('/generate-image', async (request, response) => { | ||
| 470 | 479 | |
| 471 | 480 | if (!imagePart) { |
| 472 | 481 | console.warn(`${apiName} image generation error: No image data found in response`); |
| 473 | - return response.sendStatus(500); | |
| 482 | + return response.status(500).send('No image data found in response'); | |
| 474 | 483 | } |
| 475 | 484 | |
| 476 | 485 | return response.send({ image: imagePart }); |
| @@ -482,3 +491,148 @@ router.post('/generate-image', async (request, response) => { | ||
| 482 | 491 | return response.end(); |
| 483 | 492 | } |
| 484 | 493 | }); |
| 494 | + | |
| 495 | +router.post('/generate-video', async (request, response) => { | |
| 496 | + try { | |
| 497 | + const controller = new AbortController(); | |
| 498 | + request.socket.removeAllListeners('close'); | |
| 499 | + request.socket.on('close', function () { | |
| 500 | + controller.abort(); | |
| 501 | + }); | |
| 502 | + | |
| 503 | + const model = request.body.model || 'veo-3.1-generate-preview'; | |
| 504 | + const { url, headers, apiName, baseUrl } = await getGoogleApiConfig(request, model, 'predictLongRunning'); | |
| 505 | + const useVertexAi = request.body.api === 'vertexai'; | |
| 506 | + | |
| 507 | + const isVeo3 = /veo-3/.test(model); | |
| 508 | + const lowerBound = isVeo3 ? 4 : 5; | |
| 509 | + const upperBound = isVeo3 ? 8 : 8; | |
| 510 | + | |
| 511 | + const requestBody = { | |
| 512 | + instances: [{ | |
| 513 | + prompt: String(request.body.prompt || ''), | |
| 514 | + }], | |
| 515 | + parameters: { | |
| 516 | + negativePrompt: String(request.body.negative_prompt || ''), | |
| 517 | + durationSeconds: lodash.clamp(Number(request.body.seconds || 6), lowerBound, upperBound), | |
| 518 | + aspectRatio: String(request.body.aspect_ratio || '16:9'), | |
| 519 | + personGeneration: 'allow_all', | |
| 520 | + seed: isVeo3 ? Number(request.body.seed ?? Math.floor(Math.random() * 1000000)) : undefined, | |
| 521 | + }, | |
| 522 | + }; | |
| 523 | + | |
| 524 | + console.debug(`${apiName} video generation request:`, model, requestBody); | |
| 525 | + const videoJobResponse = await fetch(url, { | |
| 526 | + method: 'POST', | |
| 527 | + headers: headers, | |
| 528 | + body: JSON.stringify(requestBody), | |
| 529 | + }); | |
| 530 | + | |
| 531 | + if (!videoJobResponse.ok) { | |
| 532 | + const errorText = await videoJobResponse.text(); | |
| 533 | + console.warn(`${apiName} video generation error: ${videoJobResponse.status} ${videoJobResponse.statusText}`, errorText); | |
| 534 | + return response.status(500).send('Video generation request failed'); | |
| 535 | + } | |
| 536 | + | |
| 537 | + /** @type {any} */ | |
| 538 | + const videoJobData = await videoJobResponse.json(); | |
| 539 | + const videoJobName = videoJobData?.name; | |
| 540 | + | |
| 541 | + if (!videoJobName) { | |
| 542 | + console.warn(`${apiName} video generation error: No job name found in response`); | |
| 543 | + return response.status(500).send('No video job name found in response'); | |
| 544 | + } | |
| 545 | + | |
| 546 | + console.debug(`${apiName} video job name:`, videoJobName); | |
| 547 | + | |
| 548 | + for (let attempt = 0; attempt < 30; attempt++) { | |
| 549 | + if (controller.signal.aborted) { | |
| 550 | + console.info(`${apiName} video generation aborted by client`); | |
| 551 | + return response.status(500).send('Video generation aborted by client'); | |
| 552 | + } | |
| 553 | + | |
| 554 | + await delay(5000 + attempt * 1000); | |
| 555 | + | |
| 556 | + if (useVertexAi) { | |
| 557 | + const { url: pollUrl, headers: pollHeaders } = await getGoogleApiConfig(request, model, 'fetchPredictOperation'); | |
| 558 | + | |
| 559 | + const pollResponse = await fetch(pollUrl, { | |
| 560 | + method: 'POST', | |
| 561 | + headers: pollHeaders, | |
| 562 | + body: JSON.stringify({ operationName: videoJobName }), | |
| 563 | + }); | |
| 564 | + | |
| 565 | + if (!pollResponse.ok) { | |
| 566 | + const errorText = await pollResponse.text(); | |
| 567 | + console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText); | |
| 568 | + return response.status(500).send('Video job status request failed'); | |
| 569 | + } | |
| 570 | + | |
| 571 | + /** @type {any} */ | |
| 572 | + const pollData = await pollResponse.json(); | |
| 573 | + const jobDone = pollData?.done; | |
| 574 | + console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`); | |
| 575 | + | |
| 576 | + if (jobDone) { | |
| 577 | + const videoData = pollData?.response?.videos?.[0]?.bytesBase64Encoded; | |
| 578 | + if (!videoData) { | |
| 579 | + const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 }); | |
| 580 | + console.warn(`${apiName} video generation error: No video data found in response`, pollDataLog); | |
| 581 | + return response.status(500).send('No video data found in response'); | |
| 582 | + } | |
| 583 | + | |
| 584 | + return response.send({ video: videoData }); | |
| 585 | + } | |
| 586 | + } else { | |
| 587 | + const pollUrl = urlJoin(baseUrl, videoJobName); | |
| 588 | + const pollResponse = await fetch(pollUrl, { | |
| 589 | + method: 'GET', | |
| 590 | + headers: headers, | |
| 591 | + }); | |
| 592 | + | |
| 593 | + if (!pollResponse.ok) { | |
| 594 | + const errorText = await pollResponse.text(); | |
| 595 | + console.warn(`${apiName} video job status error: ${pollResponse.status} ${pollResponse.statusText}`, errorText); | |
| 596 | + return response.status(500).send('Video job status request failed'); | |
| 597 | + } | |
| 598 | + | |
| 599 | + /** @type {any} */ | |
| 600 | + const pollData = await pollResponse.json(); | |
| 601 | + const jobDone = pollData?.done; | |
| 602 | + console.debug(`${apiName} video job status attempt ${attempt + 1}: ${jobDone ? 'done' : 'running'}`); | |
| 603 | + | |
| 604 | + if (jobDone) { | |
| 605 | + const videoUri = pollData?.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri; | |
| 606 | + console.debug(`${apiName} video URI:`, videoUri); | |
| 607 | + | |
| 608 | + if (!videoUri) { | |
| 609 | + const pollDataLog = util.inspect(pollData, { depth: 5, colors: true, maxStringLength: 500 }); | |
| 610 | + console.warn(`${apiName} video generation error: No video URI found in response`, pollDataLog); | |
| 611 | + return response.status(500).send('No video URI found in response'); | |
| 612 | + } | |
| 613 | + | |
| 614 | + const videoResponse = await fetch(videoUri, { | |
| 615 | + method: 'GET', | |
| 616 | + headers: headers, | |
| 617 | + }); | |
| 618 | + | |
| 619 | + if (!videoResponse.ok) { | |
| 620 | + console.warn(`${apiName} video fetch error: ${videoResponse.status} ${videoResponse.statusText}`); | |
| 621 | + return response.status(500).send('Video fetch request failed'); | |
| 622 | + } | |
| 623 | + | |
| 624 | + const videoData = await videoResponse.arrayBuffer(); | |
| 625 | + const videoBase64 = Buffer.from(videoData).toString('base64'); | |
| 626 | + | |
| 627 | + return response.send({ video: videoBase64 }); | |
| 628 | + } | |
| 629 | + } | |
| 630 | + } | |
| 631 | + | |
| 632 | + console.warn(`${apiName} video generation error: Job timed out after multiple attempts`); | |
| 633 | + return response.status(500).send('Video generation timed out'); | |
| 634 | + } catch (error) { | |
| 635 | + console.error('Google Video generation failed:', error); | |
| 636 | + return response.sendStatus(500); | |
| 637 | + } | |
| 638 | +}); | |