Refactor saveBase64AsFile uploads (#4200) * Refactor saveBase64AsFile uploads * Add request body check * Extract server-side constants * Allow .jfif media attachments * Allow .bmp uploads * Enhance image prompt handling: support additional MIME types and prevent upscaling in thumbnails * Convert file extension to lowercase * Enhance thumbnail creation: improve image quality and add white background * Add toast for error in media upload
Signed| @@ -43,6 +43,7 @@ import { | |||
| 43 | extractTextFromOffice, | 43 | extractTextFromOffice, |
| 44 | download, | 44 | download, |
| 45 | getFileText, | 45 | getFileText, |
| 46 | getFileExtension, | ||
| 46 | } from './utils.js'; | 47 | } from './utils.js'; |
| 47 | import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; | 48 | import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; |
| 48 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; | 49 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| @@ -204,7 +205,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input | |||
| 204 | const fileNamePrefix = `${Date.now()}_${slug}`; | 205 | const fileNamePrefix = `${Date.now()}_${slug}`; |
| 205 | const fileBase64 = await getBase64Async(file); | 206 | const fileBase64 = await getBase64Async(file); |
| 206 | let base64Data = fileBase64.split(',')[1]; | 207 | let base64Data = fileBase64.split(',')[1]; |
| 207 | const extension = file.name.substring((file.name.lastIndexOf('.') + file.name.length) % file.name.length + 1); | 208 | const extension = getFileExtension(file); |
| 208 | 209 | ||
| 209 | // If file is image | 210 | // If file is image |
| 210 | if (file.type.startsWith('image/')) { | 211 | if (file.type.startsWith('image/')) { |
| @@ -246,6 +247,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input | |||
| 246 | 247 | ||
| 247 | } catch (error) { | 248 | } catch (error) { |
| 248 | console.error('Could not upload file', error); | 249 | console.error('Could not upload file', error); |
| 250 | toastr.error(t`Either the file is corrupted or its format is not supported.`, t`Could not upload the file`); | ||
| 249 | } finally { | 251 | } finally { |
| 250 | $('#file_form').trigger('reset'); | 252 | $('#file_form').trigger('reset'); |
| 251 | } | 253 | } |
| @@ -27,4 +27,4 @@ export const IGNORE_SYMBOL = Symbol.for('ignore'); | |||
| 27 | * Common video file extensions. Should be the same as supported by Gemini. | 27 | * Common video file extensions. Should be the same as supported by Gemini. |
| 28 | * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats | 28 | * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats |
| 29 | */ | 29 | */ |
| 30 | export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv']; | 30 | export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg']; |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { ensureImageFormatSupported, getBase64Async, isTrueBoolean, saveBase64AsFile } from '../../utils.js'; | 1 | import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js'; |
| 2 | import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js'; | 2 | import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 3 | import { appendMediaToMessage, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js'; | 3 | import { appendMediaToMessage, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js'; |
| 4 | import { getMessageTimeStamp } from '../../RossAscends-mods.js'; | 4 | import { getMessageTimeStamp } from '../../RossAscends-mods.js'; |
| @@ -327,11 +327,11 @@ async function getCaptionForFile(file, prompt, quiet) { | |||
| 327 | setSpinnerIcon(); | 327 | setSpinnerIcon(); |
| 328 | const context = getContext(); | 328 | const context = getContext(); |
| 329 | const fileData = await getBase64Async(await ensureImageFormatSupported(file)); | 329 | const fileData = await getBase64Async(await ensureImageFormatSupported(file)); |
| 330 | const base64Format = fileData.split(',')[0].split(';')[0].split('/')[1]; | 330 | const extension = getFileExtension(file); |
| 331 | const base64Data = fileData.split(',')[1]; | 331 | const base64Data = fileData.split(',')[1]; |
| 332 | const { caption } = await doCaptionRequest(base64Data, fileData, prompt); | 332 | const { caption } = await doCaptionRequest(base64Data, fileData, prompt); |
| 333 | if (!quiet) { | 333 | if (!quiet) { |
| 334 | const imagePath = await saveBase64AsFile(base64Data, context.name2, '', base64Format); | 334 | const imagePath = await saveBase64AsFile(base64Data, context.name2, '', extension); |
| 335 | await sendCaptionedMessage(caption, imagePath); | 335 | await sendCaptionedMessage(caption, imagePath); |
| 336 | } | 336 | } |
| 337 | return caption; | 337 | return caption; |
| @@ -8,7 +8,7 @@ import { | |||
| 8 | animation_easing, | 8 | animation_easing, |
| 9 | } from '../../../script.js'; | 9 | } from '../../../script.js'; |
| 10 | import { groups, selected_group } from '../../group-chats.js'; | 10 | import { groups, selected_group } from '../../group-chats.js'; |
| 11 | import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js'; | 11 | import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension } from '../../utils.js'; |
| 12 | import { loadMovingUIState } from '../../power-user.js'; | 12 | import { loadMovingUIState } from '../../power-user.js'; |
| 13 | import { dragElement } from '../../RossAscends-mods.js'; | 13 | import { dragElement } from '../../RossAscends-mods.js'; |
| 14 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; | 14 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| @@ -341,27 +341,12 @@ async function showCharGallery(deleteModeState = false) { | |||
| 341 | async function uploadFile(file, url) { | 341 | async function uploadFile(file, url) { |
| 342 | try { | 342 | try { |
| 343 | // Convert the file to a base64 string | 343 | // Convert the file to a base64 string |
| 344 | const base64Data = await getBase64Async(file); | 344 | const fileBase64 = await getBase64Async(file); |
| 345 | const base64Data = fileBase64.split(',')[1]; | ||
| 346 | const extension = getFileExtension(file); | ||
| 347 | const path = await saveBase64AsFile(base64Data, url, '', extension); | ||
| 345 | 348 | ||
| 346 | // Create the payload | 349 | toastr.success(t`File uploaded successfully. Saved at: ${path}`); |
| 347 | const payload = { | ||
| 348 | image: base64Data, | ||
| 349 | ch_name: url, | ||
| 350 | }; | ||
| 351 | |||
| 352 | const response = await fetch('/api/images/upload', { | ||
| 353 | method: 'POST', | ||
| 354 | headers: getRequestHeaders(), | ||
| 355 | body: JSON.stringify(payload), | ||
| 356 | }); | ||
| 357 | |||
| 358 | if (!response.ok) { | ||
| 359 | throw new Error(`HTTP error! Status: ${response.status}`); | ||
| 360 | } | ||
| 361 | |||
| 362 | const result = await response.json(); | ||
| 363 | |||
| 364 | toastr.success(t`File uploaded successfully. Saved at: ${result.path}`); | ||
| 365 | } catch (error) { | 350 | } catch (error) { |
| 366 | console.error('There was an issue uploading the file:', error); | 351 | console.error('There was an issue uploading the file:', error); |
| 367 | 352 | ||
| @@ -38,10 +38,14 @@ export async function getMultimodalCaption(base64Img, prompt) { | |||
| 38 | const isVllm = extension_settings.caption.multimodal_api === 'vllm'; | 38 | const isVllm = extension_settings.caption.multimodal_api === 'vllm'; |
| 39 | const base64Bytes = base64Img.length * 0.75; | 39 | const base64Bytes = base64Img.length * 0.75; |
| 40 | const compressionLimit = 2 * 1024 * 1024; | 40 | const compressionLimit = 2 * 1024 * 1024; |
| 41 | const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; | ||
| 42 | const mimeType = base64Img?.split(';')?.[0]?.split(':')?.[1]; | ||
| 41 | const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api); | 43 | const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api); |
| 42 | if ((thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) { | 44 | if ((thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) { |
| 43 | const maxSide = 1024; | 45 | const maxSide = 2048; |
| 44 | base64Img = await createThumbnail(base64Img, maxSide, maxSide, 'image/jpeg'); | 46 | base64Img = await createThumbnail(base64Img, maxSide, maxSide); |
| 47 | } else if (!safeMimeTypes.includes(mimeType)) { | ||
| 48 | base64Img = await createThumbnail(base64Img, null, null); | ||
| 45 | } | 49 | } |
| 46 | 50 | ||
| 47 | const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : ''; | 51 | const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : ''; |
| @@ -2949,13 +2949,15 @@ class Message { | |||
| 2949 | chat_completion_sources.MISTRALAI, | 2949 | chat_completion_sources.MISTRALAI, |
| 2950 | chat_completion_sources.VERTEXAI, | 2950 | chat_completion_sources.VERTEXAI, |
| 2951 | ]; | 2951 | ]; |
| 2952 | if (compressImageSources.includes(oai_settings.chat_completion_source)) { | 2952 | const sizeThreshold = 2 * 1024 * 1024; |
| 2953 | const sizeThreshold = 2 * 1024 * 1024; | 2953 | const dataSize = image.length * 0.75; |
| 2954 | const dataSize = image.length * 0.75; | 2954 | const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; |
| 2955 | const maxSide = 1024; | 2955 | const mimeType = image?.split(';')?.[0]?.split(':')?.[1]; |
| 2956 | if (dataSize > sizeThreshold) { | 2956 | if (compressImageSources.includes(oai_settings.chat_completion_source) && dataSize > sizeThreshold) { |
| 2957 | image = await createThumbnail(image, maxSide); | 2957 | const maxSide = 2048; |
| 2958 | } | 2958 | image = await createThumbnail(image, maxSide, maxSide); |
| 2959 | } else if (!safeMimeTypes.includes(mimeType)) { | ||
| 2960 | image = await createThumbnail(image, null, null); | ||
| 2959 | } | 2961 | } |
| 2960 | return image; | 2962 | return image; |
| 2961 | } | 2963 | } |
| @@ -1410,32 +1410,27 @@ export async function getSanitizedFilename(fileName) { | |||
| 1410 | * Sends a base64 encoded image to the backend to be saved as a file. | 1410 | * Sends a base64 encoded image to the backend to be saved as a file. |
| 1411 | * | 1411 | * |
| 1412 | * @param {string} base64Data - The base64 encoded image data. | 1412 | * @param {string} base64Data - The base64 encoded image data. |
| 1413 | * @param {string} characterName - The character name to determine the sub-directory for saving. | 1413 | * @param {string} subFolder - The character name to determine the sub-directory for saving. |
| 1414 | * @param {string} ext - The file extension for the image (e.g., 'jpg', 'png', 'webp'). | 1414 | * @param {string} fileName - The name of the file to save the image as (without extension). |
| 1415 | * @param {string} extension - The file extension for the image (e.g., 'jpg', 'png', 'webp'). | ||
| 1415 | * | 1416 | * |
| 1416 | * @returns {Promise<string>} - Resolves to the saved image's path on the server. | 1417 | * @returns {Promise<string>} - Resolves to the saved image's path on the server. |
| 1417 | * Rejects with an error if the upload fails. | 1418 | * Rejects with an error if the upload fails. |
| 1418 | */ | 1419 | */ |
| 1419 | export async function saveBase64AsFile(base64Data, characterName, filename = '', ext) { | 1420 | export async function saveBase64AsFile(base64Data, subFolder, fileName, extension) { |
| 1420 | // Construct the full data URL | ||
| 1421 | const format = ext; // Extract the file extension (jpg, png, webp) | ||
| 1422 | const dataURL = `data:image/${format};base64,${base64Data}`; | ||
| 1423 | |||
| 1424 | // Prepare the request body | 1421 | // Prepare the request body |
| 1425 | const requestBody = { | 1422 | const requestBody = { |
| 1426 | image: dataURL, | 1423 | image: base64Data, |
| 1427 | ch_name: characterName, | 1424 | format: extension, |
| 1428 | filename: String(filename).replace(/\./g, '_'), | 1425 | ch_name: subFolder, |
| 1426 | filename: String(fileName).replace(/\./g, '_'), | ||
| 1429 | }; | 1427 | }; |
| 1430 | 1428 | ||
| 1431 | // Send the data URL to your backend using fetch | 1429 | // Send the data URL to your backend using fetch |
| 1432 | const response = await fetch('/api/images/upload', { | 1430 | const response = await fetch('/api/images/upload', { |
| 1433 | method: 'POST', | 1431 | method: 'POST', |
| 1432 | headers: getRequestHeaders(), | ||
| 1434 | body: JSON.stringify(requestBody), | 1433 | body: JSON.stringify(requestBody), |
| 1435 | headers: { | ||
| 1436 | ...getRequestHeaders(), | ||
| 1437 | 'Content-Type': 'application/json', | ||
| 1438 | }, | ||
| 1439 | }); | 1434 | }); |
| 1440 | 1435 | ||
| 1441 | // If the response is successful, get the saved image path from the server's response | 1436 | // If the response is successful, get the saved image path from the server's response |
| @@ -1449,6 +1444,15 @@ export async function saveBase64AsFile(base64Data, characterName, filename = '', | |||
| 1449 | } | 1444 | } |
| 1450 | 1445 | ||
| 1451 | /** | 1446 | /** |
| 1447 | * Gets the file extension from a File object. | ||
| 1448 | * @param {File} file The file to get the extension from | ||
| 1449 | * @returns {string} The file extension of the given file | ||
| 1450 | */ | ||
| 1451 | export function getFileExtension(file) { | ||
| 1452 | return file.name.substring((file.name.lastIndexOf('.') + file.name.length) % file.name.length + 1).toLowerCase().trim(); | ||
| 1453 | } | ||
| 1454 | |||
| 1455 | /** | ||
| 1452 | * Loads either a CSS or JS file and appends it to the appropriate document section. | 1456 | * Loads either a CSS or JS file and appends it to the appropriate document section. |
| 1453 | * | 1457 | * |
| 1454 | * @param {string} url - The URL of the file to be loaded. | 1458 | * @param {string} url - The URL of the file to be loaded. |
| @@ -1554,15 +1558,25 @@ export function createThumbnail(dataUrl, maxWidth = null, maxHeight = null, type | |||
| 1554 | maxHeight = img.height; | 1558 | maxHeight = img.height; |
| 1555 | } | 1559 | } |
| 1556 | 1560 | ||
| 1557 | if (img.width > img.height) { | 1561 | // Do not upscale if image is already smaller than max dimensions |
| 1558 | thumbnailHeight = maxWidth / aspectRatio; | 1562 | if (img.width <= maxWidth && img.height <= maxHeight) { |
| 1563 | thumbnailWidth = img.width; | ||
| 1564 | thumbnailHeight = img.height; | ||
| 1559 | } else { | 1565 | } else { |
| 1560 | thumbnailWidth = maxHeight * aspectRatio; | 1566 | if (img.width > img.height) { |
| 1567 | thumbnailHeight = maxWidth / aspectRatio; | ||
| 1568 | } else { | ||
| 1569 | thumbnailWidth = maxHeight * aspectRatio; | ||
| 1570 | } | ||
| 1561 | } | 1571 | } |
| 1562 | 1572 | ||
| 1563 | // Set the canvas dimensions and draw the resized image | 1573 | // Set the canvas dimensions and draw the resized image |
| 1564 | canvas.width = thumbnailWidth; | 1574 | canvas.width = thumbnailWidth; |
| 1565 | canvas.height = thumbnailHeight; | 1575 | canvas.height = thumbnailHeight; |
| 1576 | ctx.imageSmoothingEnabled = true; | ||
| 1577 | ctx.imageSmoothingQuality = 'high'; | ||
| 1578 | ctx.fillStyle = 'white'; | ||
| 1579 | ctx.fillRect(0, 0, thumbnailWidth, thumbnailHeight); | ||
| 1566 | ctx.drawImage(img, 0, 0, thumbnailWidth, thumbnailHeight); | 1580 | ctx.drawImage(img, 0, 0, thumbnailWidth, thumbnailHeight); |
| 1567 | 1581 | ||
| 1568 | // Convert the canvas to a data URL and resolve the promise | 1582 | // Convert the canvas to a data URL and resolve the promise |
| @@ -412,3 +412,26 @@ export const LOG_LEVELS = { | |||
| 412 | WARN: 2, | 412 | WARN: 2, |
| 413 | ERROR: 3, | 413 | ERROR: 3, |
| 414 | }; | 414 | }; |
| 415 | |||
| 416 | /** | ||
| 417 | * An array of supported media file extensions. | ||
| 418 | * This is used to validate file uploads and ensure that only supported media types are processed. | ||
| 419 | */ | ||
| 420 | export const MEDIA_EXTENSIONS = [ | ||
| 421 | 'bmp', | ||
| 422 | 'png', | ||
| 423 | 'jpg', | ||
| 424 | 'webp', | ||
| 425 | 'jpeg', | ||
| 426 | 'jfif', | ||
| 427 | 'gif', | ||
| 428 | 'mp4', | ||
| 429 | 'avi', | ||
| 430 | 'mov', | ||
| 431 | 'wmv', | ||
| 432 | 'flv', | ||
| 433 | 'webm', | ||
| 434 | '3gp', | ||
| 435 | 'mkv', | ||
| 436 | 'mpg', | ||
| 437 | ]; | ||
| @@ -6,6 +6,7 @@ import express from 'express'; | |||
| 6 | import sanitize from 'sanitize-filename'; | 6 | import sanitize from 'sanitize-filename'; |
| 7 | 7 | ||
| 8 | import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js'; | 8 | import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js'; |
| 9 | import { MEDIA_EXTENSIONS } from '../constants.js'; | ||
| 9 | 10 | ||
| 10 | /** | 11 | /** |
| 11 | * Ensure the directory for the provided file path exists. | 12 | * Ensure the directory for the provided file path exists. |
| @@ -36,17 +37,18 @@ export const router = express.Router(); | |||
| 36 | * @returns {Object} response - The response object containing the path where the image was saved. | 37 | * @returns {Object} response - The response object containing the path where the image was saved. |
| 37 | */ | 38 | */ |
| 38 | router.post('/upload', async (request, response) => { | 39 | router.post('/upload', async (request, response) => { |
| 39 | // Check for image data | ||
| 40 | if (!request.body || !request.body.image) { | ||
| 41 | return response.status(400).send({ error: 'No image data provided' }); | ||
| 42 | } | ||
| 43 | |||
| 44 | try { | 40 | try { |
| 45 | // Extracting the base64 data and the image format | 41 | if (!request.body) { |
| 46 | const splitParts = request.body.image.split(','); | 42 | return response.status(400).send({ error: 'No data provided' }); |
| 47 | const format = splitParts[0].split(';')[0].split('/')[1]; | 43 | } |
| 48 | const base64Data = splitParts[1]; | 44 | |
| 49 | const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv'].includes(format); | 45 | const { image, format } = request.body; |
| 46 | |||
| 47 | if (!image) { | ||
| 48 | return response.status(400).send({ error: 'No image data provided' }); | ||
| 49 | } | ||
| 50 | |||
| 51 | const validFormat = MEDIA_EXTENSIONS.includes(format); | ||
| 50 | if (!validFormat) { | 52 | if (!validFormat) { |
| 51 | return response.status(400).send({ error: 'Invalid image format' }); | 53 | return response.status(400).send({ error: 'Invalid image format' }); |
| 52 | } | 54 | } |
| @@ -66,7 +68,7 @@ router.post('/upload', async (request, response) => { | |||
| 66 | } | 68 | } |
| 67 | 69 | ||
| 68 | ensureDirectoryExistence(pathToNewFile); | 70 | ensureDirectoryExistence(pathToNewFile); |
| 69 | const imageBuffer = Buffer.from(base64Data, 'base64'); | 71 | const imageBuffer = Buffer.from(image, 'base64'); |
| 70 | await fs.promises.writeFile(pathToNewFile, new Uint8Array(imageBuffer)); | 72 | await fs.promises.writeFile(pathToNewFile, new Uint8Array(imageBuffer)); |
| 71 | response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) }); | 73 | response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) }); |
| 72 | } catch (error) { | 74 | } catch (error) { |