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.debounce": "^4.0.8",
"lodash.shuffle": "^4.2.0", "lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mediabunny": "^1.25.3",
"multiformats": "^13.4.2", "multiformats": "^13.4.2",
"nanoid": "^5.0.5", "nanoid": "^5.0.5",
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
+23
View File
@@ -570,6 +570,9 @@ importers:
lodash.throttle: lodash.throttle:
specifier: ^4.1.1 specifier: ^4.1.1
version: 4.1.1 version: 4.1.1
mediabunny:
specifier: ^1.25.3
version: 1.49.0
multiformats: multiformats:
specifier: ^13.4.2 specifier: ^13.4.2
version: 13.4.2 version: 13.4.2
@@ -3494,6 +3497,12 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} 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': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -6849,6 +6858,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mediabunny@1.49.0:
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
memfs@3.5.3: memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
@@ -12748,6 +12760,12 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.12.4 '@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': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@@ -16664,6 +16682,11 @@ snapshots:
media-typer@0.3.0: {} 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: memfs@3.5.3:
dependencies: dependencies:
fs-monkey: 1.1.0 fs-monkey: 1.1.0
-1
View File
@@ -142,7 +142,6 @@ export function ImageEmbed({
onPress(0, [containerRef], [dims]) onPress(0, [containerRef], [dims])
} }
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
hideBadge={rest.isWithinQuote}
/> />
</ImageContextMenu> </ImageContextMenu>
</View> </View>
+8 -7
View File
@@ -65,7 +65,6 @@ export function ConstrainedImage({
export function AutoSizedImage({ export function AutoSizedImage({
image, image,
crop = 'constrained', crop = 'constrained',
hideBadge,
onPress, onPress,
onLongPress, onLongPress,
onPressIn, onPressIn,
@@ -74,7 +73,6 @@ export function AutoSizedImage({
}: { }: {
image: AppBskyEmbedImages.ViewImage image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained' crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: ( onPress?: (
containerRef: AnimatedRef<any>, containerRef: AnimatedRef<any>,
fetchedDims: Dimensions | null, fetchedDims: Dimensions | null,
@@ -146,7 +144,7 @@ export function AutoSizedImage({
/> />
<MediaInsetBorder /> <MediaInsetBorder />
{(hasAlt || isCropped) && !hideBadge ? ( {hasAlt || isCropped ? (
<View <View
accessible={false} accessible={false}
style={[ style={[
@@ -165,8 +163,10 @@ export function AutoSizedImage({
]}> ]}>
{isCropped && ( {isCropped && (
<View <View
accessible={false}
style={[ style={[
a.rounded_xs, a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
padding: 3, padding: 3,
@@ -186,17 +186,18 @@ export function AutoSizedImage({
)} )}
{hasAlt && ( {hasAlt && (
<View <View
accessible={false}
style={[ style={[
a.justify_center, a.justify_center,
a.rounded_xs, a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
padding: 3,
opacity: 0.8, opacity: 0.8,
}, },
largeAlt && [ largeAlt && [
{ {
padding: 5, padding: 6,
}, },
], ],
]}> ]}>
+4 -7
View File
@@ -115,7 +115,6 @@ export function Gallery({
const bps = useBreakpoints() const bps = useBreakpoints()
const window = useWindowDimensions() const window = useWindowDimensions()
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
const hideBadges = isWithinQuote
const contentHeight = useMemo(() => { const contentHeight = useMemo(() => {
if (isWithinChat) { if (isWithinChat) {
return 120 return 120
@@ -250,7 +249,6 @@ export function Gallery({
onPress?.(index, [containerRef], [dims]) onPress?.(index, [containerRef], [dims])
} }
onPressIn={() => onPressIn?.(index)} onPressIn={() => onPressIn?.(index)}
hideBadge={isWithinQuote}
/> />
))} ))}
</View> </View>
@@ -300,7 +298,6 @@ export function Gallery({
: undefined : undefined
return ( return (
<GalleryImage <GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge} largeAltBadge={largeAltBadge}
image={item} image={item}
contentHeight={contentHeight} contentHeight={contentHeight}
@@ -396,7 +393,6 @@ function GalleryImage({
imageCount, imageCount,
onWidthChange, onWidthChange,
itemRef, itemRef,
hideBadges,
largeAltBadge, largeAltBadge,
onContainerRef, onContainerRef,
onThumbDims, onThumbDims,
@@ -410,7 +406,6 @@ function GalleryImage({
imageCount: number imageCount: number
onWidthChange: (index: number, width: number) => void onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void itemRef: (node: View | null) => void
hideBadges?: boolean
largeAltBadge?: boolean largeAltBadge?: boolean
onContainerRef: (index: number, ref: AnimatedRef<any>) => void onContainerRef: (index: number, ref: AnimatedRef<any>) => void
onThumbDims: (index: number, dims: Dimensions) => void onThumbDims: (index: number, dims: Dimensions) => void
@@ -499,7 +494,7 @@ function GalleryImage({
useAppleWebpCodec useAppleWebpCodec
/> />
{!hideBadges && imageCount > 1 ? ( {imageCount > 1 ? (
<View <View
accessible={false} accessible={false}
pointerEvents="none" pointerEvents="none"
@@ -532,7 +527,7 @@ function GalleryImage({
</View> </View>
) : null} ) : null}
{(hasAlt || isCropped) && !hideBadges ? ( {hasAlt || isCropped ? (
<View <View
accessible={false} accessible={false}
style={[ style={[
@@ -549,6 +544,7 @@ function GalleryImage({
]}> ]}>
{isCropped && ( {isCropped && (
<View <View
accessible={false}
style={[ style={[
a.rounded_sm, a.rounded_sm,
a.p_xs, a.p_xs,
@@ -568,6 +564,7 @@ function GalleryImage({
)} )}
{hasAlt && ( {hasAlt && (
<View <View
accessible={false}
style={[ style={[
a.justify_center, a.justify_center,
a.rounded_sm, 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 {atoms as a, tokens, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu' 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' import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void type EventFunction = (index: number) => void
@@ -42,8 +42,6 @@ export function GalleryItem({
onPress, onPress,
onPressIn, onPressIn,
onLongPress, onLongPress,
viewContext,
isWithinQuote,
insetBorderStyle, insetBorderStyle,
containerRefs, containerRefs,
thumbDimsRef, thumbDimsRef,
@@ -53,9 +51,6 @@ export function GalleryItem({
const largeAltBadge = useLargeAltBadgeEnabled() const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index] const image = images[index]
const hasAlt = !!image.alt const hasAlt = !!image.alt
const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect = const aspect =
image.aspectRatio && image.aspectRatio.height > 0 image.aspectRatio && image.aspectRatio.height > 0
@@ -114,26 +109,24 @@ export function GalleryItem({
<MediaInsetBorder style={insetBorderStyle} /> <MediaInsetBorder style={insetBorderStyle} />
</Pressable> </Pressable>
</ImageContextMenu> </ImageContextMenu>
{hasAlt && !hideBadges ? ( {hasAlt ? (
<View <View
accessible={false} accessible={false}
style={[ style={[
a.absolute, a.absolute,
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.rounded_xs, a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
gap: 3,
padding: 3,
bottom: a.p_xs.padding, bottom: a.p_xs.padding,
right: a.p_xs.padding, right: a.p_xs.padding,
opacity: 0.8, opacity: 0.8,
}, },
largeAltBadge && [ largeAltBadge && [
{ {
gap: 4, padding: 6,
padding: 5,
}, },
], ],
]}> ]}>
+299 -28
View File
@@ -1,58 +1,329 @@
import {type ImagePickerAsset} from 'expo-image-picker' 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 {VIDEO_MAX_SIZE} from '#/lib/constants'
import {VideoTooLargeError} from '#/lib/media/video/errors' 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' 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( export async function compressVideo(
asset: ImagePickerAsset, asset: ImagePickerAsset,
_opts?: { opts?: {
signal?: AbortSignal signal?: AbortSignal
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(asset.uri) const {onProgress, signal} = opts || {}
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
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) { if (blob.size > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError() throw new VideoTooLargeError()
} }
return { return {
uri: asset.uri,
size: blob.size, size: blob.size,
uri,
bytes: await blob.arrayBuffer(), 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, mimeType,
// web today is pure pass-through (no WebCodecs); see frontend spec §A1. // web today is pure pass-through (no WebCodecs); see frontend spec §A1.
passthroughReason: 'web-passthrough', passthroughReason: 'web-passthrough',
} }
} }
function parseDataUrl(dataUrl: string) { function calculateDimensions(
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,') width: number,
if (!mimeType || !base64) { height: number,
throw new Error('Invalid data URL') maxDimension: number,
} ): {width: number; height: number} {
return {mimeType, base64} const maxSide = Math.max(width, height)
if (maxSide <= maxDimension) {
return {width, height}
} }
function base64ToBlob(base64: string, mimeType: string) { const scale = maxDimension / maxSide
const byteCharacters = atob(base64) return {
const byteArrays = [] width: Math.round(width * scale),
height: Math.round(height * scale),
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})
} }
+12 -6
View File
@@ -1,8 +1,14 @@
// Target encode bitrate when we do compress. Matches the web path. // Shared compression knobs. Mirrored between native (compress.ts) and web
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3mbps // (compress.web.ts) so both platforms produce videos with the same target.
// Source files at or under this bitrate skip compression (paired with
// COMPRESSION_MAX_DIMENSION). Slightly above the encode target so we // Target encode bitrate when we do compress.
// don't re-encode files that are already close to what we'd produce. export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
export const COMPRESSION_PASSTHROUGH_BITRATE = 5_000_000 // 5mbps
// Output dimension cap when compressing, and skip threshold for source files. // Output dimension cap when compressing, and skip threshold for source files.
export const COMPRESSION_MAX_DIMENSION = 1920 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. #. Badge showing the current image position out of the total number of images in a gallery.
#. placeholder {0}: index + 1 #. placeholder {0}: index + 1
#: src/components/images/Gallery/index.tsx:526 #: src/components/images/Gallery/index.tsx:521
msgctxt "gallery-badge-image-position-numbers" msgctxt "gallery-badge-image-position-numbers"
msgid "{0}/{imageCount}" msgid "{0}/{imageCount}"
msgstr "{0}/{imageCount}" msgstr "{0}/{imageCount}"
@@ -1435,9 +1435,9 @@ msgstr ""
msgid "Already signed in as @{0}" msgid "Already signed in as @{0}"
msgstr "" msgstr ""
#: src/components/images/AutoSizedImage.tsx:204 #: src/components/images/AutoSizedImage.tsx:205
#: src/components/images/Gallery/index.tsx:588 #: src/components/images/Gallery/index.tsx:585
#: src/components/images/ImageLayoutGridItem.tsx:142 #: src/components/images/ImageLayoutGridItem.tsx:135
#: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94
#: src/view/com/composer/GifAltText.tsx:100 #: src/view/com/composer/GifAltText.tsx:100
#: src/view/com/composer/photos/Gallery.tsx:211 #: src/view/com/composer/photos/Gallery.tsx:211
@@ -2406,7 +2406,7 @@ msgstr ""
msgid "Captions & alt text" msgid "Captions & alt text"
msgstr "" msgstr ""
#: src/components/images/Gallery/index.tsx:275 #: src/components/images/Gallery/index.tsx:273
msgid "carousel" msgid "carousel"
msgstr "carousel" msgstr "carousel"
@@ -6097,12 +6097,12 @@ msgstr ""
msgid "If you're trying to change your handle or email, do so before you deactivate." msgid "If you're trying to change your handle or email, do so before you deactivate."
msgstr "" msgstr ""
#: src/components/images/ImageLayoutGridItem.tsx:96 #: src/components/images/ImageLayoutGridItem.tsx:91
msgid "Image" msgid "Image"
msgstr "" msgstr ""
#. placeholder {0}: index + 1 #. placeholder {0}: index + 1
#: src/components/images/Gallery/index.tsx:459 #: src/components/images/Gallery/index.tsx:454
msgid "Image {0}" msgid "Image {0}"
msgstr "Image {0}" msgstr "Image {0}"
@@ -6113,7 +6113,7 @@ msgid "Image {0} of {1}"
msgstr "" msgstr ""
#. placeholder {0}: index + 1 #. placeholder {0}: index + 1
#: src/components/images/Gallery/index.tsx:444 #: src/components/images/Gallery/index.tsx:439
msgid "Image {0} of {imageCount}" msgid "Image {0} of {imageCount}"
msgstr "Image {0} of {imageCount}" msgstr "Image {0} of {imageCount}"
@@ -6128,7 +6128,7 @@ msgid "Image cache cleared, freed {0}"
msgstr "" msgstr ""
#. placeholder {0}: images.length #. placeholder {0}: images.length
#: src/components/images/Gallery/index.tsx:276 #: src/components/images/Gallery/index.tsx:274
msgid "Image gallery, {0} images" msgid "Image gallery, {0} images"
msgstr "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" msgid "Opens flow to sign in to your existing Bluesky account"
msgstr "" msgstr ""
#: src/components/images/Gallery/index.tsx:460 #: src/components/images/Gallery/index.tsx:455
msgid "Opens full image" msgid "Opens full image"
msgstr "Opens full image" msgstr "Opens full image"
@@ -10975,7 +10975,7 @@ msgstr ""
msgid "Skip to next step" msgid "Skip to next step"
msgstr "" msgstr ""
#: src/components/images/Gallery/index.tsx:443 #: src/components/images/Gallery/index.tsx:438
msgid "slide" msgid "slide"
msgstr "slide" msgstr "slide"
@@ -13259,8 +13259,8 @@ msgstr ""
msgid "View your verifications" msgid "View your verifications"
msgstr "" msgstr ""
#: src/components/images/AutoSizedImage.tsx:221 #: src/components/images/AutoSizedImage.tsx:222
#: src/components/images/AutoSizedImage.tsx:253 #: src/components/images/AutoSizedImage.tsx:254
msgid "Views full image" msgid "Views full image"
msgstr "" msgstr ""
+1 -1
View File
@@ -180,7 +180,7 @@ import {
type VideoState, type VideoState,
} from './state/video' } from './state/video'
import {type TextInputRef} from './text-input/TextInput.types' import {type TextInputRef} from './text-input/TextInput.types'
import {getVideoMetadata} from './videos/pickVideo' import {getVideoMetadata} from './videos/metadata'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
type CancelRef = { 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 * as toast from '#/components/Toast'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif' import {isAnimatedGif} from './videos/isAnimatedGif'
import {hasWebCodecs} from './videos/metadata'
export type SelectMediaButtonProps = { export type SelectMediaButtonProps = {
disabled?: boolean disabled?: boolean
@@ -291,9 +292,15 @@ async function processImagePickerAssets(
/* /*
* Filesize appears to be stable across all platforms, so we can use it * 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, * 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) errors.add(SelectedAssetError.FileTooBig)
continue continue
} }
@@ -309,8 +316,7 @@ async function processImagePickerAssets(
if (type === 'gif') { if (type === 'gif') {
/* /*
* Filesize appears to be stable across all platforms, so we can use it * 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 * to filter out large files. We can't compress GIFs on either platform.
* videos anyway, so we only check on web.
*/ */
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
errors.add(SelectedAssetError.FileTooBig) errors.add(SelectedAssetError.FileTooBig)
@@ -1,7 +1,26 @@
import {atoms as a, flatten} from '#/alf'
export function clearThumbnailCache() { export function clearThumbnailCache() {
// no-op // no-op on web
} }
export function VideoTranscodeBackdrop() { export function VideoTranscodeBackdrop({uri}: {uri: string}) {
return null 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 {atoms as a, useTheme} from '#/alf'
import {ConstrainedImage} from '#/components/images/AutoSizedImage' import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {IS_WEB} from '#/env'
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn' import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
@@ -20,8 +19,6 @@ export function VideoTranscodeProgress({
}) { }) {
const t = useTheme() const t = useTheme()
if (IS_WEB) return null
let aspectRatio: number | undefined let aspectRatio: number | undefined
if (asset.width && asset.height) { if (asset.width && asset.height) {
const raw = 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)
})
}