diff --git a/packages/studio-server/src/preview-server/routes/download-remote-asset.ts b/packages/studio-server/src/preview-server/routes/download-remote-asset.ts index 064f9d11285..9547607dd8d 100644 --- a/packages/studio-server/src/preview-server/routes/download-remote-asset.ts +++ b/packages/studio-server/src/preview-server/routes/download-remote-asset.ts @@ -339,7 +339,8 @@ export const downloadRemoteAssetHandler: ApiHandler< assetType: fileType.type === 'gif' ? 'gif' - : fileType.type === 'apng' + : fileType.type === 'apng' || + (fileType.type === 'webp' && fileType.animated) ? 'animated-image' : 'image', src: assetPath, diff --git a/packages/studio-shared/src/detect-file-type.ts b/packages/studio-shared/src/detect-file-type.ts index 7095b50e3fc..115cfd8356b 100644 --- a/packages/studio-shared/src/detect-file-type.ts +++ b/packages/studio-shared/src/detect-file-type.ts @@ -287,13 +287,41 @@ const getWebPDimensions = (bytes: Uint8Array): FileDimensions | null => { return null; }; +const isAnimatedWebp = (data: Uint8Array): boolean => { + const animationChunk = new Uint8Array([0x41, 0x4e, 0x49, 0x4d]); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + let offset = 12; + + while (offset + 8 <= data.length) { + const chunkType = data.subarray(offset, offset + 4); + if (matchesPattern(animationChunk)(chunkType)) { + return true; + } + + const chunkLength = view.getUint32(offset + 4, true); + const nextOffset = offset + 8 + chunkLength + (chunkLength % 2); + if (nextOffset <= offset) { + return false; + } + + offset = nextOffset; + } + + return false; +}; + const isWebp = (data: Uint8Array): WebpType | null => { - const webpPattern = new Uint8Array([0x52, 0x49, 0x46, 0x46]); + const riffPattern = new Uint8Array([0x52, 0x49, 0x46, 0x46]); + const webpPattern = new Uint8Array([0x57, 0x45, 0x42, 0x50]); - if (matchesPattern(webpPattern)(data.subarray(0, 4))) { + if ( + matchesPattern(riffPattern)(data.subarray(0, 4)) && + matchesPattern(webpPattern)(data.subarray(8, 12)) + ) { return { type: 'webp', dimensions: getWebPDimensions(data), + animated: isAnimatedWebp(data), }; } @@ -393,6 +421,7 @@ export type JpegType = { export type WebpType = { type: 'webp'; dimensions: FileDimensions | null; + animated: boolean; }; export type BmpType = { diff --git a/packages/studio-shared/src/test/detect-file-type.test.ts b/packages/studio-shared/src/test/detect-file-type.test.ts index 7aacab289f8..61f6acf5b2f 100644 --- a/packages/studio-shared/src/test/detect-file-type.test.ts +++ b/packages/studio-shared/src/test/detect-file-type.test.ts @@ -37,6 +37,41 @@ test('detects animated PNG dimensions', () => { }); }); +test('detects animated WebP files', () => { + const webp = new Uint8Array(44); + webp.set([0x52, 0x49, 0x46, 0x46, 36, 0, 0, 0], 0); + webp.set([0x57, 0x45, 0x42, 0x50], 8); + webp.set([0x56, 0x50, 0x38, 0x58, 10, 0, 0, 0], 12); + webp.set([0x02, 0, 0, 0, 0x3f, 0x01, 0, 0xb3, 0, 0], 20); + webp.set([0x41, 0x4e, 0x49, 0x4d, 6, 0, 0, 0], 30); + + expect(detectFileType(webp)).toEqual({ + type: 'webp', + animated: true, + dimensions: { + width: 320, + height: 180, + }, + }); +}); + +test('detects static WebP files', () => { + const webp = new Uint8Array(30); + webp.set([0x52, 0x49, 0x46, 0x46, 22, 0, 0, 0], 0); + webp.set([0x57, 0x45, 0x42, 0x50], 8); + webp.set([0x56, 0x50, 0x38, 0x58, 10, 0, 0, 0], 12); + webp.set([0, 0, 0, 0, 0x3f, 0x01, 0, 0xb3, 0, 0], 20); + + expect(detectFileType(webp)).toEqual({ + type: 'webp', + animated: false, + dimensions: { + width: 320, + height: 180, + }, + }); +}); + test('detects GIF dimensions', () => { const gif = new Uint8Array([ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x20, 0x03, 0x58, 0x02, diff --git a/packages/studio/src/components/import-assets.ts b/packages/studio/src/components/import-assets.ts index 66aba1d4284..75753195ab7 100644 --- a/packages/studio/src/components/import-assets.ts +++ b/packages/studio/src/components/import-assets.ts @@ -38,6 +38,17 @@ export const getAssetElement = ({ fileType: FileType; src: string; }): InsertableAssetElement | null => { + if (fileType.type === 'webp' && fileType.animated) { + return { + type: 'asset', + assetType: 'animated-image', + src, + srcType: 'static', + dimensions: fileType.dimensions, + position: null, + }; + } + if ( fileType.type === 'png' || fileType.type === 'jpeg' || @@ -435,7 +446,7 @@ const getStaticAssetFileType = async ( assetPath: string, ): Promise => { const extension = assetPath.split('.').pop()?.toLowerCase(); - if (extension !== 'png' && extension !== 'apng') { + if (extension !== 'png' && extension !== 'apng' && extension !== 'webp') { return null; } diff --git a/packages/studio/src/test/import-assets.test.ts b/packages/studio/src/test/import-assets.test.ts index 057a2fc2c48..6d5fbce59a5 100644 --- a/packages/studio/src/test/import-assets.test.ts +++ b/packages/studio/src/test/import-assets.test.ts @@ -53,6 +53,46 @@ test('maps animated PNG file types to AnimatedImage assets', () => { }); }); +test('maps animated WebP file types to AnimatedImage assets', () => { + expect( + getAssetElement({ + fileType: { + type: 'webp', + animated: true, + dimensions: {width: 480, height: 290}, + }, + src: 'animated.webp', + }), + ).toEqual({ + type: 'asset', + assetType: 'animated-image', + src: 'animated.webp', + srcType: 'static', + dimensions: {width: 480, height: 290}, + position: null, + }); +}); + +test('maps static WebP file types to Img assets', () => { + expect( + getAssetElement({ + fileType: { + type: 'webp', + animated: false, + dimensions: {width: 480, height: 290}, + }, + src: 'static.webp', + }), + ).toEqual({ + type: 'asset', + assetType: 'image', + src: 'static.webp', + srcType: 'static', + dimensions: {width: 480, height: 290}, + position: null, + }); +}); + test('maps existing static file paths to insertable assets', () => { expect(getAssetElementFromPath('nested/photo.JPG')).toEqual({ type: 'asset',