From fa1a8f3d901cc39b0bda90bf562c5338e58c2db1 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 12 Mar 2026 11:16:41 +0200 Subject: [PATCH] fix dataurl roundtripping --- src/lib/media/video/compress.web.ts | 49 +++------- src/view/com/composer/Composer.tsx | 20 ++-- .../com/composer/text-input/TextInput.web.tsx | 23 +++-- .../com/composer/videos/videoMetadata.web.ts | 97 +++++++++---------- 4 files changed, 78 insertions(+), 111 deletions(-) diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts index 83fdfde533..81144520aa 100644 --- a/src/lib/media/video/compress.web.ts +++ b/src/lib/media/video/compress.web.ts @@ -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 { - 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}) -} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index a8cf4a5b2f..581ca31e7a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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]) } }, diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index bb314f116c..696b7e8af7 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -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) } } } diff --git a/src/view/com/composer/videos/videoMetadata.web.ts b/src/view/com/composer/videos/videoMetadata.web.ts index dc6b22d593..9584ec609d 100644 --- a/src/view/com/composer/videos/videoMetadata.web.ts +++ b/src/view/com/composer/videos/videoMetadata.web.ts @@ -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 { @@ -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) }) }