Replace react-native-compressor video path with native expo-bluesky-video-compress module [APP-2428] (#10954)
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
@@ -1365,6 +1365,25 @@ export type Events = {
|
||||
engine: string
|
||||
sourceBytes?: number
|
||||
}
|
||||
// Native-only. Raw container metadata returned by the new module's probe()
|
||||
// (bitrate, codec, HDR, frame rate, rotation, etc.). Fires once per upload
|
||||
// between compressStarted and the compressSkipped/compressCompleted decision.
|
||||
// The web (mediabunny) and legacy rn-compressor engines do not surface this.
|
||||
'video:upload:probed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
mimeType: string
|
||||
codec: string
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
bitrate: number
|
||||
fileSize: number
|
||||
hasAudio: boolean
|
||||
frameRate: number
|
||||
rotation: number
|
||||
isHDR: boolean
|
||||
}
|
||||
'video:upload:compressCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
|
||||
@@ -2,7 +2,9 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
||||
import {type CompressedVideo} from './types'
|
||||
import {logger} from '#/logger'
|
||||
import {probe} from '../../../../modules/expo-bluesky-video-compress'
|
||||
import {type CompressedVideo, type ProbedMetadata} from './types'
|
||||
import {extToMime} from './util'
|
||||
|
||||
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
|
||||
@@ -12,9 +14,20 @@ export async function compressVideo(
|
||||
opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
onProbe?: (metadata: ProbedMetadata) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, signal} = opts || {}
|
||||
const {onProgress, signal, onProbe} = opts || {}
|
||||
|
||||
// Probe data is purely informational - fired into telemetry to validate
|
||||
// future smart-skip thresholds. Failures must not block the upload.
|
||||
if (onProbe && file.mimeType !== 'image/gif') {
|
||||
try {
|
||||
onProbe(await probe(file.uri))
|
||||
} catch (e) {
|
||||
logger.debug('video probe failed', {safeMessage: e})
|
||||
}
|
||||
}
|
||||
|
||||
if (file.mimeType === 'image/gif') {
|
||||
// let's hope they're small enough that they don't need compression!
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
COMPRESSION_MIN_SIZE_BYTES,
|
||||
COMPRESSION_TARGET_BITRATE,
|
||||
} from './constants'
|
||||
import {type CompressedVideo} from './types'
|
||||
import {type CompressedVideo, type ProbedMetadata} from './types'
|
||||
|
||||
// Codecs to try in order of preference
|
||||
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
|
||||
@@ -34,9 +34,10 @@ export async function compressVideo(
|
||||
opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
onProbe?: (metadata: ProbedMetadata) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, signal} = opts || {}
|
||||
const {onProgress, signal, onProbe} = opts || {}
|
||||
|
||||
logger.debug('compress: starting', {
|
||||
uri: asset.uri.slice(0, 50),
|
||||
@@ -49,6 +50,18 @@ export async function compressVideo(
|
||||
const isGif = blob.type === 'image/gif'
|
||||
const hasCodecs = hasWebCodecs()
|
||||
|
||||
// Probe pass: extract source metadata via mediabunny before any compression
|
||||
// decision. Fires before doCompression so the probed event lands ahead of
|
||||
// compressCompleted/compressSkipped in the funnel. GIFs and missing
|
||||
// WebCodecs both skip - mediabunny needs a parseable container + decoder.
|
||||
if (onProbe && !isGif && hasCodecs) {
|
||||
try {
|
||||
onProbe(await probeWithMediaBunny(blob))
|
||||
} catch (e) {
|
||||
logger.debug('video probe failed', {safeMessage: e})
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('compress: fetched blob', {
|
||||
size: blob.size,
|
||||
mimeType: blob.type,
|
||||
@@ -98,6 +111,44 @@ export async function compressVideo(
|
||||
}
|
||||
}
|
||||
|
||||
async function probeWithMediaBunny(blob: Blob): Promise<ProbedMetadata> {
|
||||
const input = new Input({source: new BlobSource(blob), formats: ALL_FORMATS})
|
||||
try {
|
||||
const videoTrack = await input.getPrimaryVideoTrack()
|
||||
if (!videoTrack) {
|
||||
throw new Error('No video track found')
|
||||
}
|
||||
const audioTrack = await input.getPrimaryAudioTrack()
|
||||
const [codec, codedWidth, codedHeight, rotation, isHDR, stats, duration] =
|
||||
await Promise.all([
|
||||
videoTrack.getCodec(),
|
||||
videoTrack.getCodedWidth(),
|
||||
videoTrack.getCodedHeight(),
|
||||
videoTrack.getRotation(),
|
||||
videoTrack.hasHighDynamicRange(),
|
||||
// Sample a small fixed slice instead of scanning the whole file - we
|
||||
// only want an approximate bitrate / frame rate for telemetry.
|
||||
videoTrack.computePacketStats(100),
|
||||
input.computeDuration(),
|
||||
])
|
||||
return {
|
||||
mimeType: blob.type || 'video/mp4',
|
||||
codec: codec ?? 'unknown',
|
||||
width: codedWidth,
|
||||
height: codedHeight,
|
||||
duration,
|
||||
bitrate: Math.round(stats.averageBitrate),
|
||||
fileSize: blob.size,
|
||||
hasAudio: audioTrack !== null,
|
||||
frameRate: stats.averagePacketRate,
|
||||
rotation,
|
||||
isHDR,
|
||||
}
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function findEncodableVideoCodec(
|
||||
width: number,
|
||||
height: number,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// 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
|
||||
// Web skips compression entirely for files under this size; native applies its
|
||||
// own threshold logic inside expo-bluesky-video-compress's probe step.
|
||||
// Web only: files under this size skip compression entirely. Native applies
|
||||
// its own threshold logic inside react-native-compressor.
|
||||
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
|
||||
|
||||
@@ -2,7 +2,10 @@ import {Platform} from 'react-native'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
|
||||
import {
|
||||
type ProbedMetadata,
|
||||
type VideoCompressSkipReason,
|
||||
} from '#/lib/media/video/types'
|
||||
import {Sentry} from '#/logger/sentry/lib'
|
||||
import {type Metrics} from '#/analytics/metrics'
|
||||
|
||||
@@ -28,6 +31,7 @@ export type VideoTelemetry = {
|
||||
readonly engine: string
|
||||
picked: () => void
|
||||
compressStarted: () => void
|
||||
probed: (metadata: ProbedMetadata) => void
|
||||
compressSkipped: (video: {
|
||||
size: number
|
||||
mimeType: string
|
||||
@@ -155,6 +159,24 @@ export function createVideoTelemetry({
|
||||
})
|
||||
},
|
||||
|
||||
probed(metadata) {
|
||||
metric('video:upload:probed', {
|
||||
uploadId,
|
||||
engine,
|
||||
mimeType: metadata.mimeType,
|
||||
codec: metadata.codec,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
duration: metadata.duration,
|
||||
bitrate: metadata.bitrate,
|
||||
fileSize: metadata.fileSize,
|
||||
hasAudio: metadata.hasAudio,
|
||||
frameRate: metadata.frameRate,
|
||||
rotation: metadata.rotation,
|
||||
isHDR: metadata.isHDR,
|
||||
})
|
||||
},
|
||||
|
||||
compressSkipped({size, mimeType, skipReason}) {
|
||||
metric('video:upload:compressSkipped', {
|
||||
uploadId,
|
||||
|
||||
@@ -18,3 +18,20 @@ export type CompressedVideo = {
|
||||
// bytes were actually re-encoded.
|
||||
passthroughReason?: VideoCompressSkipReason
|
||||
}
|
||||
|
||||
// Source container metadata read off the input before any encoding decision.
|
||||
// Same shape across native (expo-bluesky-video-compress probe) and web
|
||||
// (mediabunny Input + track inspection). Numbers are raw - no bucketing.
|
||||
export type ProbedMetadata = {
|
||||
mimeType: string
|
||||
codec: string
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
bitrate: number
|
||||
fileSize: number
|
||||
hasAudio: boolean
|
||||
frameRate: number
|
||||
rotation: number
|
||||
isHDR: boolean
|
||||
}
|
||||
|
||||
@@ -288,6 +288,7 @@ export async function processVideo(
|
||||
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
||||
},
|
||||
signal,
|
||||
onProbe: metadata => telemetry.probed(metadata),
|
||||
})
|
||||
} catch (e) {
|
||||
const message = getCompressErrorMessage(e, i18n)
|
||||
|
||||
Reference in New Issue
Block a user