Probe web uploads via mediabunny for telemetry parity
Defines ProbedMetadata as the shared shape and emits it from both compress entry points via a new onProbe opt: - compress.ts (native) calls expo-bluesky-video-compress's probe() before rn-compressor takes over. Pure side effect for telemetry; no decision is gated on the result. Same call moved out of processVideo so the seam is uniform across platforms. - compress.web.ts probes via mediabunny: opens the Input, reads codec / coded dims / rotation / HDR off the primary video track, samples computePacketStats(100) for bitrate and frame rate, and reports hasAudio off the primary audio track. Skips on gif and missing WebCodecs - both already cause the compression path to fall through. processVideo now passes onProbe: telemetry.probed straight into compressVideo. telemetry.probed types its arg as ProbedMetadata (was the new module's VideoMetadata) so it stays platform-neutral.
This commit is contained in:
@@ -2,7 +2,9 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
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'
|
import {extToMime} from './util'
|
||||||
|
|
||||||
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
|
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
|
||||||
@@ -12,9 +14,20 @@ export async function compressVideo(
|
|||||||
opts?: {
|
opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
|
onProbe?: (metadata: ProbedMetadata) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): 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') {
|
if (file.mimeType === 'image/gif') {
|
||||||
// let's hope they're small enough that they don't need compression!
|
// let's hope they're small enough that they don't need compression!
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
COMPRESSION_MIN_SIZE_BYTES,
|
COMPRESSION_MIN_SIZE_BYTES,
|
||||||
COMPRESSION_TARGET_BITRATE,
|
COMPRESSION_TARGET_BITRATE,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import {type CompressedVideo} from './types'
|
import {type CompressedVideo, type ProbedMetadata} from './types'
|
||||||
|
|
||||||
// Codecs to try in order of preference
|
// Codecs to try in order of preference
|
||||||
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
|
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
|
||||||
@@ -34,9 +34,10 @@ export async function compressVideo(
|
|||||||
opts?: {
|
opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
|
onProbe?: (metadata: ProbedMetadata) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, signal} = opts || {}
|
const {onProgress, signal, onProbe} = opts || {}
|
||||||
|
|
||||||
logger.debug('compress: starting', {
|
logger.debug('compress: starting', {
|
||||||
uri: asset.uri.slice(0, 50),
|
uri: asset.uri.slice(0, 50),
|
||||||
@@ -49,6 +50,18 @@ export async function compressVideo(
|
|||||||
const isGif = blob.type === 'image/gif'
|
const isGif = blob.type === 'image/gif'
|
||||||
const hasCodecs = hasWebCodecs()
|
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', {
|
logger.debug('compress: fetched blob', {
|
||||||
size: blob.size,
|
size: blob.size,
|
||||||
mimeType: blob.type,
|
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(
|
async function findEncodableVideoCodec(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import {Platform} from 'react-native'
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
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 {Sentry} from '#/logger/sentry/lib'
|
||||||
import {type Metrics} from '#/analytics/metrics'
|
import {type Metrics} from '#/analytics/metrics'
|
||||||
import {type VideoMetadata} from '../../../../modules/expo-bluesky-video-compress'
|
|
||||||
|
|
||||||
type MetricFn = <E extends keyof Metrics>(event: E, payload: Metrics[E]) => void
|
type MetricFn = <E extends keyof Metrics>(event: E, payload: Metrics[E]) => void
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ export type VideoTelemetry = {
|
|||||||
readonly engine: string
|
readonly engine: string
|
||||||
picked: () => void
|
picked: () => void
|
||||||
compressStarted: () => void
|
compressStarted: () => void
|
||||||
probed: (metadata: VideoMetadata) => void
|
probed: (metadata: ProbedMetadata) => void
|
||||||
compressSkipped: (video: {
|
compressSkipped: (video: {
|
||||||
size: number
|
size: number
|
||||||
mimeType: string
|
mimeType: string
|
||||||
|
|||||||
@@ -18,3 +18,20 @@ export type CompressedVideo = {
|
|||||||
// bytes were actually re-encoded.
|
// bytes were actually re-encoded.
|
||||||
passthroughReason?: VideoCompressSkipReason
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import {Platform} from 'react-native'
|
|
||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api'
|
import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api'
|
||||||
import {type I18n} from '@lingui/core'
|
import {type I18n} from '@lingui/core'
|
||||||
@@ -18,7 +17,6 @@ import {uploadVideo} from '#/lib/media/video/upload'
|
|||||||
import {createVideoAgent} from '#/lib/media/video/util'
|
import {createVideoAgent} from '#/lib/media/video/util'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {probe} from '../../../../../modules/expo-bluesky-video-compress'
|
|
||||||
|
|
||||||
type CaptionsTrack = {lang: string; file: File}
|
type CaptionsTrack = {lang: string; file: File}
|
||||||
|
|
||||||
@@ -282,19 +280,6 @@ export async function processVideo(
|
|||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
telemetry: VideoTelemetry,
|
telemetry: VideoTelemetry,
|
||||||
) {
|
) {
|
||||||
// Native-only side channel: fire telemetry.probed with raw container
|
|
||||||
// metadata (bitrate, codec, isHDR, frame rate, etc.) that rn-compressor
|
|
||||||
// does not surface. The compress path itself is unchanged - probe data is
|
|
||||||
// purely for analytics to validate future smart-skip thresholds.
|
|
||||||
if (Platform.OS !== 'web') {
|
|
||||||
try {
|
|
||||||
const metadata = await probe(asset.uri)
|
|
||||||
telemetry.probed(metadata)
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('video probe failed', {safeMessage: e})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let video: CompressedVideo | undefined
|
let video: CompressedVideo | undefined
|
||||||
try {
|
try {
|
||||||
telemetry.compressStarted()
|
telemetry.compressStarted()
|
||||||
@@ -303,6 +288,7 @@ export async function processVideo(
|
|||||||
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
|
onProbe: metadata => telemetry.probed(metadata),
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = getCompressErrorMessage(e, i18n)
|
const message = getCompressErrorMessage(e, i18n)
|
||||||
|
|||||||
Reference in New Issue
Block a user