Add captioning for video attachments (#4749) * Add captioning for video attachments * Unify error toast titles * Add MEDIA_SOURCE enum and update media handling to include source information * Unify attachment handling logic * Add error handling for auto-captioning failures * Use string formatting for console error
Signed| @@ -70,6 +70,7 @@ declare global { | ||
| 70 | 70 | url: string; |
| 71 | 71 | title?: string; |
| 72 | 72 | type: string; |
| 73 | + source?: string; | |
| 73 | 74 | } |
| 74 | 75 | |
| 75 | 76 | interface ImageGenerationAttachmentProps { |
| @@ -78,7 +79,10 @@ declare global { | ||
| 78 | 79 | } |
| 79 | 80 | |
| 80 | 81 | interface ImageCaptionAttachmentProps { |
| 82 | + /** Append title to the message text in case of non-inline captions. */ | |
| 81 | 83 | append_title?: boolean; |
| 84 | + /** Marker for captioned images to prevent auto-caption from firing again. */ | |
| 85 | + captioned?: boolean; | |
| 82 | 86 | } |
| 83 | 87 | |
| 84 | 88 | // Global namespace modules |
| @@ -7295,6 +7295,7 @@ | ||
| 7295 | 7295 | <div class="mes_media_container mes_video_container"> |
| 7296 | 7296 | <div class="mes_video_controls"> |
| 7297 | 7297 | <div title="Expand and zoom" class="right_menu_button fa-lg fa-solid fa-magnifying-glass mes_media_enlarge" data-i18n="[title]Expand and zoom"></div> |
| 7298 | + <div title="Caption" class="right_menu_button fa-lg fa-solid fa-envelope-open-text mes_img_caption" data-i18n="[title]Caption"></div> | |
| 7298 | 7299 | <div title="Delete" class="right_menu_button fa-lg fa-solid fa-trash-can mes_media_delete" data-i18n="[title]Delete"></div> |
| 7299 | 7300 | </div> |
| 7300 | 7301 | <video class="mes_video" controls preload="metadata"></video> |
| @@ -183,7 +183,7 @@ import { | ||
| 183 | 183 | trimSpaces, |
| 184 | 184 | clamp, |
| 185 | 185 | } from './scripts/utils.js'; |
| 186 | 186 | import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, SWIPE_DIRECTION } from './scripts/constants.js'; |
| 187 | 187 | |
| 188 | 188 | import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js'; |
| 189 | 189 | import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js'; |
| @@ -6414,7 +6414,7 @@ function saveImageToMessage(img, mes) { | ||
| 6414 | 6414 | if (!Array.isArray(mes.extra.media)) { |
| 6415 | 6415 | mes.extra.media = []; |
| 6416 | 6416 | } |
| 6417 | 6417 | mes.extra.media.push({ url: img.image, type: MEDIA_TYPE.IMAGE, title: img.title, source: MEDIA_SOURCE.API }); |
| 6418 | 6418 | mes.extra.inline_image = img.inline; |
| 6419 | 6419 | } |
| 6420 | 6420 | } |
| @@ -57,7 +57,7 @@ import { renderTemplateAsync } from './templates.js'; | ||
| 57 | 57 | import { t } from './i18n.js'; |
| 58 | 58 | import { humanizedDateTime } from './RossAscends-mods.js'; |
| 59 | 59 | import { accountStorage } from './util/AccountStorage.js'; |
| 60 | 60 | import { MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, SWIPE_DIRECTION } from './constants.js'; |
| 61 | 61 | |
| 62 | 62 | /** |
| 63 | 63 | * @typedef {Object} FileAttachment |
| @@ -216,7 +216,14 @@ export async function populateFileAttachment(message, inputId = 'file_form_input | ||
| 216 | 216 | if (!Array.isArray(message.extra.media)) { |
| 217 | 217 | message.extra.media = []; |
| 218 | 218 | } |
| 219 | - message.extra.media.push({ url: imageUrl, type: mediaType, title: file.name }); | |
| 219 | + /** @type {MediaAttachment} */ | |
| 220 | + const mediaAttachment = { | |
| 221 | + url: imageUrl, | |
| 222 | + type: mediaType, | |
| 223 | + title: file.name, | |
| 224 | + source: MEDIA_SOURCE.UPLOAD, | |
| 225 | + }; | |
| 226 | + message.extra.media.push(mediaAttachment); | |
| 220 | 227 | message.extra.media_index = message.extra.media.length - 1; |
| 221 | 228 | message.extra.inline_image = true; |
| 222 | 229 | } else { |
| @@ -71,6 +71,17 @@ export const COMETAPI_IGNORE_PATTERNS = [ | ||
| 71 | 71 | * @enum {string} |
| 72 | 72 | * @readonly |
| 73 | 73 | */ |
| 74 | +export const MEDIA_SOURCE = { | |
| 75 | + API: 'api', | |
| 76 | + UPLOAD: 'upload', | |
| 77 | + GENERATED: 'generated', | |
| 78 | + CAPTIONED: 'captioned', | |
| 79 | +}; | |
| 80 | + | |
| 81 | +/** | |
| 82 | + * @enum {string} | |
| 83 | + * @readonly | |
| 84 | + */ | |
| 74 | 85 | export const MEDIA_DISPLAY = { |
| 75 | 86 | LIST: 'list', |
| 76 | 87 | GALLERY: 'gallery', |
| @@ -10,7 +10,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js'; | ||
| 10 | 10 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 11 | 11 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 12 | 12 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 13 | 13 | import { MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR } from '../../constants.js'; |
| 14 | 14 | export { MODULE_NAME }; |
| 15 | 15 | |
| 16 | 16 | const MODULE_NAME = 'caption'; |
| @@ -135,14 +135,18 @@ async function captionExistingMessage(message, mediaIndex) { | ||
| 135 | 135 | |
| 136 | 136 | const mediaAttachment = message.extra.media[mediaIndex]; |
| 137 | 137 | |
| 138 | 138 | if (!mediaAttachment || !mediaAttachment.url || mediaAttachment.type === MEDIA_TYPE.VIDEOAUDIO) { |
| 139 | 139 | return; |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | + if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) { | |
| 143 | + throw new Error('Captioning videos is not supported for the current source.'); | |
| 144 | + } | |
| 145 | + | |
| 142 | 146 | const imageData = await fetch(mediaAttachment.url); |
| 143 | 147 | const blob = await imageData.blob(); |
| 144 | - const type = imageData.headers.get('Content-Type'); | |
| 148 | + const fileName = mediaAttachment.url.split('/').pop().split('?')[0] || 'image.jpg'; | |
| 145 | 149 | const file = new File([blob], 'image.png'fileName, { type: blob.type }); |
| 146 | 150 | const caption = await getCaptionForFile(file, null, true); |
| 147 | 151 | |
| 148 | 152 | if (!caption) { |
| @@ -158,11 +162,12 @@ async function captionExistingMessage(message, mediaIndex) { | ||
| 158 | 162 | message.extra.inline_image = false; |
| 159 | 163 | message.mes = wrappedCaption; |
| 160 | 164 | mediaAttachment.title = wrappedCaption; |
| 161 | - } | |
| 165 | + mediaAttachment.captioned = true; | |
| 162 | 166 | } else { |
| 163 | 167 | message.extra.inline_image = true; |
| 164 | 168 | mediaAttachment.append_title = true; |
| 165 | 169 | mediaAttachment.title = wrappedCaption; |
| 170 | + mediaAttachment.captioned = true; | |
| 166 | 171 | } |
| 167 | 172 | } |
| 168 | 173 | |
| @@ -170,8 +175,10 @@ async function captionExistingMessage(message, mediaIndex) { | ||
| 170 | 175 | * Sends a captioned message to the chat. |
| 171 | 176 | * @param {string} caption Caption text |
| 172 | 177 | * @param {string} image Image URL |
| 178 | + * @param {string} mimeType Image MIME type | |
| 179 | + * @returns {Promise<void>} | |
| 173 | 180 | */ |
| 174 | 181 | async function sendCaptionedMessage(caption, image, mimeType) { |
| 175 | 182 | const messageText = await wrapCaptionTemplate(caption); |
| 176 | 183 | |
| 177 | 184 | const context = getContext(); |
| @@ -179,8 +186,10 @@ async function sendCaptionedMessage(caption, image) { | ||
| 179 | 186 | /** @type {MediaAttachment} */ |
| 180 | 187 | const mediaAttachment = { |
| 181 | 188 | url: image, |
| 182 | 189 | type: MEDIA_TYPE.getFromMime(mimeType) || MEDIA_TYPE.IMAGE, |
| 183 | 190 | title: messageText, |
| 191 | + captioned: true, | |
| 192 | + source: MEDIA_SOURCE.CAPTIONED, | |
| 184 | 193 | }; |
| 185 | 194 | /** @type {ChatMessage} */ |
| 186 | 195 | const message = { |
| @@ -351,6 +360,10 @@ async function onSelectImage(e, prompt, quiet) { | ||
| 351 | 360 | */ |
| 352 | 361 | async function getCaptionForFile(file, prompt, quiet) { |
| 353 | 362 | try { |
| 363 | + if (file.type.startsWith('video/') && !isVideoCaptioningAvailable()) { | |
| 364 | + throw new Error('Video captioning is not available for the current source.'); | |
| 365 | + } | |
| 366 | + | |
| 354 | 367 | setSpinnerIcon(); |
| 355 | 368 | const context = getContext(); |
| 356 | 369 | const fileData = await getBase64Async(await ensureImageFormatSupported(file)); |
| @@ -359,13 +372,13 @@ async function getCaptionForFile(file, prompt, quiet) { | ||
| 359 | 372 | const { caption } = await doCaptionRequest(base64Data, fileData, prompt); |
| 360 | 373 | if (!quiet) { |
| 361 | 374 | const imagePath = await saveBase64AsFile(base64Data, context.name2, '', extension); |
| 362 | 375 | await sendCaptionedMessage(caption, imagePath, file.type); |
| 363 | 376 | } |
| 364 | 377 | return caption; |
| 365 | 378 | } |
| 366 | 379 | catch (error) { |
| 367 | 380 | const errorMessage = error.message || 'Unknown error'; |
| 368 | 381 | toastr.error(errorMessage, 'Failed to caption image.'); |
| 369 | 382 | console.error(error); |
| 370 | 383 | return ''; |
| 371 | 384 | } |
| @@ -399,13 +412,18 @@ async function captionCommandCallback(args, prompt) { | ||
| 399 | 412 | toastr.error('The specified message does not contain an image.'); |
| 400 | 413 | return ''; |
| 401 | 414 | } |
| 402 | 415 | if (mediaAttachment.type === MEDIA_TYPE.VIDEOAUDIO) { |
| 403 | 416 | toastr.error('The specified media is aan videoaudio file. Captioning videosaudio files is not supported.'); |
| 417 | + return ''; | |
| 418 | + } | |
| 419 | + if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) { | |
| 420 | + toastr.error('The specified media is a video. Captioning videos is not supported for the current source.'); | |
| 404 | 421 | return ''; |
| 405 | 422 | } |
| 406 | 423 | const fetchResult = await fetch(mediaAttachment.url); |
| 407 | 424 | const blob = await fetchResult.blob(); |
| 408 | - const file = new File([blob], 'image.jpg', { type: blob.type }); | |
| 425 | + const fileName = mediaAttachment.url.split('/').pop().split('?')[0] || 'image.jpg'; | |
| 426 | + const file = new File([blob], fileName, { type: blob.type }); | |
| 409 | 427 | return await getCaptionForFile(file, prompt, quiet); |
| 410 | 428 | } catch (error) { |
| 411 | 429 | toastr.error('Failed to get image from the message. Make sure the image is accessible.'); |
| @@ -417,7 +435,7 @@ async function captionCommandCallback(args, prompt) { | ||
| 417 | 435 | return new Promise(resolve => { |
| 418 | 436 | const input = document.createElement('input'); |
| 419 | 437 | input.type = 'file'; |
| 420 | 438 | input.accept = 'image/*,video/*'; |
| 421 | 439 | input.onchange = async (e) => { |
| 422 | 440 | const caption = await onSelectImage(e, prompt, quiet); |
| 423 | 441 | resolve(caption); |
| @@ -427,6 +445,18 @@ async function captionCommandCallback(args, prompt) { | ||
| 427 | 445 | }); |
| 428 | 446 | } |
| 429 | 447 | |
| 448 | +/** | |
| 449 | + * Checks if video captioning is available for the current source. | |
| 450 | + * @returns {boolean} True if video captioning is supported for the current source. | |
| 451 | + */ | |
| 452 | +function isVideoCaptioningAvailable() { | |
| 453 | + if (extension_settings.caption.source !== 'multimodal') { | |
| 454 | + return false; | |
| 455 | + } | |
| 456 | + | |
| 457 | + return ['google', 'vertexai'].includes(extension_settings.caption.multimodal_api); | |
| 458 | +} | |
| 459 | + | |
| 430 | 460 | jQuery(async function () { |
| 431 | 461 | function addSendPictureButton() { |
| 432 | 462 | const sendButton = $(` |
| @@ -515,13 +545,17 @@ jQuery(async function () { | ||
| 515 | 545 | }); |
| 516 | 546 | } |
| 517 | 547 | function addPictureSendForm() { |
| 518 | - const inputHtml = '<input id="img_file" type="file" hidden accept="image/*">'; | |
| 548 | + const imgInput = document.createElement('input'); | |
| 549 | + imgInput.type = 'file'; | |
| 550 | + imgInput.id = 'img_file'; | |
| 551 | + imgInput.accept = 'image/*,video/*'; | |
| 552 | + imgInput.hidden = true; | |
| 553 | + imgInput.addEventListener('change', (e) => onSelectImage(e, '', false)); | |
| 519 | 554 | const imgForm = document.createElement('form'); |
| 520 | 555 | imgForm.id = 'img_form'; |
| 521 | 556 | $(imgForm).appendappendChild(inputHtmlimgInput); |
| 522 | - $(imgForm).hide(); | |
| 557 | + imgForm.hidden = true; | |
| 523 | 558 | $('#form_sheld').append(imgForm); |
| 524 | - $('#img_file').on('change', (e) => onSelectImage(e.originalEvent, '', false)); | |
| 525 | 559 | } |
| 526 | 560 | async function switchMultimodalBlocks() { |
| 527 | 561 | await addRemoteEndpointModels(); |
| @@ -668,13 +702,33 @@ jQuery(async function () { | ||
| 668 | 702 | saveSettingsDebounced(); |
| 669 | 703 | }); |
| 670 | 704 | |
| 671 | 705 | const onMessageEvent = async (/** @type {number} */ messageId) => { |
| 672 | 706 | if (!extension_settings.caption.auto_mode) { |
| 673 | 707 | return; |
| 674 | 708 | } |
| 675 | 709 | |
| 676 | 710 | const message = getContext().chat[messageId]; |
| 677 | - await captionExistingMessage(message, 0); | |
| 711 | + if (Array.isArray(message?.extra?.media) && message.extra.media.length > 0) { | |
| 712 | + for (let mediaIndex = 0; mediaIndex < message.extra.media.length; mediaIndex++) { | |
| 713 | + const mediaAttachment = message.extra.media[mediaIndex]; | |
| 714 | + if (mediaAttachment.type === MEDIA_TYPE.VIDEO && !isVideoCaptioningAvailable()) { | |
| 715 | + continue; | |
| 716 | + } | |
| 717 | + if (mediaAttachment.type === MEDIA_TYPE.AUDIO) { | |
| 718 | + continue; | |
| 719 | + } | |
| 720 | + // Skip already captioned images and non-uploaded (generated, etc.) images | |
| 721 | + if (mediaAttachment.source !== MEDIA_SOURCE.UPLOAD || mediaAttachment.captioned) { | |
| 722 | + continue; | |
| 723 | + } | |
| 724 | + try { | |
| 725 | + await captionExistingMessage(message, mediaIndex); | |
| 726 | + } catch (e) { | |
| 727 | + console.error(`Auto-captioning failed for message ID ${messageId}, media index ${mediaIndex}`, e); | |
| 728 | + continue; | |
| 729 | + } | |
| 730 | + } | |
| 731 | + } | |
| 678 | 732 | }; |
| 679 | 733 | |
| 680 | 734 | eventSource.on(event_types.MESSAGE_SENT, onMessageEvent); |
| @@ -683,21 +737,22 @@ jQuery(async function () { | ||
| 683 | 737 | $(document).on('click', '.mes_img_caption', async function () { |
| 684 | 738 | const animationClass = 'fa-fade'; |
| 685 | 739 | const messageBlock = $(this).closest('.mes'); |
| 686 | 740 | const imageBlockmediaContainer = $(this).closest('.mes_img_containermes_media_container'); |
| 687 | 741 | const messageImgmessageMedia = imageBlockmediaContainer.find('.mes_img, .mes_video'); |
| 688 | 742 | if (messageImgmessageMedia.hasClass(animationClass)) return; |
| 689 | 743 | messageImgmessageMedia.addClass(animationClass); |
| 690 | 744 | try { |
| 691 | 745 | const messageId = Number(messageBlock.attr('mesid')); |
| 692 | 746 | const imageIndexmediaIndex = Number(imageBlockmediaContainer.attr('data-index')); |
| 693 | 747 | const data = getContext().chat[messageId]; |
| 694 | 748 | await captionExistingMessage(data, imageIndexmediaIndex); |
| 695 | 749 | appendMediaToMessage(data, messageBlock, SCROLL_BEHAVIOR.KEEP); |
| 696 | 750 | await saveChatConditional(); |
| 697 | 751 | } catch (e) { |
| 698 | 752 | console.error('Message image recaption failed', e); |
| 753 | + toastr.error(e.message || 'Unknown error', 'Failed to caption'); | |
| 699 | 754 | } finally { |
| 700 | 755 | messageImgmessageMedia.removeClass(animationClass); |
| 701 | 756 | } |
| 702 | 757 | }); |
| 703 | 758 | |
| @@ -33,12 +33,13 @@ export async function getMultimodalCaption(base64Img, prompt) { | ||
| 33 | 33 | const base64Bytes = base64Img.length * 0.75; |
| 34 | 34 | const compressionLimit = 2 * 1024 * 1024; |
| 35 | 35 | const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; |
| 36 | 36 | const mimeType = base64Img?.split(';')?.[0]?.split(':')?.[1] || 'image/jpeg'; |
| 37 | + const isImage = mimeType.startsWith('image/'); | |
| 37 | 38 | const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api); |
| 38 | 39 | if ((isImage && thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) { |
| 39 | 40 | const maxSide = 2048; |
| 40 | 41 | base64Img = await createThumbnail(base64Img, maxSide, maxSide); |
| 41 | 42 | } else if (isImage && !safeMimeTypes.includes(mimeType)) { |
| 42 | 43 | base64Img = await createThumbnail(base64Img, null, null); |
| 43 | 44 | } |
| 44 | 45 | if (isOllama && base64Img.startsWith('data:image/')) { |
| @@ -52,7 +52,7 @@ import { | ||
| 52 | 52 | SlashCommandArgument, |
| 53 | 53 | SlashCommandNamedArgument, |
| 54 | 54 | } from '../../slash-commands/SlashCommandArgument.js'; |
| 55 | 55 | import { debounce_timeout, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, SCROLL_BEHAVIOR, VIDEO_EXTENSIONS } from '../../constants.js'; |
| 56 | 56 | import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 57 | 57 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 58 | 58 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| @@ -4123,6 +4123,7 @@ async function sendMessage(prompt, image, generationType, additionalNegativePref | ||
| 4123 | 4123 | title: prompt, |
| 4124 | 4124 | generation_type: generationType, |
| 4125 | 4125 | negative: additionalNegativePrefix, |
| 4126 | + source: MEDIA_SOURCE.GENERATED, | |
| 4126 | 4127 | }; |
| 4127 | 4128 | /** @type {ChatMessage} */ |
| 4128 | 4129 | const message = { |
| @@ -4386,6 +4387,7 @@ async function generateMediaSwipe(mediaAttachment, message, onStart, onComplete, | ||
| 4386 | 4387 | const result = { |
| 4387 | 4388 | url: '', |
| 4388 | 4389 | type: MEDIA_TYPE.IMAGE, |
| 4390 | + source: MEDIA_SOURCE.GENERATED, | |
| 4389 | 4391 | }; |
| 4390 | 4392 | |
| 4391 | 4393 | try { |