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>

c4d89b2067be362e6ad0085e7d3a1b54d36a257b

Nikolas Brown <nbrown72504@gmail.com>

Signed
7 files changed, +158 -2Showing whitespace changes
public/index.html+14 -0
@@ -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>
public/script.js+25 -0
@@ -2473,6 +2473,31 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
2473 }2473 }
2474 }2474 }
24752475
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 message2501 // 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();
public/scripts/chats.js+6 -0
@@ -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`;
216222
public/scripts/openai.js+81 -1
@@ -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 }
600604
@@ -886,6 +890,7 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
886 }890 }
887891
888 const imageInlining = isImageInliningSupported();892 const imageInlining = isImageInliningSupported();
893 const videoInlining = isVideoInliningSupported();
889 const canUseTools = ToolManager.isToolCallingSupported();894 const canUseTools = ToolManager.isToolCallingSupported();
890895
891 // Insert chat messages as long as there is budget available896 // 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 }
910915
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 }
27832792
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);
34503492
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}
53895434
5390/**5435/**
5436 * Check if the model supports video inlining
5437 * @returns {boolean} True if the model supports video inlining
5438 */
5439export 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 stuff5466 * Proxy stuff
5392 */5467 */
5393export function loadProxyPresets(settings) {5468export function loadProxyPresets(settings) {
@@ -5945,6 +6020,11 @@ export function initOpenAI() {
5945 saveSettingsDebounced();6020 saveSettingsDebounced();
5946 });6021 });
59476022
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();
public/style.css+18 -0
@@ -5198,6 +5198,24 @@ body:not(.sd) .mes_img_swipes {
5198 max-width: 100% !important;5198 max-width: 100% !important;
5199}5199}
52005200
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;
src/endpoints/images.js+1 -1
@@ -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 }
src/prompt-converters.js+13 -0
@@ -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 });
476489