From 3da9b0ef7515f29e9023391157e8b3d7c111c585 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Thu, 25 Jun 2026 19:05:07 -0400 Subject: [PATCH] Scope this PR to module-add + probe-only telemetry Reverts the encoder swap. react-native-compressor stays the native encode path; expo-bluesky-video-compress is added as a module but only its probe() function is used, fired from processVideo before compressStarted to emit the video:upload:probed telemetry event. This lets the new module ship behind the wall so we can validate probe reliability and gather bitrate/HDR/codec distributions across the device matrix without coupling the rollout to a change in the active encoder. A follow-up PR flips the compress path to the new module once probe data backs the smart-skip thresholds. - compress.ts reverts to main's rn-compressor path (matches origin/main) - COMPRESSION_PASSTHROUGH_BITRATE constant removed (was new-module only) - 'below-thresholds' skipReason removed (only reachable via new compress path) - engine label stays native:react-native-compressor@1.13.0 - VideoTelemetry.probed and video:upload:probed event kept; probe is now called from processVideo on native, not from compressVideo --- src/lib/media/video/compress.ts | 142 ++++++++------------------- src/lib/media/video/constants.ts | 6 +- src/lib/media/video/telemetry.ts | 2 +- src/lib/media/video/types.ts | 1 - src/view/com/composer/state/video.ts | 16 ++- 5 files changed, 59 insertions(+), 108 deletions(-) diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index 0be4c73954..dfb2e17b4b 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -1,32 +1,25 @@ +import {getVideoMetaData, Video} from 'react-native-compressor' import {type ImagePickerAsset} from 'expo-image-picker' -import { - SUPPORTED_MIME_TYPES, - type SupportedMimeTypes, - VIDEO_MAX_SIZE, -} from '#/lib/constants' -import {logger} from '#/logger' -import { - compress, - probe, - type VideoMetadata, -} from '../../../../modules/expo-bluesky-video-compress' -import { - COMPRESSION_MAX_DIMENSION, - COMPRESSION_PASSTHROUGH_BITRATE, - COMPRESSION_TARGET_BITRATE, -} from './constants' +import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants' import {type CompressedVideo} from './types' +import {extToMime} from './util' + +const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb export async function compressVideo( file: ImagePickerAsset, opts?: { signal?: AbortSignal onProgress?: (progress: number) => void - onProbe?: (metadata: VideoMetadata) => void }, ): Promise { + const {onProgress, signal} = opts || {} + if (file.mimeType === 'image/gif') { + // let's hope they're small enough that they don't need compression! + // this compression library doesn't support gifs + // worst case - server rejects them. I think that's fine -sfn return { uri: file.uri, size: file.fileSize ?? -1, @@ -35,101 +28,50 @@ export async function compressVideo( } } + // Pre-check the threshold ourselves so we can label the skip in telemetry. + // rnc would do the same skip internally via minimumFileSizeForCompress, but + // that path is invisible to us. const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( file.mimeType as SupportedMimeTypes, ) - - let metadata - try { - metadata = await probe(file.uri) - } catch (e) { - logger.debug('probe failed, falling through to passthrough', { - safeMessage: e, - }) + if ( + isAcceptableFormat && + file.fileSize != null && + file.fileSize < MIN_SIZE_FOR_COMPRESSION_BYTES + ) { return { uri: file.uri, - size: file.fileSize ?? -1, + size: file.fileSize, mimeType: file.mimeType ?? 'video/mp4', - passthroughReason: 'compress-error-fallback', + passthroughReason: 'below-byte-threshold', } } - opts?.onProbe?.(metadata) - - if (!shouldCompress(metadata, isAcceptableFormat)) { - return { - uri: file.uri, - size: metadata.fileSize, - mimeType: file.mimeType ?? 'video/mp4', - passthroughReason: 'below-thresholds', - } - } - - const result = await compress( + const compressed = await Video.compress( file.uri, { - targetBitrate: COMPRESSION_TARGET_BITRATE, - maxSize: COMPRESSION_MAX_DIMENSION, - codec: 'h264', - }, - { - onProgress: opts?.onProgress, - signal: opts?.signal, + compressionMethod: 'manual', + bitrate: 3_000_000, // 3mbps + maxSize: 1920, + // Force a transcode for unacceptable-format files regardless of size. + // rnc's default minimumFileSizeForCompress would otherwise pass small + // unacceptable-format files through unchanged and the server would + // reject them. Acceptable formats are already short-circuited above so + // they never reach this call. + // WARNING: this ONE SPECIFIC ARG is in MB -sfn + minimumFileSizeForCompress: 0, + getCancellationId: id => { + if (signal) { + signal.addEventListener('abort', () => { + Video.cancelCompression(id) + }) + } + }, }, + onProgress, ) - return { - uri: result.uri, - size: result.size, - mimeType: result.mimeType, - } -} - -function shouldCompress( - metadata: { - bitrate: number - width: number - height: number - fileSize: number - isHDR: boolean - }, - isAcceptableFormat: boolean, -): boolean { - const maxDimension = Math.max(metadata.width, metadata.height) - const bitrateKbps = Math.round(metadata.bitrate / 1000) - const sizeMB = (metadata.fileSize / 1_000_000).toFixed(1) - - if (!isAcceptableFormat) { - logger.debug('shouldCompress: yes (unsupported format)') - return true - } - - // HDR sources need the SDR BT.709 tone-map in the compress path; otherwise we - // would upload HLG/PQ/Dolby Vision untouched. - if (metadata.isHDR) { - logger.debug('shouldCompress: yes (HDR source)') - return true - } - - if (metadata.fileSize > VIDEO_MAX_SIZE) { - logger.debug(`shouldCompress: yes (file too large: ${sizeMB}MB)`) - return true - } - - if ( - metadata.bitrate <= COMPRESSION_PASSTHROUGH_BITRATE && - maxDimension <= COMPRESSION_MAX_DIMENSION - ) { - logger.debug( - `shouldCompress: no (${bitrateKbps}kbps, ${maxDimension}px, ${sizeMB}MB)`, - ) - return false - } - - if (metadata.bitrate > COMPRESSION_PASSTHROUGH_BITRATE) { - logger.debug(`shouldCompress: yes (bitrate ${bitrateKbps}kbps)`) - } else { - logger.debug(`shouldCompress: yes (dimension ${maxDimension}px)`) - } - return true + const info = await getVideoMetaData(compressed) + + return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)} } diff --git a/src/lib/media/video/constants.ts b/src/lib/media/video/constants.ts index 269b647f74..60580efc8c 100644 --- a/src/lib/media/video/constants.ts +++ b/src/lib/media/video/constants.ts @@ -5,10 +5,6 @@ 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. +// its own threshold logic inside react-native-compressor. export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000 diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts index 43f179030f..044c3b2ad0 100644 --- a/src/lib/media/video/telemetry.ts +++ b/src/lib/media/video/telemetry.ts @@ -15,7 +15,7 @@ type MetricFn = (event: E, payload: Metrics[E]) => void const COMPRESS_ENGINE = Platform.OS === 'web' ? 'web:mediabunny@1.25.3' - : 'native:expo-bluesky-video-compress@1' + : 'native:react-native-compressor@1.13.0' type Phase = 'compress' | 'upload' | 'processing' diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts index a6e35bcf80..d22832794a 100644 --- a/src/lib/media/video/types.ts +++ b/src/lib/media/video/types.ts @@ -5,7 +5,6 @@ export type VideoCompressSkipReason = | 'gif' | 'below-byte-threshold' - | 'below-thresholds' | 'no-webcodecs' | 'compress-error-fallback' diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 492eac1c5d..89c273b9d2 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -1,3 +1,4 @@ +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' @@ -17,6 +18,7 @@ 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} @@ -280,6 +282,19 @@ 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() @@ -288,7 +303,6 @@ 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)