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 -16Showing whitespace changes
public/scripts/extensions/stable-diffusion/index.js+48 -0
@@ -337,6 +337,7 @@ const defaultSettings = {
337337 // Google settings
338338 google_api: 'makersuite',
339339 google_enhance: true,
340+ google_duration: 6,
340341};
341342
342343const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
@@ -526,6 +527,7 @@ async function loadSettings() {
526527 $('#sd_bfl_upsampling').prop('checked', extension_settings.sd.bfl_upsampling);
527528 $('#sd_google_api').val(extension_settings.sd.google_api);
528529 $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance);
530+ $('#sd_google_duration').val(extension_settings.sd.google_duration);
529531
530532 for (const style of extension_settings.sd.styles) {
531533 const option = document.createElement('option');
@@ -2149,6 +2151,9 @@ async function loadNovelModels() {
21492151
21502152async function loadGoogleModels() {
21512153 return [
2154+ 'imagen-4.0-generate-001',
2155+ 'imagen-4.0-ultra-generate-001',
2156+ 'imagen-4.0-fast-generate-001',
21522157 'imagen-4.0-generate-preview-06-06',
21532158 'imagen-4.0-fast-generate-preview-06-06',
21542159 'imagen-4.0-ultra-generate-preview-06-06',
@@ -2159,6 +2164,13 @@ async function loadGoogleModels() {
21592164 'imagegeneration@006',
21602165 'imagegeneration@005',
21612166 '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',
21622174 ].map(name => ({ value: name, text: name }));
21632175}
21642176
@@ -3889,6 +3901,38 @@ async function generateFalaiImage(prompt, negativePrompt, signal) {
38893901 * @returns {Promise<{format: string, data: string}>} A promise that resolves when the image generation and processing are complete.
38903902 */
38913903async 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+
38923936 const result = await fetch('/api/google/generate-image', {
38933937 method: 'POST',
38943938 headers: getRequestHeaders(),
@@ -4837,6 +4881,10 @@ jQuery(async () => {
48374881 extension_settings.sd.google_enhance = $(this).prop('checked');
48384882 saveSettingsDebounced();
48394883 });
4884+ $('#sd_google_duration').on('input', function () {
4885+ extension_settings.sd.google_duration = Number($(this).val());
4886+ saveSettingsDebounced();
4887+ });
48404888 $('#sd_electronhub_quality').on('change', function () {
48414889 extension_settings.sd.electronhub_quality = String($(this).val());
48424890 saveSettingsDebounced();
public/scripts/extensions/stable-diffusion/settings.html+9 -1
@@ -302,13 +302,21 @@
302302 </select>
303303 </div>
304304 </div>
305305 <div class="flex-container alignItemsCenter">
306306 <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).">
307307 <input id="sd_google_enhance" type="checkbox" />
308308 <span data-i18n="Enhance">
309309 Enhance
310310 </span>
311311 </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>
312320 </div>
313321 </div>
314322
src/endpoints/google.js+169 -15
@@ -3,10 +3,13 @@ import fetch from 'node-fetch';
33import express from 'express';
44import { speak, languages } from 'google-translate-api-x';
55import crypto from 'node:crypto';
6+import util from 'node:util';
7+import urlJoin from 'url-join';
8+import lodash from 'lodash';
69
710import { readSecret, SECRET_KEYS } from './secrets.js';
811import { GEMINI_SAFETY } from '../constants.js';
912import { delay, getConfigValue, trimTrailingSlash } from '../util.js';
1013
1114const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
1215const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
@@ -156,7 +159,7 @@ export function getProjectIdFromServiceAccount(serviceAccount) {
156159 * @param {express.Request} request Express request object
157160 * @param {string} model Model name to use
158161 * @param {string} endpoint API endpoint (default: 'generateContent')
159162 * @returns {Promise<{url: string, headers: object, apiName: string, baseUrl: string}>} URL, headers, and API name
160163 */
161164export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') {
162165 const useVertexAi = request.body.api === 'vertexai';
@@ -164,6 +167,7 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon
164167 const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';
165168
166169 let url;
170+ let baseUrl;
167171 let headers = {
168172 'Content-Type': 'application/json',
169173 };
@@ -176,12 +180,13 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon
176180 // Express mode: use API key parameter
177181 const keyParam = authHeader.replace('Bearer ', '');
178182 const projectId = request.body.vertexai_express_project_id;
179183 const baseUrl = region === 'global'
180184 ? 'https://aiplatform.googleapis.com/v1'
181185 : `https://${region}-aiplatform.googleapis.com/v1`;
182186 url = projectId
183187 ? `https://aiplatform.googleapis.com/v1${baseUrl}/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}?key=${keyParam}`
184188 : `${baseUrl}/v1/publishers/google/models/${model}:${endpoint}?key=${keyParam}`;
189+ headers['x-goog-api-key'] = keyParam;
185190 } else if (authType === 'full') {
186191 // Full mode: use project-specific URL with Authorization header
187192 // Get project ID from Service Account JSON
@@ -198,14 +203,16 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon
198203 throw new Error('Failed to extract project ID from Service Account JSON.');
199204 }
200205 // Handle global region differently - no region prefix in hostname
201206 urlbaseUrl = region === 'global'
202- ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`
207+ ? 'https://aiplatform.googleapis.com/v1'
203208 : `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}`;
204210 headers['Authorization'] = authHeader;
205211 } else {
206212 // Proxy mode: use Authorization header
207213 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI);
208214 urlbaseUrl = `${apiUrl}/v1/publishers/google/models/${model}:${endpoint}`;
215+ url = `${baseUrl}/publishers/google/models/${model}:${endpoint}`;
209216 headers['Authorization'] = authHeader;
210217 }
211218 } else {
@@ -213,10 +220,12 @@ export async function getGoogleApiConfig(request, model, endpoint = 'generateCon
213220 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
214221 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE);
215222 const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta');
216223 urlbaseUrl = `${apiUrl}/${apiVersion}/models/${model}:${endpoint}?key=${apiKey}`;
224+ url = `${baseUrl}/models/${model}:${endpoint}`;
225+ headers['x-goog-api-key'] = apiKey;
217226 }
218227
219228 return { url, headers, apiName, baseUrl };
220229}
221230
222231export const router = express.Router();
@@ -461,7 +470,7 @@ router.post('/generate-image', async (request, response) => {
461470 if (!result.ok) {
462471 const errorText = await result.text();
463472 console.warn(`${apiName} image generation error: ${result.status} ${result.statusText}`, errorText);
464473 return response.sendStatusstatus(500).send('Image generation request failed');
465474 }
466475
467476 /** @type {any} */
@@ -470,7 +479,7 @@ router.post('/generate-image', async (request, response) => {
470479
471480 if (!imagePart) {
472481 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');
474483 }
475484
476485 return response.send({ image: imagePart });
@@ -482,3 +491,148 @@ router.post('/generate-image', async (request, response) => {
482491 return response.end();
483492 }
484493});
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+});