fix dataurl roundtripping

This commit is contained in:
Samuel Newman
2026-03-12 11:16:41 +02:00
parent 997f0006a3
commit fa1a8f3d90
4 changed files with 78 additions and 111 deletions
+14 -35
View File
@@ -4,7 +4,7 @@ import {VIDEO_MAX_SIZE} from '#/lib/constants'
import {VideoTooLargeError} from '#/lib/media/video/errors'
import {type CompressedVideo} from './types'
// doesn't actually compress, converts to ArrayBuffer
// doesn't actually compress, just reads the File bytes
export async function compressVideo(
asset: ImagePickerAsset,
_opts?: {
@@ -12,45 +12,24 @@ export async function compressVideo(
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(asset.uri)
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
let bytes: ArrayBuffer
if (asset.file) {
bytes = await asset.file.arrayBuffer()
} else {
// fallback: fetch from blob/object URL
bytes = await fetch(asset.uri).then(res => res.arrayBuffer())
}
if (blob.size > VIDEO_MAX_SIZE) {
if (bytes.byteLength > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
}
const mimeType = asset.mimeType ?? asset.file?.type ?? 'video/mp4'
return {
size: blob.size,
uri,
bytes: await blob.arrayBuffer(),
size: bytes.byteLength,
uri: asset.uri,
bytes,
mimeType,
}
}
function parseDataUrl(dataUrl: string) {
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,')
if (!mimeType || !base64) {
throw new Error('Invalid data URL')
}
return {mimeType, base64}
}
function base64ToBlob(base64: string, mimeType: string) {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, {type: mimeType})
}
+6 -14
View File
@@ -465,7 +465,7 @@ export const ComposePost = ({
let asset: ImagePickerAsset
if (IS_WEB) {
// Web: Convert blob URL to a File, then get video metadata (returns data URL)
// Web: Convert blob URL to a File, then get video metadata
const response = await fetch(videoInfo.uri)
const blob = await response.blob()
const file = new File([blob], 'restored-video', {
@@ -1545,26 +1545,18 @@ let ComposerPost = memo(function ComposerPost({
)
const onPhotoPasted = useCallback(
async (uri: string) => {
if (
uri.startsWith('data:video/') ||
(IS_WEB && uri.startsWith('data:image/gif'))
) {
if (IS_NATIVE) return // web only
const [mimeType] = uri.slice('data:'.length).split(';')
async (uriOrFile: string | File) => {
if (uriOrFile instanceof File) {
const mimeType = uriOrFile.type
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
Toast.show(l`Unsupported video type: ${mimeType}`, {
type: 'error',
})
return
}
const name = `pasted.${mimeToExt(mimeType)}`
const file = await fetch(uri)
.then(res => res.blob())
.then(blob => new File([blob], name, {type: mimeType}))
onSelectVideo(post.id, await getVideoMetadata(file))
onSelectVideo(post.id, await getVideoMetadata(uriOrFile))
} else {
const res = await pasteImage(uri)
const res = await pasteImage(uriOrFile)
onImageAdd([res])
}
},
@@ -113,8 +113,8 @@ export function TextInput({
if (transfer) {
const items = transfer.items
getImageOrVideoFromUri(items, (uri: string) => {
textInputWebEmitter.emit('media-pasted', uri)
getImageOrVideoFromUri(items, (uriOrFile: string | File) => {
textInputWebEmitter.emit('media-pasted', uriOrFile)
})
}
@@ -190,9 +190,12 @@ export function TextInput({
view.pasteText(text)
preventDefault = true
}
getImageOrVideoFromUri(clipboardData.items, (uri: string) => {
textInputWebEmitter.emit('media-pasted', uri)
})
getImageOrVideoFromUri(
clipboardData.items,
(uriOrFile: string | File) => {
textInputWebEmitter.emit('media-pasted', uriOrFile)
},
)
if (preventDefault) {
// Return `true` to prevent ProseMirror's default paste behavior.
return true
@@ -478,7 +481,7 @@ const styles = StyleSheet.create({
function getImageOrVideoFromUri(
items: DataTransferItemList,
callback: (uri: string) => void,
callback: (uri: string | File) => void,
) {
for (let index = 0; index < items.length; index++) {
const item = items[index]
@@ -495,21 +498,21 @@ function getImageOrVideoFromUri(
}
if (blob.type.startsWith('video/')) {
blobToDataUri(blob).then(callback, err => console.error(err))
callback(new File([blob], 'dropped', {type: blob.type}))
}
}
})
} else if (type.startsWith('image/')) {
} else if (type.startsWith('image/') && !type.startsWith('image/gif')) {
const file = item.getAsFile()
if (file) {
blobToDataUri(file).then(callback, err => console.error(err))
}
} else if (type.startsWith('video/')) {
} else if (type.startsWith('video/') || type.startsWith('image/gif')) {
const file = item.getAsFile()
if (file) {
blobToDataUri(file).then(callback, err => console.error(err))
callback(file)
}
}
}
@@ -1,10 +1,5 @@
import {type ImagePickerAsset} from 'expo-image-picker'
// TODO: we're converting to a dataUrl here, and then converting back to an
// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it
// lets us use the ImagePickerAsset type, which the rest of the code expects.
// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
// instead of a string -sfn
export function getVideoMetadata(
file: File | string,
): Promise<ImagePickerAsset> {
@@ -12,55 +7,53 @@ export function getVideoMetadata(
throw new Error(
'getVideoMetadata was passed a uri, when on web it should be a File',
)
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const uri = reader.result as string
const blobUrl = URL.createObjectURL(file)
if (file.type === 'image/gif') {
const img = new Image()
img.onload = () => {
resolve({
uri,
mimeType: 'image/gif',
width: img.width,
height: img.height,
// todo: calculate gif duration. seems possible if you read the bytes
// https://codepen.io/Ryman/pen/nZpYwY
// for now let's just let the server reject it, since that seems uncommon -sfn
duration: null,
})
}
img.onerror = (_ev, _source, _lineno, _colno, error) => {
console.log('Failed to grab GIF metadata', error)
reject(new Error('Failed to grab GIF metadata'))
}
img.src = uri
} else {
const video = document.createElement('video')
const blobUrl = URL.createObjectURL(file)
video.preload = 'metadata'
video.src = blobUrl
video.onloadedmetadata = () => {
URL.revokeObjectURL(blobUrl)
resolve({
uri,
mimeType: file.type,
width: video.videoWidth,
height: video.videoHeight,
// convert seconds to ms
duration: video.duration * 1000,
})
}
video.onerror = (_ev, _source, _lineno, _colno, error) => {
URL.revokeObjectURL(blobUrl)
console.log('Failed to grab video metadata', error)
reject(new Error('Failed to grab video metadata'))
}
if (file.type === 'image/gif') {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
resolve({
uri: blobUrl,
file,
mimeType: 'image/gif',
width: img.width,
height: img.height,
// todo: calculate gif duration. seems possible if you read the bytes
// https://codepen.io/Ryman/pen/nZpYwY
// for now let's just let the server reject it, since that seems uncommon -sfn
duration: null,
})
}
img.onerror = (_ev, _source, _lineno, _colno, error) => {
URL.revokeObjectURL(blobUrl)
console.log('Failed to grab GIF metadata', error)
reject(new Error('Failed to grab GIF metadata'))
}
img.src = blobUrl
})
}
return new Promise((resolve, reject) => {
const video = document.createElement('video')
video.preload = 'metadata'
video.src = blobUrl
video.onloadedmetadata = () => {
resolve({
uri: blobUrl,
file,
mimeType: file.type,
width: video.videoWidth,
height: video.videoHeight,
// convert seconds to ms
duration: video.duration * 1000,
})
}
video.onerror = (_ev, _source, _lineno, _colno, error) => {
URL.revokeObjectURL(blobUrl)
console.log('Failed to grab video metadata', error)
reject(new Error('Failed to grab video metadata'))
}
reader.readAsDataURL(file)
})
}