Gemini inline video (#4078) * Add inline video attachment support for Gemini 2.5 Pro * file formatting * removed redundant function for saving video to message * removed other redundant function for saving video to message * Seperate inlining check for video * Edit video token cost to be a conservative estimate of 10000 tokens * fixed missing semicolon * Adds seperate ui toggle for video inlining. * Move mes_video out of img_container * Remove title from video element for now * Better visibilty of video with controls --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -2020,6 +2020,20 @@ | |||
| 2020 | </div> | 2020 | </div> |
| 2021 | </div> | 2021 | </div> |
| 2022 | <div class="range-block" data-source="makersuite,vertexai"> | 2022 | <div class="range-block" data-source="makersuite,vertexai"> |
| 2023 | <label for="openai_video_inlining" class="checkbox_label flexWrap widthFreeExpand"> | ||
| 2024 | <input id="openai_video_inlining" type="checkbox" /> | ||
| 2025 | <span data-i18n="Send inline videos">Send inline videos</span> | ||
| 2026 | </label> | ||
| 2027 | <div id="video_inlining_hint" class="flexBasis100p toggle-description justifyLeft"> | ||
| 2028 | <span data-i18n="video_inlining_hint_1">Sends videos in prompts if the model supports it. Use the</span> | ||
| 2029 | <code><i class="fa-solid fa-paperclip"></i></code> | ||
| 2030 | <span data-i18n="video_inlining_hint_2">action on any message or the</span> | ||
| 2031 | <code><i class="fa-solid fa-wand-magic-sparkles"></i></code> | ||
| 2032 | <span data-i18n="video_inlining_hint_3">menu to attach a video file to the chat.</span> | ||
| 2033 | <strong data-i18n="video_inlining_hint_4">Videos must be less than 20 MB and under 1 minute long</strong> | ||
| 2034 | </div> | ||
| 2035 | </div> | ||
| 2036 | <div class="range-block" data-source="makersuite,vertexai"> | ||
| 2023 | <label for="openai_request_images" class="checkbox_label widthFreeExpand"> | 2037 | <label for="openai_request_images" class="checkbox_label widthFreeExpand"> |
| 2024 | <input id="openai_request_images" type="checkbox" /> | 2038 | <input id="openai_request_images" type="checkbox" /> |
| 2025 | <span> | 2039 | <span> |
| @@ -2473,6 +2473,31 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) { | |||
| 2473 | } | 2473 | } |
| 2474 | } | 2474 | } |
| 2475 | 2475 | ||
| 2476 | // Add video to message | ||
| 2477 | if (mes.extra?.video) { | ||
| 2478 | const container = messageElement.find('.mes_block'); | ||
| 2479 | const chatHeight = $('#chat').prop('scrollHeight'); | ||
| 2480 | |||
| 2481 | // Create video element if it doesn't exist | ||
| 2482 | let video = messageElement.find('.mes_video'); | ||
| 2483 | if (video.length === 0) { | ||
| 2484 | video = $('<video class="mes_video" controls preload="metadata"></video>'); | ||
| 2485 | container.append(video); | ||
| 2486 | } | ||
| 2487 | |||
| 2488 | video.off('loadedmetadata').on('loadedmetadata', function () { | ||
| 2489 | if (!adjustScroll) { | ||
| 2490 | return; | ||
| 2491 | } | ||
| 2492 | const scrollPosition = $('#chat').scrollTop(); | ||
| 2493 | const newChatHeight = $('#chat').prop('scrollHeight'); | ||
| 2494 | const diff = newChatHeight - chatHeight; | ||
| 2495 | $('#chat').scrollTop(scrollPosition + diff); | ||
| 2496 | }); | ||
| 2497 | |||
| 2498 | video.attr('src', mes.extra?.video); | ||
| 2499 | } | ||
| 2500 | |||
| 2476 | // Add file to message | 2501 | // Add file to message |
| 2477 | if (mes.extra?.file) { | 2502 | if (mes.extra?.file) { |
| 2478 | messageElement.find('.mes_file_container').remove(); | 2503 | messageElement.find('.mes_file_container').remove(); |
| @@ -211,6 +211,12 @@ export async function populateFileAttachment(message, inputId = 'file_form_input | |||
| 211 | const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); | 211 | const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); |
| 212 | message.extra.image = imageUrl; | 212 | message.extra.image = imageUrl; |
| 213 | message.extra.inline_image = true; | 213 | message.extra.inline_image = true; |
| 214 | } | ||
| 215 | // If file is video | ||
| 216 | else if (file.type.startsWith('video/')) { | ||
| 217 | const extension = file.type.split('/')[1]; | ||
| 218 | const videoUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); | ||
| 219 | message.extra.video = videoUrl; | ||
| 214 | } else { | 220 | } else { |
| 215 | const uniqueFileName = `${fileNamePrefix}.txt`; | 221 | const uniqueFileName = `${fileNamePrefix}.txt`; |
| 216 | 222 | ||
| @@ -313,6 +313,7 @@ export const settingsToUpdate = { | |||
| 313 | squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true, false], | 313 | squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true, false], |
| 314 | image_inlining: ['#openai_image_inlining', 'image_inlining', true, false], | 314 | image_inlining: ['#openai_image_inlining', 'image_inlining', true, false], |
| 315 | inline_image_quality: ['#openai_inline_image_quality', 'inline_image_quality', false, false], | 315 | inline_image_quality: ['#openai_inline_image_quality', 'inline_image_quality', false, false], |
| 316 | video_inlining: ['#openai_video_inlining', 'video_inlining', true, false], | ||
| 316 | continue_prefill: ['#continue_prefill', 'continue_prefill', true, false], | 317 | continue_prefill: ['#continue_prefill', 'continue_prefill', true, false], |
| 317 | continue_postfix: ['#continue_postfix', 'continue_postfix', false, false], | 318 | continue_postfix: ['#continue_postfix', 'continue_postfix', false, false], |
| 318 | function_calling: ['#openai_function_calling', 'function_calling', true, false], | 319 | function_calling: ['#openai_function_calling', 'function_calling', true, false], |
| @@ -396,6 +397,7 @@ const default_settings = { | |||
| 396 | squash_system_messages: false, | 397 | squash_system_messages: false, |
| 397 | image_inlining: false, | 398 | image_inlining: false, |
| 398 | inline_image_quality: 'low', | 399 | inline_image_quality: 'low', |
| 400 | video_inlining: false, | ||
| 399 | bypass_status_check: false, | 401 | bypass_status_check: false, |
| 400 | continue_prefill: false, | 402 | continue_prefill: false, |
| 401 | function_calling: false, | 403 | function_calling: false, |
| @@ -482,6 +484,7 @@ const oai_settings = { | |||
| 482 | squash_system_messages: false, | 484 | squash_system_messages: false, |
| 483 | image_inlining: false, | 485 | image_inlining: false, |
| 484 | inline_image_quality: 'low', | 486 | inline_image_quality: 'low', |
| 487 | video_inlining: false, | ||
| 485 | bypass_status_check: false, | 488 | bypass_status_check: false, |
| 486 | continue_prefill: false, | 489 | continue_prefill: false, |
| 487 | function_calling: false, | 490 | function_calling: false, |
| @@ -593,8 +596,9 @@ function setOpenAIMessages(chat) { | |||
| 593 | if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`; | 596 | if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`; |
| 594 | const name = chat[j]['name']; | 597 | const name = chat[j]['name']; |
| 595 | const image = chat[j]?.extra?.image; | 598 | const image = chat[j]?.extra?.image; |
| 599 | const video = chat[j]?.extra?.video; | ||
| 596 | const invocations = chat[j]?.extra?.tool_invocations; | 600 | const invocations = chat[j]?.extra?.tool_invocations; |
| 597 | messages[i] = { 'role': role, 'content': content, name: name, 'image': image, 'invocations': invocations }; | 601 | messages[i] = { 'role': role, 'content': content, name: name, 'image': image, 'video': video, 'invocations': invocations }; |
| 598 | j++; | 602 | j++; |
| 599 | } | 603 | } |
| 600 | 604 | ||
| @@ -886,6 +890,7 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul | |||
| 886 | } | 890 | } |
| 887 | 891 | ||
| 888 | const imageInlining = isImageInliningSupported(); | 892 | const imageInlining = isImageInliningSupported(); |
| 893 | const videoInlining = isVideoInliningSupported(); | ||
| 889 | const canUseTools = ToolManager.isToolCallingSupported(); | 894 | const canUseTools = ToolManager.isToolCallingSupported(); |
| 890 | 895 | ||
| 891 | // Insert chat messages as long as there is budget available | 896 | // Insert chat messages as long as there is budget available |
| @@ -908,6 +913,10 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul | |||
| 908 | await chatMessage.addImage(chatPrompt.image); | 913 | await chatMessage.addImage(chatPrompt.image); |
| 909 | } | 914 | } |
| 910 | 915 | ||
| 916 | if (videoInlining && chatPrompt.video) { | ||
| 917 | await chatMessage.addVideo(chatPrompt.video); | ||
| 918 | } | ||
| 919 | |||
| 911 | if (canUseTools && Array.isArray(chatPrompt.invocations)) { | 920 | if (canUseTools && Array.isArray(chatPrompt.invocations)) { |
| 912 | /** @type {import('./tool-calling.js').ToolInvocation[]} */ | 921 | /** @type {import('./tool-calling.js').ToolInvocation[]} */ |
| 913 | const invocations = chatPrompt.invocations; | 922 | const invocations = chatPrompt.invocations; |
| @@ -2781,6 +2790,38 @@ class Message { | |||
| 2781 | } | 2790 | } |
| 2782 | } | 2791 | } |
| 2783 | 2792 | ||
| 2793 | async addVideo(video) { | ||
| 2794 | const textContent = this.content; | ||
| 2795 | const isDataUrl = isDataURL(video); | ||
| 2796 | if (!isDataUrl) { | ||
| 2797 | try { | ||
| 2798 | const response = await fetch(video, { method: 'GET', cache: 'force-cache' }); | ||
| 2799 | if (!response.ok) throw new Error('Failed to fetch video'); | ||
| 2800 | const blob = await response.blob(); | ||
| 2801 | video = await getBase64Async(blob); | ||
| 2802 | } catch (error) { | ||
| 2803 | console.error('Video adding skipped', error); | ||
| 2804 | return; | ||
| 2805 | } | ||
| 2806 | } | ||
| 2807 | |||
| 2808 | // Note: No compression for videos (unlike images) | ||
| 2809 | this.content = [ | ||
| 2810 | { type: 'text', text: textContent }, | ||
| 2811 | { type: 'video_url', video_url: { 'url': video } }, | ||
| 2812 | ]; | ||
| 2813 | |||
| 2814 | try { | ||
| 2815 | // Convservative estimate for video token cost without knowing duration | ||
| 2816 | // Using Gemini calculation (263 tokens per second) | ||
| 2817 | const tokens = 10000; // ~40 second video (60 seconds max) | ||
| 2818 | this.tokens += tokens; | ||
| 2819 | } catch (error) { | ||
| 2820 | this.tokens += 10000; | ||
| 2821 | console.error('Failed to get video token cost', error); | ||
| 2822 | } | ||
| 2823 | } | ||
| 2824 | |||
| 2784 | /** | 2825 | /** |
| 2785 | * Compress an image if it exceeds the size threshold for the current chat completion source. | 2826 | * Compress an image if it exceeds the size threshold for the current chat completion source. |
| 2786 | * @param {string} image Data URL of the image. | 2827 | * @param {string} image Data URL of the image. |
| @@ -3398,6 +3439,7 @@ function loadOpenAISettings(data, settings) { | |||
| 3398 | oai_settings.assistant_impersonation = settings.assistant_impersonation ?? default_settings.assistant_impersonation; | 3439 | oai_settings.assistant_impersonation = settings.assistant_impersonation ?? default_settings.assistant_impersonation; |
| 3399 | oai_settings.image_inlining = settings.image_inlining ?? default_settings.image_inlining; | 3440 | oai_settings.image_inlining = settings.image_inlining ?? default_settings.image_inlining; |
| 3400 | oai_settings.inline_image_quality = settings.inline_image_quality ?? default_settings.inline_image_quality; | 3441 | oai_settings.inline_image_quality = settings.inline_image_quality ?? default_settings.inline_image_quality; |
| 3442 | oai_settings.video_inlining = settings.video_inlining ?? default_settings.video_inlining; | ||
| 3401 | oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check; | 3443 | oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check; |
| 3402 | oai_settings.show_thoughts = settings.show_thoughts ?? default_settings.show_thoughts; | 3444 | oai_settings.show_thoughts = settings.show_thoughts ?? default_settings.show_thoughts; |
| 3403 | oai_settings.reasoning_effort = settings.reasoning_effort ?? default_settings.reasoning_effort; | 3445 | oai_settings.reasoning_effort = settings.reasoning_effort ?? default_settings.reasoning_effort; |
| @@ -3448,6 +3490,8 @@ function loadOpenAISettings(data, settings) { | |||
| 3448 | $('#openai_inline_image_quality').val(oai_settings.inline_image_quality); | 3490 | $('#openai_inline_image_quality').val(oai_settings.inline_image_quality); |
| 3449 | $(`#openai_inline_image_quality option[value="${oai_settings.inline_image_quality}"]`).prop('selected', true); | 3491 | $(`#openai_inline_image_quality option[value="${oai_settings.inline_image_quality}"]`).prop('selected', true); |
| 3450 | 3492 | ||
| 3493 | $('#openai_video_inlining').prop('checked', oai_settings.video_inlining); | ||
| 3494 | |||
| 3451 | $('#model_openai_select').val(oai_settings.openai_model); | 3495 | $('#model_openai_select').val(oai_settings.openai_model); |
| 3452 | $(`#model_openai_select option[value="${oai_settings.openai_model}"`).prop('selected', true); | 3496 | $(`#model_openai_select option[value="${oai_settings.openai_model}"`).prop('selected', true); |
| 3453 | $('#model_claude_select').val(oai_settings.claude_model); | 3497 | $('#model_claude_select').val(oai_settings.claude_model); |
| @@ -3824,6 +3868,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { | |||
| 3824 | squash_system_messages: settings.squash_system_messages, | 3868 | squash_system_messages: settings.squash_system_messages, |
| 3825 | image_inlining: settings.image_inlining, | 3869 | image_inlining: settings.image_inlining, |
| 3826 | inline_image_quality: settings.inline_image_quality, | 3870 | inline_image_quality: settings.inline_image_quality, |
| 3871 | video_inlining: settings.video_inlining, | ||
| 3827 | bypass_status_check: settings.bypass_status_check, | 3872 | bypass_status_check: settings.bypass_status_check, |
| 3828 | continue_prefill: settings.continue_prefill, | 3873 | continue_prefill: settings.continue_prefill, |
| 3829 | continue_postfix: settings.continue_postfix, | 3874 | continue_postfix: settings.continue_postfix, |
| @@ -5388,6 +5433,36 @@ export function isImageInliningSupported() { | |||
| 5388 | } | 5433 | } |
| 5389 | 5434 | ||
| 5390 | /** | 5435 | /** |
| 5436 | * Check if the model supports video inlining | ||
| 5437 | * @returns {boolean} True if the model supports video inlining | ||
| 5438 | */ | ||
| 5439 | export function isVideoInliningSupported() { | ||
| 5440 | if (main_api !== 'openai') { | ||
| 5441 | return false; | ||
| 5442 | } | ||
| 5443 | |||
| 5444 | if (!oai_settings.video_inlining) { | ||
| 5445 | return false; | ||
| 5446 | } | ||
| 5447 | |||
| 5448 | // Only Gemini models support video for now | ||
| 5449 | const videoSupportedModels = [ | ||
| 5450 | 'gemini-2.0', | ||
| 5451 | 'gemini-2.5', | ||
| 5452 | 'gemini-exp-1206', | ||
| 5453 | ]; | ||
| 5454 | |||
| 5455 | switch (oai_settings.chat_completion_source) { | ||
| 5456 | case chat_completion_sources.MAKERSUITE: | ||
| 5457 | return videoSupportedModels.some(model => oai_settings.google_model.includes(model)); | ||
| 5458 | case chat_completion_sources.VERTEXAI: | ||
| 5459 | return videoSupportedModels.some(model => oai_settings.vertexai_model.includes(model)); | ||
| 5460 | default: | ||
| 5461 | return false; | ||
| 5462 | } | ||
| 5463 | } | ||
| 5464 | |||
| 5465 | /** | ||
| 5391 | * Proxy stuff | 5466 | * Proxy stuff |
| 5392 | */ | 5467 | */ |
| 5393 | export function loadProxyPresets(settings) { | 5468 | export function loadProxyPresets(settings) { |
| @@ -5945,6 +6020,11 @@ export function initOpenAI() { | |||
| 5945 | saveSettingsDebounced(); | 6020 | saveSettingsDebounced(); |
| 5946 | }); | 6021 | }); |
| 5947 | 6022 | ||
| 6023 | $('#openai_video_inlining').on('input', function () { | ||
| 6024 | oai_settings.video_inlining = !!$(this).prop('checked'); | ||
| 6025 | saveSettingsDebounced(); | ||
| 6026 | }); | ||
| 6027 | |||
| 5948 | $('#continue_prefill').on('input', function () { | 6028 | $('#continue_prefill').on('input', function () { |
| 5949 | oai_settings.continue_prefill = !!$(this).prop('checked'); | 6029 | oai_settings.continue_prefill = !!$(this).prop('checked'); |
| 5950 | saveSettingsDebounced(); | 6030 | saveSettingsDebounced(); |
| @@ -5198,6 +5198,24 @@ body:not(.sd) .mes_img_swipes { | |||
| 5198 | max-width: 100% !important; | 5198 | max-width: 100% !important; |
| 5199 | } | 5199 | } |
| 5200 | 5200 | ||
| 5201 | /* Video message styling */ | ||
| 5202 | .mes_video { | ||
| 5203 | max-width: 100%; | ||
| 5204 | max-height: 400px; | ||
| 5205 | border-radius: 8px; | ||
| 5206 | background: #000; | ||
| 5207 | margin: 0.5rem; | ||
| 5208 | } | ||
| 5209 | |||
| 5210 | /* Ensure video controls are visible */ | ||
| 5211 | .mes_video::-webkit-media-controls { | ||
| 5212 | display: flex !important; | ||
| 5213 | } | ||
| 5214 | |||
| 5215 | .mes_video::-webkit-media-controls-panel { | ||
| 5216 | background-color: rgba(0, 0, 0, 0.2); | ||
| 5217 | } | ||
| 5218 | |||
| 5201 | /* Align the content of this span to the right */ | 5219 | /* Align the content of this span to the right */ |
| 5202 | .delete-button { | 5220 | .delete-button { |
| 5203 | margin-right: 10px; | 5221 | margin-right: 10px; |
| @@ -46,7 +46,7 @@ router.post('/upload', async (request, response) => { | |||
| 46 | const splitParts = request.body.image.split(','); | 46 | const splitParts = request.body.image.split(','); |
| 47 | const format = splitParts[0].split(';')[0].split('/')[1]; | 47 | const format = splitParts[0].split(';')[0].split('/')[1]; |
| 48 | const base64Data = splitParts[1]; | 48 | const base64Data = splitParts[1]; |
| 49 | const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif'].includes(format); | 49 | const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv'].includes(format); |
| 50 | if (!validFormat) { | 50 | if (!validFormat) { |
| 51 | return response.status(400).send({ error: 'Invalid image format' }); | 51 | return response.status(400).send({ error: 'Invalid image format' }); |
| 52 | } | 52 | } |
| @@ -471,6 +471,19 @@ export function convertGooglePrompt(messages, _model, useSysPrompt, names) { | |||
| 471 | data: base64Data, | 471 | data: base64Data, |
| 472 | }, | 472 | }, |
| 473 | }); | 473 | }); |
| 474 | } else if (part.type === 'video_url') { | ||
| 475 | const videoUrl = part.video_url?.url; | ||
| 476 | if (videoUrl && videoUrl.startsWith('data:')) { | ||
| 477 | const [header, data] = videoUrl.split(','); | ||
| 478 | const mimeType = header.match(/data:([^;]+)/)?.[1] || 'video/mp4'; | ||
| 479 | |||
| 480 | parts.push({ | ||
| 481 | inlineData: { | ||
| 482 | mimeType: mimeType, | ||
| 483 | data: data, | ||
| 484 | }, | ||
| 485 | }); | ||
| 486 | } | ||
| 474 | } | 487 | } |
| 475 | }); | 488 | }); |
| 476 | 489 | ||