diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index afe47c2793..60a480320a 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -12,7 +12,7 @@ const mockResizedImage = { size: 100, width: 100, height: 100, - mime: 'image/jpeg', + mime: 'image/webp', } describe('downloadAndResize', () => { @@ -63,7 +63,7 @@ describe('downloadAndResize', () => { expect(manipulateAsync).toHaveBeenCalledWith( expect.any(String), [{resize: {height: opts.height, width: opts.width}}], - {format: SaveFormat.JPEG, compress: 1.0}, + {format: SaveFormat.WEBP, compress: 1.0}, ) expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), { idempotent: true, diff --git a/bskyogcard/src/components/Img.tsx b/bskyogcard/src/components/Img.tsx index dac223180c..733e7e2262 100644 --- a/bskyogcard/src/components/Img.tsx +++ b/bskyogcard/src/components/Img.tsx @@ -1,10 +1,21 @@ import React from 'react' +function detectMime(buf: Buffer): string { + if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg' + if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png' + if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp' + if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif' + return 'image/jpeg' +} + export function Img( props: Omit, 'src'> & {src: Buffer}, ) { const {src, ...others} = props return ( - + ) } diff --git a/jest/jestSetup.js b/jest/jestSetup.js index c839d9e53f..6a79f511fa 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -43,6 +43,7 @@ jest.mock('expo-image-manipulator', () => ({ }), SaveFormat: { JPEG: 'jpeg', + WEBP: 'webp', }, })) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index aa48a3a52e..db192be037 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -8,6 +8,7 @@ import { EncodingType, getInfoAsync, makeDirectoryAsync, + moveAsync, StorageAccessFramework, writeAsStringAsync, } from 'expo-file-system/legacy' @@ -37,7 +38,7 @@ export async function compressIfNeeded( }) const finalImageMovedPath = await moveToPermanentPath( resizedImage.path, - '.jpg', + '.webp', ) const finalImg = { ...resizedImage, @@ -56,25 +57,19 @@ export interface DownloadAndResizeOpts { } export async function downloadAndResize(opts: DownloadAndResizeOpts) { - let appendExt = 'jpeg' try { - const urip = new URL(opts.uri) - const ext = urip.pathname.split('.').pop() - if (ext === 'png') { - appendExt = 'png' - } + new URL(opts.uri) } catch (e: any) { console.error('Invalid URI', opts.uri, e) return } - const path = createPath(appendExt) + const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout) try { - await downloadImage(opts.uri, path, opts.timeout) return await doResize(path, opts) } finally { - safeDeleteAsync(path) + void safeDeleteAsync(path) } } @@ -84,27 +79,17 @@ export async function shareImageModal({uri}: {uri: string}) { return } - // we're currently relying on the fact our CDN only serves jpegs - // -prf - const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) - const imagePath = await moveToPermanentPath(imageUri, '.jpg') - safeDeleteAsync(imageUri) + const imagePath = await downloadImage(uri, String(uuid.v4()), 15e3) await Sharing.shareAsync(imagePath, { - mimeType: 'image/jpeg', - UTI: 'image/jpeg', + mimeType: mimeFromExt(imagePath), + UTI: mimeFromExt(imagePath), }) } const ALBUM_NAME = 'Bluesky' export async function saveImageToMediaLibrary({uri}: {uri: string}) { - // download the file to cache - // NOTE - // assuming JPEG - // we're currently relying on the fact our CDN only serves jpegs - // -prf - const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) - const imagePath = await moveToPermanentPath(imageUri, '.jpg') + const imagePath = await downloadImage(uri, String(uuid.v4()), 15e3) // save try { @@ -219,7 +204,7 @@ async function doResize( localUri, [{resize: newDimensions}], { - format: SaveFormat.JPEG, + format: SaveFormat.WEBP, compress: qualityPercentage / 100, }, ) @@ -237,7 +222,7 @@ async function doResize( minQualityPercentage = qualityPercentage newDataUri = { path: normalizePath(resizeRes.uri), - mime: 'image/jpeg', + mime: 'image/webp', size: fileInfo.size, width: resizeRes.width, height: resizeRes.height, @@ -402,18 +387,15 @@ export function getResizedDimensions(originalDims: { } } -function createPath(ext: string) { - // cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. - // we use a web-only function for downloadAndResize on web - return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` -} - -async function downloadImage(uri: string, path: string, timeout: number) { - const dlResumable = createDownloadResumable(uri, path, {cache: true}) +async function downloadImage(uri: string, destName: string, timeout: number) { + // Download to a temp path first, then rename with the correct extension + // based on the response's mimeType. + const tempPath = `${cacheDirectory ?? ''}/${destName}.bin` + const dlResumable = createDownloadResumable(uri, tempPath, {cache: true}) let timedOut = false const to1 = setTimeout(() => { timedOut = true - dlResumable.cancelAsync() + void dlResumable.cancelAsync() }, timeout) const dlRes = await dlResumable.downloadAsync() @@ -427,5 +409,33 @@ async function downloadImage(uri: string, path: string, timeout: number) { } } - return normalizePath(dlRes.uri) + const ext = extFromMime(dlRes.mimeType) + const finalPath = `${cacheDirectory ?? ''}/${destName}.${ext}` + await moveAsync({from: dlRes.uri, to: finalPath}) + + return normalizePath(finalPath) +} + +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/png': 'png', + 'image/gif': 'gif', +} + +function extFromMime(mimeType?: string | null): string { + return (mimeType && MIME_TO_EXT[mimeType]) || 'jpg' +} + +const EXT_TO_MIME: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + png: 'image/png', + gif: 'image/gif', +} + +function mimeFromExt(path: string): string { + const ext = path.split('.').pop() + return (ext && EXT_TO_MIME[ext]) || 'image/jpeg' } diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 1f6d90ce34..9dec4683d6 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -103,7 +103,7 @@ async function doResize( } return { path: newDataUri, - mime: 'image/jpeg', + mime: 'image/webp', size: getDataUriSize(newDataUri), width: opts.width, height: opts.height, @@ -146,7 +146,7 @@ function createResizedImage( canvas.height = h ctx.drawImage(img, 0, 0, w, h) - resolve(canvas.toDataURL('image/jpeg', quality)) + resolve(canvas.toDataURL('image/webp', quality)) }) img.addEventListener('error', ev => { reject(ev.error) diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index f01217d2a5..4f8c3a8238 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -64,7 +64,8 @@ export async function openCamera(): Promise { export async function openCropper(opts: OpenCropperOptions) { const item = await ExpoImageCropTool.openCropperAsync({ ...opts, - format: 'jpeg', + // @ts-expect-error update @bsky.app/expo-image-crop-tool to pick up webp support + format: 'webp', }) return { diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index c9a52b8bad..b010674679 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -37,7 +37,8 @@ export async function openCropper(opts: OpenCropperOptions) { doneButtonText: t`Done`, cancelButtonText: t`Cancel`, ...opts, - format: 'jpeg', + // @ts-expect-error update @bsky.app/expo-image-crop-tool to pick up webp support + format: 'webp', }) return { diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx index acc0893350..3942a45f59 100644 --- a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx +++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx @@ -52,7 +52,7 @@ export const PlaceholderCanvas = React.forwardRef( ref={viewshotRef} options={{ fileName: 'placeholderAvatar', - format: 'jpg', + format: 'webp', quality: 0.8, height: 150 * SIZE_MULTIPLIER, width: 150 * SIZE_MULTIPLIER, diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index a342979ea1..85183f1b7b 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -114,15 +114,16 @@ export function StepProfile() { !asset.mimeType?.startsWith('image/') || (!asset.mimeType?.endsWith('jpeg') && !asset.mimeType?.endsWith('jpg') && - !asset.mimeType?.endsWith('png')) + !asset.mimeType?.endsWith('png') && + !asset.mimeType?.endsWith('webp')) ) { - setError(_(msg`Only .jpg and .png files are supported`)) + setError(_(msg`Only .jpg, .png, and .webp files are supported`)) return false } return true }) .map(image => ({ - mime: 'image/jpeg', + mime: image.mimeType || 'image/jpeg', height: image.height, width: image.width, path: image.uri, diff --git a/src/state/gallery.ts b/src/state/gallery.ts index a3e35ac26c..b5a6da91ad 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -219,7 +219,7 @@ export async function compressImage(img: ComposerImage): Promise { [{resize: {width: w, height: h}}], { compress: qualityPercentage / 100, - format: SaveFormat.JPEG, + format: SaveFormat.WEBP, base64: true, }, ) @@ -232,7 +232,7 @@ export async function compressImage(img: ComposerImage): Promise { path: await moveIfNecessary(res.uri), width: res.width, height: res.height, - mime: 'image/jpeg', + mime: 'image/webp', size, } } else {