diff --git a/api/server/services/Files/Azure/images.js b/api/server/services/Files/Azure/images.js index 4d857d0d1db..9905465d3ef 100644 --- a/api/server/services/Files/Azure/images.js +++ b/api/server/services/Files/Azure/images.js @@ -37,6 +37,7 @@ async function uploadImageToAzure({ buffer: resizedBuffer, width, height, + type, } = await resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); const userId = req.user.id; @@ -44,7 +45,12 @@ async function uploadImageToAzure({ let fileName = `${file_id}__${path.basename(inputFilePath)}`; const targetExtension = `.${appConfig.imageOutputType}`; - if (extension.toLowerCase() === targetExtension) { + /* The name is only a claim about the contents; the bytes have to agree before conversion is + * skipped, or the record is typed from a name that misdescribes what was stored. */ + if ( + extension.toLowerCase() === targetExtension && + type === `image/${appConfig.imageOutputType}` + ) { webPBuffer = resizedBuffer; } else { webPBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); diff --git a/api/server/services/Files/Firebase/images.js b/api/server/services/Files/Firebase/images.js index cffafa890cb..a0f94d6a7c6 100644 --- a/api/server/services/Files/Firebase/images.js +++ b/api/server/services/Files/Firebase/images.js @@ -32,6 +32,7 @@ async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution buffer: resizedBuffer, width, height, + type, } = await resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); const userId = req.user.id; @@ -39,7 +40,12 @@ async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution let webPBuffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; const targetExtension = `.${appConfig.imageOutputType}`; - if (extension.toLowerCase() === targetExtension) { + /* The name is only a claim about the contents; the bytes have to agree before conversion is + * skipped, or the record is typed from a name that misdescribes what was stored. */ + if ( + extension.toLowerCase() === targetExtension && + type === `image/${appConfig.imageOutputType}` + ) { webPBuffer = resizedBuffer; } else { webPBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); diff --git a/api/server/services/Files/Local/__tests__/images.spec.js b/api/server/services/Files/Local/__tests__/images.spec.js index f64d5477655..28a3cf6dfab 100644 --- a/api/server/services/Files/Local/__tests__/images.spec.js +++ b/api/server/services/Files/Local/__tests__/images.spec.js @@ -1,4 +1,8 @@ -jest.mock('sharp', () => ({})); +jest.mock('sharp', () => { + const toBuffer = jest.fn(async () => Buffer.from('converted-bytes')); + const sharpMock = jest.fn(() => ({ toFormat: jest.fn().mockReturnThis(), toBuffer })); + return sharpMock; +}); jest.mock('@librechat/api', () => ({ stripCacheBust: jest.fn((filepath) => filepath.split('?')[0]), })); @@ -11,8 +15,10 @@ jest.mock('~/models', () => ({ const fs = require('fs'); const os = require('os'); const path = require('path'); +const sharp = require('sharp'); const { updateFile } = require('~/models'); -const { prepareImagesLocal } = require('../images'); +const { resizeImageBuffer } = require('../../images/resize'); +const { prepareImagesLocal, uploadLocalImage } = require('../images'); describe('prepareImagesLocal', () => { let tmpDir; @@ -62,3 +68,80 @@ describe('prepareImagesLocal', () => { expect(encoded).toBe(Buffer.from('plain-png-bytes').toString('base64')); }); }); + +describe('uploadLocalImage', () => { + let tmpDir; + let imageOutput; + + beforeEach(() => { + jest.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-local-image-')); + imageOutput = path.join(tmpDir, 'images'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const makeReq = (imageOutputType) => ({ + user: { id: 'user-1' }, + config: { paths: { imageOutput }, imageOutputType }, + }); + + /** The caller records the upload as `image/${imageOutputType}`, so whatever this writes has to + * be in that format — the type is handed to providers verbatim as `media_type`. */ + const upload = async (name, resized) => { + const inputFilePath = path.join(tmpDir, name); + fs.writeFileSync(inputFilePath, Buffer.from('uploaded-bytes')); + resizeImageBuffer.mockResolvedValue({ width: 8, height: 8, ...resized }); + + const { filepath } = await uploadLocalImage({ + req: makeReq('png'), + file: { path: inputFilePath }, + file_id: 'file-1', + endpoint: 'anthropic', + }); + + return fs.readFileSync(path.join(imageOutput, 'user-1', path.basename(filepath))); + }; + + it('converts a JPEG that was given a .png name', async () => { + const written = await upload('fake.png', { + buffer: Buffer.from('resized-jpeg-bytes'), + type: 'image/jpeg', + }); + + expect(sharp).toHaveBeenCalledWith(Buffer.from('resized-jpeg-bytes')); + expect(written).toEqual(Buffer.from('converted-bytes')); + }); + + it('writes the resized bytes untouched when the name and the bytes both match the target', async () => { + const written = await upload('real.png', { + buffer: Buffer.from('resized-png-bytes'), + type: 'image/png', + }); + + expect(sharp).not.toHaveBeenCalled(); + expect(written).toEqual(Buffer.from('resized-png-bytes')); + }); + + it('converts when sharp named no format for the resized bytes', async () => { + const written = await upload('unknown.png', { + buffer: Buffer.from('resized-unknown-bytes'), + type: undefined, + }); + + expect(sharp).toHaveBeenCalled(); + expect(written).toEqual(Buffer.from('converted-bytes')); + }); + + it('still converts a name that does not carry the target extension', async () => { + const written = await upload('photo.jpg', { + buffer: Buffer.from('resized-jpeg-bytes'), + type: 'image/jpeg', + }); + + expect(sharp).toHaveBeenCalled(); + expect(written).toEqual(Buffer.from('converted-bytes')); + }); +}); diff --git a/api/server/services/Files/Local/images.js b/api/server/services/Files/Local/images.js index ebfaf1e4c43..073f3700a28 100644 --- a/api/server/services/Files/Local/images.js +++ b/api/server/services/Files/Local/images.js @@ -36,6 +36,7 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi buffer: resizedBuffer, width, height, + type, } = await resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); @@ -50,7 +51,14 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi const newPath = path.join(userPath, fileName); const targetExtension = `.${appConfig.imageOutputType}`; - if (extension.toLowerCase() === targetExtension) { + /* The name is only a claim about the contents, so conversion is skipped only when the bytes + * agree with it. A JPEG named `.png` otherwise reaches `createFile` typed `image/png`, and that + * type is handed to providers verbatim; Anthropic then refuses the request, and keeps refusing + * it, because the image stays in the conversation's history. */ + if ( + extension.toLowerCase() === targetExtension && + type === `image/${appConfig.imageOutputType}` + ) { const bytes = Buffer.byteLength(resizedBuffer); await fs.promises.writeFile(newPath, resizedBuffer); const filepath = path.posix.join('/', 'images', req.user.id, path.basename(newPath)); diff --git a/api/server/services/Files/images/convert.js b/api/server/services/Files/images/convert.js index 09d8c0ecc3e..610fca5fbf1 100644 --- a/api/server/services/Files/images/convert.js +++ b/api/server/services/Files/images/convert.js @@ -38,11 +38,13 @@ async function convertImage(req, file, resolution = 'high', basename = '') { buffer: resizedBuffer, width, height, + type, } = await resizeImageBuffer(inputBuffer, resolution); - // Check if the file is already in target format; if it isn't, convert it: + // Check if the file is already in target format; if it isn't, convert it. The name is only a + // claim about the contents, so the bytes have to agree with it before conversion is skipped. const targetExtension = `.${appConfig.imageOutputType}`; - if (extension === targetExtension) { + if (extension === targetExtension && type === `image/${appConfig.imageOutputType}`) { outputBuffer = resizedBuffer; } else { outputBuffer = await sharp(resizedBuffer).toFormat(appConfig.imageOutputType).toBuffer(); diff --git a/packages/api/src/files/encode/image.spec.ts b/packages/api/src/files/encode/image.spec.ts index f447faedc17..248c90ae383 100644 --- a/packages/api/src/files/encode/image.spec.ts +++ b/packages/api/src/files/encode/image.spec.ts @@ -1,3 +1,4 @@ +import sharp from 'sharp'; import { Readable } from 'node:stream'; import { FileSources, VisionModes } from 'librechat-data-provider'; import type { ServerRequest } from '~/types'; @@ -202,6 +203,96 @@ describe('encodeAndFormatImages', () => { expect(result.image_urls).toEqual([expected]); }); + describe('a record whose type does not describe its bytes', () => { + /** A JPEG stored under a `.png` name: the upload was typed from the name, so the record reads + * `image/png`. Sending that as `media_type` is what Anthropic rejects, on this turn and on + * every later one, because the image stays in the conversation's history. */ + let jpegBytes: Buffer; + let jpegContent: string; + const mislabeled = () => ({ ...file, bytes: jpegBytes.length, type: 'image/png' }); + + beforeAll(async () => { + jpegBytes = await sharp({ + create: { width: 8, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }) + .jpeg() + .toBuffer(); + jpegContent = jpegBytes.toString('base64'); + }); + + beforeEach(() => { + getDownloadStream.mockImplementation(async () => Readable.from(jpegBytes)); + }); + + it('declares the bytes to Anthropic, not the stored type', async () => { + const result = await encodeAndFormatImages( + makeReq(), + [mislabeled()], + { endpoint: 'anthropic' }, + deps, + ); + + expect(result.image_urls).toEqual([ + { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: jpegContent } }, + ]); + }); + + it('declares the bytes to Google', async () => { + const result = await encodeAndFormatImages( + makeReq(), + [mislabeled()], + { provider: 'google' }, + deps, + VisionModes.generative, + ); + + expect(result.image_urls).toEqual([ + { type: 'image_url', inlineData: { mimeType: 'image/jpeg', data: jpegContent } }, + ]); + }); + + it('declares the bytes in the data URL', async () => { + const result = await encodeAndFormatImages(makeReq(), [mislabeled()], {}, deps); + + expect(result.image_urls).toEqual([ + { + type: 'image_url', + image_url: { url: `data:image/jpeg;base64,${jpegContent}`, detail: 'auto' }, + }, + ]); + }); + + it('keeps the stored type when the bytes name no format of their own', async () => { + getDownloadStream.mockImplementation(async () => Readable.from(imageBytes)); + + const result = await encodeAndFormatImages( + makeReq(), + [{ ...file, type: 'image/png' }], + { endpoint: 'anthropic' }, + deps, + ); + + expect(result.image_urls).toEqual([ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: content } }, + ]); + }); + + it('leaves an external URL alone, having no bytes to read', async () => { + prepareImagePayload.mockImplementation(async (_req, image) => [image, file.filepath]); + + const result = await encodeAndFormatImages( + makeReq(), + [{ ...mislabeled(), source: FileSources.openai }], + { endpoint: 'openai' }, + deps, + ); + + expect(result.image_urls).toEqual([ + { type: 'image_url', image_url: { url: file.filepath, detail: 'auto' } }, + ]); + }); + }); + it.each([ [undefined, undefined, 'auto'], ['low', undefined, 'low'], diff --git a/packages/api/src/files/encode/image.ts b/packages/api/src/files/encode/image.ts index bcd486a54d8..4788b753f6e 100644 --- a/packages/api/src/files/encode/image.ts +++ b/packages/api/src/files/encode/image.ts @@ -13,6 +13,7 @@ import type { AxiosInstance } from 'axios'; import type { ServerRequest, StrategyFunctions } from '~/types'; import { getFileStream, isAttachmentObjectNotFoundError } from './utils'; import { validateImage } from '~/files/validation'; +import { sniffImageMimeType } from '~/files/mime'; import { runGuardedEncode } from './memoryGuard'; import { logAxiosError } from '~/utils/axios'; @@ -185,33 +186,43 @@ export async function encodeAndFormatImages( } const isURL = imageContent.startsWith('http'); - if (file.height && file.width && !isURL) { + /** The stored type is a claim about bytes it need not describe: an upload is typed from the + * output format it was meant to be converted into, and a file whose name already carries that + * extension skips conversion whatever is inside it. The claim is handed to providers verbatim + * below, and Anthropic refuses a request whose `media_type` disagrees with the bytes — every + * later turn of the conversation included, since the image stays in the history. The bytes are + * decoded here anyway, so they are what the request should be built from. */ + let mediaType = file.type; + if (!isURL) { const imageBuffer = Buffer.from(imageContent, 'base64'); - const validation = await validateImage( - imageBuffer, - imageBuffer.length, - effectiveEndpoint ?? '', - configuredFileSizeLimit, - ); - if (!validation.isValid) { - throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`); + mediaType = sniffImageMimeType(imageBuffer) ?? file.type; + if (file.height && file.width) { + const validation = await validateImage( + imageBuffer, + imageBuffer.length, + effectiveEndpoint ?? '', + configuredFileSizeLimit, + ); + if (!validation.isValid) { + throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`); + } } } - const url = isURL ? imageContent : `data:${file.type};base64,${imageContent}`; + const url = isURL ? imageContent : `data:${mediaType};base64,${imageContent}`; if (mode === VisionModes.agents) { result.image_urls.push({ type: ContentTypes.IMAGE_URL, image_url: { url, detail } }); } else if (effectiveEndpoint === EModelEndpoint.google && mode === VisionModes.generative) { result.image_urls.push({ type: ContentTypes.IMAGE_URL, - inlineData: { mimeType: file.type, data: imageContent }, + inlineData: { mimeType: mediaType, data: imageContent }, }); } else if (effectiveEndpoint === EModelEndpoint.google) { result.image_urls.push({ type: ContentTypes.IMAGE_URL, image_url: url }); } else if (effectiveEndpoint === EModelEndpoint.anthropic) { result.image_urls.push({ type: 'image', - source: { type: 'base64', media_type: file.type, data: imageContent }, + source: { type: 'base64', media_type: mediaType, data: imageContent }, }); } else { result.image_urls.push({ type: ContentTypes.IMAGE_URL, image_url: { url, detail } }); diff --git a/packages/api/src/files/mime.spec.ts b/packages/api/src/files/mime.spec.ts index 9f28fb170b0..dfa3838c97a 100644 --- a/packages/api/src/files/mime.spec.ts +++ b/packages/api/src/files/mime.spec.ts @@ -1,14 +1,14 @@ import sharp from 'sharp'; -import { resolveImageMimeType } from './mime'; +import { resolveImageMimeType, sniffImageMimeType } from './mime'; -describe('resolveImageMimeType', () => { - const encode = async (format: 'png' | 'jpeg' | 'webp' | 'gif' | 'tiff'): Promise => - await sharp({ - create: { width: 8, height: 8, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } }, - }) - [format]() - .toBuffer(); +const encode = async (format: 'png' | 'jpeg' | 'webp' | 'gif' | 'tiff'): Promise => + await sharp({ + create: { width: 8, height: 8, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } }, + }) + [format]() + .toBuffer(); +describe('resolveImageMimeType', () => { it.each([ ['png', 'image/png'], ['jpeg', 'image/jpeg'], @@ -46,3 +46,60 @@ describe('resolveImageMimeType', () => { expect(resolveImageMimeType({ format: 'heif', compression: undefined })).toBeUndefined(); }); }); + +describe('sniffImageMimeType', () => { + it.each([ + ['png', 'image/png'], + ['jpeg', 'image/jpeg'], + ['webp', 'image/webp'], + ['gif', 'image/gif'], + ['tiff', 'image/tiff'], + ] as const)('names %s bytes %s without decoding them', async (format, expected) => { + expect(sniffImageMimeType(await encode(format))).toBe(expected); + }); + + it('agrees with what sharp reads back out of the same bytes', async () => { + for (const format of ['png', 'jpeg', 'webp', 'gif', 'tiff'] as const) { + const buffer = await encode(format); + expect(sniffImageMimeType(buffer)).toBe(resolveImageMimeType(await sharp(buffer).metadata())); + } + }); + + it('reads the brand of an ISO base media container rather than the container itself', () => { + const ftyp = (brand: string) => + Buffer.concat([ + Buffer.from([0, 0, 0, 0x20]), + Buffer.from('ftyp', 'latin1'), + Buffer.from(brand, 'latin1'), + ]); + + expect(sniffImageMimeType(ftyp('avif'))).toBe('image/avif'); + expect(sniffImageMimeType(ftyp('heic'))).toBe('image/heic'); + expect(sniffImageMimeType(ftyp('mif1'))).toBe('image/heic'); + /** `isom` is a plain MP4; naming it an image is the guess this declines to make. */ + expect(sniffImageMimeType(ftyp('isom'))).toBeUndefined(); + }); + + it('names a file by its bytes, not by the extension it was given', async () => { + const jpegNamedPng = await encode('jpeg'); + + expect(sniffImageMimeType(jpegNamedPng)).toBe('image/jpeg'); + }); + + it('returns undefined rather than naming bytes it does not recognize', () => { + expect(sniffImageMimeType(Buffer.from('not an image at all'))).toBeUndefined(); + expect(sniffImageMimeType(Buffer.alloc(0))).toBeUndefined(); + /** Shorter than the longest signature, so there is nothing to match against. */ + expect(sniffImageMimeType(Buffer.from([0x89, 0x50, 0x4e, 0x47]))).toBeUndefined(); + }); + + it('does not mistake a RIFF container that is not a WebP', () => { + const wav = Buffer.concat([ + Buffer.from('RIFF', 'latin1'), + Buffer.from([0, 0, 0, 0]), + Buffer.from('WAVE', 'latin1'), + ]); + + expect(sniffImageMimeType(wav)).toBeUndefined(); + }); +}); diff --git a/packages/api/src/files/mime.ts b/packages/api/src/files/mime.ts index 1fa85a46d0d..b330ea30313 100644 --- a/packages/api/src/files/mime.ts +++ b/packages/api/src/files/mime.ts @@ -44,3 +44,55 @@ export function resolveImageMimeType(metadata: EncodedImageMetadata): string | u } return SHARP_FORMAT_MIME_TYPES[format]; } + +/** ISO base media file format brands, read from bytes 8..12 of an `ftyp` box. */ +const ISO_BMFF_BRAND_MIME_TYPES: Readonly> = { + avif: 'image/avif', + avis: 'image/avif', + heic: 'image/heic', + heim: 'image/heic', + heis: 'image/heic', + heix: 'image/heic', + hevc: 'image/heic', + hevx: 'image/heic', + mif1: 'image/heic', + msf1: 'image/heic', +}; + +/** + * Reads the media type of an image out of its leading bytes. + * + * {@link resolveImageMimeType} answers the same question, but only where sharp has already decoded + * the image and reported what it found. The encode path has neither: it holds raw bytes, and it + * holds them for every image of every turn, so paying for a decode to name a format that the first + * twelve bytes already state would be a cost per message rather than per upload. + * + * Returns `undefined` when nothing matches. An unrecognized container is not evidence that the + * stored type is wrong, so the caller keeps what it already has rather than guessing. + */ +export function sniffImageMimeType(buffer: Buffer): string | undefined { + if (buffer.length < 12) { + return undefined; + } + if (buffer.readUInt32BE(0) === 0x89504e47 && buffer.readUInt32BE(4) === 0x0d0a1a0a) { + return 'image/png'; + } + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'image/jpeg'; + } + const leading = buffer.toString('latin1', 0, 12); + if (leading.startsWith('GIF87a') || leading.startsWith('GIF89a')) { + return 'image/gif'; + } + /** A WebP is a RIFF container whose form type, at bytes 8..12, names the payload. */ + if (leading.startsWith('RIFF') && leading.slice(8, 12) === 'WEBP') { + return 'image/webp'; + } + if (leading.slice(4, 8) === 'ftyp') { + return ISO_BMFF_BRAND_MIME_TYPES[leading.slice(8, 12).toLowerCase()]; + } + if (leading.startsWith('II\x2a\x00') || leading.startsWith('MM\x00\x2a')) { + return 'image/tiff'; + } + return undefined; +} diff --git a/packages/api/src/storage/__tests__/images.test.ts b/packages/api/src/storage/__tests__/images.test.ts index ec8f71910ec..a99d29a047e 100644 --- a/packages/api/src/storage/__tests__/images.test.ts +++ b/packages/api/src/storage/__tests__/images.test.ts @@ -43,6 +43,7 @@ describe('ImageService', () => { buffer: Buffer.from('resized'), width: 800, height: 600, + type: 'image/jpeg', }), updateUser: jest.fn().mockResolvedValue(undefined), updateFile: jest.fn().mockResolvedValue(null), @@ -108,11 +109,18 @@ describe('ImageService', () => { ).rejects.toThrow('User not authenticated'); }); - it('skips format conversion when extension matches target', async () => { - const webpFile = { - path: '/tmp/upload-123.webp', - originalname: 'photo.webp', - } as Express.Multer.File; + const webpFile = { + path: '/tmp/upload-123.webp', + originalname: 'photo.webp', + } as Express.Multer.File; + + it('skips format conversion when the name and the bytes both match the target', async () => { + (mockDeps.resizeImageBuffer as jest.Mock).mockResolvedValue({ + buffer: Buffer.from('resized'), + width: 800, + height: 600, + type: 'image/webp', + }); await service.uploadImage({ req: mockReq as ServerRequest, @@ -124,6 +132,38 @@ describe('ImageService', () => { expect(sharp).not.toHaveBeenCalled(); }); + /* The record is typed from the output format the upload was meant to be in, so bytes that + * skipped conversion on the strength of their name alone leave it describing a format they are + * not in — and that type is handed to providers verbatim as `media_type`. */ + it('converts bytes that are not in the target format, whatever the name says', async () => { + await service.uploadImage({ + req: mockReq as ServerRequest, + file: webpFile, + file_id: 'file-456', + endpoint: 'openAI', + }); + + expect(sharp).toHaveBeenCalled(); + }); + + it('converts when sharp named no format for the resized bytes', async () => { + (mockDeps.resizeImageBuffer as jest.Mock).mockResolvedValue({ + buffer: Buffer.from('resized'), + width: 800, + height: 600, + type: undefined, + }); + + await service.uploadImage({ + req: mockReq as ServerRequest, + file: webpFile, + file_id: 'file-456', + endpoint: 'openAI', + }); + + expect(sharp).toHaveBeenCalled(); + }); + it('uses custom resolution when provided', async () => { await service.uploadImage({ req: mockReq as ServerRequest, diff --git a/packages/api/src/storage/images.ts b/packages/api/src/storage/images.ts index e1c770923c9..2626867233d 100644 --- a/packages/api/src/storage/images.ts +++ b/packages/api/src/storage/images.ts @@ -18,7 +18,13 @@ export interface ImageServiceDeps { buffer: Buffer, resolution: string, endpoint: string, - ) => Promise<{ buffer: Buffer; width: number; height: number }>; + ) => Promise<{ + buffer: Buffer; + width: number; + height: number; + /** Media type of the re-encoded bytes; `undefined` when sharp named no type for them. */ + type?: string; + }>; updateUser: (userId: string, update: { avatar: string }) => Promise; updateFile: (params: { file_id: string }) => Promise; } @@ -71,6 +77,7 @@ export class ImageService { buffer: resizedBuffer, width, height, + type, } = await this.deps.resizeImageBuffer(inputBuffer, resolution, endpoint); const extension = path.extname(inputFilePath); @@ -81,7 +88,9 @@ export class ImageService { let processedBuffer: Buffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; - if (extension.toLowerCase() === targetExtension) { + /* The name is only a claim about the contents; the bytes have to agree before conversion is + * skipped, or the record is typed from a name that misdescribes what was stored. */ + if (extension.toLowerCase() === targetExtension && type === `image/${outputType}`) { processedBuffer = resizedBuffer; } else { const outputFormat = outputType as keyof FormatEnum;