From c2fd87bd8b02c71dea47264e7c3057db1542bfdc Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 2 Mar 2026 20:23:07 +0000 Subject: [PATCH] Remove JPG hardcoding from image processing systems (#9955) --- bskyogcard/src/components/Img.tsx | 13 +++- jest/jestSetup.js | 2 + src/lib/media/manip.ts | 71 +++++++++++--------- src/screens/Onboarding/StepProfile/index.tsx | 47 +++++++------ 4 files changed, 81 insertions(+), 52 deletions(-) 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..f9bc36f6bf 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -34,6 +34,7 @@ jest.mock('react-native-safe-area-context', () => { jest.mock('expo-file-system/legacy', () => ({ getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), deleteAsync: jest.fn(), + moveAsync: jest.fn().mockResolvedValue(undefined), createDownloadResumable: jest.fn(), })) @@ -43,6 +44,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..0cc92f446d 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' @@ -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,11 +79,13 @@ 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 downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3) + const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], { + format: SaveFormat.JPEG, + compress: 1.0, + }) + void safeDeleteAsync(downloadedPath) + const imagePath = await moveToPermanentPath(jpegUri, '.jpg') await Sharing.shareAsync(imagePath, { mimeType: 'image/jpeg', UTI: 'image/jpeg', @@ -98,13 +95,13 @@ export async function shareImageModal({uri}: {uri: string}) { 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 downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3) + const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], { + format: SaveFormat.JPEG, + compress: 1.0, + }) + void safeDeleteAsync(downloadedPath) + const imagePath = await moveToPermanentPath(jpegUri, '.jpg') // save try { @@ -402,18 +399,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 +421,20 @@ 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' } diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index a342979ea1..8dc1747cc4 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -1,6 +1,7 @@ import React from 'react' import {View} from 'react-native' import {Image as ExpoImage} from 'expo-image' +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator' import { type ImagePickerOptions, launchImageLibraryAsync, @@ -107,27 +108,33 @@ export function StepProfile() { }), ) - return (response.assets ?? []) - .slice(0, 1) - .filter(asset => { - if ( - !asset.mimeType?.startsWith('image/') || - (!asset.mimeType?.endsWith('jpeg') && - !asset.mimeType?.endsWith('jpg') && - !asset.mimeType?.endsWith('png')) - ) { - setError(_(msg`Only .jpg and .png files are supported`)) - return false - } - return true + const asset = (response.assets ?? [])[0] + if (!asset) return [] + + try { + const context = ImageManipulator.manipulate(asset.uri) + const rendered = await context.renderAsync() + const result = await rendered.saveAsync({ + format: SaveFormat.JPEG, + compress: 1.0, }) - .map(image => ({ - mime: 'image/jpeg', - height: image.height, - width: image.width, - path: image.uri, - size: getDataUriSize(image.uri), - })) + return [ + { + mime: 'image/jpeg', + height: rendered.height, + width: rendered.width, + path: result.uri, + size: getDataUriSize(result.uri), + }, + ] + } catch { + setError( + _( + msg`This image could not be used. Try a different format like .jpg or .png.`, + ), + ) + return [] + } }, [_, setError, sheetWrapper], )