diff --git a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx index c63f9f593..18a76447c 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -85,7 +85,7 @@ interface IRenderAndPerformActionsOptions { * optionally go to review modal */ const renderComponent = async (options: IRenderAndPerformActionsOptions = {}) => { - jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(JPG_BASE64); + jest.spyOn(ImageHelper, "convertToDataUrl").mockResolvedValue(JPG_BASE64); jest.spyOn(FileHelper, "getType").mockResolvedValue({ ext: "jpg", mime: "image/jpeg" }); const { @@ -165,6 +165,12 @@ describe("image-upload", () => { jest.spyOn(FileHelper, "truncateFileName").mockImplementation((fileName) => fileName); uploadSpy = jest.spyOn(AxiosApiClient.prototype, "post").mockResolvedValue({ id: 1 }); extractMetadataSpy = jest.spyOn(ImageHelper, "getMetadata").mockResolvedValue(METADATA); + jest.spyOn(ImageHelper, "convertHeicToBlob").mockImplementation(async (blob) => blob); + jest.spyOn(ImageHelper, "blobToImage").mockResolvedValue(new Image()); + jest.spyOn(ImageHelper, "drawImageToCanvas").mockResolvedValue({ + canvas: document.createElement("canvas"), + blob: FILE_1, + }); }); it("should be able to render the field", async () => { @@ -492,7 +498,7 @@ describe("image-upload", () => { describe("when there is a generic error", () => { beforeEach(async () => { - jest.spyOn(ImageHelper, "convertBlob").mockRejectedValueOnce("error"); + jest.spyOn(ImageHelper, "convertToDataUrl").mockRejectedValueOnce("error"); await renderComponent({ files: [FILE_1, FILE_2], uploadType: inputType, @@ -522,8 +528,8 @@ describe("image-upload", () => { describe("when there is a file size limit and images are not compressed", () => { beforeEach(async () => { - jest.spyOn(ImageHelper, "convertBlob").mockResolvedValueOnce(`${JPG_BASE64}${JPG_BASE64}`); - jest.spyOn(ImageHelper, "convertBlob").mockResolvedValueOnce(JPG_BASE64); + jest.spyOn(ImageHelper, "convertToDataUrl").mockResolvedValueOnce(`${JPG_BASE64}${JPG_BASE64}`); + jest.spyOn(ImageHelper, "convertToDataUrl").mockResolvedValueOnce(JPG_BASE64); await renderComponent({ files: [FILE_1, FILE_2], @@ -556,13 +562,9 @@ describe("image-upload", () => { }); describe("image compression", () => { - beforeEach(() => { - jest.spyOn(ImageHelper, "dataUrlToImage").mockResolvedValue(new Image()); - jest.spyOn(ImageHelper, "resampleImage").mockResolvedValue(FILE_1); - }); it("should not compress image by default", async () => { - const compressSpy = jest.spyOn(ImageHelper, "compressImage"); + const compressSpy = jest.spyOn(ImageHelper, "compressToTargetSize"); await renderComponent({ files: [FILE_1], uploadType: inputType, @@ -573,7 +575,12 @@ describe("image-upload", () => { }); it("should compress image if compress=true and max size is defined", async () => { - const compressSpy = jest.spyOn(ImageHelper, "compressImage"); + const largeBlob = new Blob(["x".repeat(2048)]); + jest.spyOn(ImageHelper, "drawImageToCanvas").mockResolvedValue({ + canvas: document.createElement("canvas"), + blob: largeBlob, + }); + const compressSpy = jest.spyOn(ImageHelper, "compressToTargetSize").mockResolvedValue(FILE_1); await act(async () => { await renderComponent({ files: [FILE_1], @@ -587,7 +594,7 @@ describe("image-upload", () => { }); it("Should extract image metadata", async () => { - jest.spyOn(ImageHelper, "compressImage").mockResolvedValue(FILE_1); + jest.spyOn(ImageHelper, "compressToTargetSize").mockResolvedValue(FILE_1); await waitFor(async () => { await renderComponent({ @@ -602,7 +609,7 @@ describe("image-upload", () => { }); it("should resize image to fit dimensions when crop is false", async () => { - const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); + const drawSpy = jest.spyOn(ImageHelper, "drawImageToCanvas"); await act(async () => { await renderComponent({ files: [FILE_1], @@ -616,12 +623,15 @@ describe("image-upload", () => { await flushPromise(); }); - await waitFor(() => expect(resampleSpy).toHaveBeenCalled()); - expect(resampleSpy).toHaveBeenCalledWith(expect.any(Image), { scale: expect.any(Number) }); + await waitFor(() => expect(drawSpy).toHaveBeenCalled()); + expect(drawSpy).toHaveBeenCalledWith(expect.any(Image), { + scale: expect.any(Number), + type: "image/jpeg", + }); }); it("should crop image to exact dimensions when crop is true", async () => { - const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); + const drawSpy = jest.spyOn(ImageHelper, "drawImageToCanvas"); await act(async () => { await renderComponent({ files: [FILE_1], @@ -635,17 +645,18 @@ describe("image-upload", () => { await flushPromise(); }); - await waitFor(() => expect(resampleSpy).toHaveBeenCalled()); - expect(resampleSpy).toHaveBeenCalledWith(expect.any(Image), { + await waitFor(() => expect(drawSpy).toHaveBeenCalled()); + expect(drawSpy).toHaveBeenCalledWith(expect.any(Image), { width: 500, height: 500, crop: true, + type: "image/jpeg", }); }); it("should not use crop when compress is false even if crop is true", async () => { - const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); - const convertSpy = jest.spyOn(ImageHelper, "convertBlob"); + const drawSpy = jest.spyOn(ImageHelper, "drawImageToCanvas"); + const convertSpy = jest.spyOn(ImageHelper, "convertToDataUrl"); await act(async () => { await renderComponent({ files: [FILE_1], @@ -661,7 +672,7 @@ describe("image-upload", () => { // convertImage is called instead of compressImage when compress=false await waitFor(() => expect(convertSpy).toHaveBeenCalled()); - expect(resampleSpy).not.toHaveBeenCalled(); + expect(drawSpy).not.toHaveBeenCalled(); }); }); }); @@ -788,7 +799,7 @@ describe("image-upload", () => { reviewImage: true, }); - jest.spyOn(ImageHelper, "convertBlob").mockRejectedValue("error"); + jest.spyOn(ImageHelper, "convertToDataUrl").mockRejectedValue("error"); await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } })); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload @@ -802,14 +813,14 @@ describe("image-upload", () => { describe("when there is no need to compress", () => { let compressSpy: jest.SpyInstance; beforeEach(async () => { - compressSpy = jest.spyOn(ImageHelper, "compressImage"); + compressSpy = jest.spyOn(ImageHelper, "compressToTargetSize"); await renderComponent({ files: [FILE_1], overrideField: { editImage: true, validation: [{ maxSizeInKb: 0.15 }] }, reviewImage: true, }); - jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(`${JPG_BASE64}${JPG_BASE64}`); + jest.spyOn(ImageHelper, "convertToDataUrl").mockResolvedValue(`${JPG_BASE64}${JPG_BASE64}`); await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } })); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload @@ -869,9 +880,12 @@ describe("image-upload", () => { describe("when editing image with crop enabled", () => { it("should recompress with crop when crop is enabled and image is edited", async () => { - const resampleSpy = jest.spyOn(ImageHelper, "resampleImage").mockResolvedValue(FILE_1); + const drawSpy = jest.spyOn(ImageHelper, "drawImageToCanvas").mockResolvedValue({ + canvas: document.createElement("canvas"), + blob: FILE_1, + }); jest.spyOn(ImageHelper, "dataUrlToImage").mockResolvedValue(new Image()); - jest.spyOn(ImageHelper, "compressImage").mockResolvedValue(FILE_1); + jest.spyOn(ImageHelper, "compressToTargetSize").mockResolvedValue(FILE_1); await renderComponent({ files: [FILE_1], @@ -893,10 +907,11 @@ describe("image-upload", () => { }); await waitFor(() => - expect(resampleSpy).toHaveBeenCalledWith(expect.any(Image), { + expect(drawSpy).toHaveBeenCalledWith(expect.any(Image), { width: 400, height: 400, crop: true, + type: "image/jpeg", }) ); }); @@ -1317,7 +1332,7 @@ describe("image-upload", () => { beforeEach(() => { formIsDirty = undefined; - jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(JPG_BASE64); + jest.spyOn(ImageHelper, "convertToDataUrl").mockResolvedValue(JPG_BASE64); jest.spyOn(ImageHelper, "getMetadata").mockResolvedValue(METADATA); jest.spyOn(FileHelper, "dataUrlToBlob").mockResolvedValue(FILE_1); jest.spyOn(FileHelper, "getType").mockResolvedValue({ ext: "jpg", mime: "image/jpeg" }); diff --git a/src/components/fields/image-upload/image-manager/image-manager.ts b/src/components/fields/image-upload/image-manager/image-manager.ts index ce0e13b96..5be02b1bc 100644 --- a/src/components/fields/image-upload/image-manager/image-manager.ts +++ b/src/components/fields/image-upload/image-manager/image-manager.ts @@ -268,7 +268,10 @@ export const ImageManager = (props: IProps) => { const convertImage = async (index: number, image: IImage) => { try { - const dataURL = await ImageHelper.convertBlob(image.file, FileHelper.fileExtensionToMimeType(outputType)); + const dataURL = await ImageHelper.convertToDataUrl( + image.file, + FileHelper.fileExtensionToMimeType(outputType) + ); const filesize = FileHelper.getFilesizeFromBase64(dataURL); if (maxSizeInKb && filesize > maxSizeInKb * 1024) { @@ -307,27 +310,35 @@ export const ImageManager = (props: IProps) => { const compressImage = async (index: number, imageToCompress: IImage) => { try { - const dataURL = await ImageHelper.convertBlob( + const decodableBlob = await ImageHelper.convertHeicToBlob( imageToCompress.file, FileHelper.fileExtensionToMimeType(outputType) ); - const image = await ImageHelper.dataUrlToImage(dataURL); + const image = await ImageHelper.blobToImage(decodableBlob); const origDim = { w: image.naturalWidth, h: image.naturalHeight }; let compressed: Blob; + let canvas: HTMLCanvasElement; + const outputMimeType = FileHelper.fileExtensionToMimeType(outputType); + if (crop) { - compressed = await ImageHelper.resampleImage(image, { + ({ canvas, blob: compressed } = await ImageHelper.drawImageToCanvas(image, { width: dimensions.width, height: dimensions.height, crop: true, - }); + type: outputMimeType, + })); } else { const scale = getScale(origDim.w, origDim.h); - compressed = await ImageHelper.resampleImage(image, { scale }); + ({ canvas, blob: compressed } = await ImageHelper.drawImageToCanvas(image, { + scale, + type: outputMimeType, + })); } - if (maxSizeInKb) { - compressed = (await ImageHelper.compressImage(compressed, { + if (maxSizeInKb && compressed.size > maxSizeInKb * 1024) { + compressed = await ImageHelper.compressToTargetSize(canvas, { fileSize: maxSizeInKb, - })) as File; + type: outputMimeType, + }); } if (maxSizeInKb && compressed.size > maxSizeInKb * 1024) { @@ -369,21 +380,33 @@ export const ImageManager = (props: IProps) => { if (imageToCompress.drawingDataURL) { try { const image = await ImageHelper.dataUrlToImage(imageToCompress.drawingDataURL); - const origDim = { w: image.naturalWidth, h: image.naturalHeight }; - let scaledFile: Blob; + const outputMimeType = FileHelper.fileExtensionToMimeType(outputType); + let compressed: Blob; + let canvas: HTMLCanvasElement; + if (crop) { - scaledFile = await ImageHelper.resampleImage(image, { + ({ canvas, blob: compressed } = await ImageHelper.drawImageToCanvas(image, { width: dimensions.width, height: dimensions.height, crop: true, - }); + type: outputMimeType, + })); } else { - const scale = getScale(origDim.w, origDim.h); - scaledFile = await ImageHelper.resampleImage(image, { scale }); + const scale = getScale(image.naturalWidth, image.naturalHeight); + ({ canvas, blob: compressed } = await ImageHelper.drawImageToCanvas(image, { + scale, + type: outputMimeType, + })); + } + + if (compressed.size > maxSizeInKb * 1024) { + compressed = await ImageHelper.compressToTargetSize(canvas, { + fileSize: maxSizeInKb, + type: outputMimeType, + }); } - scaledFile = (await ImageHelper.compressImage(scaledFile, { fileSize: maxSizeInKb })) as File; - if (scaledFile.size > maxSizeInKb * 1024) { + if (compressed.size > maxSizeInKb * 1024) { const updatedImages = [...images]; updatedImages[index] = { ...images[index], @@ -391,7 +414,7 @@ export const ImageManager = (props: IProps) => { }; setImages(updatedImages); } else { - const dataURL = await FileHelper.fileToDataUrl(scaledFile); + const dataURL = await FileHelper.fileToDataUrl(compressed); const updatedImages = [...images]; updatedImages[index] = { ...images[index], diff --git a/src/utils/image-helper.ts b/src/utils/image-helper.ts index 5e6b2ae03..185847a33 100644 --- a/src/utils/image-helper.ts +++ b/src/utils/image-helper.ts @@ -4,9 +4,29 @@ import { IImageMetadata } from "../components/fields/image-upload"; export namespace ImageHelper { /** - * convert image type + * Converts HEIC/HEIF images to a browser-decodable blob while leaving + * already supported image formats untouched. */ + export const convertHeicToBlob = async (blob: File | Blob, outputMimeType = "image/jpeg"): Promise => { + const inputMimeType = (await FileHelper.getType(blob)).mime; + if (inputMimeType === "image/heic" || inputMimeType === "image/heif") { + const { heicTo } = await import("heic-to/csp"); + return (await heicTo({ + blob, + type: outputMimeType as `image/${string}`, + })) as Blob; + } + return blob; + }; + export const convertBlob = async (blob: File | Blob, outputMimeType = "image/jpeg") => { + return convertToDataUrl(blob, outputMimeType); + }; + + /** + * convert image type + */ + export const convertToDataUrl = async (blob: File | Blob, outputMimeType = "image/jpeg") => { const inputMimeType = (await FileHelper.getType(blob)).mime; if (inputMimeType === "image/heic" || inputMimeType === "image/heif") { const { heicTo } = await import("heic-to/csp"); @@ -68,13 +88,30 @@ export namespace ImageHelper { height: number; crop?: boolean | undefined; } + + const canvasToBlob = (canvas: HTMLCanvasElement, type: string, quality: number): Promise => { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("canvasToBlob(): failed to encode canvas")); + } + }, + type, + quality + ); + }); + }; + /** - * resamples image by rendering it on canvas + * resamples image by rendering it on canvas and returns both the rendered canvas and encoded blob */ - export const resampleImage = async ( + export const drawImageToCanvas = async ( image: HTMLImageElement, options: IResampleOptionsWithScale | IResampleOptionsWithDimensions - ): Promise => { + ): Promise<{ canvas: HTMLCanvasElement; blob: Blob }> => { const { crop, scale, width, height, quality = 1, type = "image/jpeg" } = options; const cvs = document.createElement("canvas"); const ctx = cvs.getContext("2d"); @@ -101,15 +138,80 @@ export namespace ImageHelper { ctx.drawImage(image, sx, sy, sw, sh, 0, 0, width, height); } - return new Promise((resolve) => cvs.toBlob((blob) => resolve(blob), type, quality)); + return { canvas: cvs, blob: await canvasToBlob(cvs, type, quality) }; + }; + + /** + * resample image by rendering it on canvas + */ + export const resampleImage = async ( + image: HTMLImageElement, + options: IResampleOptionsWithScale | IResampleOptionsWithDimensions + ): Promise => { + const { blob } = await drawImageToCanvas(image, options); + return blob; }; /** - * rescursively attempt to compress image till the desired filesize or lowest quality is reached - * - * fileSize is in kb - * optional `maxAttempts` to limit the attempts, if file size still exceeds at the end of it, return the best compressed image + * Compresses an image to the desired filesize using binary search over JPEG quality. + * Accepts a pre-drawn canvas to skip decoding. */ + export const compressToTargetSize = async ( + input: File | Blob | HTMLCanvasElement, + options: { + fileSize: number; + maxAttempts?: number; + type?: string; + } + ): Promise => { + const { fileSize, maxAttempts = 6, type = "image/jpeg" } = options; + const targetBytes = fileSize * 1024; + const maxIterations = Math.max(1, maxAttempts); + + let canvas: HTMLCanvasElement; + if (input instanceof HTMLCanvasElement) { + canvas = input; + } else { + if (input.size <= targetBytes) return input; + + const image = await blobToImage(input); + canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + canvas.getContext("2d").drawImage(image, 0, 0); + } + + let low = 0; + let high = 1; + let smallestBlob = await canvasToBlob(canvas, type, 0.5); + let bestFit: Blob | undefined; + + if (smallestBlob.size <= targetBytes) { + bestFit = smallestBlob; + low = 0.5; + } else { + high = 0.5; + } + + for (let i = 1; i < maxIterations; i++) { + const quality = (low + high) / 2; + const compressed = await canvasToBlob(canvas, type, quality); + + if (compressed.size < smallestBlob.size) smallestBlob = compressed; + + if (compressed.size <= targetBytes) { + bestFit = compressed; + low = quality; + } else { + high = quality; + } + + if (high - low < 0.05) break; + } + + return bestFit || smallestBlob; + }; + export const compressImage = async ( file: File | Blob, options: { quality?: number; fileSize: number; attempts?: number; maxAttempts?: number }