Bluesky app fork with some witchin' additions 💫

Remove JPG hardcoding from image processing systems (#9955)

authored by samuel.fm and committed by

GitHub c2fd87bd d247a656

+81 -52
+12 -1
bskyogcard/src/components/Img.tsx
··· 1 1 import React from 'react' 2 2 3 + function detectMime(buf: Buffer): string { 4 + if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg' 5 + if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png' 6 + if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp' 7 + if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif' 8 + return 'image/jpeg' 9 + } 10 + 3 11 export function Img( 4 12 props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer}, 5 13 ) { 6 14 const {src, ...others} = props 7 15 return ( 8 - <img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} /> 16 + <img 17 + {...others} 18 + src={`data:${detectMime(src)};base64,${src.toString('base64')}`} 19 + /> 9 20 ) 10 21 }
+2
jest/jestSetup.js
··· 34 34 jest.mock('expo-file-system/legacy', () => ({ 35 35 getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), 36 36 deleteAsync: jest.fn(), 37 + moveAsync: jest.fn().mockResolvedValue(undefined), 37 38 createDownloadResumable: jest.fn(), 38 39 })) 39 40 ··· 43 44 }), 44 45 SaveFormat: { 45 46 JPEG: 'jpeg', 47 + WEBP: 'webp', 46 48 }, 47 49 })) 48 50
+40 -31
src/lib/media/manip.ts
··· 8 8 EncodingType, 9 9 getInfoAsync, 10 10 makeDirectoryAsync, 11 + moveAsync, 11 12 StorageAccessFramework, 12 13 writeAsStringAsync, 13 14 } from 'expo-file-system/legacy' ··· 56 57 } 57 58 58 59 export async function downloadAndResize(opts: DownloadAndResizeOpts) { 59 - let appendExt = 'jpeg' 60 60 try { 61 - const urip = new URL(opts.uri) 62 - const ext = urip.pathname.split('.').pop() 63 - if (ext === 'png') { 64 - appendExt = 'png' 65 - } 61 + new URL(opts.uri) 66 62 } catch (e: any) { 67 63 console.error('Invalid URI', opts.uri, e) 68 64 return 69 65 } 70 66 71 - const path = createPath(appendExt) 67 + const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout) 72 68 73 69 try { 74 - await downloadImage(opts.uri, path, opts.timeout) 75 70 return await doResize(path, opts) 76 71 } finally { 77 - safeDeleteAsync(path) 72 + void safeDeleteAsync(path) 78 73 } 79 74 } 80 75 ··· 84 79 return 85 80 } 86 81 87 - // we're currently relying on the fact our CDN only serves jpegs 88 - // -prf 89 - const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 90 - const imagePath = await moveToPermanentPath(imageUri, '.jpg') 91 - safeDeleteAsync(imageUri) 82 + const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3) 83 + const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], { 84 + format: SaveFormat.JPEG, 85 + compress: 1.0, 86 + }) 87 + void safeDeleteAsync(downloadedPath) 88 + const imagePath = await moveToPermanentPath(jpegUri, '.jpg') 92 89 await Sharing.shareAsync(imagePath, { 93 90 mimeType: 'image/jpeg', 94 91 UTI: 'image/jpeg', ··· 98 95 const ALBUM_NAME = 'Bluesky' 99 96 100 97 export async function saveImageToMediaLibrary({uri}: {uri: string}) { 101 - // download the file to cache 102 - // NOTE 103 - // assuming JPEG 104 - // we're currently relying on the fact our CDN only serves jpegs 105 - // -prf 106 - const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 107 - const imagePath = await moveToPermanentPath(imageUri, '.jpg') 98 + const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3) 99 + const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], { 100 + format: SaveFormat.JPEG, 101 + compress: 1.0, 102 + }) 103 + void safeDeleteAsync(downloadedPath) 104 + const imagePath = await moveToPermanentPath(jpegUri, '.jpg') 108 105 109 106 // save 110 107 try { ··· 402 399 } 403 400 } 404 401 405 - function createPath(ext: string) { 406 - // cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. 407 - // we use a web-only function for downloadAndResize on web 408 - return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` 409 - } 410 - 411 - async function downloadImage(uri: string, path: string, timeout: number) { 412 - const dlResumable = createDownloadResumable(uri, path, {cache: true}) 402 + async function downloadImage(uri: string, destName: string, timeout: number) { 403 + // Download to a temp path first, then rename with the correct extension 404 + // based on the response's mimeType. 405 + const tempPath = `${cacheDirectory ?? ''}/${destName}.bin` 406 + const dlResumable = createDownloadResumable(uri, tempPath, {cache: true}) 413 407 let timedOut = false 414 408 const to1 = setTimeout(() => { 415 409 timedOut = true 416 - dlResumable.cancelAsync() 410 + void dlResumable.cancelAsync() 417 411 }, timeout) 418 412 419 413 const dlRes = await dlResumable.downloadAsync() ··· 427 421 } 428 422 } 429 423 430 - return normalizePath(dlRes.uri) 424 + const ext = extFromMime(dlRes.mimeType) 425 + const finalPath = `${cacheDirectory ?? ''}/${destName}.${ext}` 426 + await moveAsync({from: dlRes.uri, to: finalPath}) 427 + 428 + return normalizePath(finalPath) 429 + } 430 + 431 + const MIME_TO_EXT: Record<string, string> = { 432 + 'image/jpeg': 'jpg', 433 + 'image/webp': 'webp', 434 + 'image/png': 'png', 435 + 'image/gif': 'gif', 436 + } 437 + 438 + function extFromMime(mimeType?: string | null): string { 439 + return (mimeType && MIME_TO_EXT[mimeType]) || 'jpg' 431 440 }
+27 -20
src/screens/Onboarding/StepProfile/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 import {Image as ExpoImage} from 'expo-image' 4 + import {ImageManipulator, SaveFormat} from 'expo-image-manipulator' 4 5 import { 5 6 type ImagePickerOptions, 6 7 launchImageLibraryAsync, ··· 107 108 }), 108 109 ) 109 110 110 - return (response.assets ?? []) 111 - .slice(0, 1) 112 - .filter(asset => { 113 - if ( 114 - !asset.mimeType?.startsWith('image/') || 115 - (!asset.mimeType?.endsWith('jpeg') && 116 - !asset.mimeType?.endsWith('jpg') && 117 - !asset.mimeType?.endsWith('png')) 118 - ) { 119 - setError(_(msg`Only .jpg and .png files are supported`)) 120 - return false 121 - } 122 - return true 111 + const asset = (response.assets ?? [])[0] 112 + if (!asset) return [] 113 + 114 + try { 115 + const context = ImageManipulator.manipulate(asset.uri) 116 + const rendered = await context.renderAsync() 117 + const result = await rendered.saveAsync({ 118 + format: SaveFormat.JPEG, 119 + compress: 1.0, 123 120 }) 124 - .map(image => ({ 125 - mime: 'image/jpeg', 126 - height: image.height, 127 - width: image.width, 128 - path: image.uri, 129 - size: getDataUriSize(image.uri), 130 - })) 121 + return [ 122 + { 123 + mime: 'image/jpeg', 124 + height: rendered.height, 125 + width: rendered.width, 126 + path: result.uri, 127 + size: getDataUriSize(result.uri), 128 + }, 129 + ] 130 + } catch { 131 + setError( 132 + _( 133 + msg`This image could not be used. Try a different format like .jpg or .png.`, 134 + ), 135 + ) 136 + return [] 137 + } 131 138 }, 132 139 [_, setError, sheetWrapper], 133 140 )