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 | 43 | extractTextFromOffice, |
| 44 | 44 | download, |
| 45 | 45 | getFileText, |
| 46 | + getFileExtension, | |
| 46 | 47 | } from './utils.js'; |
| 47 | 48 | import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; |
| 48 | 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 | 205 | const fileNamePrefix = `${Date.now()}_${slug}`; |
| 205 | 206 | const fileBase64 = await getBase64Async(file); |
| 206 | 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 | 210 | // If file is image |
| 210 | 211 | if (file.type.startsWith('image/')) { |
| @@ -246,6 +247,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input | ||
| 246 | 247 | |
| 247 | 248 | } catch (error) { |
| 248 | 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 | 251 | } finally { |
| 250 | 252 | $('#file_form').trigger('reset'); |
| 251 | 253 | } |
| @@ -27,4 +27,4 @@ export const IGNORE_SYMBOL = Symbol.for('ignore'); | ||
| 27 | 27 | * Common video file extensions. Should be the same as supported by Gemini. |
| 28 | 28 | * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats |
| 29 | 29 | */ |
| 30 | 30 | export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg']; |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js'; |
| 2 | 2 | import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 3 | 3 | import { appendMediaToMessage, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js'; |
| 4 | 4 | import { getMessageTimeStamp } from '../../RossAscends-mods.js'; |
| @@ -327,11 +327,11 @@ async function getCaptionForFile(file, prompt, quiet) { | ||
| 327 | 327 | setSpinnerIcon(); |
| 328 | 328 | const context = getContext(); |
| 329 | 329 | const fileData = await getBase64Async(await ensureImageFormatSupported(file)); |
| 330 | - const base64Format = fileData.split(',')[0].split(';')[0].split('/')[1]; | |
| 330 | + const extension = getFileExtension(file); | |
| 331 | 331 | const base64Data = fileData.split(',')[1]; |
| 332 | 332 | const { caption } = await doCaptionRequest(base64Data, fileData, prompt); |
| 333 | 333 | if (!quiet) { |
| 334 | 334 | const imagePath = await saveBase64AsFile(base64Data, context.name2, '', base64Formatextension); |
| 335 | 335 | await sendCaptionedMessage(caption, imagePath); |
| 336 | 336 | } |
| 337 | 337 | return caption; |
| @@ -8,7 +8,7 @@ import { | ||
| 8 | 8 | animation_easing, |
| 9 | 9 | } from '../../../script.js'; |
| 10 | 10 | import { groups, selected_group } from '../../group-chats.js'; |
| 11 | 11 | import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension } from '../../utils.js'; |
| 12 | 12 | import { loadMovingUIState } from '../../power-user.js'; |
| 13 | 13 | import { dragElement } from '../../RossAscends-mods.js'; |
| 14 | 14 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| @@ -341,27 +341,12 @@ async function showCharGallery(deleteModeState = false) { | ||
| 341 | 341 | async function uploadFile(file, url) { |
| 342 | 342 | try { |
| 343 | 343 | // Convert the file to a base64 string |
| 344 | 344 | const base64DatafileBase64 = 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 | 350 | } catch (error) { |
| 366 | 351 | console.error('There was an issue uploading the file:', error); |
| 367 | 352 | |
| @@ -38,10 +38,14 @@ export async function getMultimodalCaption(base64Img, prompt) { | ||
| 38 | 38 | const isVllm = extension_settings.caption.multimodal_api === 'vllm'; |
| 39 | 39 | const base64Bytes = base64Img.length * 0.75; |
| 40 | 40 | const compressionLimit = 2 * 1024 * 1024; |
| 41 | + const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; | |
| 42 | + const mimeType = base64Img?.split(';')?.[0]?.split(':')?.[1]; | |
| 41 | 43 | const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api); |
| 42 | 44 | if ((thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) { |
| 43 | 45 | const maxSide = 10242048; |
| 44 | 46 | base64Img = await createThumbnail(base64Img, maxSide, maxSide, 'image/jpeg'); |
| 47 | + } else if (!safeMimeTypes.includes(mimeType)) { | |
| 48 | + base64Img = await createThumbnail(base64Img, null, null); | |
| 45 | 49 | } |
| 46 | 50 | |
| 47 | 51 | const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : ''; |
| @@ -2949,13 +2949,15 @@ class Message { | ||
| 2949 | 2949 | chat_completion_sources.MISTRALAI, |
| 2950 | 2950 | chat_completion_sources.VERTEXAI, |
| 2951 | 2951 | ]; |
| 2952 | - if (compressImageSources.includes(oai_settings.chat_completion_source)) { | |
| 2953 | 2952 | const sizeThreshold = 2 * 1024 * 1024; |
| 2954 | 2953 | const dataSize = image.length * 0.75; |
| 2955 | - const maxSide = 1024; | |
| 2954 | + const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; | |
| 2956 | - if (dataSize > sizeThreshold) { | |
| 2955 | + const mimeType = image?.split(';')?.[0]?.split(':')?.[1]; | |
| 2957 | - image = await createThumbnail(image, maxSide); | |
| 2956 | + if (compressImageSources.includes(oai_settings.chat_completion_source) && dataSize > sizeThreshold) { | |
| 2958 | - } | |
| 2957 | + const maxSide = 2048; | |
| 2958 | + image = await createThumbnail(image, maxSide, maxSide); | |
| 2959 | + } else if (!safeMimeTypes.includes(mimeType)) { | |
| 2960 | + image = await createThumbnail(image, null, null); | |
| 2959 | 2961 | } |
| 2960 | 2962 | return image; |
| 2961 | 2963 | } |
| @@ -1410,32 +1410,27 @@ export async function getSanitizedFilename(fileName) { | ||
| 1410 | 1410 | * Sends a base64 encoded image to the backend to be saved as a file. |
| 1411 | 1411 | * |
| 1412 | 1412 | * @param {string} base64Data - The base64 encoded image data. |
| 1413 | 1413 | * @param {string} characterNamesubFolder - The character name to determine the sub-directory for saving. |
| 1414 | 1414 | * @param {string} extfileName - The name of the file extensionto forsave the image (e.g., 'jpg',as 'png',(without 'webp'extension). |
| 1415 | + * @param {string} extension - The file extension for the image (e.g., 'jpg', 'png', 'webp'). | |
| 1415 | 1416 | * |
| 1416 | 1417 | * @returns {Promise<string>} - Resolves to the saved image's path on the server. |
| 1417 | 1418 | * Rejects with an error if the upload fails. |
| 1418 | 1419 | */ |
| 1419 | 1420 | export async function saveBase64AsFile(base64Data, characterNamesubFolder, filename = ''fileName, extextension) { |
| 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 | 1421 | // Prepare the request body |
| 1425 | 1422 | const requestBody = { |
| 1426 | 1423 | image: dataURLbase64Data, |
| 1427 | 1424 | ch_nameformat: characterNameextension, |
| 1428 | - filename: String(filename).replace(/\./g, '_'), | |
| 1425 | + ch_name: subFolder, | |
| 1426 | + filename: String(fileName).replace(/\./g, '_'), | |
| 1429 | 1427 | }; |
| 1430 | 1428 | |
| 1431 | 1429 | // Send the data URL to your backend using fetch |
| 1432 | 1430 | const response = await fetch('/api/images/upload', { |
| 1433 | 1431 | method: 'POST', |
| 1432 | + headers: getRequestHeaders(), | |
| 1434 | 1433 | body: JSON.stringify(requestBody), |
| 1435 | - headers: { | |
| 1436 | - ...getRequestHeaders(), | |
| 1437 | - 'Content-Type': 'application/json', | |
| 1438 | - }, | |
| 1439 | 1434 | }); |
| 1440 | 1435 | |
| 1441 | 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 | 1456 | * Loads either a CSS or JS file and appends it to the appropriate document section. |
| 1453 | 1457 | * |
| 1454 | 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 | 1558 | maxHeight = img.height; |
| 1555 | 1559 | } |
| 1556 | 1560 | |
| 1561 | + // Do not upscale if image is already smaller than max dimensions | |
| 1562 | + if (img.width <= maxWidth && img.height <= maxHeight) { | |
| 1563 | + thumbnailWidth = img.width; | |
| 1564 | + thumbnailHeight = img.height; | |
| 1565 | + } else { | |
| 1557 | 1566 | if (img.width > img.height) { |
| 1558 | 1567 | thumbnailHeight = maxWidth / aspectRatio; |
| 1559 | 1568 | } else { |
| 1560 | 1569 | thumbnailWidth = maxHeight * aspectRatio; |
| 1561 | 1570 | } |
| 1571 | + } | |
| 1562 | 1572 | |
| 1563 | 1573 | // Set the canvas dimensions and draw the resized image |
| 1564 | 1574 | canvas.width = thumbnailWidth; |
| 1565 | 1575 | canvas.height = thumbnailHeight; |
| 1576 | + ctx.imageSmoothingEnabled = true; | |
| 1577 | + ctx.imageSmoothingQuality = 'high'; | |
| 1578 | + ctx.fillStyle = 'white'; | |
| 1579 | + ctx.fillRect(0, 0, thumbnailWidth, thumbnailHeight); | |
| 1566 | 1580 | ctx.drawImage(img, 0, 0, thumbnailWidth, thumbnailHeight); |
| 1567 | 1581 | |
| 1568 | 1582 | // Convert the canvas to a data URL and resolve the promise |
| @@ -412,3 +412,26 @@ export const LOG_LEVELS = { | ||
| 412 | 412 | WARN: 2, |
| 413 | 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 | 6 | import sanitize from 'sanitize-filename'; |
| 7 | 7 | |
| 8 | 8 | import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js'; |
| 9 | +import { MEDIA_EXTENSIONS } from '../constants.js'; | |
| 9 | 10 | |
| 10 | 11 | /** |
| 11 | 12 | * Ensure the directory for the provided file path exists. |
| @@ -36,17 +37,18 @@ export const router = express.Router(); | ||
| 36 | 37 | * @returns {Object} response - The response object containing the path where the image was saved. |
| 37 | 38 | */ |
| 38 | 39 | router.post('/upload', async (request, response) => { |
| 39 | - // Check for image data | |
| 40 | + try { | |
| 40 | 41 | if (!request.body || !request.body.image) { |
| 42 | + return response.status(400).send({ error: 'No data provided' }); | |
| 43 | + } | |
| 44 | + | |
| 45 | + const { image, format } = request.body; | |
| 46 | + | |
| 47 | + if (!image) { | |
| 41 | 48 | return response.status(400).send({ error: 'No image data provided' }); |
| 42 | 49 | } |
| 43 | 50 | |
| 44 | - try { | |
| 51 | + const validFormat = MEDIA_EXTENSIONS.includes(format); | |
| 45 | - // Extracting the base64 data and the image format | |
| 46 | - const splitParts = request.body.image.split(','); | |
| 47 | - const format = splitParts[0].split(';')[0].split('/')[1]; | |
| 48 | - const base64Data = splitParts[1]; | |
| 49 | - const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv'].includes(format); | |
| 50 | 52 | if (!validFormat) { |
| 51 | 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 | 70 | ensureDirectoryExistence(pathToNewFile); |
| 69 | 71 | const imageBuffer = Buffer.from(base64Dataimage, 'base64'); |
| 70 | 72 | await fs.promises.writeFile(pathToNewFile, new Uint8Array(imageBuffer)); |
| 71 | 73 | response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) }); |
| 72 | 74 | } catch (error) { |