Skip to content

Commit f17f28e

Browse files
committed
feat: use jsquash for webp encoding
1 parent 5cdae26 commit f17f28e

4 files changed

Lines changed: 145 additions & 117 deletions

File tree

package-lock.json

Lines changed: 16 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/decap-cms-core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"license": "MIT",
2626
"dependencies": {
2727
"@iarna/toml": "2.2.5",
28-
"@silvia-odwyer/photon": "0.3.3",
28+
"@jsquash/webp": "1.5.0",
2929
"@vercel/stega": "^0.1.2",
3030
"ajv": "^8.17.1",
3131
"ajv-errors": "^3.0.0",

packages/decap-cms-core/src/lib/__tests__/imageTransformations.spec.ts

Lines changed: 64 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,52 +7,42 @@ import {
77
transformImage,
88
} from '../imageTransformations';
99

10-
const mockOriginalImage = {
11-
get_width: jest.fn(() => 400),
12-
get_height: jest.fn(() => 300),
13-
get_bytes_webp: jest.fn(() => new Uint8Array([1, 2, 3])),
14-
get_bytes_jpeg: jest.fn(() => new Uint8Array([4, 5, 6])),
15-
get_bytes: jest.fn(() => new Uint8Array([7, 8, 9])),
16-
free: jest.fn(),
17-
};
18-
const mockCroppedImage = {
19-
get_width: jest.fn(() => 400),
20-
get_height: jest.fn(() => 225),
21-
get_bytes_webp: jest.fn(() => new Uint8Array([10, 11, 12])),
22-
get_bytes_jpeg: jest.fn(() => new Uint8Array([13, 14, 15])),
23-
get_bytes: jest.fn(() => new Uint8Array([16, 17, 18])),
24-
free: jest.fn(),
25-
};
26-
const mockResizedImage = {
27-
get_width: jest.fn(() => 160),
28-
get_height: jest.fn(() => 90),
29-
get_bytes_webp: jest.fn(() => new Uint8Array([19, 20, 21])),
30-
get_bytes_jpeg: jest.fn(() => new Uint8Array([22, 23, 24])),
31-
get_bytes: jest.fn(() => new Uint8Array([25, 26, 27])),
32-
free: jest.fn(),
33-
};
34-
const mockNewFromByteslice = jest.fn(() => mockOriginalImage);
35-
const mockCrop = jest.fn(() => mockCroppedImage);
36-
const mockResize = jest.fn(() => mockResizedImage);
37-
38-
jest.mock(
39-
'@silvia-odwyer/photon',
40-
() => ({
41-
__esModule: true,
42-
default: jest.fn(() => Promise.resolve()),
43-
PhotonImage: {
44-
new_from_byteslice: mockNewFromByteslice,
45-
},
46-
crop: mockCrop,
47-
resize: mockResize,
48-
SamplingFilter: { Lanczos3: 'Lanczos3' },
49-
}),
50-
{ virtual: true },
51-
);
10+
const mockEncodeWebp = jest.fn(() => Promise.resolve(new ArrayBuffer(3)));
11+
const mockCloseImage = jest.fn();
12+
const mockDrawImage = jest.fn();
13+
const mockImageData = { width: 160, height: 90, data: new Uint8ClampedArray(160 * 90 * 4) };
14+
const mockGetImageData = jest.fn(() => mockImageData);
15+
const mockToBlob = jest.fn(callback => callback(new Blob(['encoded'], { type: 'image/jpeg' })));
16+
17+
jest.mock('@jsquash/webp', () => ({ encode: mockEncodeWebp }), { virtual: true });
5218

5319
describe('imageTransformations', () => {
20+
const originalCreateImageBitmap = global.createImageBitmap;
21+
const originalCreateElement = document.createElement;
22+
5423
beforeEach(() => {
5524
jest.clearAllMocks();
25+
global.createImageBitmap = jest.fn(() =>
26+
Promise.resolve({
27+
width: 400,
28+
height: 300,
29+
close: mockCloseImage,
30+
} as unknown as ImageBitmap),
31+
);
32+
document.createElement = jest.fn(() => ({
33+
width: 0,
34+
height: 0,
35+
getContext: jest.fn(() => ({
36+
drawImage: mockDrawImage,
37+
getImageData: mockGetImageData,
38+
})),
39+
toBlob: mockToBlob,
40+
})) as unknown as typeof document.createElement;
41+
});
42+
43+
afterEach(() => {
44+
global.createImageBitmap = originalCreateImageBitmap;
45+
document.createElement = originalCreateElement;
5646
});
5747

5848
describe('getMediaProcessingConfig', () => {
@@ -180,7 +170,7 @@ describe('imageTransformations', () => {
180170
});
181171

182172
describe('transformImage', () => {
183-
it('encodes one processed image with Photon and strips metadata by re-encoding', async () => {
173+
it('encodes webp with jSquash and strips metadata by re-encoding', async () => {
184174
const file = new File(['original'], 'kittens.jpg', { type: 'image/jpeg' });
185175
const files = await transformImage(file, 'static/media/kittens.jpg', {
186176
format: 'webp',
@@ -194,13 +184,37 @@ describe('imageTransformations', () => {
194184
expect(files[0].file.name).toBe('kittens.webp');
195185
expect(files[0].file.type).toBe('image/webp');
196186
expect(files[0].path).toBe('static/media/kittens.webp');
197-
expect(mockNewFromByteslice).toHaveBeenCalledWith(expect.any(Uint8Array));
198-
expect(mockCrop).toHaveBeenCalledWith(mockOriginalImage, 0, 38, 400, 263);
199-
expect(mockResize).toHaveBeenCalledWith(mockCroppedImage, 160, 90, 'Lanczos3');
200-
expect(mockResizedImage.get_bytes_webp).toHaveBeenCalledTimes(1);
201-
expect(mockOriginalImage.free).toHaveBeenCalledTimes(1);
202-
expect(mockCroppedImage.free).toHaveBeenCalledTimes(1);
203-
expect(mockResizedImage.free).toHaveBeenCalledTimes(1);
187+
expect(mockDrawImage).toHaveBeenCalledWith(
188+
expect.any(Object),
189+
0,
190+
38,
191+
400,
192+
225,
193+
0,
194+
0,
195+
160,
196+
90,
197+
);
198+
expect(mockGetImageData).toHaveBeenCalledWith(0, 0, 160, 90);
199+
expect(mockEncodeWebp).toHaveBeenCalledWith(mockImageData, { quality: 80 });
200+
expect(mockCloseImage).toHaveBeenCalledTimes(1);
201+
});
202+
203+
it('encodes jpeg with canvas', async () => {
204+
const file = new File(['original'], 'kittens.png', { type: 'image/png' });
205+
const files = await transformImage(file, 'static/media/kittens.png', {
206+
format: 'jpeg',
207+
quality: 0.7,
208+
width: null,
209+
height: null,
210+
aspectRatio: null,
211+
});
212+
213+
expect(files[0].file.name).toBe('kittens.jpg');
214+
expect(files[0].file.type).toBe('image/jpeg');
215+
expect(files[0].path).toBe('static/media/kittens.jpg');
216+
expect(mockToBlob).toHaveBeenCalledWith(expect.any(Function), 'image/jpeg', 0.7);
217+
expect(mockEncodeWebp).not.toHaveBeenCalled();
204218
});
205219
});
206220
});

packages/decap-cms-core/src/lib/imageTransformations.ts

Lines changed: 64 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import { basename, fileExtension, fileExtensionWithSeparator } from 'decap-cms-lib-util';
22

3-
import type * as Photon from '@silvia-odwyer/photon';
43
import type { CmsMediaProcessing, EntryField } from '../types/redux';
54

6-
type PhotonImage = InstanceType<typeof Photon.PhotonImage>;
7-
type PhotonModule = typeof Photon & { default: () => Promise<unknown> };
8-
95
export type MediaProcessingConfig = {
106
format?: 'jpeg' | 'webp';
117
quality?: number;
@@ -191,33 +187,50 @@ function getSourceCrop(sourceWidth: number, sourceHeight: number, aspectRatio?:
191187
return { x: 0, y: Math.round((sourceHeight - height) / 2), width: sourceWidth, height };
192188
}
193189

194-
async function getFileBytes(file: File) {
195-
if (typeof file.arrayBuffer === 'function') {
196-
return new Uint8Array(await file.arrayBuffer());
190+
function loadImage(file: File) {
191+
if (typeof createImageBitmap === 'function') {
192+
return createImageBitmap(file);
197193
}
198194

199-
return new Promise<Uint8Array>((resolve, reject) => {
200-
const reader = new FileReader();
201-
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
202-
reader.onerror = () => reject(reader.error);
203-
reader.readAsArrayBuffer(file);
195+
return new Promise<HTMLImageElement>((resolve, reject) => {
196+
const url = URL.createObjectURL(file);
197+
const image = new Image();
198+
image.onload = () => {
199+
URL.revokeObjectURL(url);
200+
resolve(image);
201+
};
202+
image.onerror = () => {
203+
URL.revokeObjectURL(url);
204+
reject(new Error(`Failed to load image '${file.name}'`));
205+
};
206+
image.src = url;
204207
});
205208
}
206209

207-
function getImageBytes(image: PhotonImage, format: string, quality = 0.75) {
208-
if (format === 'jpeg') {
209-
return image.get_bytes_jpeg(Math.round(quality * 100));
210-
}
211-
210+
async function encodeImageData(imageData: ImageData, format: string, quality = 0.75) {
212211
if (format === 'webp') {
213-
return image.get_bytes_webp();
212+
const { encode } = await import('@jsquash/webp');
213+
return encode(imageData, { quality: Math.round(quality * 100) });
214214
}
215215

216-
if (format === 'png') {
217-
return image.get_bytes();
218-
}
216+
throw new Error(`ImageData encoding is not supported for '${format}'`);
217+
}
219218

220-
throw new Error(`Photon does not support encoding images as '${format}'`);
219+
function encodeCanvas(canvas: HTMLCanvasElement, format: string, quality = 0.75) {
220+
return new Promise<Blob>((resolve, reject) => {
221+
canvas.toBlob(
222+
blob => {
223+
if (!blob) {
224+
reject(new Error(`Failed to encode image as '${format}'`));
225+
return;
226+
}
227+
228+
resolve(blob);
229+
},
230+
getMimeType(format),
231+
quality,
232+
);
233+
});
221234
}
222235

223236
export async function transformImage(
@@ -227,42 +240,34 @@ export async function transformImage(
227240
): Promise<ImageTransformationFile[]> {
228241
const outputFormat = getOutputFormat(file.name, config);
229242
const mimeType = getMimeType(outputFormat);
230-
const photon = (await import('@silvia-odwyer/photon')) as unknown as PhotonModule;
231-
await photon.default();
232-
233-
const imageBytes = await getFileBytes(file);
234-
const originalImage = photon.PhotonImage.new_from_byteslice(imageBytes);
235-
const imagesToFree: PhotonImage[] = [originalImage];
236-
237-
try {
238-
const sourceWidth = originalImage.get_width();
239-
const sourceHeight = originalImage.get_height();
240-
const crop = getSourceCrop(sourceWidth, sourceHeight, config.aspectRatio);
241-
const croppedImage =
242-
crop.x === 0 && crop.y === 0 && crop.width === sourceWidth && crop.height === sourceHeight
243-
? originalImage
244-
: photon.crop(originalImage, crop.x, crop.y, crop.x + crop.width, crop.y + crop.height);
245-
246-
if (croppedImage !== originalImage) {
247-
imagesToFree.push(croppedImage);
248-
}
249-
250-
const { width, height } = getTargetDimensions(crop.width, crop.height, config);
251-
const processedImage =
252-
width === croppedImage.get_width() && height === croppedImage.get_height()
253-
? croppedImage
254-
: photon.resize(croppedImage, width, height, photon.SamplingFilter.Lanczos3);
255-
256-
if (processedImage !== croppedImage) {
257-
imagesToFree.push(processedImage);
258-
}
259-
260-
const bytes = getImageBytes(processedImage, outputFormat, config.quality);
261-
const fileName = getMediaProcessingFileName(file.name, config);
262-
const transformedFile = new File([bytes], fileName, { type: mimeType });
263-
264-
return [{ file: transformedFile, path: getProcessedPath(originalPath, fileName) }];
265-
} finally {
266-
imagesToFree.forEach(image => image.free());
243+
const image = await loadImage(file);
244+
const crop = getSourceCrop(image.width, image.height, config.aspectRatio);
245+
const { width, height } = getTargetDimensions(crop.width, crop.height, config);
246+
const canvas = document.createElement('canvas');
247+
const context = canvas.getContext('2d');
248+
249+
if (!context) {
250+
throw new Error('Canvas 2D context is not available');
267251
}
252+
253+
canvas.width = width;
254+
canvas.height = height;
255+
context.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, width, height);
256+
257+
if ('close' in image && typeof image.close === 'function') {
258+
image.close();
259+
}
260+
261+
const encoded =
262+
outputFormat === 'webp'
263+
? await encodeImageData(
264+
context.getImageData(0, 0, width, height),
265+
outputFormat,
266+
config.quality,
267+
)
268+
: await encodeCanvas(canvas, outputFormat, config.quality);
269+
const fileName = getMediaProcessingFileName(file.name, config);
270+
const transformedFile = new File([encoded], fileName, { type: mimeType });
271+
272+
return [{ file: transformedFile, path: getProcessedPath(originalPath, fileName) }];
268273
}

0 commit comments

Comments
 (0)