un-hardcoded jpg, switch to webp in a few cases

This commit is contained in:
Samuel Newman
2026-02-25 21:47:52 +02:00
parent db9c2a1596
commit 90f1b46e3f
10 changed files with 74 additions and 49 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ const mockResizedImage = {
size: 100, size: 100,
width: 100, width: 100,
height: 100, height: 100,
mime: 'image/jpeg', mime: 'image/webp',
} }
describe('downloadAndResize', () => { describe('downloadAndResize', () => {
@@ -63,7 +63,7 @@ describe('downloadAndResize', () => {
expect(manipulateAsync).toHaveBeenCalledWith( expect(manipulateAsync).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
[{resize: {height: opts.height, width: opts.width}}], [{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), { expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
idempotent: true, idempotent: true,
+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')}`}
/>
) )
} }
+1
View File
@@ -43,6 +43,7 @@ jest.mock('expo-image-manipulator', () => ({
}), }),
SaveFormat: { SaveFormat: {
JPEG: 'jpeg', JPEG: 'jpeg',
WEBP: 'webp',
}, },
})) }))
+46 -36
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'
@@ -37,7 +38,7 @@ export async function compressIfNeeded(
}) })
const finalImageMovedPath = await moveToPermanentPath( const finalImageMovedPath = await moveToPermanentPath(
resizedImage.path, resizedImage.path,
'.jpg', '.webp',
) )
const finalImg = { const finalImg = {
...resizedImage, ...resizedImage,
@@ -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,27 +79,17 @@ export async function shareImageModal({uri}: {uri: string}) {
return return
} }
// we're currently relying on the fact our CDN only serves jpegs const imagePath = await downloadImage(uri, String(uuid.v4()), 15e3)
// -prf
const imageUri = await downloadImage(uri, createPath('jpg'), 15e3)
const imagePath = await moveToPermanentPath(imageUri, '.jpg')
safeDeleteAsync(imageUri)
await Sharing.shareAsync(imagePath, { await Sharing.shareAsync(imagePath, {
mimeType: 'image/jpeg', mimeType: mimeFromExt(imagePath),
UTI: 'image/jpeg', UTI: mimeFromExt(imagePath),
}) })
} }
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 imagePath = await downloadImage(uri, String(uuid.v4()), 15e3)
// 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')
// save // save
try { try {
@@ -219,7 +204,7 @@ async function doResize(
localUri, localUri,
[{resize: newDimensions}], [{resize: newDimensions}],
{ {
format: SaveFormat.JPEG, format: SaveFormat.WEBP,
compress: qualityPercentage / 100, compress: qualityPercentage / 100,
}, },
) )
@@ -237,7 +222,7 @@ async function doResize(
minQualityPercentage = qualityPercentage minQualityPercentage = qualityPercentage
newDataUri = { newDataUri = {
path: normalizePath(resizeRes.uri), path: normalizePath(resizeRes.uri),
mime: 'image/jpeg', mime: 'image/webp',
size: fileInfo.size, size: fileInfo.size,
width: resizeRes.width, width: resizeRes.width,
height: resizeRes.height, height: resizeRes.height,
@@ -402,18 +387,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 +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<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'
}
const EXT_TO_MIME: Record<string, string> = {
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'
} }
+2 -2
View File
@@ -103,7 +103,7 @@ async function doResize(
} }
return { return {
path: newDataUri, path: newDataUri,
mime: 'image/jpeg', mime: 'image/webp',
size: getDataUriSize(newDataUri), size: getDataUriSize(newDataUri),
width: opts.width, width: opts.width,
height: opts.height, height: opts.height,
@@ -146,7 +146,7 @@ function createResizedImage(
canvas.height = h canvas.height = h
ctx.drawImage(img, 0, 0, w, h) ctx.drawImage(img, 0, 0, w, h)
resolve(canvas.toDataURL('image/jpeg', quality)) resolve(canvas.toDataURL('image/webp', quality))
}) })
img.addEventListener('error', ev => { img.addEventListener('error', ev => {
reject(ev.error) reject(ev.error)
+2 -1
View File
@@ -64,7 +64,8 @@ export async function openCamera(): Promise<PickerImage> {
export async function openCropper(opts: OpenCropperOptions) { export async function openCropper(opts: OpenCropperOptions) {
const item = await ExpoImageCropTool.openCropperAsync({ const item = await ExpoImageCropTool.openCropperAsync({
...opts, ...opts,
format: 'jpeg', // @ts-expect-error update @bsky.app/expo-image-crop-tool to pick up webp support
format: 'webp',
}) })
return { return {
+2 -1
View File
@@ -37,7 +37,8 @@ export async function openCropper(opts: OpenCropperOptions) {
doneButtonText: t`Done`, doneButtonText: t`Done`,
cancelButtonText: t`Cancel`, cancelButtonText: t`Cancel`,
...opts, ...opts,
format: 'jpeg', // @ts-expect-error update @bsky.app/expo-image-crop-tool to pick up webp support
format: 'webp',
}) })
return { return {
@@ -52,7 +52,7 @@ export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
ref={viewshotRef} ref={viewshotRef}
options={{ options={{
fileName: 'placeholderAvatar', fileName: 'placeholderAvatar',
format: 'jpg', format: 'webp',
quality: 0.8, quality: 0.8,
height: 150 * SIZE_MULTIPLIER, height: 150 * SIZE_MULTIPLIER,
width: 150 * SIZE_MULTIPLIER, width: 150 * SIZE_MULTIPLIER,
+4 -3
View File
@@ -114,15 +114,16 @@ export function StepProfile() {
!asset.mimeType?.startsWith('image/') || !asset.mimeType?.startsWith('image/') ||
(!asset.mimeType?.endsWith('jpeg') && (!asset.mimeType?.endsWith('jpeg') &&
!asset.mimeType?.endsWith('jpg') && !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 false
} }
return true return true
}) })
.map(image => ({ .map(image => ({
mime: 'image/jpeg', mime: image.mimeType || 'image/jpeg',
height: image.height, height: image.height,
width: image.width, width: image.width,
path: image.uri, path: image.uri,
+2 -2
View File
@@ -219,7 +219,7 @@ export async function compressImage(img: ComposerImage): Promise<PickerImage> {
[{resize: {width: w, height: h}}], [{resize: {width: w, height: h}}],
{ {
compress: qualityPercentage / 100, compress: qualityPercentage / 100,
format: SaveFormat.JPEG, format: SaveFormat.WEBP,
base64: true, base64: true,
}, },
) )
@@ -232,7 +232,7 @@ export async function compressImage(img: ComposerImage): Promise<PickerImage> {
path: await moveIfNecessary(res.uri), path: await moveIfNecessary(res.uri),
width: res.width, width: res.width,
height: res.height, height: res.height,
mime: 'image/jpeg', mime: 'image/webp',
size, size,
} }
} else { } else {