Remove JPG hardcoding from image processing systems (#9955)

This commit is contained in:
Samuel Newman
2026-03-02 20:23:07 +00:00
committed by GitHub
parent d247a65683
commit c2fd87bd8b
4 changed files with 81 additions and 52 deletions
+12 -1
View File
@@ -1,10 +1,21 @@
import React from 'react' 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( export function Img(
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer}, props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) { ) {
const {src, ...others} = props const {src, ...others} = props
return ( return (
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} /> <img
{...others}
src={`data:${detectMime(src)};base64,${src.toString('base64')}`}
/>
) )
} }
+2
View File
@@ -34,6 +34,7 @@ jest.mock('react-native-safe-area-context', () => {
jest.mock('expo-file-system/legacy', () => ({ jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
deleteAsync: jest.fn(), deleteAsync: jest.fn(),
moveAsync: jest.fn().mockResolvedValue(undefined),
createDownloadResumable: jest.fn(), createDownloadResumable: jest.fn(),
})) }))
@@ -43,6 +44,7 @@ jest.mock('expo-image-manipulator', () => ({
}), }),
SaveFormat: { SaveFormat: {
JPEG: 'jpeg', JPEG: 'jpeg',
WEBP: 'webp',
}, },
})) }))
+40 -31
View File
@@ -8,6 +8,7 @@ import {
EncodingType, EncodingType,
getInfoAsync, getInfoAsync,
makeDirectoryAsync, makeDirectoryAsync,
moveAsync,
StorageAccessFramework, StorageAccessFramework,
writeAsStringAsync, writeAsStringAsync,
} from 'expo-file-system/legacy' } from 'expo-file-system/legacy'
@@ -56,25 +57,19 @@ export interface DownloadAndResizeOpts {
} }
export async function downloadAndResize(opts: DownloadAndResizeOpts) { export async function downloadAndResize(opts: DownloadAndResizeOpts) {
let appendExt = 'jpeg'
try { try {
const urip = new URL(opts.uri) new URL(opts.uri)
const ext = urip.pathname.split('.').pop()
if (ext === 'png') {
appendExt = 'png'
}
} catch (e: any) { } catch (e: any) {
console.error('Invalid URI', opts.uri, e) console.error('Invalid URI', opts.uri, e)
return return
} }
const path = createPath(appendExt) const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout)
try { try {
await downloadImage(opts.uri, path, opts.timeout)
return await doResize(path, opts) return await doResize(path, opts)
} finally { } finally {
safeDeleteAsync(path) void safeDeleteAsync(path)
} }
} }
@@ -84,11 +79,13 @@ export async function shareImageModal({uri}: {uri: string}) {
return return
} }
// we're currently relying on the fact our CDN only serves jpegs const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3)
// -prf const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], {
const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) format: SaveFormat.JPEG,
const imagePath = await moveToPermanentPath(imageUri, '.jpg') compress: 1.0,
safeDeleteAsync(imageUri) })
void safeDeleteAsync(downloadedPath)
const imagePath = await moveToPermanentPath(jpegUri, '.jpg')
await Sharing.shareAsync(imagePath, { await Sharing.shareAsync(imagePath, {
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
UTI: 'image/jpeg', UTI: 'image/jpeg',
@@ -98,13 +95,13 @@ export async function shareImageModal({uri}: {uri: string}) {
const ALBUM_NAME = 'Bluesky' const ALBUM_NAME = 'Bluesky'
export async function saveImageToMediaLibrary({uri}: {uri: string}) { export async function saveImageToMediaLibrary({uri}: {uri: string}) {
// download the file to cache const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3)
// NOTE const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], {
// assuming JPEG format: SaveFormat.JPEG,
// we're currently relying on the fact our CDN only serves jpegs compress: 1.0,
// -prf })
const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) void safeDeleteAsync(downloadedPath)
const imagePath = await moveToPermanentPath(imageUri, '.jpg') const imagePath = await moveToPermanentPath(jpegUri, '.jpg')
// save // save
try { try {
@@ -402,18 +399,15 @@ export function getResizedDimensions(originalDims: {
} }
} }
function createPath(ext: string) { async function downloadImage(uri: string, destName: string, timeout: number) {
// cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. // Download to a temp path first, then rename with the correct extension
// we use a web-only function for downloadAndResize on web // based on the response's mimeType.
return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` const tempPath = `${cacheDirectory ?? ''}/${destName}.bin`
} const dlResumable = createDownloadResumable(uri, tempPath, {cache: true})
async function downloadImage(uri: string, path: string, timeout: number) {
const dlResumable = createDownloadResumable(uri, path, {cache: true})
let timedOut = false let timedOut = false
const to1 = setTimeout(() => { const to1 = setTimeout(() => {
timedOut = true timedOut = true
dlResumable.cancelAsync() void dlResumable.cancelAsync()
}, timeout) }, timeout)
const dlRes = await dlResumable.downloadAsync() 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<string, string> = {
'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'
} }
+27 -20
View File
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image as ExpoImage} from 'expo-image' import {Image as ExpoImage} from 'expo-image'
import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'
import { import {
type ImagePickerOptions, type ImagePickerOptions,
launchImageLibraryAsync, launchImageLibraryAsync,
@@ -107,27 +108,33 @@ export function StepProfile() {
}), }),
) )
return (response.assets ?? []) const asset = (response.assets ?? [])[0]
.slice(0, 1) if (!asset) return []
.filter(asset => {
if ( try {
!asset.mimeType?.startsWith('image/') || const context = ImageManipulator.manipulate(asset.uri)
(!asset.mimeType?.endsWith('jpeg') && const rendered = await context.renderAsync()
!asset.mimeType?.endsWith('jpg') && const result = await rendered.saveAsync({
!asset.mimeType?.endsWith('png')) format: SaveFormat.JPEG,
) { compress: 1.0,
setError(_(msg`Only .jpg and .png files are supported`))
return false
}
return true
}) })
.map(image => ({ return [
mime: 'image/jpeg', {
height: image.height, mime: 'image/jpeg',
width: image.width, height: rendered.height,
path: image.uri, width: rendered.width,
size: getDataUriSize(image.uri), 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], [_, setError, sheetWrapper],
) )