Merge remote-tracking branch 'origin/main' into app-2458

# Conflicts:
#	src/lib/media/video/constants.ts
This commit is contained in:
vineyardbovines
2026-06-23 11:54:02 -04:00
17 changed files with 550 additions and 229 deletions
+1
View File
@@ -204,6 +204,7 @@
"lodash.debounce": "^4.0.8",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"mediabunny": "^1.25.3",
"multiformats": "^13.4.2",
"nanoid": "^5.0.5",
"normalize-url": "^8.0.0",
+23
View File
@@ -570,6 +570,9 @@ importers:
lodash.throttle:
specifier: ^4.1.1
version: 4.1.1
mediabunny:
specifier: ^1.25.3
version: 1.49.0
multiformats:
specifier: ^13.4.2
version: 13.4.2
@@ -3494,6 +3497,12 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/dom-mediacapture-transform@0.1.11':
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
'@types/dom-webcodecs@0.1.13':
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -6849,6 +6858,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
mediabunny@1.49.0:
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'}
@@ -12748,6 +12760,12 @@ snapshots:
dependencies:
'@types/node': 24.12.4
'@types/dom-mediacapture-transform@0.1.11':
dependencies:
'@types/dom-webcodecs': 0.1.13
'@types/dom-webcodecs@0.1.13': {}
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -16664,6 +16682,11 @@ snapshots:
media-typer@0.3.0: {}
mediabunny@1.49.0:
dependencies:
'@types/dom-mediacapture-transform': 0.1.11
'@types/dom-webcodecs': 0.1.13
memfs@3.5.3:
dependencies:
fs-monkey: 1.1.0
-1
View File
@@ -142,7 +142,6 @@ export function ImageEmbed({
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)}
hideBadge={rest.isWithinQuote}
/>
</ImageContextMenu>
</View>
+8 -7
View File
@@ -65,7 +65,6 @@ export function ConstrainedImage({
export function AutoSizedImage({
image,
crop = 'constrained',
hideBadge,
onPress,
onLongPress,
onPressIn,
@@ -74,7 +73,6 @@ export function AutoSizedImage({
}: {
image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: (
containerRef: AnimatedRef<any>,
fetchedDims: Dimensions | null,
@@ -146,7 +144,7 @@ export function AutoSizedImage({
/>
<MediaInsetBorder />
{(hasAlt || isCropped) && !hideBadge ? (
{hasAlt || isCropped ? (
<View
accessible={false}
style={[
@@ -165,8 +163,10 @@ export function AutoSizedImage({
]}>
{isCropped && (
<View
accessible={false}
style={[
a.rounded_xs,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
padding: 3,
@@ -186,17 +186,18 @@ export function AutoSizedImage({
)}
{hasAlt && (
<View
accessible={false}
style={[
a.justify_center,
a.rounded_xs,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
padding: 3,
opacity: 0.8,
},
largeAlt && [
{
padding: 5,
padding: 6,
},
],
]}>
+4 -7
View File
@@ -115,7 +115,6 @@ export function Gallery({
const bps = useBreakpoints()
const window = useWindowDimensions()
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
const hideBadges = isWithinQuote
const contentHeight = useMemo(() => {
if (isWithinChat) {
return 120
@@ -250,7 +249,6 @@ export function Gallery({
onPress?.(index, [containerRef], [dims])
}
onPressIn={() => onPressIn?.(index)}
hideBadge={isWithinQuote}
/>
))}
</View>
@@ -300,7 +298,6 @@ export function Gallery({
: undefined
return (
<GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge}
image={item}
contentHeight={contentHeight}
@@ -396,7 +393,6 @@ function GalleryImage({
imageCount,
onWidthChange,
itemRef,
hideBadges,
largeAltBadge,
onContainerRef,
onThumbDims,
@@ -410,7 +406,6 @@ function GalleryImage({
imageCount: number
onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void
hideBadges?: boolean
largeAltBadge?: boolean
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
onThumbDims: (index: number, dims: Dimensions) => void
@@ -499,7 +494,7 @@ function GalleryImage({
useAppleWebpCodec
/>
{!hideBadges && imageCount > 1 ? (
{imageCount > 1 ? (
<View
accessible={false}
pointerEvents="none"
@@ -532,7 +527,7 @@ function GalleryImage({
</View>
) : null}
{(hasAlt || isCropped) && !hideBadges ? (
{hasAlt || isCropped ? (
<View
accessible={false}
style={[
@@ -549,6 +544,7 @@ function GalleryImage({
]}>
{isCropped && (
<View
accessible={false}
style={[
a.rounded_sm,
a.p_xs,
@@ -568,6 +564,7 @@ function GalleryImage({
)}
{hasAlt && (
<View
accessible={false}
style={[
a.justify_center,
a.rounded_sm,
+5 -12
View File
@@ -12,7 +12,7 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, tokens, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {type PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void
@@ -42,8 +42,6 @@ export function GalleryItem({
onPress,
onPressIn,
onLongPress,
viewContext,
isWithinQuote,
insetBorderStyle,
containerRefs,
thumbDimsRef,
@@ -53,9 +51,6 @@ export function GalleryItem({
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
const hasAlt = !!image.alt
const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
@@ -114,26 +109,24 @@ export function GalleryItem({
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
</ImageContextMenu>
{hasAlt && !hideBadges ? (
{hasAlt ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
a.align_center,
a.rounded_xs,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
gap: 3,
padding: 3,
bottom: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && [
{
gap: 4,
padding: 5,
padding: 6,
},
],
]}>
+300 -29
View File
@@ -1,58 +1,329 @@
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 {
COMPRESSION_MAX_DIMENSION,
COMPRESSION_MIN_SIZE_BYTES,
COMPRESSION_TARGET_BITRATE,
} from './constants'
import {type CompressedVideo} from './types'
// doesn't actually compress, converts to ArrayBuffer
// 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: COMPRESSION_MIN_SIZE_BYTES,
})
// Try MediaBunny compression if WebCodecs is available and file is large enough
// Skip GIFs - MediaBunny doesn't support them
if (hasWebCodecs() && blob.size >= COMPRESSION_MIN_SIZE_BYTES && !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: COMPRESSION_MIN_SIZE_BYTES,
})
}
// 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',
// Telemetry: this path runs when WebCodecs is unavailable, the file is
// under the size threshold, the source is a GIF, or mediabunny threw.
// All of those mean we didn't actually compress.
passthroughReason: 'web-passthrough',
}
}
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: COMPRESSION_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,
COMPRESSION_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: COMPRESSION_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')
void 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) {
// mediabunny's BufferTarget reports a null buffer after a successful
// execute(). Should not happen in normal use; recoverable here because
// the outer compressVideo() catches and falls back to the original blob,
// but worth flagging in Sentry so we can chase the root cause.
const err = new Error('Compression produced empty output')
logger.error(err, {})
throw err
}
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,
// web today is pure pass-through (no WebCodecs); see frontend spec §A1.
passthroughReason: 'web-passthrough',
}
}
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),
}
}
+12 -6
View File
@@ -1,8 +1,14 @@
// Target encode bitrate when we do compress. Matches the web path.
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3mbps
// Source files at or under this bitrate skip compression (paired with
// COMPRESSION_MAX_DIMENSION). Slightly above the encode target so we
// don't re-encode files that are already close to what we'd produce.
export const COMPRESSION_PASSTHROUGH_BITRATE = 5_000_000 // 5mbps
// Shared compression knobs. Mirrored between native (compress.ts) and web
// (compress.web.ts) so both platforms produce videos with the same target.
// Target encode bitrate when we do compress.
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
// Output dimension cap when compressing, and skip threshold for source files.
export const COMPRESSION_MAX_DIMENSION = 1920
// Native only: source files at or under this bitrate skip compression (paired
// with COMPRESSION_MAX_DIMENSION). Slightly above the encode target so we
// don't re-encode files that are already close to what we'd produce.
export const COMPRESSION_PASSTHROUGH_BITRATE = 5_000_000 // 5 Mbps
// Web only: files under this size skip compression entirely. Native applies
// its own threshold logic inside expo-bluesky-video-compress's probe step.
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
+13 -13
View File
@@ -345,7 +345,7 @@ msgstr "{0}/{1} {2, plural, one {member} other {members}}"
#. Badge showing the current image position out of the total number of images in a gallery.
#. placeholder {0}: index + 1
#: src/components/images/Gallery/index.tsx:526
#: src/components/images/Gallery/index.tsx:521
msgctxt "gallery-badge-image-position-numbers"
msgid "{0}/{imageCount}"
msgstr "{0}/{imageCount}"
@@ -1435,9 +1435,9 @@ msgstr ""
msgid "Already signed in as @{0}"
msgstr ""
#: src/components/images/AutoSizedImage.tsx:204
#: src/components/images/Gallery/index.tsx:588
#: src/components/images/ImageLayoutGridItem.tsx:142
#: src/components/images/AutoSizedImage.tsx:205
#: src/components/images/Gallery/index.tsx:585
#: src/components/images/ImageLayoutGridItem.tsx:135
#: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94
#: src/view/com/composer/GifAltText.tsx:100
#: src/view/com/composer/photos/Gallery.tsx:211
@@ -2406,7 +2406,7 @@ msgstr ""
msgid "Captions & alt text"
msgstr ""
#: src/components/images/Gallery/index.tsx:275
#: src/components/images/Gallery/index.tsx:273
msgid "carousel"
msgstr "carousel"
@@ -6097,12 +6097,12 @@ msgstr ""
msgid "If you're trying to change your handle or email, do so before you deactivate."
msgstr ""
#: src/components/images/ImageLayoutGridItem.tsx:96
#: src/components/images/ImageLayoutGridItem.tsx:91
msgid "Image"
msgstr ""
#. placeholder {0}: index + 1
#: src/components/images/Gallery/index.tsx:459
#: src/components/images/Gallery/index.tsx:454
msgid "Image {0}"
msgstr "Image {0}"
@@ -6113,7 +6113,7 @@ msgid "Image {0} of {1}"
msgstr ""
#. placeholder {0}: index + 1
#: src/components/images/Gallery/index.tsx:444
#: src/components/images/Gallery/index.tsx:439
msgid "Image {0} of {imageCount}"
msgstr "Image {0} of {imageCount}"
@@ -6128,7 +6128,7 @@ msgid "Image cache cleared, freed {0}"
msgstr ""
#. placeholder {0}: images.length
#: src/components/images/Gallery/index.tsx:276
#: src/components/images/Gallery/index.tsx:274
msgid "Image gallery, {0} images"
msgstr "Image gallery, {0} images"
@@ -8324,7 +8324,7 @@ msgstr ""
msgid "Opens flow to sign in to your existing Bluesky account"
msgstr ""
#: src/components/images/Gallery/index.tsx:460
#: src/components/images/Gallery/index.tsx:455
msgid "Opens full image"
msgstr "Opens full image"
@@ -10975,7 +10975,7 @@ msgstr ""
msgid "Skip to next step"
msgstr ""
#: src/components/images/Gallery/index.tsx:443
#: src/components/images/Gallery/index.tsx:438
msgid "slide"
msgstr "slide"
@@ -13259,8 +13259,8 @@ msgstr ""
msgid "View your verifications"
msgstr ""
#: src/components/images/AutoSizedImage.tsx:221
#: src/components/images/AutoSizedImage.tsx:253
#: src/components/images/AutoSizedImage.tsx:222
#: src/components/images/AutoSizedImage.tsx:254
msgid "Views full image"
msgstr ""
+1 -1
View File
@@ -180,7 +180,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 = {
+10 -4
View File
@@ -24,6 +24,7 @@ import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Ima
import * as toast from '#/components/Toast'
import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif'
import {hasWebCodecs} from './videos/metadata'
export type SelectMediaButtonProps = {
disabled?: boolean
@@ -291,9 +292,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 (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
if (
IS_WEB &&
!hasWebCodecs() &&
asset.fileSize &&
asset.fileSize > VIDEO_MAX_SIZE
) {
errors.add(SelectedAssetError.FileTooBig)
continue
}
@@ -309,8 +316,7 @@ 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 (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
errors.add(SelectedAssetError.FileTooBig)
@@ -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
/>
)
}
@@ -5,7 +5,6 @@ import {type ImagePickerAsset} from 'expo-image-picker'
import {atoms as a, useTheme} from '#/alf'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {IS_WEB} from '#/env'
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
@@ -20,8 +19,6 @@ export function VideoTranscodeProgress({
}) {
const t = useTheme()
if (IS_WEB) return null
let aspectRatio: number | undefined
if (asset.width && asset.height) {
const raw = asset.width / asset.height
+25
View File
@@ -0,0 +1,25 @@
import {getVideoMetaData} from 'react-native-compressor'
import {type ImagePickerAsset} from 'expo-image-picker'
import {extToMime} from '#/lib/media/video/util'
export async function getVideoMetadata(
file: File | string,
): Promise<ImagePickerAsset> {
if (typeof file !== 'string')
throw new Error(
'getVideoMetadata was passed a File, when on native it should be a uri',
)
const metadata = await getVideoMetaData(file)
return {
uri: file,
mimeType: extToMime(metadata.extension),
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
}
}
export function hasWebCodecs(): boolean {
return false
}
@@ -0,0 +1,126 @@
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 | string,
): Promise<ImagePickerAsset> {
if (typeof file === 'string')
throw new Error(
'getVideoMetadata was passed a uri, when on web it should be a File',
)
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'))
}
}
})
}
-43
View File
@@ -1,43 +0,0 @@
import {getVideoMetaData} from 'react-native-compressor'
import {
type ImagePickerAsset,
launchImageLibraryAsync,
UIImagePickerPreferredAssetRepresentationMode,
} from 'expo-image-picker'
import {VIDEO_MAX_DURATION_MS} from '#/lib/constants'
import {extToMime} from '#/lib/media/video/util'
export async function pickVideo() {
return await launchImageLibraryAsync({
exif: false,
mediaTypes: ['videos'],
quality: 1,
legacy: true,
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Current,
videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
})
}
/**
* Gets video metadata from a file or uri, depending on the platform
*
* @param file File on web, uri on native
*/
export async function getVideoMetadata(
file: File | string,
): Promise<ImagePickerAsset> {
if (typeof file !== 'string')
throw new Error(
'getVideoMetadata was passed a File, when on native it should be a uri',
)
const metadata = await getVideoMetaData(file)
return {
uri: file,
mimeType: extToMime(metadata.extension),
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
}
}
@@ -1,100 +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 function getVideoMetadata(
file: File | string,
): Promise<ImagePickerAsset> {
if (typeof file === 'string')
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
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)
})
}