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 | 2020 | </div> |
| 2021 | 2021 | </div> |
| 2022 | 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 | 2037 | <label for="openai_request_images" class="checkbox_label widthFreeExpand"> |
| 2024 | 2038 | <input id="openai_request_images" type="checkbox" /> |
| 2025 | 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 | 2501 | // Add file to message |
| 2477 | 2502 | if (mes.extra?.file) { |
| 2478 | 2503 | messageElement.find('.mes_file_container').remove(); |
| @@ -211,6 +211,12 @@ export async function populateFileAttachment(message, inputId = 'file_form_input | ||
| 211 | 211 | const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension); |
| 212 | 212 | message.extra.image = imageUrl; |
| 213 | 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 | 220 | } else { |
| 215 | 221 | const uniqueFileName = `${fileNamePrefix}.txt`; |
| 216 | 222 | |
| @@ -313,6 +313,7 @@ export const settingsToUpdate = { | ||
| 313 | 313 | squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true, false], |
| 314 | 314 | image_inlining: ['#openai_image_inlining', 'image_inlining', true, false], |
| 315 | 315 | inline_image_quality: ['#openai_inline_image_quality', 'inline_image_quality', false, false], |
| 316 | + video_inlining: ['#openai_video_inlining', 'video_inlining', true, false], | |
| 316 | 317 | continue_prefill: ['#continue_prefill', 'continue_prefill', true, false], |
| 317 | 318 | continue_postfix: ['#continue_postfix', 'continue_postfix', false, false], |
| 318 | 319 | function_calling: ['#openai_function_calling', 'function_calling', true, false], |
| @@ -396,6 +397,7 @@ const default_settings = { | ||
| 396 | 397 | squash_system_messages: false, |
| 397 | 398 | image_inlining: false, |
| 398 | 399 | inline_image_quality: 'low', |
| 400 | + video_inlining: false, | |
| 399 | 401 | bypass_status_check: false, |
| 400 | 402 | continue_prefill: false, |
| 401 | 403 | function_calling: false, |
| @@ -482,6 +484,7 @@ const oai_settings = { | ||
| 482 | 484 | squash_system_messages: false, |
| 483 | 485 | image_inlining: false, |
| 484 | 486 | inline_image_quality: 'low', |
| 487 | + video_inlining: false, | |
| 485 | 488 | bypass_status_check: false, |
| 486 | 489 | continue_prefill: false, |
| 487 | 490 | function_calling: false, |
| @@ -593,8 +596,9 @@ function setOpenAIMessages(chat) { | ||
| 593 | 596 | if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`; |
| 594 | 597 | const name = chat[j]['name']; |
| 595 | 598 | const image = chat[j]?.extra?.image; |
| 599 | + const video = chat[j]?.extra?.video; | |
| 596 | 600 | const invocations = chat[j]?.extra?.tool_invocations; |
| 597 | 601 | messages[i] = { 'role': role, 'content': content, name: name, 'image': image, 'video': video, 'invocations': invocations }; |
| 598 | 602 | j++; |
| 599 | 603 | } |
| 600 | 604 | |
| @@ -886,6 +890,7 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul | ||
| 886 | 890 | } |
| 887 | 891 | |
| 888 | 892 | const imageInlining = isImageInliningSupported(); |
| 893 | + const videoInlining = isVideoInliningSupported(); | |
| 889 | 894 | const canUseTools = ToolManager.isToolCallingSupported(); |
| 890 | 895 | |
| 891 | 896 | // Insert chat messages as long as there is budget available |
| @@ -908,6 +913,10 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul | ||
| 908 | 913 | await chatMessage.addImage(chatPrompt.image); |
| 909 | 914 | } |
| 910 | 915 | |
| 916 | + if (videoInlining && chatPrompt.video) { | |
| 917 | + await chatMessage.addVideo(chatPrompt.video); | |
| 918 | + } | |
| 919 | + | |
| 911 | 920 | if (canUseTools && Array.isArray(chatPrompt.invocations)) { |
| 912 | 921 | /** @type {import('./tool-calling.js').ToolInvocation[]} */ |
| 913 | 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 | 2826 | * Compress an image if it exceeds the size threshold for the current chat completion source. |
| 2786 | 2827 | * @param {string} image Data URL of the image. |
| @@ -3398,6 +3439,7 @@ function loadOpenAISettings(data, settings) { | ||
| 3398 | 3439 | oai_settings.assistant_impersonation = settings.assistant_impersonation ?? default_settings.assistant_impersonation; |
| 3399 | 3440 | oai_settings.image_inlining = settings.image_inlining ?? default_settings.image_inlining; |
| 3400 | 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 | 3443 | oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check; |
| 3402 | 3444 | oai_settings.show_thoughts = settings.show_thoughts ?? default_settings.show_thoughts; |
| 3403 | 3445 | oai_settings.reasoning_effort = settings.reasoning_effort ?? default_settings.reasoning_effort; |
| @@ -3448,6 +3490,8 @@ function loadOpenAISettings(data, settings) { | ||
| 3448 | 3490 | $('#openai_inline_image_quality').val(oai_settings.inline_image_quality); |
| 3449 | 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 | 3495 | $('#model_openai_select').val(oai_settings.openai_model); |
| 3452 | 3496 | $(`#model_openai_select option[value="${oai_settings.openai_model}"`).prop('selected', true); |
| 3453 | 3497 | $('#model_claude_select').val(oai_settings.claude_model); |
| @@ -3824,6 +3868,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { | ||
| 3824 | 3868 | squash_system_messages: settings.squash_system_messages, |
| 3825 | 3869 | image_inlining: settings.image_inlining, |
| 3826 | 3870 | inline_image_quality: settings.inline_image_quality, |
| 3871 | + video_inlining: settings.video_inlining, | |
| 3827 | 3872 | bypass_status_check: settings.bypass_status_check, |
| 3828 | 3873 | continue_prefill: settings.continue_prefill, |
| 3829 | 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 | 5466 | * Proxy stuff |
| 5392 | 5467 | */ |
| 5393 | 5468 | export function loadProxyPresets(settings) { |
| @@ -5945,6 +6020,11 @@ export function initOpenAI() { | ||
| 5945 | 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 | 6028 | $('#continue_prefill').on('input', function () { |
| 5949 | 6029 | oai_settings.continue_prefill = !!$(this).prop('checked'); |
| 5950 | 6030 | saveSettingsDebounced(); |
| @@ -5198,6 +5198,24 @@ body:not(.sd) .mes_img_swipes { | ||
| 5198 | 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 | 5219 | /* Align the content of this span to the right */ |
| 5202 | 5220 | .delete-button { |
| 5203 | 5221 | margin-right: 10px; |
| @@ -46,7 +46,7 @@ router.post('/upload', async (request, response) => { | ||
| 46 | 46 | const splitParts = request.body.image.split(','); |
| 47 | 47 | const format = splitParts[0].split(';')[0].split('/')[1]; |
| 48 | 48 | const base64Data = splitParts[1]; |
| 49 | 49 | const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv'].includes(format); |
| 50 | 50 | if (!validFormat) { |
| 51 | 51 | return response.status(400).send({ error: 'Invalid image format' }); |
| 52 | 52 | } |
| @@ -471,6 +471,19 @@ export function convertGooglePrompt(messages, _model, useSysPrompt, names) { | ||
| 471 | 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 | |