From 68ac7b4b8d170ebb30788268c2fada2f2d5183f9 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Thu, 25 Jun 2026 19:26:11 -0400 Subject: [PATCH] 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. --- src/lib/media/video/compress.ts | 17 ++++++++- src/lib/media/video/compress.web.ts | 55 +++++++++++++++++++++++++++- src/lib/media/video/telemetry.ts | 8 ++-- src/lib/media/video/types.ts | 17 +++++++++ src/view/com/composer/state/video.ts | 16 +------- 5 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index dfb2e17b4b..72fb2a246c 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -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 { - 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! diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts index 62e8469351..7f96141b74 100644 --- a/src/lib/media/video/compress.web.ts +++ b/src/lib/media/video/compress.web.ts @@ -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 { - 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 { + 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, diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts index 044c3b2ad0..34eb431420 100644 --- a/src/lib/media/video/telemetry.ts +++ b/src/lib/media/video/telemetry.ts @@ -2,10 +2,12 @@ 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' -import {type VideoMetadata} from '../../../../modules/expo-bluesky-video-compress' type MetricFn = (event: E, payload: Metrics[E]) => void @@ -29,7 +31,7 @@ export type VideoTelemetry = { readonly engine: string picked: () => void compressStarted: () => void - probed: (metadata: VideoMetadata) => void + probed: (metadata: ProbedMetadata) => void compressSkipped: (video: { size: number mimeType: string diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts index d22832794a..6825f03ba3 100644 --- a/src/lib/media/video/types.ts +++ b/src/lib/media/video/types.ts @@ -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 +} diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 89c273b9d2..492eac1c5d 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -1,4 +1,3 @@ -import {Platform} from 'react-native' import {type ImagePickerAsset} from 'expo-image-picker' import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api' 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 {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {probe} from '../../../../../modules/expo-bluesky-video-compress' type CaptionsTrack = {lang: string; file: File} @@ -282,19 +280,6 @@ export async function processVideo( i18n: I18n, 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 try { telemetry.compressStarted() @@ -303,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)