Remove JPG hardcoding from image processing systems (#9955)
This commit is contained in:
@@ -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')}`}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user