Image Generation: Support video outputs for ComfyUI (#4194) * ImgGen: ComfyUI support video outputs * Fix video format check to handle undefined and case sensitivity * Fix error handling and add output debug log in comfy workflows * Refactor Comfy generation result * Add video file extensions constant
Signed| @@ -22,3 +22,9 @@ export const debounce_timeout = { | ||
| 22 | 22 | * which is needed to preserve world info timed effects. |
| 23 | 23 | */ |
| 24 | 24 | export const IGNORE_SYMBOL = Symbol.for('ignore'); |
| 25 | + | |
| 26 | +/** | |
| 27 | + * Common video file extensions. Should be the same as supported by Gemini. | |
| 28 | + * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats | |
| 29 | + */ | |
| 30 | +export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv']; | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { getRequestHeaders } from '../script.js'; |
| 2 | +import { VIDEO_EXTENSIONS } from './constants.js'; | |
| 2 | 3 | import { t } from './i18n.js'; |
| 3 | 4 | import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js'; |
| 4 | 5 | import { renderTemplateAsync } from './templates.js'; |
| @@ -350,7 +351,7 @@ class DataMaidDialog { | ||
| 350 | 351 | * @private |
| 351 | 352 | */ |
| 352 | 353 | async getViewElement(url, name) { |
| 353 | - const isVideo = /\.(mp4|webm|ogg|avi|mov|3gp|flv|mkv|wmv)$/i.test(name); | |
| 354 | + const isVideo = VIDEO_EXTENSIONS.includes(name.split('.').pop()); | |
| 354 | 355 | const mediaElement = document.createElement(isVideo ? 'video' : 'img'); |
| 355 | 356 | if (mediaElement instanceof HTMLVideoElement) { |
| 356 | 357 | mediaElement.controls = true; |
| @@ -51,7 +51,7 @@ import { | ||
| 51 | 51 | SlashCommandArgument, |
| 52 | 52 | SlashCommandNamedArgument, |
| 53 | 53 | } from '../../slash-commands/SlashCommandArgument.js'; |
| 54 | 54 | import { debounce_timeout, VIDEO_EXTENSIONS } from '../../constants.js'; |
| 55 | 55 | import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 56 | 56 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 57 | 57 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| @@ -339,6 +339,7 @@ const defaultSettings = { | ||
| 339 | 339 | }; |
| 340 | 340 | |
| 341 | 341 | const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed); |
| 342 | +const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase()); | |
| 342 | 343 | |
| 343 | 344 | /** |
| 344 | 345 | * Generate interceptor for interactive mode triggers. |
| @@ -2476,14 +2477,14 @@ async function generatePicture(initiator, args, trigger, message, callback) { | ||
| 2476 | 2477 | |
| 2477 | 2478 | if (generationType === generationMode.BACKGROUND) { |
| 2478 | 2479 | const callbackOriginal = callback; |
| 2479 | 2480 | callback = async function (prompt, imagePath, generationType, _negativePromptPrefix, _initiator, prefixedPrompt, format) { |
| 2480 | 2481 | const imgUrl = `url("${encodeURI(imagePath)}")`; |
| 2481 | 2482 | await eventSource.emit(event_types.FORCE_SET_BACKGROUND, { url: imgUrl, path: imagePath }); |
| 2482 | 2483 | |
| 2483 | 2484 | if (typeof callbackOriginal === 'function') { |
| 2484 | 2485 | await callbackOriginal(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt, format); |
| 2485 | 2486 | } else { |
| 2486 | 2487 | await sendMessage(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt, format); |
| 2487 | 2488 | } |
| 2488 | 2489 | }; |
| 2489 | 2490 | } |
| @@ -2838,8 +2839,8 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 2838 | 2839 | const filename = `${characterName}_${humanizedDateTime()}`; |
| 2839 | 2840 | const base64Image = await saveBase64AsFile(result.data, characterName, filename, result.format); |
| 2840 | 2841 | callback |
| 2841 | 2842 | ? await callback(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, result.format) |
| 2842 | 2843 | : await sendMessage(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, result.format); |
| 2843 | 2844 | return base64Image; |
| 2844 | 2845 | } |
| 2845 | 2846 | |
| @@ -3524,7 +3525,8 @@ async function generateComfyImage(prompt, negativePrompt, signal) { | ||
| 3524 | 3525 | const text = await promptResult.text(); |
| 3525 | 3526 | throw new Error(text); |
| 3526 | 3527 | } |
| 3527 | 3528 | returnconst { format: 'png', data: } = await promptResult.textjson() }; |
| 3529 | + return { format, data }; | |
| 3528 | 3530 | } |
| 3529 | 3531 | |
| 3530 | 3532 | |
| @@ -3864,8 +3866,9 @@ async function onComfyDeleteWorkflowClick() { | ||
| 3864 | 3866 | * @param {string} additionalNegativePrefix Additional negative prompt used for the image generation |
| 3865 | 3867 | * @param {string} initiator The initiator of the image generation |
| 3866 | 3868 | * @param {string} prefixedPrompt Prompt with an attached specific prefix |
| 3869 | + * @param {string} format Format of the image (e.g., 'png', 'jpg') | |
| 3867 | 3870 | */ |
| 3868 | 3871 | async function sendMessage(prompt, image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, format) { |
| 3869 | 3872 | const context = getContext(); |
| 3870 | 3873 | const name = context.groupId ? systemUserName : context.name2; |
| 3871 | 3874 | const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}'; |
| @@ -3885,6 +3888,12 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref | ||
| 3885 | 3888 | image_swipes: [image], |
| 3886 | 3889 | }, |
| 3887 | 3890 | }; |
| 3891 | + if (isVideo(format)) { | |
| 3892 | + message.extra.video = image; | |
| 3893 | + delete message.extra.image; | |
| 3894 | + delete message.extra.image_swipes; | |
| 3895 | + delete message.extra.inline_image; | |
| 3896 | + } | |
| 3888 | 3897 | context.chat.push(message); |
| 3889 | 3898 | const messageId = context.chat.length - 1; |
| 3890 | 3899 | await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension'); |
| @@ -4068,7 +4077,7 @@ async function sdMessageButton(e) { | ||
| 4068 | 4077 | } |
| 4069 | 4078 | } |
| 4070 | 4079 | |
| 4071 | 4080 | function saveGeneratedImage(prompt, image, generationType, negative, _initiator, _prefixedPrompt, format) { |
| 4072 | 4081 | // Some message sources may not create the extra object |
| 4073 | 4082 | if (typeof message.extra !== 'object' || message.extra === null) { |
| 4074 | 4083 | message.extra = {}; |
| @@ -4085,17 +4094,24 @@ async function sdMessageButton(e) { | ||
| 4085 | 4094 | swipes.push(message.extra.image); |
| 4086 | 4095 | } |
| 4087 | 4096 | |
| 4088 | - swipes.push(image); | |
| 4097 | + const isVideoFormat = isVideo(format); | |
| 4098 | + | |
| 4099 | + if (isVideoFormat) { | |
| 4100 | + message.extra.video = image; | |
| 4101 | + } else { | |
| 4102 | + swipes.push(image); | |
| 4103 | + | |
| 4104 | + // If already contains an image and it's not inline - leave it as is | |
| 4105 | + message.extra.inline_image = !(message.extra.image && !message.extra.inline_image); | |
| 4106 | + message.extra.image = image; | |
| 4107 | + } | |
| 4089 | 4108 | |
| 4090 | - // If already contains an image and it's not inline - leave it as is | |
| 4091 | - message.extra.inline_image = !(message.extra.image && !message.extra.inline_image); | |
| 4092 | - message.extra.image = image; | |
| 4093 | 4109 | message.extra.title = prompt; |
| 4094 | 4110 | message.extra.generationType = generationType; |
| 4095 | 4111 | message.extra.negative = negative; |
| 4096 | 4112 | appendMediaToMessage(message, $mes); |
| 4097 | 4113 | |
| 4098 | 4114 | return context.saveChat(); |
| 4099 | 4115 | } |
| 4100 | 4116 | } |
| 4101 | 4117 | |
| @@ -440,7 +440,7 @@ comfy.post('/models', async (request, response) => { | ||
| 440 | 440 | models.forEach(it => it.text = it.text.replace(/\.[^.]*$/, '').replace(/_/g, ' ')); |
| 441 | 441 | |
| 442 | 442 | return response.send(models); |
| 443 | 443 | } catch (error) { |
| 444 | 444 | console.error(error); |
| 445 | 445 | return response.sendStatus(500); |
| 446 | 446 | } |
| @@ -581,15 +581,21 @@ comfy.post('/generate', async (request, response) => { | ||
| 581 | 581 | .join('\n') || ''; |
| 582 | 582 | throw new Error(`ComfyUI generation did not succeed.\n\n${errorMessages}`.trim()); |
| 583 | 583 | } |
| 584 | 584 | const imgInfooutputs = Object.keys(item.outputs).map(it => item.outputs[it].images).flat()[0]; |
| 585 | + console.debug('ComfyUI outputs:', outputs); | |
| 586 | + const imgInfo = outputs.map(it => it.images).flat()[0] ?? outputs.map(it => it.gifs).flat()[0]; | |
| 587 | + if (!imgInfo) { | |
| 588 | + throw new Error('ComfyUI did not return any recognizable outputs.'); | |
| 589 | + } | |
| 585 | 590 | const imgUrl = new URL(urlJoin(request.body.url, '/view')); |
| 586 | 591 | imgUrl.search = `?filename=${imgInfo.filename}&subfolder=${imgInfo.subfolder}&type=${imgInfo.type}`; |
| 587 | 592 | const imgResponse = await fetch(imgUrl); |
| 588 | 593 | if (!imgResponse.ok) { |
| 589 | 594 | throw new Error('ComfyUI returned an error.'); |
| 590 | 595 | } |
| 596 | + const format = path.extname(imgInfo.filename).slice(1).toLowerCase() || 'png'; | |
| 591 | 597 | const imgBuffer = await imgResponse.arrayBuffer(); |
| 592 | 598 | return response.send({ format: format, data: Buffer.from(imgBuffer).toString('base64') }); |
| 593 | 599 | } catch (error) { |
| 594 | 600 | console.error('ComfyUI error:', error); |
| 595 | 601 | response.status(500).send(error.message); |