From 14b74a298617d667192eeaca7d9672263074b89b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 11 Mar 2026 14:37:11 +0200 Subject: [PATCH] delete comparison code, react-native-compressor --- app.config.js | 1 - .../blueskyvideocompress/VideoProber.kt | 3 + .../ios/VideoProber.swift | 10 ++ .../expo-bluesky-video-compress/src/types.ts | 1 + package.json | 1 - patches/react-native-compressor@1.13.0.patch | 59 -------- .../react-native-compressor@1.13.0.patch.md | 5 - pnpm-lock.yaml | 16 --- pnpm-workspace.yaml | 1 - src/lib/media/video/compress.legacy.ts | 56 -------- src/lib/media/video/compress.new.ts | 103 -------------- src/lib/media/video/compress.ts | 134 +++++++++++------- src/view/com/composer/Composer.tsx | 5 +- .../videos/VideoTranscodeBackdrop.tsx | 8 +- src/view/com/composer/videos/pickVideo.ts | 43 ------ src/view/com/composer/videos/videoMetadata.ts | 23 +++ ...{pickVideo.web.ts => videoMetadata.web.ts} | 36 +---- 17 files changed, 130 insertions(+), 375 deletions(-) delete mode 100644 patches/react-native-compressor@1.13.0.patch delete mode 100644 patches/react-native-compressor@1.13.0.patch.md delete mode 100644 src/lib/media/video/compress.legacy.ts delete mode 100644 src/lib/media/video/compress.new.ts delete mode 100644 src/view/com/composer/videos/pickVideo.ts create mode 100644 src/view/com/composer/videos/videoMetadata.ts rename src/view/com/composer/videos/{pickVideo.web.ts => videoMetadata.web.ts} (67%) diff --git a/app.config.js b/app.config.js index 790ae490eb..8bfb16969d 100644 --- a/app.config.js +++ b/app.config.js @@ -287,7 +287,6 @@ module.exports = function (_config) { sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], }, ], - 'react-native-compressor', [ '@bitdrift/react-native', { diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt index 5cf15bbbd3..8498083863 100644 --- a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt @@ -39,6 +39,7 @@ object VideoProber { // Get codec and frame rate from MediaExtractor for more accuracy val extractor = MediaExtractor() var codec = "unknown" + var mimeType = "video/mp4" var extractedFrameRate = frameRate try { @@ -52,6 +53,7 @@ object VideoProber { val format = extractor.getTrackFormat(i) val mime = format.getString(MediaFormat.KEY_MIME) if (mime?.startsWith("video/") == true) { + mimeType = mime codec = mime.removePrefix("video/") if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) { extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat() @@ -79,6 +81,7 @@ object VideoProber { "duration" to durationSeconds, "bitrate" to effectiveBitrate, "fileSize" to fileSize, + "mimeType" to mimeType, "codec" to codec, "hasAudio" to hasAudio, "frameRate" to extractedFrameRate.toDouble(), diff --git a/modules/expo-bluesky-video-compress/ios/VideoProber.swift b/modules/expo-bluesky-video-compress/ios/VideoProber.swift index fd9d1d62e9..ac741c8743 100644 --- a/modules/expo-bluesky-video-compress/ios/VideoProber.swift +++ b/modules/expo-bluesky-video-compress/ios/VideoProber.swift @@ -1,4 +1,5 @@ import AVFoundation +import UniformTypeIdentifiers struct VideoProber { static func probe(url: URL) async throws -> [String: Any] { @@ -49,6 +50,14 @@ struct VideoProber { fileSize = 0 } + // MIME type from file extension + let mimeType: String + if let utType = UTType(filenameExtension: url.pathExtension) { + mimeType = utType.preferredMIMEType ?? "video/mp4" + } else { + mimeType = "video/mp4" + } + // Bitrate: use estimated data rate, or calculate from file size let durationSeconds = CMTimeGetSeconds(duration) var bitrate = Int(estimatedDataRate) @@ -62,6 +71,7 @@ struct VideoProber { "duration": durationSeconds, "bitrate": bitrate, "fileSize": fileSize, + "mimeType": mimeType, "codec": codec, "hasAudio": hasAudio, "frameRate": nominalFrameRate, diff --git a/modules/expo-bluesky-video-compress/src/types.ts b/modules/expo-bluesky-video-compress/src/types.ts index 93baea1fc7..c3ff25d5e7 100644 --- a/modules/expo-bluesky-video-compress/src/types.ts +++ b/modules/expo-bluesky-video-compress/src/types.ts @@ -4,6 +4,7 @@ export type VideoMetadata = { duration: number // seconds bitrate: number // bps fileSize: number // bytes + mimeType: string codec: string hasAudio: boolean frameRate: number diff --git a/package.json b/package.json index 7499cf8114..cf48733dcf 100644 --- a/package.json +++ b/package.json @@ -217,7 +217,6 @@ "react-is": "19", "react-keyed-flatten-children": "^5.0.0", "react-native": "0.81.5", - "react-native-compressor": "1.13.0", "react-native-date-picker": "^5.0.13", "react-native-device-attest": "^0.1.6", "react-native-drawer-layout": "^4.2.3", diff --git a/patches/react-native-compressor@1.13.0.patch b/patches/react-native-compressor@1.13.0.patch deleted file mode 100644 index 1a0f4c61d7..0000000000 --- a/patches/react-native-compressor@1.13.0.patch +++ /dev/null @@ -1,59 +0,0 @@ -diff --git a/android/build.gradle b/android/build.gradle -index 5071139f8ee5fbba085d2afe3b2093de8eda915c..84bee34a238c6510169f6b6bdb0fda0594c77136 100644 ---- a/android/build.gradle -+++ b/android/build.gradle -@@ -115,7 +115,6 @@ dependencies { - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" - implementation 'org.mp4parser:isoparser:1.9.56' -- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1' - implementation 'javazoom:jlayer:1.0.1' - } - -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt -deleted file mode 100644 -index 9292d3ee50776bd9d7760b8dcf6d123d44b4e31b..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt -deleted file mode 100644 -index c6551828014437a14dc8f2f19488b647dba1bbe1..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt -deleted file mode 100644 -index 42040b4916573463415ef2f57789b3c4fa25d135..0000000000000000000000000000000000000000 -diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -index 446d4fb8b69e7cfdb51b29603aa2d52aac1ab8c8..f02190992dac823b6bbf2d77880a25adc48f16c7 100644 ---- a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt -@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) { - promise: Promise) { - try { - -- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise) -+ // Skip compression on Android to avoid libandroidlame dependency -+ // Return the original file URL without compression -+ promise.resolve(fileUrl) - } catch (ex: Exception) { - promise.reject(ex) - } -diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -index c14b727e930f4114765bfbe15b742ddcdeaa392f..1198908fcc66eeeea5e537085d7632a0d4b04545 100644 ---- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -+++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt -@@ -7,7 +7,6 @@ import android.provider.OpenableColumns - import android.util.Log - import com.facebook.react.bridge.Promise - import com.facebook.react.bridge.ReactApplicationContext --import com.reactnativecompressor.Audio.AudioCompressor - import com.reactnativecompressor.Video.VideoCompressor.CompressionListener - import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass - import java.io.FileNotFoundException -@@ -152,10 +151,6 @@ object Utils { - } - } - -- fun addLog(log: String) { -- Log.d(AudioCompressor.TAG, log) -- } -- - val exifAttributes = arrayOf( - "FNumber", - "ApertureValue", diff --git a/patches/react-native-compressor@1.13.0.patch.md b/patches/react-native-compressor@1.13.0.patch.md deleted file mode 100644 index de59e55244..0000000000 --- a/patches/react-native-compressor@1.13.0.patch.md +++ /dev/null @@ -1,5 +0,0 @@ -# react-native-compressor - -Patch file taken from https://github.com/numandev1/react-native-compressor/pull/355#issuecomment-3180870738 - -This patch removes the audio compression feature on Android from the library. This is because `libandroidlame.so`, the native dependency, does not support 16kb page sizes, and the Play Store has made this mandatory as of 1st Nov 2025. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1efbc5defc..637ccf31d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,7 +225,6 @@ patchedDependencies: expo-modules-core@3.0.30: 952fb1c8cb6dc8a0d8ef4e1114942a341d89b025e011f1acfff788929997346e expo-notifications@0.32.17: a45a8dcf3d8c4b5df4ee0e62bc79b755fcf9d28ca51a6f685b85830bf4afc2e5 expo-updates@29.0.17: 04f28cb005b770e9ae8f0065eab96e43cbb1e58107f5f6ad1bdd18f6deb66487 - react-native-compressor@1.13.0: 58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925 react-native-date-picker@5.0.13: f2a6697da7a7ca79b4f39efda142eee1b82db6de3aa5010ad4bd59df41fe2ce1 react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130 react-native-keyboard-controller@1.21.8: 2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1 @@ -613,9 +612,6 @@ importers: react-native: specifier: 0.81.5 version: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-compressor: - specifier: 1.13.0 - version: 1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-date-picker: specifier: ^5.0.13 version: 5.0.13(patch_hash=f2a6697da7a7ca79b4f39efda142eee1b82db6de3aa5010ad4bd59df41fe2ce1)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -8000,13 +7996,6 @@ packages: react: '>=18.0.0' react-is: '>=18.0.0' - react-native-compressor@1.13.0: - resolution: {integrity: sha512-vJYbrcjz2S7kgE3Q7444F71CjEDq5Qg6sGn67N9fJ0I1K6EhB/ZxpULdDe3FxJ4h/ncJc0oxOzNWZhxtGTqcQw==} - engines: {node: '>= 16.0.0'} - peerDependencies: - react: '*' - react-native: '*' - react-native-date-picker@5.0.13: resolution: {integrity: sha512-qCLUODZVsJetO5zuoXjw1D39K527XWqBG8sOfhWdHyPzf13h8RXR1/RSKd1N0fdRDi5GdyizYmB0lPAK12/hbw==} peerDependencies: @@ -17855,11 +17844,6 @@ snapshots: react: 19.1.0 react-is: 19.2.6 - react-native-compressor@1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): - dependencies: - react: 19.1.0 - react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-date-picker@5.0.13(patch_hash=f2a6697da7a7ca79b4f39efda142eee1b82db6de3aa5010ad4bd59df41fe2ce1)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e9fd14022d..7202920814 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,7 +29,6 @@ patchedDependencies: 'expo-modules-core@3.0.30': patches/expo-modules-core@3.0.30.patch 'expo-notifications@0.32.17': patches/expo-notifications@0.32.17.patch 'expo-updates@29.0.17': patches/expo-updates@29.0.17.patch - 'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch 'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch 'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch 'react-native-keyboard-controller@1.21.8': patches/react-native-keyboard-controller@1.21.8.patch diff --git a/src/lib/media/video/compress.legacy.ts b/src/lib/media/video/compress.legacy.ts deleted file mode 100644 index 1d00bfcea1..0000000000 --- a/src/lib/media/video/compress.legacy.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 {extToMime} from './util' - -const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb - -export async function compressVideo( - file: ImagePickerAsset, - opts?: { - signal?: AbortSignal - onProgress?: (progress: number) => void - }, -): Promise { - const {onProgress, signal} = opts || {} - - const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( - file.mimeType as SupportedMimeTypes, - ) - - 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, mimeType: 'image/gif'} - } - - const minimumFileSizeForCompress = isAcceptableFormat - ? MIN_SIZE_FOR_COMPRESSION - : 0 - - const compressed = await Video.compress( - file.uri, - { - compressionMethod: 'manual', - bitrate: 3_000_000, // 3mbps - maxSize: 1920, - // WARNING: this ONE SPECIFIC ARG is in MB -sfn - minimumFileSizeForCompress, - getCancellationId: id => { - if (signal) { - signal.addEventListener('abort', () => { - Video.cancelCompression(id) - }) - } - }, - }, - onProgress, - ) - - const info = await getVideoMetaData(compressed) - - return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)} -} diff --git a/src/lib/media/video/compress.new.ts b/src/lib/media/video/compress.new.ts deleted file mode 100644 index e78c3779c2..0000000000 --- a/src/lib/media/video/compress.new.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {type ImagePickerAsset} from 'expo-image-picker' - -import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants' -import {logger} from '#/logger' -import {compress, probe} from '../../../../modules/expo-bluesky-video-compress' -import {type CompressedVideo} from './types' - -// Skip compression if bitrate is at/below this threshold (bps) -const PASSTHROUGH_BITRATE = 5_000_000 -// Max dimension that doesn't need downscaling -const PASSTHROUGH_MAX_DIMENSION = 1920 -// Max file size the server accepts (bytes) -const MAX_UPLOAD_SIZE = 100 * 1000 * 1000 // 100MB - -export async function compressVideo( - file: ImagePickerAsset, - opts?: { - signal?: AbortSignal - onProgress?: (progress: number) => void - }, -): Promise { - if (file.mimeType === 'image/gif') { - return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'} - } - - const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( - file.mimeType as SupportedMimeTypes, - ) - - // Probe the video to make a smart compression decision - const metadata = await probe(file.uri) - - const needsCompression = shouldCompress(metadata, isAcceptableFormat) - - if (!needsCompression) { - return { - uri: file.uri, - size: metadata.fileSize, - mimeType: file.mimeType ?? 'video/mp4', - } - } - - const result = await compress( - file.uri, - { - targetBitrate: 3_000_000, - maxSize: 1920, - }, - { - onProgress: opts?.onProgress, - signal: opts?.signal, - }, - ) - - return { - uri: result.uri, - size: result.size, - mimeType: result.mimeType, - } -} - -function shouldCompress( - metadata: {bitrate: number; width: number; height: number; fileSize: number}, - 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) - - // Always compress unacceptable formats (e.g. MOV → MP4) - if (!isAcceptableFormat) { - logger.debug('shouldCompress: yes (unsupported format)') - return true - } - - // Must compress if over upload limit - if (metadata.fileSize > MAX_UPLOAD_SIZE) { - logger.debug(`shouldCompress: yes (file too large: ${sizeMB}MB)`) - return true - } - - // Skip if already low bitrate, small resolution, and under upload limit - if ( - metadata.bitrate <= PASSTHROUGH_BITRATE && - maxDimension <= PASSTHROUGH_MAX_DIMENSION - ) { - logger.debug( - `shouldCompress: no (${bitrateKbps}kbps, ${maxDimension}px, ${sizeMB}MB)`, - ) - return false - } - - if (metadata.bitrate > PASSTHROUGH_BITRATE) { - logger.debug( - `shouldCompress: yes (bitrate ${bitrateKbps}kbps > ${PASSTHROUGH_BITRATE / 1000}kbps)`, - ) - } else { - logger.debug( - `shouldCompress: yes (dimension ${maxDimension}px > ${PASSTHROUGH_MAX_DIMENSION}px)`, - ) - } - return true -} diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index fa39da0958..e78c3779c2 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -1,14 +1,16 @@ import {type ImagePickerAsset} from 'expo-image-picker' -import * as Toast from '#/components/Toast' -import {IS_DEV} from '#/env' +import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants' +import {logger} from '#/logger' +import {compress, probe} from '../../../../modules/expo-bluesky-video-compress' import {type CompressedVideo} from './types' -// Toggle for A/B comparison. Set to true to use the new Expo module. -const USE_NEW_COMPRESSOR = false - -// In dev, run both compressors and show a comparison toast. -const DEV_COMPARE_BOTH = true +// Skip compression if bitrate is at/below this threshold (bps) +const PASSTHROUGH_BITRATE = 5_000_000 +// Max dimension that doesn't need downscaling +const PASSTHROUGH_MAX_DIMENSION = 1920 +// Max file size the server accepts (bytes) +const MAX_UPLOAD_SIZE = 100 * 1000 * 1000 // 100MB export async function compressVideo( file: ImagePickerAsset, @@ -17,49 +19,85 @@ export async function compressVideo( onProgress?: (progress: number) => void }, ): Promise { - if (IS_DEV && DEV_COMPARE_BOTH) { - return compressAndCompare(file, opts) + if (file.mimeType === 'image/gif') { + return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'} } - if (USE_NEW_COMPRESSOR) { - const {compressVideo: compressVideoNew} = await import('./compress.new') - return compressVideoNew(file, opts) - } else { - const {compressVideo: compressVideoLegacy} = await import( - './compress.legacy' - ) - return compressVideoLegacy(file, opts) - } -} - -async function compressAndCompare( - file: ImagePickerAsset, - opts?: { - signal?: AbortSignal - onProgress?: (progress: number) => void - }, -): Promise { - const {compressVideo: compressVideoLegacy} = await import('./compress.legacy') - const {compressVideo: compressVideoNew} = await import('./compress.new') - - // Run legacy first (progress goes to UI) - const legacyStart = performance.now() - const legacyResult = await compressVideoLegacy(file, opts) - const legacyMs = performance.now() - legacyStart - - // Run new second - const newStart = performance.now() - const newResult = await compressVideoNew(file, opts) - const newMs = performance.now() - newStart - - const fmt = (ms: number, size: number) => - `${(ms / 1000).toFixed(1)}s → ${(size / 1_000_000).toFixed(1)}MB` - - Toast.show( - `legacy: ${fmt(legacyMs, legacyResult.size)}\nnew: ${fmt(newMs, newResult.size)}`, - {duration: 8000}, + const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( + file.mimeType as SupportedMimeTypes, ) - // Return whichever is selected - return USE_NEW_COMPRESSOR ? newResult : legacyResult + // Probe the video to make a smart compression decision + const metadata = await probe(file.uri) + + const needsCompression = shouldCompress(metadata, isAcceptableFormat) + + if (!needsCompression) { + return { + uri: file.uri, + size: metadata.fileSize, + mimeType: file.mimeType ?? 'video/mp4', + } + } + + const result = await compress( + file.uri, + { + targetBitrate: 3_000_000, + maxSize: 1920, + }, + { + onProgress: opts?.onProgress, + signal: opts?.signal, + }, + ) + + return { + uri: result.uri, + size: result.size, + mimeType: result.mimeType, + } +} + +function shouldCompress( + metadata: {bitrate: number; width: number; height: number; fileSize: number}, + 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) + + // Always compress unacceptable formats (e.g. MOV → MP4) + if (!isAcceptableFormat) { + logger.debug('shouldCompress: yes (unsupported format)') + return true + } + + // Must compress if over upload limit + if (metadata.fileSize > MAX_UPLOAD_SIZE) { + logger.debug(`shouldCompress: yes (file too large: ${sizeMB}MB)`) + return true + } + + // Skip if already low bitrate, small resolution, and under upload limit + if ( + metadata.bitrate <= PASSTHROUGH_BITRATE && + maxDimension <= PASSTHROUGH_MAX_DIMENSION + ) { + logger.debug( + `shouldCompress: no (${bitrateKbps}kbps, ${maxDimension}px, ${sizeMB}MB)`, + ) + return false + } + + if (metadata.bitrate > PASSTHROUGH_BITRATE) { + logger.debug( + `shouldCompress: yes (bitrate ${bitrateKbps}kbps > ${PASSTHROUGH_BITRATE / 1000}kbps)`, + ) + } else { + logger.debug( + `shouldCompress: yes (dimension ${maxDimension}px > ${PASSTHROUGH_MAX_DIMENSION}px)`, + ) + } + return true } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 3ddaf41ed7..a8cf4a5b2f 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -180,7 +180,7 @@ import { type VideoState, } from './state/video' import {type TextInputRef} from './text-input/TextInput.types' -import {getVideoMetadata} from './videos/pickVideo' +import {getVideoMetadata} from './videos/videoMetadata' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' type CancelRef = { @@ -476,8 +476,7 @@ export const ComposePost = ({ let uri = videoInfo.uri if (IS_ANDROID) { // Android: expo-file-system double-encodes filenames with special chars. - // The file exists, but react-native-compressor's MediaMetadataRetriever - // can't handle the double-encoded URI. Copy to a temp file with a simple name. + // Copy to a temp file with a simple name to avoid URI parsing issues. const sourceFile = new FileSystem.File(videoInfo.uri) const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}` const tempFile = new FileSystem.File( diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx index 3daf7954f7..cda73afbe7 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx @@ -1,6 +1,6 @@ -import {clearCache, createVideoThumbnail} from 'react-native-compressor' import Animated, {FadeIn} from 'react-native-reanimated' import {Image} from 'expo-image' +import * as VideoThumbnails from 'expo-video-thumbnails' import {type QueryClient, useQuery} from '@tanstack/react-query' import {atoms as a} from '#/alf' @@ -8,7 +8,6 @@ import {atoms as a} from '#/alf' export const RQKEY = 'video-thumbnail' export function clearThumbnailCache(queryClient: QueryClient) { - clearCache().catch(() => {}) void queryClient.resetQueries({queryKey: [RQKEY]}) } @@ -16,7 +15,8 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) { const {data: thumbnail} = useQuery({ queryKey: [RQKEY, uri], queryFn: async () => { - return await createVideoThumbnail(uri) + const result = await VideoThumbnails.getThumbnailAsync(uri) + return result.uri }, }) @@ -25,7 +25,7 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) { { - 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, - } -} diff --git a/src/view/com/composer/videos/videoMetadata.ts b/src/view/com/composer/videos/videoMetadata.ts new file mode 100644 index 0000000000..8b6b9f2451 --- /dev/null +++ b/src/view/com/composer/videos/videoMetadata.ts @@ -0,0 +1,23 @@ +import {type ImagePickerAsset} from 'expo-image-picker' + +import {probe} from '../../../../../modules/expo-bluesky-video-compress' + +/** + * Gets video metadata from a uri on native. + */ +export async function getVideoMetadata( + file: File | string, +): Promise { + if (typeof file !== 'string') + throw new Error( + 'getVideoMetadata was passed a File, when on native it should be a uri', + ) + const metadata = await probe(file) + return { + uri: file, + mimeType: metadata.mimeType, + width: metadata.width, + height: metadata.height, + duration: metadata.duration * 1000, // seconds -> ms + } +} diff --git a/src/view/com/composer/videos/pickVideo.web.ts b/src/view/com/composer/videos/videoMetadata.web.ts similarity index 67% rename from src/view/com/composer/videos/pickVideo.web.ts rename to src/view/com/composer/videos/videoMetadata.web.ts index aa53702a24..dc6b22d593 100644 --- a/src/view/com/composer/videos/pickVideo.web.ts +++ b/src/view/com/composer/videos/videoMetadata.web.ts @@ -1,38 +1,4 @@ -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 { - 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) - }) -} +import {type ImagePickerAsset} from 'expo-image-picker' // 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