Skip to content

Commit

Permalink
Add jsquash image optimizers
Browse files Browse the repository at this point in the history
  • Loading branch information
cwparsons committed Jan 19, 2024
1 parent 416cca7 commit a95cd6f
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 75 deletions.
56 changes: 41 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
"dependencies": {
"@emotion/react": "11.11.3",
"@fluentui/react": "8.114.3",
"@jsquash/avif": "1.1.2-single-thread-only",
"@jsquash/jpeg": "1.4.0",
"@jsquash/oxipng": "2.0.0-rc.0-single-thread-only",
"@jsquash/png": "3.0.0",
"@jsquash/webp": "1.4.0",
"@vitejs/plugin-react-swc": "3.5.0",
"mozjpeg-js": "3.3.1",
"optipng-js": "0.1.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "14.2.3",
Expand All @@ -18,14 +21,14 @@
"react-router-dom": "6.21.3",
"react-uid": "2.3.3",
"vite": "5.0.11",
"vite-plugin-svgr": "^4.2.0",
"vite-plugin-svgr": "4.2.0",
"vite-tsconfig-paths": "4.3.1"
},
"devDependencies": {
"@types/node": "^20.11.5",
"@types/node": "20.11.5",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"@types/react-gtm-module": "^2.0.3",
"@types/react-gtm-module": "2.0.3",
"@types/react-image-crop": "8.1.6",
"source-map-explorer": "2.5.3",
"typescript": "5.3.3"
Expand Down
14 changes: 12 additions & 2 deletions src/ImageResizer/components/ImageResizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,26 @@ export const ImageResizer = (props: ImageResizerProps) => {

// If there was no default format, set the format based on the input image.
if (!props.format) {
if (file.type === 'image/png') {
if (file.type === 'image/avif') {
setFormState((prevState) => ({
...prevState,
format: 'png'
format: 'avif'
}));
} else if (file.type === 'image/jpeg') {
setFormState((prevState) => ({
...prevState,
format: 'jpeg'
}));
} else if (file.type === 'image/png') {
setFormState((prevState) => ({
...prevState,
format: 'png'
}));
} else if (file.type === 'image/webp') {
setFormState((prevState) => ({
...prevState,
format: 'webp'
}));
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/ImageResizer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type FileState =
src: string;
};

export type Formats = 'jpeg' | 'png' | 'webp';
export type Formats = 'avif' | 'jpeg' | 'png' | 'webp';

export type FormState = {
aspectRatioHeight: number;
Expand Down
82 changes: 37 additions & 45 deletions src/ImageResizer/utilities/download-image.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Crop } from 'react-image-crop';

import type { Formats, Results } from '../types';
import { QUALITY_LEVELS } from '../../constants';

const MIME_TYPE_MAP = {
avif: 'image/avif',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
Expand All @@ -15,7 +17,6 @@ type DownloadImageOptions = {
image: HTMLImageElement;
maxWidth: number;
optimize?: boolean;
quality?: number;
};

/**
Expand All @@ -27,8 +28,7 @@ export async function downloadImage({
format = 'png',
image,
maxWidth,
optimize = false,
quality = 0.85
optimize = false
}: DownloadImageOptions): Promise<Results> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
Expand Down Expand Up @@ -75,21 +75,19 @@ export async function downloadImage({
}

ctx.drawImage(image, sx, sy, sWidth, sHeight, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);

const fileNameWithoutExtension = fileName.split('.').slice(0, -1).join('.');
const newFileName = `${fileNameWithoutExtension}.${format}`;
let blob: Blob;

let blob = await toBlob(canvas, MIME_TYPE_MAP[format], quality);
const res = await encodeImage(imageData, format, optimize);

if (optimize) {
const blobArrayBuffer = await blob.arrayBuffer();
const res = await optimizeImage(new Uint8Array(blobArrayBuffer), blob.type);

blob = new Blob([res.data], { type: blob.type });
}
blob = new Blob([res], { type: MIME_TYPE_MAP[format] });

const href = URL.createObjectURL(blob);

const fileNameWithoutExtension = fileName.split('.').slice(0, -1).join('.');
const newFileName = `${fileNameWithoutExtension}.${format}`;

downloadHref(newFileName, href);

// Return the output height, width and size for the results table.
Expand All @@ -100,31 +98,6 @@ export async function downloadImage({
};
}

/**
* Convert the toBlob function to use a promise instead of a callback for async
* and await usage.
* @param canvas The canvas element to get a blob from.
* @param type The mime type of the image format to retrieve.
* @param quality A quality number from 0 - 1.
*/
function toBlob(canvas: HTMLCanvasElement, type?: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob === null) {
reject(`Blob cannot be null`);

return;
}

resolve(blob);
},
type,
quality
);
});
}

/**
* Download a blob by creating and clicking on a fake link.
* @param filename Name of the file to be downloaded.
Expand All @@ -141,16 +114,35 @@ function downloadHref(filename: string, href: string) {
document.body.removeChild(a);
}

async function optimizeImage(data: Uint8Array, type: string): Promise<{ data: Uint8Array }> {
if (type === 'image/png') {
const optipng = await import(/* webpackChunkName: "optipng-js" */ 'optipng-js');
async function encodeImage(data: ImageData, type: Formats, optimize: boolean): Promise<ArrayBuffer> {
const quality = optimize ? QUALITY_LEVELS.optimize[type] : QUALITY_LEVELS.unoptimized[type];

if (type === 'avif') {
const { encode } = await import(/* webpackChunkName: "jsquash-avif" */ '@jsquash/avif');

return encode(data, { cqLevel: quality });
}
if (type === 'png') {
const { encode } = await import(/* webpackChunkName: "jsquash-png" */ '@jsquash/png');

if (!optimize) {
return encode(data);
} else {
const result = await encode(data);

const optimise = await import(/* webpackChunkName: "jsquash-oxipng" */ '@jsquash/oxipng/optimise');

return optimise.default(result, { level: quality });
}
} else if (type === 'jpeg') {
const { encode } = await import(/* webpackChunkName: "jsquash-jpeg" */ '@jsquash/jpeg');

return optipng.default(data, ['-o2']);
} else if (type === 'image/jpeg') {
const { encode } = await import(/* webpackChunkName: "mozjpeg-js" */ 'mozjpeg-js');
return encode(data, { quality });
} else if (type === 'webp') {
const { encode } = await import(/* webpackChunkName: "jsquash-webp" */ '@jsquash/webp');

return encode(data);
return encode(data, { quality });
}

return { data };
return data.data;
}
22 changes: 21 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,30 @@ export const IMAGE_FORMAT_OPTIONS = [
{
key: 'webp',
text: 'WebP',
optimize: false
optimize: true
},
{
key: 'avif',
text: 'AVIF',
optimize: true
}
];

export const QUALITY_LEVELS = {
optimize: {
avif: 40,
png: 3,
jpeg: 50,
webp: 50
},
unoptimized: {
avif: 10,
png: undefined,
jpeg: 90,
webp: 90
}
};

export const CUSTOM_ID = 'custom';

export const DEFAULT_ID = 'opengraph';
Expand Down
10 changes: 4 additions & 6 deletions vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ import svgr from 'vite-plugin-svgr';
export default defineConfig({
base: '/image-resizer',

plugins: [react(), svgr()],
optimizeDeps: {
exclude: ['@jsquash/avif', '@jsquash/jpeg', '@jsquash/oxipng', '@jsquash/png', '@jsquash/webp']
},

server: {
watch: {
usePolling: true
}
}
plugins: [react(), svgr()]
});

0 comments on commit a95cd6f

Please sign in to comment.