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

7755fc50a5c6897b91d9edf1c5c4efe21a380806

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

Signed
4 files changed, +47 -18Ignore whitespace
public/scripts/constants.js+6 -0
@@ -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 */
24export const IGNORE_SYMBOL = Symbol.for('ignore');24export 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 */
30export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv'];
public/scripts/data-maid.js+2 -1
@@ -1,4 +1,5 @@
1import { getRequestHeaders } from '../script.js';1import { getRequestHeaders } from '../script.js';
2import { VIDEO_EXTENSIONS } from './constants.js';
2import { t } from './i18n.js';3import { t } from './i18n.js';
3import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';4import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
4import { renderTemplateAsync } from './templates.js';5import { renderTemplateAsync } from './templates.js';
@@ -350,7 +351,7 @@ class DataMaidDialog {
350 * @private351 * @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;
public/scripts/extensions/stable-diffusion/index.js+30 -14
@@ -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';
54import { debounce_timeout } from '../../constants.js';54import { debounce_timeout, VIDEO_EXTENSIONS } from '../../constants.js';
55import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';55import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
56import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';56import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
57import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';57import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
@@ -339,6 +339,7 @@ const defaultSettings = {
339};339};
340340
341const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);341const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed);
342const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase());
342343
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) {
24762477
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 });
24822483
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 callback2841 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}
28452846
@@ -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}
35293531
35303532
@@ -3864,8 +3866,9 @@ async function onComfyDeleteWorkflowClick() {
3864 * @param {string} additionalNegativePrefix Additional negative prompt used for the image generation3866 * @param {string} additionalNegativePrefix Additional negative prompt used for the image generation
3865 * @param {string} initiator The initiator of the image generation3867 * @param {string} initiator The initiator of the image generation
3866 * @param {string} prefixedPrompt Prompt with an attached specific prefix3868 * @param {string} prefixedPrompt Prompt with an attached specific prefix
3869 * @param {string} format Format of the image (e.g., 'png', 'jpg')
3867 */3870 */
3868async function sendMessage(prompt, image, generationType, additionalNegativePrefix, initiator, prefixedPrompt) {3871async 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 }
40704079
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 object4081 // 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 }
40874096
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 }
40894108
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 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);
40974113
4098 context.saveChat();4114 return context.saveChat();
4099 }4115 }
4100}4116}
41014117
src/endpoints/stable-diffusion.js+9 -3
@@ -440,7 +440,7 @@ comfy.post('/models', async (request, response) => {
440 models.forEach(it => it.text = it.text.replace(/\.[^.]*$/, '').replace(/_/g, ' '));440 models.forEach(it => it.text = it.text.replace(/\.[^.]*$/, '').replace(/_/g, ' '));
441441
442 return response.send(models);442 return response.send(models);
443 } catch (error) {443 } catch (error) {
444 console.error(error);444 console.error(error);
445 return response.sendStatus(500);445 return response.sendStatus(500);
446 }446 }
@@ -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);