Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0081b37900 | |||
| 0a7209c623 |
@@ -173,6 +173,7 @@
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mediabunny": "^1.25.3",
|
||||
"multiformats": "9.9.0",
|
||||
"nanoid": "^5.0.5",
|
||||
"normalize-url": "^8.0.0",
|
||||
|
||||
@@ -1,56 +1,316 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
type AudioCodec,
|
||||
BlobSource,
|
||||
BufferTarget,
|
||||
canEncodeAudio,
|
||||
canEncodeVideo,
|
||||
Conversion,
|
||||
Input,
|
||||
Mp4OutputFormat,
|
||||
Output,
|
||||
type VideoCodec,
|
||||
WebMOutputFormat,
|
||||
} from 'mediabunny'
|
||||
|
||||
import {VIDEO_MAX_SIZE} from '#/lib/constants'
|
||||
import {VideoTooLargeError} from '#/lib/media/video/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {hasWebCodecs} from '#/view/com/composer/videos/metadata'
|
||||
import {type CompressedVideo} from './types'
|
||||
|
||||
// doesn't actually compress, converts to ArrayBuffer
|
||||
const TARGET_BITRATE = 3_000_000 // 3mbps, matches native
|
||||
const MAX_DIMENSION = 1920 // matches native
|
||||
const MIN_SIZE_FOR_COMPRESSION = 25 * 1000 * 1000 // 25mb, matches native
|
||||
|
||||
// Codecs to try in order of preference
|
||||
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
|
||||
const VIDEO_CODECS: VideoCodec[] = ['avc', 'hevc', 'vp9', 'vp8']
|
||||
|
||||
export async function compressVideo(
|
||||
asset: ImagePickerAsset,
|
||||
_opts?: {
|
||||
opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {mimeType, base64} = parseDataUrl(asset.uri)
|
||||
const blob = base64ToBlob(base64, mimeType)
|
||||
const uri = URL.createObjectURL(blob)
|
||||
const {onProgress, signal} = opts || {}
|
||||
|
||||
logger.debug('compress: starting', {
|
||||
uri: asset.uri.slice(0, 50),
|
||||
hasWebCodecs: hasWebCodecs(),
|
||||
})
|
||||
|
||||
const response = await fetch(asset.uri)
|
||||
const blob = await response.blob()
|
||||
|
||||
const isGif = blob.type === 'image/gif'
|
||||
|
||||
logger.debug('compress: fetched blob', {
|
||||
size: blob.size,
|
||||
mimeType: blob.type,
|
||||
isGif,
|
||||
minSizeForCompression: MIN_SIZE_FOR_COMPRESSION,
|
||||
})
|
||||
|
||||
// Try MediaBunny compression if WebCodecs is available and file is large enough
|
||||
// Skip GIFs - MediaBunny doesn't support them
|
||||
if (hasWebCodecs() && blob.size >= MIN_SIZE_FOR_COMPRESSION && !isGif) {
|
||||
try {
|
||||
return await doCompression(blob, asset.uri, {onProgress, signal})
|
||||
} catch (e) {
|
||||
logger.warn('compress: MediaBunny compression failed, using original', {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.debug('compress: skipping compression', {
|
||||
hasWebCodecs: hasWebCodecs(),
|
||||
blobSize: blob.size,
|
||||
minSize: MIN_SIZE_FOR_COMPRESSION,
|
||||
})
|
||||
}
|
||||
|
||||
// No compression path - just return the blob as-is
|
||||
if (blob.size > VIDEO_MAX_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
return {
|
||||
uri: asset.uri,
|
||||
size: blob.size,
|
||||
uri,
|
||||
bytes: await blob.arrayBuffer(),
|
||||
mimeType: blob.type || 'video/mp4',
|
||||
}
|
||||
}
|
||||
|
||||
async function findEncodableVideoCodec(
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<{codec: VideoCodec; useWebM: boolean} | null> {
|
||||
for (const codec of VIDEO_CODECS) {
|
||||
const canEncode = await canEncodeVideo(codec, {
|
||||
width,
|
||||
height,
|
||||
bitrate: TARGET_BITRATE,
|
||||
})
|
||||
logger.debug('compress: checking video codec', {
|
||||
codec,
|
||||
canEncode,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
if (canEncode) {
|
||||
// vp8/vp9 need WebM container, others use MP4
|
||||
const useWebM = codec === 'vp8' || codec === 'vp9'
|
||||
return {codec, useWebM}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Audio codecs to try - aac for MP4, opus for WebM
|
||||
const AUDIO_CODECS_MP4: AudioCodec[] = ['aac']
|
||||
const AUDIO_CODECS_WEBM: AudioCodec[] = ['opus', 'vorbis']
|
||||
|
||||
async function findEncodableAudioCodec(
|
||||
audioTrack: Awaited<ReturnType<Input['getPrimaryAudioTrack']>>,
|
||||
useWebM: boolean,
|
||||
): Promise<{codec: AudioCodec} | null> {
|
||||
if (!audioTrack) {
|
||||
return null
|
||||
}
|
||||
|
||||
// First check if we can decode the source audio
|
||||
const canDecodeSource = await audioTrack.canDecode()
|
||||
logger.debug('compress: checking audio source', {
|
||||
sourceCodec: audioTrack.codec,
|
||||
canDecode: canDecodeSource,
|
||||
channels: audioTrack.numberOfChannels,
|
||||
sampleRate: audioTrack.sampleRate,
|
||||
})
|
||||
|
||||
if (!canDecodeSource) {
|
||||
return null
|
||||
}
|
||||
|
||||
const codecsToTry = useWebM ? AUDIO_CODECS_WEBM : AUDIO_CODECS_MP4
|
||||
|
||||
for (const codec of codecsToTry) {
|
||||
const canEncode = await canEncodeAudio(codec, {
|
||||
numberOfChannels: audioTrack.numberOfChannels,
|
||||
sampleRate: audioTrack.sampleRate,
|
||||
})
|
||||
logger.debug('compress: checking audio encode codec', {
|
||||
codec,
|
||||
canEncode,
|
||||
})
|
||||
if (canEncode) {
|
||||
return {codec}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function doCompression(
|
||||
blob: Blob,
|
||||
originalUri: string,
|
||||
opts: {
|
||||
onProgress?: (progress: number) => void
|
||||
signal?: AbortSignal
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, signal} = opts
|
||||
|
||||
const input = new Input({
|
||||
source: new BlobSource(blob),
|
||||
formats: ALL_FORMATS,
|
||||
})
|
||||
|
||||
// Get video track to determine dimensions for codec check
|
||||
const videoTrack = await input.getPrimaryVideoTrack()
|
||||
if (!videoTrack) {
|
||||
input.dispose()
|
||||
throw new Error('No video track found')
|
||||
}
|
||||
|
||||
// Get audio track to check if we can encode it
|
||||
const audioTrack = await input.getPrimaryAudioTrack()
|
||||
|
||||
const {width, height} = calculateDimensions(
|
||||
videoTrack.displayWidth,
|
||||
videoTrack.displayHeight,
|
||||
MAX_DIMENSION,
|
||||
)
|
||||
|
||||
logger.debug('compress: video dimensions', {
|
||||
original: {
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
},
|
||||
target: {width, height},
|
||||
audioCodec: audioTrack?.codec,
|
||||
})
|
||||
|
||||
// Find a video codec we can encode with
|
||||
const codecInfo = await findEncodableVideoCodec(width, height)
|
||||
if (!codecInfo) {
|
||||
input.dispose()
|
||||
throw new Error('No supported video codec available')
|
||||
}
|
||||
|
||||
// Check if we can encode the audio
|
||||
const audioCodecInfo = await findEncodableAudioCodec(
|
||||
audioTrack,
|
||||
codecInfo.useWebM,
|
||||
)
|
||||
|
||||
logger.debug('compress: using codecs', {
|
||||
video: codecInfo.codec,
|
||||
audio: audioCodecInfo?.codec ?? 'none',
|
||||
useWebM: codecInfo.useWebM,
|
||||
})
|
||||
|
||||
const target = new BufferTarget()
|
||||
const output = new Output({
|
||||
format: codecInfo.useWebM ? new WebMOutputFormat() : new Mp4OutputFormat(),
|
||||
target,
|
||||
})
|
||||
|
||||
// If we have audio but can't encode it, bail out and use the original
|
||||
if (audioTrack && !audioCodecInfo) {
|
||||
input.dispose()
|
||||
throw new Error(
|
||||
`Cannot encode audio codec: ${audioTrack.codec ?? 'unknown'}`,
|
||||
)
|
||||
}
|
||||
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
video: {
|
||||
codec: codecInfo.codec,
|
||||
bitrate: TARGET_BITRATE,
|
||||
width,
|
||||
height,
|
||||
fit: 'contain',
|
||||
},
|
||||
audio: audioCodecInfo ? {codec: audioCodecInfo.codec} : undefined,
|
||||
})
|
||||
|
||||
if (onProgress) {
|
||||
conversion.onProgress = onProgress
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
logger.debug('compress: cancelled')
|
||||
conversion.cancel()
|
||||
},
|
||||
{once: true},
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug('compress: starting conversion')
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
await conversion.execute()
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime
|
||||
const bytes = target.buffer
|
||||
|
||||
if (!bytes) {
|
||||
throw new Error('MediaBunny compression produced no output')
|
||||
}
|
||||
|
||||
const mimeType = codecInfo.useWebM ? 'video/webm' : 'video/mp4'
|
||||
|
||||
const savedBytes = blob.size - bytes.byteLength
|
||||
const savedPercent = ((savedBytes / blob.size) * 100).toFixed(1)
|
||||
|
||||
logger.debug('compress: completed', {
|
||||
from: blob.type,
|
||||
to: mimeType,
|
||||
originalSize: blob.size,
|
||||
compressedSize: bytes.byteLength,
|
||||
savedBytes,
|
||||
savedPercent: `${savedPercent}%`,
|
||||
elapsedMs: Math.round(elapsed),
|
||||
})
|
||||
|
||||
if (bytes.byteLength > VIDEO_MAX_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
return {
|
||||
uri: originalUri,
|
||||
size: bytes.byteLength,
|
||||
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)
|
||||
function calculateDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
maxDimension: number,
|
||||
): {width: number; height: number} {
|
||||
const maxSide = Math.max(width, height)
|
||||
if (maxSide <= maxDimension) {
|
||||
return {width, height}
|
||||
}
|
||||
|
||||
return new Blob(byteArrays, {type: mimeType})
|
||||
const scale = maxDimension / maxSide
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ import {
|
||||
type VideoState,
|
||||
} from './state/video'
|
||||
import {type TextInputRef} from './text-input/TextInput.types'
|
||||
import {getVideoMetadata} from './videos/pickVideo'
|
||||
import {getVideoMetadata} from './videos/metadata'
|
||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||
|
||||
type CancelRef = {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {Button} from '#/components/Button'
|
||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||
import * as toast from '#/components/Toast'
|
||||
import {hasWebCodecs} from './videos/metadata'
|
||||
|
||||
export type SelectMediaButtonProps = {
|
||||
disabled?: boolean
|
||||
@@ -265,9 +266,15 @@ async function processImagePickerAssets(
|
||||
/*
|
||||
* Filesize appears to be stable across all platforms, so we can use it
|
||||
* to filter out large files on web. On native, we compress these anyway,
|
||||
* so we only check on web.
|
||||
* so we only check on web. On web, we can reject early if the browser
|
||||
* doesn't support WebCodecs.
|
||||
*/
|
||||
if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||
if (
|
||||
isWeb &&
|
||||
!hasWebCodecs() &&
|
||||
asset.fileSize &&
|
||||
asset.fileSize > VIDEO_MAX_SIZE
|
||||
) {
|
||||
errors.add(SelectedAssetError.FileTooBig)
|
||||
continue
|
||||
}
|
||||
@@ -283,10 +290,9 @@ async function processImagePickerAssets(
|
||||
if (type === 'gif') {
|
||||
/*
|
||||
* Filesize appears to be stable across all platforms, so we can use it
|
||||
* to filter out large files on web. On native, we compress GIFs as
|
||||
* videos anyway, so we only check on web.
|
||||
* to filter out large files. We can't compress GIFs on either platform.
|
||||
*/
|
||||
if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||
if (asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||
errors.add(SelectedAssetError.FileTooBig)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,19 +7,18 @@ import {type CompressedVideo} from '#/lib/media/video/types'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
export function VideoPreview({
|
||||
asset,
|
||||
video,
|
||||
|
||||
clear,
|
||||
}: {
|
||||
asset: ImagePickerAsset
|
||||
video: CompressedVideo
|
||||
|
||||
isActivePost?: boolean // unused on web
|
||||
clear: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
@@ -63,7 +62,7 @@ export function VideoPreview({
|
||||
playsInline
|
||||
onError={err => {
|
||||
console.error('Error loading video', err)
|
||||
Toast.show(_(msg`Could not process your video`), 'xmark')
|
||||
Toast.show(_(msg`Could not process your video`), {type: 'error'})
|
||||
clear()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
|
||||
export function clearThumbnailCache() {
|
||||
// no-op
|
||||
// no-op on web
|
||||
}
|
||||
|
||||
export function VideoTranscodeBackdrop() {
|
||||
return null
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
return (
|
||||
<video
|
||||
src={uri}
|
||||
style={flatten([
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.h_full,
|
||||
a.w_full,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
filter: 'blur(15px)',
|
||||
transform: 'scale(1.1)', // hide blur edges
|
||||
},
|
||||
])}
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import ProgressPie from 'react-native-progress/Pie'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
@@ -20,8 +19,6 @@ export function VideoTranscodeProgress({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (isWeb) return null
|
||||
|
||||
let aspectRatio = asset.width / asset.height
|
||||
|
||||
if (isNaN(aspectRatio)) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => {
|
||||
throw new Error('getVideoMetadata is web only')
|
||||
}
|
||||
|
||||
export function hasWebCodecs(): boolean {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {ALL_FORMATS, BlobSource, Input} from 'mediabunny'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export function hasWebCodecs(): boolean {
|
||||
return (
|
||||
typeof VideoEncoder !== 'undefined' && typeof VideoDecoder !== 'undefined'
|
||||
)
|
||||
}
|
||||
|
||||
export async function getVideoMetadata(file: File): Promise<ImagePickerAsset> {
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
|
||||
logger.debug('metadata: starting', {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
hasWebCodecs: hasWebCodecs(),
|
||||
})
|
||||
|
||||
if (hasWebCodecs()) {
|
||||
try {
|
||||
const result = await getMetadataWithWebCodecs(file, blobUrl)
|
||||
logger.debug('metadata: WebCodecs succeeded', {
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
duration: result.duration,
|
||||
})
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.warn('metadata: WebCodecs failed, using fallback', {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to old-fashioned browser APIs
|
||||
const result = await getMetadataWithBrowserAPIs(file, blobUrl)
|
||||
logger.debug('metadata: browser API succeeded', {
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
duration: result.duration,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function getMetadataWithWebCodecs(
|
||||
file: File,
|
||||
blobUrl: string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
const input = new Input({
|
||||
source: new BlobSource(file),
|
||||
formats: ALL_FORMATS,
|
||||
})
|
||||
|
||||
try {
|
||||
const [videoTrack, duration] = await Promise.all([
|
||||
input.getPrimaryVideoTrack(),
|
||||
input.computeDuration(),
|
||||
])
|
||||
|
||||
if (!videoTrack) {
|
||||
throw new Error('No video track found')
|
||||
}
|
||||
|
||||
return {
|
||||
uri: blobUrl,
|
||||
mimeType: file.type,
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
duration: duration * 1000, // convert seconds to ms
|
||||
}
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMetadataWithBrowserAPIs(
|
||||
file: File,
|
||||
blobUrl: string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (file.type === 'image/gif') {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
uri: blobUrl,
|
||||
mimeType: 'image/gif',
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
duration: null,
|
||||
})
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
reject(new Error('Failed to load GIF'))
|
||||
}
|
||||
img.src = blobUrl
|
||||
} else {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.src = blobUrl
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
resolve({
|
||||
uri: blobUrl,
|
||||
mimeType: file.type,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
duration: video.duration * 1000,
|
||||
})
|
||||
}
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
reject(new Error('Failed to load video metadata'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {
|
||||
type ImagePickerAsset,
|
||||
launchImageLibraryAsync,
|
||||
UIImagePickerPreferredAssetRepresentationMode,
|
||||
} from 'expo-image-picker'
|
||||
|
||||
import {VIDEO_MAX_DURATION_MS} from '#/lib/constants'
|
||||
|
||||
export async function pickVideo() {
|
||||
return await launchImageLibraryAsync({
|
||||
exif: false,
|
||||
mediaTypes: ['videos'],
|
||||
quality: 1,
|
||||
legacy: true,
|
||||
preferredAssetRepresentationMode:
|
||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||
videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => {
|
||||
throw new Error('getVideoMetadata is web only')
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import {type ImagePickerAsset, type ImagePickerResult} from 'expo-image-picker'
|
||||
|
||||
import {SUPPORTED_MIME_TYPES} from '#/lib/constants'
|
||||
|
||||
// mostly copied from expo-image-picker and adapted to support gifs
|
||||
// also adds support for reading video metadata
|
||||
|
||||
export async function pickVideo(): Promise<ImagePickerResult> {
|
||||
const input = document.createElement('input')
|
||||
input.style.display = 'none'
|
||||
input.setAttribute('type', 'file')
|
||||
// TODO: do we need video/* here? -sfn
|
||||
input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(','))
|
||||
input.setAttribute('id', String(Math.random()))
|
||||
|
||||
document.body.appendChild(input)
|
||||
|
||||
return new Promise(resolve => {
|
||||
input.addEventListener('change', async () => {
|
||||
if (input.files) {
|
||||
const file = input.files[0]
|
||||
resolve({
|
||||
canceled: false,
|
||||
assets: [await getVideoMetadata(file)],
|
||||
})
|
||||
} else {
|
||||
resolve({canceled: true, assets: null})
|
||||
}
|
||||
document.body.removeChild(input)
|
||||
})
|
||||
|
||||
const event = new MouseEvent('click')
|
||||
input.dispatchEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 const getVideoMetadata = (file: File): Promise<ImagePickerAsset> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const uri = reader.result as string
|
||||
|
||||
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'))
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
@@ -7442,6 +7442,23 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/dom-mediacapture-transform@^0.1.11":
|
||||
version "0.1.11"
|
||||
resolved "https://registry.yarnpkg.com/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz#4157f81498e7c01fdb76ac45506b6889fe1f162b"
|
||||
integrity sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==
|
||||
dependencies:
|
||||
"@types/dom-webcodecs" "*"
|
||||
|
||||
"@types/dom-webcodecs@*":
|
||||
version "0.1.17"
|
||||
resolved "https://registry.yarnpkg.com/@types/dom-webcodecs/-/dom-webcodecs-0.1.17.tgz#c99201b7ab42158edd89c4667f41b47417caf585"
|
||||
integrity sha512-IwKW5uKL0Zrv5ccUJpjIlqf7ppk2v29l/ZLQxLlwHxljBfnDD9Gxm+hzMkGM0AOAL/21H0pp7cTUYLiiVUGchA==
|
||||
|
||||
"@types/dom-webcodecs@0.1.13":
|
||||
version "0.1.13"
|
||||
resolved "https://registry.yarnpkg.com/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz#d8be5da4f01b20721307b08ad2cca903ccf4f47f"
|
||||
integrity sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==
|
||||
|
||||
"@types/elliptic@^6.4.9":
|
||||
version "6.4.18"
|
||||
resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.18.tgz#bc96e26e1ccccbabe8b6f0e409c85898635482e1"
|
||||
@@ -14586,6 +14603,14 @@ media-typer@0.3.0:
|
||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||
integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
|
||||
|
||||
mediabunny@^1.25.3:
|
||||
version "1.25.3"
|
||||
resolved "https://registry.yarnpkg.com/mediabunny/-/mediabunny-1.25.3.tgz#45c5873ad6ff1c6ab69a5ca2d0beef8b628038da"
|
||||
integrity sha512-+LDXv/kybsElRQCmMlrdbKmPNvegeIyouGeOUzW5DJ8+M56H4G6wyEL2AJ1A45UcOB+U62qJMV7tuFO9S7yA7g==
|
||||
dependencies:
|
||||
"@types/dom-mediacapture-transform" "^0.1.11"
|
||||
"@types/dom-webcodecs" "0.1.13"
|
||||
|
||||
memfs@^3.4.3:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.6.0.tgz#d7a2110f86f79dd950a8b6df6d57bc984aa185f6"
|
||||
|
||||
Reference in New Issue
Block a user