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 | * which is needed to preserve world info timed effects. | 22 | * which is needed to preserve world info timed effects. |
| 23 | */ | 23 | */ |
| 24 | export const IGNORE_SYMBOL = Symbol.for('ignore'); | 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 | import { getRequestHeaders } from '../script.js'; | 1 | import { getRequestHeaders } from '../script.js'; |
| 2 | import { VIDEO_EXTENSIONS } from './constants.js'; | ||
| 2 | import { t } from './i18n.js'; | 3 | import { t } from './i18n.js'; |
| 3 | import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js'; | 4 | import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js'; |
| 4 | import { renderTemplateAsync } from './templates.js'; | 5 | import { renderTemplateAsync } from './templates.js'; |
| @@ -350,7 +351,7 @@ class DataMaidDialog { | |||
| 350 | * @private | 351 | * @private |
| 351 | */ | 352 | */ |
| 352 | async getViewElement(url, name) { | 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 | const mediaElement = document.createElement(isVideo ? 'video' : 'img'); | 355 | const mediaElement = document.createElement(isVideo ? 'video' : 'img'); |
| 355 | if (mediaElement instanceof HTMLVideoElement) { | 356 | if (mediaElement instanceof HTMLVideoElement) { |
| 356 | mediaElement.controls = true; | 357 | mediaElement.controls = true; |
| @@ -51,7 +51,7 @@ import { | |||
| 51 | SlashCommandArgument, | 51 | SlashCommandArgument, |
| 52 | SlashCommandNamedArgument, | 52 | SlashCommandNamedArgument, |
| 53 | } from '../../slash-commands/SlashCommandArgument.js'; | 53 | } from '../../slash-commands/SlashCommandArgument.js'; |
| 54 | import { debounce_timeout } from '../../constants.js'; | 54 | import { debounce_timeout, VIDEO_EXTENSIONS } from '../../constants.js'; |
| 55 | import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; | 55 | import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 56 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; | 56 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 57 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 57 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| @@ -339,6 +339,7 @@ const defaultSettings = { | |||
| 339 | }; | 339 | }; |
| 340 | 340 | ||
| 341 | const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed); | 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 | * Generate interceptor for interactive mode triggers. | 345 | * Generate interceptor for interactive mode triggers. |
| @@ -2476,14 +2477,14 @@ async function generatePicture(initiator, args, trigger, message, callback) { | |||
| 2476 | 2477 | ||
| 2477 | if (generationType === generationMode.BACKGROUND) { | 2478 | if (generationType === generationMode.BACKGROUND) { |
| 2478 | const callbackOriginal = callback; | 2479 | const callbackOriginal = callback; |
| 2479 | callback = async function (prompt, imagePath, generationType, _negativePromptPrefix, _initiator, prefixedPrompt) { | 2480 | callback = async function (prompt, imagePath, generationType, _negativePromptPrefix, _initiator, prefixedPrompt, format) { |
| 2480 | const imgUrl = `url("${encodeURI(imagePath)}")`; | 2481 | const imgUrl = `url("${encodeURI(imagePath)}")`; |
| 2481 | await eventSource.emit(event_types.FORCE_SET_BACKGROUND, { url: imgUrl, path: imagePath }); | 2482 | await eventSource.emit(event_types.FORCE_SET_BACKGROUND, { url: imgUrl, path: imagePath }); |
| 2482 | 2483 | ||
| 2483 | if (typeof callbackOriginal === 'function') { | 2484 | if (typeof callbackOriginal === 'function') { |
| 2484 | await callbackOriginal(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt); | 2485 | await callbackOriginal(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt, format); |
| 2485 | } else { | 2486 | } else { |
| 2486 | await sendMessage(prompt, imagePath, generationType, negativePromptPrefix, initiator, prefixedPrompt); | 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 | const filename = `${characterName}_${humanizedDateTime()}`; | 2839 | const filename = `${characterName}_${humanizedDateTime()}`; |
| 2839 | const base64Image = await saveBase64AsFile(result.data, characterName, filename, result.format); | 2840 | const base64Image = await saveBase64AsFile(result.data, characterName, filename, result.format); |
| 2840 | callback | 2841 | callback |
| 2841 | ? await callback(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt) | 2842 | ? await callback(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, result.format) |
| 2842 | : await sendMessage(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt); | 2843 | : await sendMessage(prompt, base64Image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, result.format); |
| 2843 | return base64Image; | 2844 | return base64Image; |
| 2844 | } | 2845 | } |
| 2845 | 2846 | ||
| @@ -3524,7 +3525,8 @@ async function generateComfyImage(prompt, negativePrompt, signal) { | |||
| 3524 | const text = await promptResult.text(); | 3525 | const text = await promptResult.text(); |
| 3525 | throw new Error(text); | 3526 | throw new Error(text); |
| 3526 | } | 3527 | } |
| 3527 | return { format: 'png', data: await promptResult.text() }; | 3528 | const { format, data } = await promptResult.json(); |
| 3529 | return { format, data }; | ||
| 3528 | } | 3530 | } |
| 3529 | 3531 | ||
| 3530 | 3532 | ||
| @@ -3864,8 +3866,9 @@ async function onComfyDeleteWorkflowClick() { | |||
| 3864 | * @param {string} additionalNegativePrefix Additional negative prompt used for the image generation | 3866 | * @param {string} additionalNegativePrefix Additional negative prompt used for the image generation |
| 3865 | * @param {string} initiator The initiator of the image generation | 3867 | * @param {string} initiator The initiator of the image generation |
| 3866 | * @param {string} prefixedPrompt Prompt with an attached specific prefix | 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 | async function sendMessage(prompt, image, generationType, additionalNegativePrefix, initiator, prefixedPrompt) { | 3871 | async function sendMessage(prompt, image, generationType, additionalNegativePrefix, initiator, prefixedPrompt, format) { |
| 3869 | const context = getContext(); | 3872 | const context = getContext(); |
| 3870 | const name = context.groupId ? systemUserName : context.name2; | 3873 | const name = context.groupId ? systemUserName : context.name2; |
| 3871 | const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}'; | 3874 | const template = extension_settings.sd.prompts[generationMode.MESSAGE] || '{{prompt}}'; |
| @@ -3885,6 +3888,12 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref | |||
| 3885 | image_swipes: [image], | 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 | context.chat.push(message); | 3897 | context.chat.push(message); |
| 3889 | const messageId = context.chat.length - 1; | 3898 | const messageId = context.chat.length - 1; |
| 3890 | await eventSource.emit(event_types.MESSAGE_RECEIVED, messageId, 'extension'); | 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 | function saveGeneratedImage(prompt, image, generationType, negative) { | 4080 | function saveGeneratedImage(prompt, image, generationType, negative, _initiator, _prefixedPrompt, format) { |
| 4072 | // Some message sources may not create the extra object | 4081 | // Some message sources may not create the extra object |
| 4073 | if (typeof message.extra !== 'object' || message.extra === null) { | 4082 | if (typeof message.extra !== 'object' || message.extra === null) { |
| 4074 | message.extra = {}; | 4083 | message.extra = {}; |
| @@ -4085,17 +4094,24 @@ async function sdMessageButton(e) { | |||
| 4085 | swipes.push(message.extra.image); | 4094 | swipes.push(message.extra.image); |
| 4086 | } | 4095 | } |
| 4087 | 4096 | ||
| 4097 | const isVideoFormat = isVideo(format); | ||
| 4098 | |||
| 4099 | if (isVideoFormat) { | ||
| 4100 | message.extra.video = image; | ||
| 4101 | } else { | ||
| 4088 | swipes.push(image); | 4102 | swipes.push(image); |
| 4089 | 4103 | ||
| 4090 | // If already contains an image and it's not inline - leave it as is | 4104 | // 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); | 4105 | message.extra.inline_image = !(message.extra.image && !message.extra.inline_image); |
| 4092 | message.extra.image = image; | 4106 | message.extra.image = image; |
| 4107 | } | ||
| 4108 | |||
| 4093 | message.extra.title = prompt; | 4109 | message.extra.title = prompt; |
| 4094 | message.extra.generationType = generationType; | 4110 | message.extra.generationType = generationType; |
| 4095 | message.extra.negative = negative; | 4111 | message.extra.negative = negative; |
| 4096 | appendMediaToMessage(message, $mes); | 4112 | appendMediaToMessage(message, $mes); |
| 4097 | 4113 | ||
| 4098 | context.saveChat(); | 4114 | return context.saveChat(); |
| 4099 | } | 4115 | } |
| 4100 | } | 4116 | } |
| 4101 | 4117 | ||
| @@ -581,15 +581,21 @@ comfy.post('/generate', async (request, response) => { | |||
| 581 | .join('\n') || ''; | 581 | .join('\n') || ''; |
| 582 | throw new Error(`ComfyUI generation did not succeed.\n\n${errorMessages}`.trim()); | 582 | throw new Error(`ComfyUI generation did not succeed.\n\n${errorMessages}`.trim()); |
| 583 | } | 583 | } |
| 584 | const imgInfo = Object.keys(item.outputs).map(it => item.outputs[it].images).flat()[0]; | 584 | const outputs = Object.keys(item.outputs).map(it => item.outputs[it]); |
| 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 | const imgUrl = new URL(urlJoin(request.body.url, '/view')); | 590 | const imgUrl = new URL(urlJoin(request.body.url, '/view')); |
| 586 | imgUrl.search = `?filename=${imgInfo.filename}&subfolder=${imgInfo.subfolder}&type=${imgInfo.type}`; | 591 | imgUrl.search = `?filename=${imgInfo.filename}&subfolder=${imgInfo.subfolder}&type=${imgInfo.type}`; |
| 587 | const imgResponse = await fetch(imgUrl); | 592 | const imgResponse = await fetch(imgUrl); |
| 588 | if (!imgResponse.ok) { | 593 | if (!imgResponse.ok) { |
| 589 | throw new Error('ComfyUI returned an error.'); | 594 | throw new Error('ComfyUI returned an error.'); |
| 590 | } | 595 | } |
| 596 | const format = path.extname(imgInfo.filename).slice(1).toLowerCase() || 'png'; | ||
| 591 | const imgBuffer = await imgResponse.arrayBuffer(); | 597 | const imgBuffer = await imgResponse.arrayBuffer(); |
| 592 | return response.send(Buffer.from(imgBuffer).toString('base64')); | 598 | return response.send({ format: format, data: Buffer.from(imgBuffer).toString('base64') }); |
| 593 | } catch (error) { | 599 | } catch (error) { |
| 594 | console.error('ComfyUI error:', error); | 600 | console.error('ComfyUI error:', error); |
| 595 | response.status(500).send(error.message); | 601 | response.status(500).send(error.message); |