diff --git a/package-lock.json b/package-lock.json index 6aa96967..f28b4e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1205,6 +1205,12 @@ "node": ">=6.9.0" } }, + "node_modules/@imagemagick/magick-wasm": { + "version": "0.0.28", + "resolved": "https://registry.npmjs.org/@imagemagick/magick-wasm/-/magick-wasm-0.0.28.tgz", + "integrity": "sha512-qbk5GMXrAOx0HAz3vqewN/h0GNBA/oX33FjBNDzN5CDRR3jhDa9hu+75mURg2XL+JPkacQhWryXgZER/cvi7Vw==", + "dev": true + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -13660,6 +13666,7 @@ "version": "6.14.0", "license": "MIT", "devDependencies": { + "@imagemagick/magick-wasm": "^0.0.28", "@vitest/browser": "^1.2.2", "playwright": "^1.41.2", "ts-node": "^10.8.1", @@ -14811,6 +14818,12 @@ "integrity": "sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==", "dev": true }, + "@imagemagick/magick-wasm": { + "version": "0.0.28", + "resolved": "https://registry.npmjs.org/@imagemagick/magick-wasm/-/magick-wasm-0.0.28.tgz", + "integrity": "sha512-qbk5GMXrAOx0HAz3vqewN/h0GNBA/oX33FjBNDzN5CDRR3jhDa9hu+75mURg2XL+JPkacQhWryXgZER/cvi7Vw==", + "dev": true + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -16251,6 +16264,7 @@ "@uploadcare/image-shrink": { "version": "file:packages/image-shrink", "requires": { + "@imagemagick/magick-wasm": "^0.0.28", "@vitest/browser": "^1.2.2", "playwright": "^1.41.2", "ts-node": "^10.8.1", diff --git a/packages/image-shrink/package.json b/packages/image-shrink/package.json index 5118f7ca..ef3ab11f 100644 --- a/packages/image-shrink/package.json +++ b/packages/image-shrink/package.json @@ -43,6 +43,7 @@ "signature" ], "devDependencies": { + "@imagemagick/magick-wasm": "^0.0.28", "@vitest/browser": "^1.2.2", "playwright": "^1.41.2", "ts-node": "^10.8.1", diff --git a/packages/image-shrink/src/test/helpers/blobToImage.ts b/packages/image-shrink/src/test/helpers/blobToImage.ts deleted file mode 100644 index 1b1a7cd8..00000000 --- a/packages/image-shrink/src/test/helpers/blobToImage.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const blobToImage = (blob: Blob) => { - return new Promise((resolve) => { - const url = URL.createObjectURL(blob) - const img = new Image() - img.onload = () => { - URL.revokeObjectURL(url) - resolve(img) - } - img.src = url - }) -} diff --git a/packages/image-shrink/src/test/helpers/fileFromUrl.ts b/packages/image-shrink/src/test/helpers/fileFromUrl.ts deleted file mode 100644 index 06494cb4..00000000 --- a/packages/image-shrink/src/test/helpers/fileFromUrl.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const fileFromUrl = async (url: string) => { - const response = await fetch(url) - const buffer = await response.arrayBuffer() - return new File([buffer], 'some.jpeg', { type: 'image/jpeg' }) -} diff --git a/packages/image-shrink/src/test/helpers/getImageAttributes.ts b/packages/image-shrink/src/test/helpers/getImageAttributes.ts new file mode 100644 index 00000000..c4d88dce --- /dev/null +++ b/packages/image-shrink/src/test/helpers/getImageAttributes.ts @@ -0,0 +1,12 @@ +import { readMagickImage } from './readMagickImage' + +export const getImageAttributes = async (inputBlob: Blob) => { + return readMagickImage(inputBlob, (image) => { + return image.attributeNames.reduce((acc, name) => { + return { + ...acc, + [name]: image.getAttribute(name) + } + }, {}) + }) +} diff --git a/packages/image-shrink/src/test/helpers/loadImageAsBlob.ts b/packages/image-shrink/src/test/helpers/loadImageAsBlob.ts new file mode 100644 index 00000000..66f67cb1 --- /dev/null +++ b/packages/image-shrink/src/test/helpers/loadImageAsBlob.ts @@ -0,0 +1,10 @@ +export const loadImageAsBlob = async ( + moduleResolver: () => Promise<{ default: string }> +) => { + const imageUrl = await moduleResolver().then((module) => module.default) + const response = await fetch(imageUrl) + const buffer = await response.arrayBuffer() + return new Blob([buffer], { + type: response.headers.get('content-type') ?? 'application/octet-stream' + }) +} diff --git a/packages/image-shrink/src/test/helpers/loadImageMagick.ts b/packages/image-shrink/src/test/helpers/loadImageMagick.ts new file mode 100644 index 00000000..42ecb369 --- /dev/null +++ b/packages/image-shrink/src/test/helpers/loadImageMagick.ts @@ -0,0 +1,17 @@ +/// +import { + ImageMagick, + Magick, + MagickFormat, + Quantum, + initializeImageMagick +} from '@imagemagick/magick-wasm' +// eslint-disable-next-line import/no-unresolved +import wasmUrl from '@imagemagick/magick-wasm/magick.wasm?url' + +export const loadImageMagick = async () => { + const wasmBytes = await fetch(wasmUrl).then((res) => res.arrayBuffer()) + await initializeImageMagick(wasmBytes) + + return { Magick, MagickFormat, Quantum, ImageMagick } +} diff --git a/packages/image-shrink/src/test/helpers/readMagickImage.ts b/packages/image-shrink/src/test/helpers/readMagickImage.ts new file mode 100644 index 00000000..e1b2be87 --- /dev/null +++ b/packages/image-shrink/src/test/helpers/readMagickImage.ts @@ -0,0 +1,15 @@ +import { loadImageMagick } from './loadImageMagick' +import { type IMagickImage } from '@imagemagick/magick-wasm' + +export const readMagickImage = async ( + inputBlob: Blob, + func: (image: IMagickImage) => T +): Promise => { + const { ImageMagick } = await loadImageMagick() + const blobArray = new Uint8Array(await inputBlob.arrayBuffer()) + return new Promise((resolve) => { + ImageMagick.read(blobArray, (image) => { + resolve(func(image)) + }) + }) +} diff --git a/packages/image-shrink/src/test/samples/exif-without-orientation.jpg b/packages/image-shrink/src/test/samples/exif-without-orientation.jpg new file mode 100644 index 00000000..6eb33f12 Binary files /dev/null and b/packages/image-shrink/src/test/samples/exif-without-orientation.jpg differ diff --git a/packages/image-shrink/src/test/samples/icc-strip-test.jpg b/packages/image-shrink/src/test/samples/icc-strip-test.jpg new file mode 100644 index 00000000..b24f953f Binary files /dev/null and b/packages/image-shrink/src/test/samples/icc-strip-test.jpg differ diff --git a/packages/image-shrink/src/test/samples/not-transparent.png b/packages/image-shrink/src/test/samples/not-transparent.png new file mode 100644 index 00000000..41002265 Binary files /dev/null and b/packages/image-shrink/src/test/samples/not-transparent.png differ diff --git a/packages/image-shrink/src/test/samples/transparent.png b/packages/image-shrink/src/test/samples/transparent.png new file mode 100644 index 00000000..58ac90c3 Binary files /dev/null and b/packages/image-shrink/src/test/samples/transparent.png differ diff --git a/packages/image-shrink/src/test/samples/with-icc-profile.jpg b/packages/image-shrink/src/test/samples/with-icc-profile.jpg new file mode 100644 index 00000000..0cb1e10c Binary files /dev/null and b/packages/image-shrink/src/test/samples/with-icc-profile.jpg differ diff --git a/packages/image-shrink/src/utils/IccProfile/getIccProfile.ts b/packages/image-shrink/src/utils/IccProfile/getIccProfile.ts index cbb24044..4ee78af9 100644 --- a/packages/image-shrink/src/utils/IccProfile/getIccProfile.ts +++ b/packages/image-shrink/src/utils/IccProfile/getIccProfile.ts @@ -4,21 +4,20 @@ export const getIccProfile = async (blob: Blob) => { const iccProfile: DataView[] = [] const { promiseReadJpegChunks, stack } = readJpegChunks() - return await promiseReadJpegChunks(blob) - .then(() => { - stack.forEach(({ marker, view }) => { - if (marker === 0xe2) { - if ( - // check for "ICC_PROFILE\0" - view.getUint32(0) === 0x4943435f && - view.getUint32(4) === 0x50524f46 && - view.getUint32(8) === 0x494c4500 - ) { - iccProfile.push(view) - } - } - }) - return iccProfile - }) - .catch(() => iccProfile) + await promiseReadJpegChunks(blob) + + stack.forEach(({ marker, view }) => { + if (marker === 0xe2) { + if ( + // check for "ICC_PROFILE\0" + view.getUint32(0) === 0x4943435f && + view.getUint32(4) === 0x50524f46 && + view.getUint32(8) === 0x494c4500 + ) { + iccProfile.push(view) + } + } + }) + + return iccProfile } diff --git a/packages/image-shrink/src/utils/exif/getExif.ts b/packages/image-shrink/src/utils/exif/getExif.ts index 47fad820..80810452 100644 --- a/packages/image-shrink/src/utils/exif/getExif.ts +++ b/packages/image-shrink/src/utils/exif/getExif.ts @@ -4,23 +4,23 @@ export const getExif = async (blob: Blob) => { let exif: DataView | null = null const { promiseReadJpegChunks, stack } = readJpegChunks() - return promiseReadJpegChunks(blob) - .then(() => { - stack.forEach(({ marker, view }) => { - if (!exif && marker === 0xe1) { - if (view.byteLength >= 14) { - if ( - // check for "Exif\0" - view.getUint32(0) === 0x45786966 && - view.getUint16(4) === 0 - ) { - exif = view - return - } - } + + await promiseReadJpegChunks(blob) + + stack.forEach(({ marker, view }) => { + if (!exif && marker === 0xe1) { + if (view.byteLength >= 14) { + if ( + // check for "Exif\0" + view.getUint32(0) === 0x45786966 && + view.getUint16(4) === 0 + ) { + exif = view + return } - }) - return exif - }) - .catch(() => exif) + } + } + }) + + return exif } diff --git a/packages/image-shrink/src/utils/image/JPEG/readJpegChunks.ts b/packages/image-shrink/src/utils/image/JPEG/readJpegChunks.ts index e4b070e4..b47347f1 100644 --- a/packages/image-shrink/src/utils/image/JPEG/readJpegChunks.ts +++ b/packages/image-shrink/src/utils/image/JPEG/readJpegChunks.ts @@ -5,6 +5,7 @@ type TChunk = { view: DataView } +// TODO: unwrap promises export const readJpegChunks = () => { const stack: TChunk[] = [] const promiseReadJpegChunks = (blob: Blob) => @@ -37,7 +38,6 @@ export const readJpegChunks = () => { break } } - readNextChunk() }) @@ -50,7 +50,7 @@ export const readJpegChunks = () => { return } - const marker = view?.getUint8(1) + const marker = view.getUint8(1) if (marker === 0xda) { resolve(true) diff --git a/packages/image-shrink/src/utils/image/JPEG/replaceJpegChunk.ts b/packages/image-shrink/src/utils/image/JPEG/replaceJpegChunk.ts index 8d57887a..6651fc02 100644 --- a/packages/image-shrink/src/utils/image/JPEG/replaceJpegChunk.ts +++ b/packages/image-shrink/src/utils/image/JPEG/replaceJpegChunk.ts @@ -1,52 +1,47 @@ import { readJpegChunks } from './readJpegChunks' -export const replaceJpegChunk = ( +export const replaceJpegChunk = async ( blob: Blob, marker: number, chunks: ArrayBuffer[] ) => { - return new Promise((resolve, reject) => { + { const oldChunkPos: number[] = [] const oldChunkLength: number[] = [] const { promiseReadJpegChunks, stack } = readJpegChunks() - return promiseReadJpegChunks(blob) - .then(() => { - stack.forEach((chunk) => { - if (chunk.marker === marker) { - oldChunkPos.push(chunk.startPos) - return oldChunkLength.push(chunk.length) - } - }) - }) - .then(() => { - const newChunks: (ArrayBuffer | Blob)[] = [blob.slice(0, 2)] - - for (const chunk of chunks) { - const intro = new DataView(new ArrayBuffer(4)) - intro.setUint16(0, 0xff00 + marker) - intro.setUint16(2, chunk.byteLength + 2) - newChunks.push(intro.buffer) - newChunks.push(chunk) - } - - let pos = 2 - for (let i = 0; i < oldChunkPos.length; i++) { - if (oldChunkPos[i] > pos) { - newChunks.push(blob.slice(pos, oldChunkPos[i])) - } - pos = oldChunkPos[i] + oldChunkLength[i] + 4 - } - - newChunks.push(blob.slice(pos, blob.size)) - - resolve( - new Blob(newChunks, { - type: blob.type - }) - ) - }) - .catch(() => reject(blob)) - }).catch(() => blob) + await promiseReadJpegChunks(blob) + + stack.forEach((chunk) => { + if (chunk.marker === marker) { + oldChunkPos.push(chunk.startPos) + return oldChunkLength.push(chunk.length) + } + }) + + const newChunks: (ArrayBuffer | Blob)[] = [blob.slice(0, 2)] + + for (const chunk of chunks) { + const intro = new DataView(new ArrayBuffer(4)) + intro.setUint16(0, 0xff00 + marker) + intro.setUint16(2, chunk.byteLength + 2) + newChunks.push(intro.buffer) + newChunks.push(chunk) + } + + let pos = 2 + for (let i = 0; i < oldChunkPos.length; i++) { + if (oldChunkPos[i] > pos) { + newChunks.push(blob.slice(pos, oldChunkPos[i])) + } + pos = oldChunkPos[i] + oldChunkLength[i] + 4 + } + + newChunks.push(blob.slice(pos, blob.size)) + + return new Blob(newChunks, { + type: blob.type + }) + } } diff --git a/packages/image-shrink/src/utils/shrinkFile.test.ts b/packages/image-shrink/src/utils/shrinkFile.test.ts index 7148c70a..d1f2aedc 100644 --- a/packages/image-shrink/src/utils/shrinkFile.test.ts +++ b/packages/image-shrink/src/utils/shrinkFile.test.ts @@ -1,22 +1,138 @@ /// -import { expect, describe, it } from 'vitest' +import { describe, expect, it } from 'vitest' +import { getImageAttributes } from '../test/helpers/getImageAttributes' +import { readMagickImage } from '../test/helpers/readMagickImage' +import { loadImageAsBlob } from '../test/helpers/loadImageAsBlob' import { shrinkFile } from './shrinkFile' -import imageUrl from '../test/samples/2000x2000.jpeg' -import { fileFromUrl } from '../test/helpers/fileFromUrl' -import { blobToImage } from '../test/helpers/blobToImage' +import { type IMagickImage } from '@imagemagick/magick-wasm' describe('shrinkFile', () => { it('should shrink the image', async () => { - const file = await fileFromUrl(imageUrl) - const blob = await shrinkFile(file, { - size: 100, - quality: 0.5 + const originalFile = await loadImageAsBlob( + () => import('../test/samples/2000x2000.jpeg') + ) + const shrinkedBlob = await shrinkFile(originalFile, { + size: 100 }) - expect(blob.size).toBeLessThan(file.size) - expect(blob.size).toBeLessThan(1000) + expect(shrinkedBlob.size).toBeLessThan(originalFile.size) + expect(shrinkedBlob.size).toBeLessThan(1000) - const img = await blobToImage(blob) - expect(img.width).toBe(10) - expect(img.height).toBe(10) + const { width, height } = await readMagickImage(shrinkedBlob, (image) => ({ + width: image.width, + height: image.height + })) + expect(width).toBe(10) + expect(height).toBe(10) + }) + + it("should skip shrink if it's not required", async () => { + const originalFile = await loadImageAsBlob( + () => import('../test/samples/2000x2000.jpeg') + ) + const promise = shrinkFile(originalFile, { + size: 2000 * 2000 + }) + expect(promise).rejects.toThrowError('Not required') + }) + + it('should keep transparent PNG as PNG', async () => { + const originalFile = await loadImageAsBlob( + () => import('../test/samples/transparent.png') + ) + const shrinkedBlob = await shrinkFile(originalFile, { + size: 100 + }) + + const { hasAlpha, format } = await readMagickImage( + shrinkedBlob, + (image) => ({ + hasAlpha: image.hasAlpha, + format: image.format + }) + ) + expect(hasAlpha).toBe(true) + expect(format).toBe('PNG') + }) + + it('should convert non-transparent PNG to JPEG', async () => { + const originalFile = await loadImageAsBlob( + () => import('../test/samples/not-transparent.png') + ) + const shrinkedBlob = await shrinkFile(originalFile, { + size: 100 + }) + + const { hasAlpha, format } = await readMagickImage( + shrinkedBlob, + (image) => ({ + hasAlpha: image.hasAlpha, + format: image.format + }) + ) + expect(hasAlpha).toBe(false) + expect(format).toBe('JPEG') + }) + + it('should keep EXIF', async () => { + const originalFile = await loadImageAsBlob( + () => import('../test/samples/exif-without-orientation.jpg') + ) + const shrinkedBlob = await shrinkFile(originalFile, { + size: 2 + }) + const filterExifAttributes = (attrs: Record) => + Object.fromEntries( + Object.entries(attrs).filter(([key]) => key.startsWith('exif:')) + ) + const originalExif = + await getImageAttributes(originalFile).then(filterExifAttributes) + const shrinkedExif = + await getImageAttributes(shrinkedBlob).then(filterExifAttributes) + + expect(originalExif).toEqual(shrinkedExif) + }) + + it('should keep ICC', async () => { + const originalFile = await loadImageAsBlob( + () => import('../test/samples/with-icc-profile.jpg') + ) + const shrinkedBlob = await shrinkFile(originalFile, { + size: 2 + }) + + const filterIccAttributes = (attrs: Record) => + Object.fromEntries( + Object.entries(attrs).filter(([key]) => key.startsWith('icc:')) + ) + const originalIccAttributes = + await getImageAttributes(originalFile).then(filterIccAttributes) + const shrinkedIccAttributes = + await getImageAttributes(shrinkedBlob).then(filterIccAttributes) + + expect(originalIccAttributes).toEqual(shrinkedIccAttributes) + }) + + it.skip('should not apply existing ICC when shrinking image', async () => { + const originalFile = await loadImageAsBlob( + () => import('../test/samples/icc-strip-test.jpg') + ) + const shrinkedBlob = await shrinkFile(originalFile, { + size: 300 * 300, + quality: 1 + }) + + const readTopLeftPixel = (image: IMagickImage) => { + return new Promise((resolve) => { + image.getPixels((pixelsCollection) => { + resolve(pixelsCollection.getPixel(0, 0)) + pixelsCollection.dispose() + }) + }) + } + + const originalPixel = await readMagickImage(originalFile, readTopLeftPixel) + const shrinkedPixel = await readMagickImage(shrinkedBlob, readTopLeftPixel) + + expect(originalPixel).toEqual(shrinkedPixel) }) }) diff --git a/packages/image-shrink/src/utils/shrinkFile.ts b/packages/image-shrink/src/utils/shrinkFile.ts index 33bdb824..aca7b6ca 100644 --- a/packages/image-shrink/src/utils/shrinkFile.ts +++ b/packages/image-shrink/src/utils/shrinkFile.ts @@ -24,7 +24,6 @@ export const shrinkFile = async ( if (shouldSkip) { throw new Error('Should skipped') } - inputBlob = await stripIccProfile(inputBlob) // Try to extract EXIF and ICC profile const exifResults = await Promise.allSettled([ @@ -43,7 +42,10 @@ export const shrinkFile = async ( exifResults // Load blob into the image - const image = await imageLoader(URL.createObjectURL(inputBlob)) + const inputBlobWithoutIcc = await stripIccProfile(inputBlob).catch( + () => inputBlob + ) + const image = await imageLoader(URL.createObjectURL(inputBlobWithoutIcc)) URL.revokeObjectURL(image.src) // Shrink the image @@ -58,33 +60,32 @@ export const shrinkFile = async ( } // Convert canvas to blob - const newBlob = await canvasToBlob(canvas, format, quality) - - const replaceChain = Promise.resolve(newBlob) + let newBlob = await canvasToBlob(canvas, format, quality) // Set EXIF for the new blob - if (exifResult.status === 'fulfilled' && exifResult.value) { + if (isJPEG && exifResult.status === 'fulfilled' && exifResult.value) { const exif = exifResult.value const isExifOrientationApplied = isExifOrientationAppliedResult.status === 'fulfilled' ? isExifOrientationAppliedResult.value : false - replaceChain - .then((blob) => replaceExif(blob, exif, isExifOrientationApplied)) - .catch(() => newBlob) + newBlob = await replaceExif(newBlob, exif, isExifOrientationApplied) + // TODO: should we continue shrink if failed to replace EXIF? + // .catch(() => newBlob) } // Set ICC profile for the new blob if ( + isJPEG && iccProfileResult.status === 'fulfilled' && iccProfileResult.value.length > 0 ) { - replaceChain - .then((blob) => replaceIccProfile(blob, iccProfileResult.value)) - .catch(() => newBlob) + newBlob = await replaceIccProfile(newBlob, iccProfileResult.value) + // TODO: should we continue shrink if failed to replace ICC? + // .catch(() => newBlob) } - return replaceChain + return newBlob } catch (e) { let message: string | undefined if (e instanceof Error) {