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

ad72fa806eb0e82d61feec890332f7a0218cd6b3

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +226 -16Ignore whitespace
public/scripts/extensions/stable-diffusion/index.js+48 -0
@@ -337,6 +337,7 @@ const defaultSettings = {
337 // Google settings337 // Google settings
338 google_api: 'makersuite',338 google_api: 'makersuite',
339 google_enhance: true,339 google_enhance: true,
340 google_duration: 6,
340};341};
341342
342const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);343const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
@@ -526,6 +527,7 @@ async function loadSettings() {
526 $('#sd_bfl_upsampling').prop('checked', extension_settings.sd.bfl_upsampling);527 $('#sd_bfl_upsampling').prop('checked', extension_settings.sd.bfl_upsampling);
527 $('#sd_google_api').val(extension_settings.sd.google_api);528 $('#sd_google_api').val(extension_settings.sd.google_api);
528 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);529 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);
530 $('#sd_google_duration').val(extension_settings.sd.google_duration);
529531
530 for (const style of extension_settings.sd.styles) {532 for (const style of extension_settings.sd.styles) {
531 const option = document.createElement('option');533 const option = document.createElement('option');
@@ -2149,6 +2151,9 @@ async function loadNovelModels() {
21492151
2150async function loadGoogleModels() {2152async function loadGoogleModels() {
2151 return [2153 return [
2154 'imagen-4.0-generate-001',
2155 'imagen-4.0-ultra-generate-001',
2156 'imagen-4.0-fast-generate-001',
2152 'imagen-4.0-generate-preview-06-06',2157 'imagen-4.0-generate-preview-06-06',
2153 'imagen-4.0-fast-generate-preview-06-06',2158 'imagen-4.0-fast-generate-preview-06-06',
2154 'imagen-4.0-ultra-generate-preview-06-06',2159 'imagen-4.0-ultra-generate-preview-06-06',
@@ -2159,6 +2164,13 @@ async function loadGoogleModels() {
2159 'imagegeneration@006',2164 'imagegeneration@006',
2160 'imagegeneration@005',2165 'imagegeneration@005',
2161 'imagegeneration@002',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 ].map(name => ({ value: name, text: name }));2174 ].map(name => ({ value: name, text: name }));
2163}2175}
21642176
@@ -3889,6 +3901,38 @@ async function generateFalaiImage(prompt, negativePrompt, signal) {
3889 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.3901 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
3890 */3902 */
3891async function generateGoogleImage(prompt, negativePrompt, signal) {3903async 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 const result = await fetch('/api/google/generate-image', {3936 const result = await fetch('/api/google/generate-image', {
3893 method: 'POST',3937 method: 'POST',
3894 headers: getRequestHeaders(),3938 headers: getRequestHeaders(),
@@ -4837,6 +4881,10 @@ jQuery(async () => {
4837 extension_settings.sd.google_enhance = $(this).prop('checked');4881 extension_settings.sd.google_enhance = $(this).prop('checked');
4838 saveSettingsDebounced();4882 saveSettingsDebounced();
4839 });4883 });
4884 $('#sd_google_duration').on('input', function () {
4885 extension_settings.sd.google_duration = Number($(this).val());
4886 saveSettingsDebounced();
4887 });
4840 $('#sd_electronhub_quality').on('change', function () {4888 $('#sd_electronhub_quality').on('change', function () {
4841 extension_settings.sd.electronhub_quality = String($(this).val());4889 extension_settings.sd.electronhub_quality = String($(this).val());
4842 saveSettingsDebounced();4890 saveSettingsDebounced();
public/scripts/extensions/stable-diffusion/settings.html+9 -1
@@ -302,13 +302,21 @@
302 </select>302 </select>
303 </div>303 </div>
304 </div>304 </div>
305 <div class="flex-container">305 <div class="flex-container alignItemsCenter">
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).">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 <input id="sd_google_enhance" type="checkbox" />307 <input id="sd_google_enhance" type="checkbox" />
308 <span data-i18n="Enhance">308 <span data-i18n="Enhance">
309 Enhance309 Enhance
310 </span>310 </span>
311 </label>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 </div>320 </div>
313 </div>321 </div>
314322
src/endpoints/google.js+169 -15
@@ -3,10 +3,13 @@ import fetch from 'node-fetch';
3import express from 'express';3import express from 'express';
4import { speak, languages } from 'google-translate-api-x';4import { speak, languages } from 'google-translate-api-x';
5import crypto from 'node:crypto';5import crypto from 'node:crypto';
6import util from 'node:util';
7import urlJoin from 'url-join';
8import lodash from 'lodash';
69
7import { readSecret, SECRET_KEYS } from './secrets.js';10import { readSecret, SECRET_KEYS } from './secrets.js';
8import { GEMINI_SAFETY } from '../constants.js';11import { GEMINI_SAFETY } from '../constants.js';
9import { getConfigValue, trimTrailingSlash } from '../util.js';12import { delay, getConfigValue, trimTrailingSlash } from '../util.js';
1013
11const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';14const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
12const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';15const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
@@ -156,7 +159,7 @@ export function getProjectIdFromServiceAccount(serviceAccount) {
156 * @param {express.Request} request Express request object159 * @param {express.Request} request Express request object
157 * @param {string} model Model name to use160 * @param {string} model Model name to use
158 * @param {string} endpoint API endpoint (default: 'generateContent')161 * @param {string} endpoint API endpoint (default: 'generateContent')
159 * @returns {Promise<{url: string, headers: object, apiName: string}>} URL, headers, and API name162 * @returns {Promise<{url: string, headers: object, apiName: string, baseUrl: string}>} URL, headers, and API name
160 */163 */
161export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') {164export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') {
162 const useVertexAi = request.body.api === 'vertexai';165 const useVertexAi = request.body.api === 'vertexai';
@@ -164,6 +167,7 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon
164 const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';167 const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';
165168
166 let url;169 let url;
170 let baseUrl;
167 let headers = {171 let headers = {
168 'Content-Type': 'application/json',172 'Content-Type': 'application/json',
169 };173 };
@@ -176,12 +180,13 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon
176 // Express mode: use API key parameter180 // Express mode: use API key parameter
177 const keyParam = authHeader.replace('Bearer ', '');181 const keyParam = authHeader.replace('Bearer ', '');
178 const projectId = request.body.vertexai_express_project_id;182 const projectId = request.body.vertexai_express_project_id;
179 const baseUrl = region === 'global'183 baseUrl = region === 'global'
180 ? 'https://aiplatform.googleapis.com'184 ? 'https://aiplatform.googleapis.com/v1'
181 : `https://${region}-aiplatform.googleapis.com`;185 : `https://${region}-aiplatform.googleapis.com/v1`;
182 url = projectId186 url = projectId
183 ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}?key=${keyParam}`187 ? `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`
184 : `${baseUrl}/v1/publishers/google/models/${model}:${endpoint}?key=${keyParam}`;188 : `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
189 headers['x-goog-api-key'] = keyParam;
185 } else if (authType === 'full') {190 } else if (authType === 'full') {
186 // Full mode: use project-specific URL with Authorization header191 // Full mode: use project-specific URL with Authorization header
187 // Get project ID from Service Account JSON192 // Get project ID from Service Account JSON
@@ -198,14 +203,16 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon
198 throw new Error('Failed to extract project ID from Service Account JSON.');203 throw new Error('Failed to extract project ID from Service Account JSON.');
199 }204 }
200 // Handle global region differently - no region prefix in hostname205 // Handle global region differently - no region prefix in hostname
201 url = region === 'global'206 baseUrl = region === 'global'
202 ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`207 ? 'https://aiplatform.googleapis.com/v1'
203 : `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`;208 : `https://${region}-aiplatform.googleapis.com/v1`;
209 url = `${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`;
204 headers['Authorization'] = authHeader;210 headers['Authorization'] = authHeader;
205 } else {211 } else {
206 // Proxy mode: use Authorization header212 // Proxy mode: use Authorization header
207 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI);213 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI);
208 url = `${apiUrl}/v1/publishers/google/models/${model}:${endpoint}`;214 baseUrl = `${apiUrl}/v1`;
215 url = `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
209 headers['Authorization'] = authHeader;216 headers['Authorization'] = authHeader;
210 }217 }
211 } else {218 } else {
@@ -213,10 +220,12 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon
213 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);220 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
214 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE);221 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE);
215 const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta');222 const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta');
216 url = `${apiUrl}/${apiVersion}/models/${model}:${endpoint}?key=${apiKey}`;223 baseUrl = `${apiUrl}/${apiVersion}`;
224 url = `${baseUrl}/models/${model}:${endpoint}`;
225 headers['x-goog-api-key'] = apiKey;
217 }226 }
218227
219 return { url, headers, apiName };228 return { url, headers, apiName, baseUrl };
220}229}
221230
222export const router = express.Router();231export const router = express.Router();
@@ -461,7 +470,7 @@ router.post('/generate-image', async (request, response) => {
461 if (!result.ok) {470 if (!result.ok) {
462 const errorText = await result.text();471 const errorText = await result.text();
463 console.warn(`${apiName} image generation error: ${result.status} ${result.statusText}`, errorText);472 console.warn(`${apiName} image generation error: ${result.status} ${result.statusText}`, errorText);
464 return response.sendStatus(500);473 return response.status(500).send('Image generation request failed');
465 }474 }
466475
467 /** @type {any} */476 /** @type {any} */
@@ -470,7 +479,7 @@ router.post('/generate-image', async (request, response) => {
470479
471 if (!imagePart) {480 if (!imagePart) {
472 console.warn(`${apiName} image generation error: No image data found in response`);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 }
475484
476 return response.send({ image: imagePart });485 return response.send({ image: imagePart });
@@ -482,3 +491,148 @@ router.post('/generate-image', async (request, response) => {
482 return response.end();491 return response.end();
483 }492 }
484});493});
494
495router.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});