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 @@
20202020 </div>
20212021 </div>
20222022 <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">
20232037 <label for="openai_request_images" class="checkbox_label widthFreeExpand">
20242038 <input id="openai_request_images" type="checkbox" />
20252039 <span>
public/script.js+25 -0
@@ -2473,6 +2473,31 @@ export function appendMediaToMessage(mes, messageElement, adjustScroll = true) {
24732473 }
24742474 }
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+
24762501 // Add file to message
24772502 if (mes.extra?.file) {
24782503 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
211211 const imageUrl = await saveBase64AsFile(base64Data, name2, fileNamePrefix, extension);
212212 message.extra.image = imageUrl;
213213 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;
214220 } else {
215221 const uniqueFileName = `${fileNamePrefix}.txt`;
216222
public/scripts/openai.js+81 -1
@@ -313,6 +313,7 @@ export const settingsToUpdate = {
313313 squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true, false],
314314 image_inlining: ['#openai_image_inlining', 'image_inlining', true, false],
315315 inline_image_quality: ['#openai_inline_image_quality', 'inline_image_quality', false, false],
316+ video_inlining: ['#openai_video_inlining', 'video_inlining', true, false],
316317 continue_prefill: ['#continue_prefill', 'continue_prefill', true, false],
317318 continue_postfix: ['#continue_postfix', 'continue_postfix', false, false],
318319 function_calling: ['#openai_function_calling', 'function_calling', true, false],
@@ -396,6 +397,7 @@ const default_settings = {
396397 squash_system_messages: false,
397398 image_inlining: false,
398399 inline_image_quality: 'low',
400+ video_inlining: false,
399401 bypass_status_check: false,
400402 continue_prefill: false,
401403 function_calling: false,
@@ -482,6 +484,7 @@ const oai_settings = {
482484 squash_system_messages: false,
483485 image_inlining: false,
484486 inline_image_quality: 'low',
487+ video_inlining: false,
485488 bypass_status_check: false,
486489 continue_prefill: false,
487490 function_calling: false,
@@ -593,8 +596,9 @@ function setOpenAIMessages(chat) {
593596 if (role == 'user' && oai_settings.wrap_in_quotes) content = `"${content}"`;
594597 const name = chat[j]['name'];
595598 const image = chat[j]?.extra?.image;
599+ const video = chat[j]?.extra?.video;
596600 const invocations = chat[j]?.extra?.tool_invocations;
597601 messages[i] = { 'role': role, 'content': content, name: name, 'image': image, 'video': video, 'invocations': invocations };
598602 j++;
599603 }
600604
@@ -886,6 +890,7 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
886890 }
887891
888892 const imageInlining = isImageInliningSupported();
893+ const videoInlining = isVideoInliningSupported();
889894 const canUseTools = ToolManager.isToolCallingSupported();
890895
891896 // Insert chat messages as long as there is budget available
@@ -908,6 +913,10 @@ async function populateChatHistory(messages, prompts, chatCompletion, type = nul
908913 await chatMessage.addImage(chatPrompt.image);
909914 }
910915
916+ if (videoInlining && chatPrompt.video) {
917+ await chatMessage.addVideo(chatPrompt.video);
918+ }
919+
911920 if (canUseTools && Array.isArray(chatPrompt.invocations)) {
912921 /** @type {import('./tool-calling.js').ToolInvocation[]} */
913922 const invocations = chatPrompt.invocations;
@@ -2781,6 +2790,38 @@ class Message {
27812790 }
27822791 }
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+
27842825 /**
27852826 * Compress an image if it exceeds the size threshold for the current chat completion source.
27862827 * @param {string} image Data URL of the image.
@@ -3398,6 +3439,7 @@ function loadOpenAISettings(data, settings) {
33983439 oai_settings.assistant_impersonation = settings.assistant_impersonation ?? default_settings.assistant_impersonation;
33993440 oai_settings.image_inlining = settings.image_inlining ?? default_settings.image_inlining;
34003441 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;
34013443 oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check;
34023444 oai_settings.show_thoughts = settings.show_thoughts ?? default_settings.show_thoughts;
34033445 oai_settings.reasoning_effort = settings.reasoning_effort ?? default_settings.reasoning_effort;
@@ -3448,6 +3490,8 @@ function loadOpenAISettings(data, settings) {
34483490 $('#openai_inline_image_quality').val(oai_settings.inline_image_quality);
34493491 $(`#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+
34513495 $('#model_openai_select').val(oai_settings.openai_model);
34523496 $(`#model_openai_select option[value="${oai_settings.openai_model}"`).prop('selected', true);
34533497 $('#model_claude_select').val(oai_settings.claude_model);
@@ -3824,6 +3868,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
38243868 squash_system_messages: settings.squash_system_messages,
38253869 image_inlining: settings.image_inlining,
38263870 inline_image_quality: settings.inline_image_quality,
3871+ video_inlining: settings.video_inlining,
38273872 bypass_status_check: settings.bypass_status_check,
38283873 continue_prefill: settings.continue_prefill,
38293874 continue_postfix: settings.continue_postfix,
@@ -5388,6 +5433,36 @@ export function isImageInliningSupported() {
53885433}
53895434
53905435/**
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+/**
53915466 * Proxy stuff
53925467 */
53935468export function loadProxyPresets(settings) {
@@ -5945,6 +6020,11 @@ export function initOpenAI() {
59456020 saveSettingsDebounced();
59466021 });
59476022
6023+ $('#openai_video_inlining').on('input', function () {
6024+ oai_settings.video_inlining = !!$(this).prop('checked');
6025+ saveSettingsDebounced();
6026+ });
6027+
59486028 $('#continue_prefill').on('input', function () {
59496029 oai_settings.continue_prefill = !!$(this).prop('checked');
59506030 saveSettingsDebounced();
public/style.css+18 -0
@@ -5198,6 +5198,24 @@ body:not(.sd) .mes_img_swipes {
51985198 max-width: 100% !important;
51995199}
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+
52015219/* Align the content of this span to the right */
52025220.delete-button {
52035221 margin-right: 10px;
src/endpoints/images.js+1 -1
@@ -46,7 +46,7 @@ router.post('/upload', async (request, response) => {
4646 const splitParts = request.body.image.split(',');
4747 const format = splitParts[0].split(';')[0].split('/')[1];
4848 const base64Data = splitParts[1];
4949 const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv'].includes(format);
5050 if (!validFormat) {
5151 return response.status(400).send({ error: 'Invalid image format' });
5252 }
src/prompt-converters.js+13 -0
@@ -471,6 +471,19 @@ export function convertGooglePrompt(messages, _model, useSysPrompt, names) {
471471 data: base64Data,
472472 },
473473 });
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+ }
474487 }
475488 });
476489