fix dataurl roundtripping
This commit is contained in:
@@ -4,7 +4,7 @@ import {VIDEO_MAX_SIZE} from '#/lib/constants'
|
|||||||
import {VideoTooLargeError} from '#/lib/media/video/errors'
|
import {VideoTooLargeError} from '#/lib/media/video/errors'
|
||||||
import {type CompressedVideo} from './types'
|
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(
|
export async function compressVideo(
|
||||||
asset: ImagePickerAsset,
|
asset: ImagePickerAsset,
|
||||||
_opts?: {
|
_opts?: {
|
||||||
@@ -12,45 +12,24 @@ export async function compressVideo(
|
|||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {mimeType, base64} = parseDataUrl(asset.uri)
|
let bytes: ArrayBuffer
|
||||||
const blob = base64ToBlob(base64, mimeType)
|
if (asset.file) {
|
||||||
const uri = URL.createObjectURL(blob)
|
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()
|
throw new VideoTooLargeError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mimeType = asset.mimeType ?? asset.file?.type ?? 'video/mp4'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
size: blob.size,
|
size: bytes.byteLength,
|
||||||
uri,
|
uri: asset.uri,
|
||||||
bytes: await blob.arrayBuffer(),
|
bytes,
|
||||||
mimeType,
|
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})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ export const ComposePost = ({
|
|||||||
let asset: ImagePickerAsset
|
let asset: ImagePickerAsset
|
||||||
|
|
||||||
if (IS_WEB) {
|
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 response = await fetch(videoInfo.uri)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const file = new File([blob], 'restored-video', {
|
const file = new File([blob], 'restored-video', {
|
||||||
@@ -1545,26 +1545,18 @@ let ComposerPost = memo(function ComposerPost({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onPhotoPasted = useCallback(
|
const onPhotoPasted = useCallback(
|
||||||
async (uri: string) => {
|
async (uriOrFile: string | File) => {
|
||||||
if (
|
if (uriOrFile instanceof File) {
|
||||||
uri.startsWith('data:video/') ||
|
const mimeType = uriOrFile.type
|
||||||
(IS_WEB && uri.startsWith('data:image/gif'))
|
|
||||||
) {
|
|
||||||
if (IS_NATIVE) return // web only
|
|
||||||
const [mimeType] = uri.slice('data:'.length).split(';')
|
|
||||||
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
||||||
Toast.show(l`Unsupported video type: ${mimeType}`, {
|
Toast.show(l`Unsupported video type: ${mimeType}`, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const name = `pasted.${mimeToExt(mimeType)}`
|
onSelectVideo(post.id, await getVideoMetadata(uriOrFile))
|
||||||
const file = await fetch(uri)
|
|
||||||
.then(res => res.blob())
|
|
||||||
.then(blob => new File([blob], name, {type: mimeType}))
|
|
||||||
onSelectVideo(post.id, await getVideoMetadata(file))
|
|
||||||
} else {
|
} else {
|
||||||
const res = await pasteImage(uri)
|
const res = await pasteImage(uriOrFile)
|
||||||
onImageAdd([res])
|
onImageAdd([res])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ export function TextInput({
|
|||||||
if (transfer) {
|
if (transfer) {
|
||||||
const items = transfer.items
|
const items = transfer.items
|
||||||
|
|
||||||
getImageOrVideoFromUri(items, (uri: string) => {
|
getImageOrVideoFromUri(items, (uriOrFile: string | File) => {
|
||||||
textInputWebEmitter.emit('media-pasted', uri)
|
textInputWebEmitter.emit('media-pasted', uriOrFile)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,9 +190,12 @@ export function TextInput({
|
|||||||
view.pasteText(text)
|
view.pasteText(text)
|
||||||
preventDefault = true
|
preventDefault = true
|
||||||
}
|
}
|
||||||
getImageOrVideoFromUri(clipboardData.items, (uri: string) => {
|
getImageOrVideoFromUri(
|
||||||
textInputWebEmitter.emit('media-pasted', uri)
|
clipboardData.items,
|
||||||
})
|
(uriOrFile: string | File) => {
|
||||||
|
textInputWebEmitter.emit('media-pasted', uriOrFile)
|
||||||
|
},
|
||||||
|
)
|
||||||
if (preventDefault) {
|
if (preventDefault) {
|
||||||
// Return `true` to prevent ProseMirror's default paste behavior.
|
// Return `true` to prevent ProseMirror's default paste behavior.
|
||||||
return true
|
return true
|
||||||
@@ -478,7 +481,7 @@ const styles = StyleSheet.create({
|
|||||||
|
|
||||||
function getImageOrVideoFromUri(
|
function getImageOrVideoFromUri(
|
||||||
items: DataTransferItemList,
|
items: DataTransferItemList,
|
||||||
callback: (uri: string) => void,
|
callback: (uri: string | File) => void,
|
||||||
) {
|
) {
|
||||||
for (let index = 0; index < items.length; index++) {
|
for (let index = 0; index < items.length; index++) {
|
||||||
const item = items[index]
|
const item = items[index]
|
||||||
@@ -495,21 +498,21 @@ function getImageOrVideoFromUri(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (blob.type.startsWith('video/')) {
|
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()
|
const file = item.getAsFile()
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
blobToDataUri(file).then(callback, err => console.error(err))
|
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()
|
const file = item.getAsFile()
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
blobToDataUri(file).then(callback, err => console.error(err))
|
callback(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
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(
|
export function getVideoMetadata(
|
||||||
file: File | string,
|
file: File | string,
|
||||||
): Promise<ImagePickerAsset> {
|
): Promise<ImagePickerAsset> {
|
||||||
@@ -12,55 +7,53 @@ export function getVideoMetadata(
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
'getVideoMetadata was passed a uri, when on web it should be a File',
|
'getVideoMetadata was passed a uri, when on web it should be a File',
|
||||||
)
|
)
|
||||||
return new Promise((resolve, reject) => {
|
const blobUrl = URL.createObjectURL(file)
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
|
||||||
const uri = reader.result as string
|
|
||||||
|
|
||||||
if (file.type === 'image/gif') {
|
if (file.type === 'image/gif') {
|
||||||
const img = new Image()
|
return new Promise((resolve, reject) => {
|
||||||
img.onload = () => {
|
const img = new Image()
|
||||||
resolve({
|
img.onload = () => {
|
||||||
uri,
|
resolve({
|
||||||
mimeType: 'image/gif',
|
uri: blobUrl,
|
||||||
width: img.width,
|
file,
|
||||||
height: img.height,
|
mimeType: 'image/gif',
|
||||||
// todo: calculate gif duration. seems possible if you read the bytes
|
width: img.width,
|
||||||
// https://codepen.io/Ryman/pen/nZpYwY
|
height: img.height,
|
||||||
// for now let's just let the server reject it, since that seems uncommon -sfn
|
// todo: calculate gif duration. seems possible if you read the bytes
|
||||||
duration: null,
|
// 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'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user