Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions TRIAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Upstream issue triage

Audited against `numandev1/react-native-compressor` open issues on 2026-04-27 and compared with the current tree in this fork.

Legend:
- `real` = credible library issue
- `fixed here` = addressed in this branch
- `duplicate` = same root cause as another issue
- `stale` = issue targets code that is no longer present in the current tree
- `needs info` = not enough detail to prove a library defect
- `feature` = request, not a bug
- `not a bug` = current expectation does not match exposed API

| Issue | Triage | Notes |
| --- | --- | --- |
| #390 | not a bug | Reports `start` / `end` time behavior for video compression, but the current public video API does not expose trim parameters. |
Comment thread
XChikuX marked this conversation as resolved.
| #387 | needs info | Gradle binary store corruption looks environment-specific; report does not isolate a library code change. |
| #384 | needs info | Performance question, not a reproducible defect report. |
| #383 | real | Android transcode pipeline can blow up on pathological audio metadata (`uint32 overflow`). This branch improves failure handling so it rejects instead of silently succeeding. |
| #382 | needs info | “Works in dev, fails in prod” has no logs or repro app. |
| #381 | feature | Nitro Modules migration request. |
| #380 | real, fixed here | Android manual compression could produce invalid tiny files when `maxSize` generated odd dimensions or invalid output. This branch normalizes dimensions and rejects invalid output files. |
| #377 | real, fixed here | Android auto compression was overly aggressive. This branch switches to adaptive bitrate + frame-rate caps for high-res sources. |
| #376 | duplicate | Same symptom family as #380 / #369: invalid tiny Android outputs on specific devices. |
| #375 | real, fixed here | Quality complaint is consistent with the old hard bitrate cap. Adaptive bitrate selection in this branch directly targets it. |
| #371 | duplicate | Likely another Android video transcode failure in the same cluster as #343 / #380 / #376. |
| #370 | stale | Current tree no longer imports `AssetsLibrary`; this is already gone. |
| #369 | real | “Playable only in VLC” is credible output-container compatibility fallout; likely same Android transcode/output-validation cluster as #380 / #376. |
| #367 | stale | Same `AssetsLibrary` removal request as #370 / #362; already addressed in current sources. |
| #366 | real | `libandroidlame.so` 16 KB page-size warning is a real Android dependency issue, but separate from video compression. |
| #365 | real, fixed here | Android parsed bitrate metadata as `Int` and could overflow on bogus sentinel values. This branch now clamps metadata safely. |
| #364 | real | Manual compression crash report is credible; likely same manual-path sizing/metadata weaknesses addressed here, but no sample was attached. |
| #363 | real, fixed here | iOS assumed a video track existed and could crash on audio-only MP4 files. This branch now guards that path. |
| #362 | stale | Another `AssetsLibrary` build failure that no longer matches the current tree. |
| #358 | feature | Live photo optimization request. |
| #356 | real, fixed here | Android AGP 8+ `BuildConfig` generation issue. This branch enables `buildConfig` in the library Gradle file. |
| #354 | stale | Old Android build failure references the previous `AndroidLame-kotlin` dependency coordinates, which are no longer in this tree. |
| #353 | feature | Audio speed-up request. |
| #352 | real | Thumbnail generation failing on some videos is plausible and has a sample, but was not investigated in this pass. |
| #348 | stale | Report targets `1.11.0` Gradle sync behavior with minimal details; no matching current-tree defect was found. |
| #347 | real | Image quality parameter complaint is credible and independent of the video work in this branch. |
| #345 | stale | Current tree has only one TurboModule spec (`src/Spec/NativeCompressor.ts`); the duplicate-spec issue no longer matches HEAD. |
| #343 | real, fixed here | Repeated 4k Android compression failures line up with old manual sizing/bitrate behavior. This branch reworks the compression profile for high-res inputs. |
| #318 | stale | Old dependency-resolution issue references outdated dependency coordinates and repository/network failures. |
| #308 | duplicate | Broad “sometimes compresses, sometimes not” report fits the Android video-quality/output cluster but lacks a repro sample. |
| #302 | needs info | Slow compression is a product concern, but the report is only a timing complaint with no reproducible defect. |
| #263 | real | iOS background upload returning an empty response body is a credible platform-specific bug outside this video-focused change set. |

## Main clusters

### Android video compression cluster

These are all likely manifestations of the same area and should be tracked together:

- #343
- #375
- #376
- #377
- #380
- #369
- #371
- #308

This branch addresses the most obvious causes in that cluster:

- odd output dimensions
- brittle bitrate heuristics
- no frame-rate cap for high-resolution sources
- success being reported for invalid output files
- overflow-prone metadata parsing

### Already obsolete issues

These should be closed upstream unless a current repro still exists on the latest code:

- #345
- #354
- #362
- #367
- #370
- #318

## Minor fixes made in this branch

- Android: enable `buildConfig` generation for AGP 8+ builds
- Android: clamp metadata parsing and reject invalid transcode output
- Android: adaptive video compression profile for high-resolution inputs
- iOS: guard missing video tracks and use the same adaptive sizing/bitrate strategy
3 changes: 3 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ android {
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
}
buildFeatures {
buildConfig true
}
buildTypes {
release {
minifyEnabled false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ object Utils {
}

@JvmStatic
fun compressVideo(srcPath: String, destinationPath: String, resultWidth: Int, resultHeight: Int, videoBitRate: Float, uuid: String,progressDivider:Int, promise: Promise, reactContext: ReactApplicationContext) {
fun compressVideo(srcPath: String, destinationPath: String, resultWidth: Int, resultHeight: Int, videoBitRate: Float, frameRate: Int, uuid: String,progressDivider:Int, promise: Promise, reactContext: ReactApplicationContext) {
val currentVideoCompression = intArrayOf(0)
val videoCompressorClass: VideoCompressorClass? = VideoCompressorClass(reactContext);
compressorExports[uuid] = videoCompressorClass
videoCompressorClass?.start(
srcPath, destinationPath, resultWidth, resultHeight, videoBitRate.toInt(),
srcPath, destinationPath, resultWidth, resultHeight, videoBitRate.toInt(), frameRate,
listener = object : CompressionListener {
override fun onProgress(index: Int, percent: Float) {
if (percent <= 100) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import java.io.File

object AutoVideoCompression {
fun createCompressionSettings(fileUrl: String?, options: VideoCompressorHelper, promise: Promise, reactContext: ReactApplicationContext?) {
val maxSize = options.maxSize
val minimumFileSizeForCompress = options.minimumFileSizeForCompress
try {
val uri = Uri.parse(fileUrl)
Expand All @@ -22,42 +21,38 @@ object AutoVideoCompression {
val sizeInMb = sizeInBytes / (1024 * 1024)
if (sizeInMb > minimumFileSizeForCompress) {
val destinationPath = generateCacheFilePath("mp4", reactContext!!)
val actualHeight = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!.toInt()
val actualWidth = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!.toInt()
val bitrate = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)!!.toInt()
val scale = if (actualWidth > actualHeight) maxSize / actualWidth else maxSize / actualHeight
val resultWidth = Math.round(actualWidth * Math.min(scale, 1f) / 2) * 2
val resultHeight = Math.round(actualHeight * Math.min(scale, 1f) / 2) * 2
val videoBitRate = makeVideoBitrate(
actualHeight, actualWidth,
bitrate,
resultHeight, resultWidth
).toFloat()
compressVideo(srcPath!!, destinationPath, resultWidth, resultHeight, videoBitRate, options.uuid!!,options.progressDivider!!, promise, reactContext)
val actualHeight = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
val actualWidth = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
val bitrate = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_BITRATE)
val frameRate = VideoCompressorHelper.getMetadataInt(metaRetriever, MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
if (actualHeight <= 0 || actualWidth <= 0) {
promise.reject(Throwable("Failed to read the input video dimensions"))
return
}
val profile = VideoCompressionProfileFactory.createAuto(
sourceWidth = actualWidth,
sourceHeight = actualHeight,
sourceBitrate = bitrate,
sourceFrameRate = frameRate,
maxSize = options.maxSize,
)
compressVideo(
srcPath!!,
destinationPath,
profile.width,
profile.height,
profile.bitrate.toFloat(),
profile.frameRate,
options.uuid!!,
options.progressDivider!!,
promise,
reactContext,
)
} else {
promise.resolve(fileUrl)
}
} catch (ex: Exception) {
promise.reject(ex)
}
}

fun makeVideoBitrate(originalHeight: Int, originalWidth: Int, originalBitrate: Int, height: Int, width: Int): Int {
val compressFactor = 0.8f
val minCompressFactor = 0.8f
val maxBitrate = 1669000
var remeasuredBitrate = (originalBitrate / Math.min(originalHeight / height.toFloat(), originalWidth / width.toFloat())).toInt()
remeasuredBitrate = (remeasuredBitrate * compressFactor).toInt()
val minBitrate = (getVideoBitrateWithFactor(minCompressFactor) / (1280f * 720f / (width * height))).toInt()
if (originalBitrate < minBitrate) {
return remeasuredBitrate
}
return if (remeasuredBitrate > maxBitrate) {
maxBitrate
} else Math.max(remeasuredBitrate, minBitrate)
}

private fun getVideoBitrateWithFactor(f: Float): Int {
return (f * 2000f * 1000f * 1.13f).toInt()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.reactnativecompressor.Video

import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt

data class VideoCompressionProfile(
val width: Int,
val height: Int,
val bitrate: Int,
val frameRate: Int,
)

object VideoCompressionProfileFactory {
private const val DEFAULT_FRAME_RATE = 30

fun createAuto(
sourceWidth: Int,
sourceHeight: Int,
sourceBitrate: Int,
sourceFrameRate: Int,
maxSize: Float,
): VideoCompressionProfile {
val dimensions = scaleWithin(sourceWidth, sourceHeight, maxSize.roundToInt())
val frameRate = normalizeFrameRate(sourceFrameRate)
val bitrate = estimateBitrate(
sourceWidth = sourceWidth,
sourceHeight = sourceHeight,
sourceBitrate = sourceBitrate,
sourceFrameRate = sourceFrameRate,
targetWidth = dimensions.first,
targetHeight = dimensions.second,
targetFrameRate = frameRate,
)

return VideoCompressionProfile(
width = dimensions.first,
height = dimensions.second,
bitrate = bitrate,
frameRate = frameRate,
)
}

fun createManual(
sourceWidth: Int,
sourceHeight: Int,
sourceBitrate: Int,
sourceFrameRate: Int,
maxSize: Float,
requestedBitrate: Float,
): VideoCompressionProfile {
val dimensions = scaleWithin(sourceWidth, sourceHeight, maxSize.roundToInt())
val frameRate = normalizeFrameRate(sourceFrameRate)
val bitrate = if (requestedBitrate > 0f) {
requestedBitrate.roundToInt().coerceAtLeast(1)
} else {
estimateBitrate(
sourceWidth = sourceWidth,
sourceHeight = sourceHeight,
sourceBitrate = sourceBitrate,
sourceFrameRate = sourceFrameRate,
targetWidth = dimensions.first,
targetHeight = dimensions.second,
targetFrameRate = frameRate,
)
}

return VideoCompressionProfile(
width = dimensions.first,
height = dimensions.second,
bitrate = bitrate,
frameRate = frameRate,
)
}

fun normalizeDimension(value: Int): Int {
val positive = value.coerceAtLeast(2)
return if (positive % 2 == 0) positive else positive - 1
}

private fun scaleWithin(sourceWidth: Int, sourceHeight: Int, requestedMaxSize: Int): Pair<Int, Int> {
val safeWidth = normalizeDimension(sourceWidth)
val safeHeight = normalizeDimension(sourceHeight)
val longSide = max(safeWidth, safeHeight)
val boundedMaxSize = requestedMaxSize.coerceAtLeast(2)

if (longSide <= boundedMaxSize) {
return Pair(safeWidth, safeHeight)
}

val scale = boundedMaxSize.toFloat() / longSide.toFloat()
val width = normalizeDimension((safeWidth * scale).roundToInt())
val height = normalizeDimension((safeHeight * scale).roundToInt())
return Pair(width, height)
}

private fun normalizeFrameRate(sourceFrameRate: Int): Int {
if (sourceFrameRate <= 0) {
return DEFAULT_FRAME_RATE
}

return sourceFrameRate.coerceIn(1, DEFAULT_FRAME_RATE)
}

private fun estimateBitrate(
sourceWidth: Int,
sourceHeight: Int,
sourceBitrate: Int,
sourceFrameRate: Int,
targetWidth: Int,
targetHeight: Int,
targetFrameRate: Int,
): Int {
val targetLongSide = max(targetWidth, targetHeight)
val floor = when {
targetLongSide >= 1920 -> 4_000_000
targetLongSide >= 1280 -> 2_200_000
targetLongSide >= 960 -> 1_600_000
targetLongSide >= 720 -> 1_200_000
else -> 850_000
}
val ceiling = when {
targetLongSide >= 1920 -> 8_000_000
targetLongSide >= 1280 -> 5_000_000
targetLongSide >= 960 -> 3_500_000
targetLongSide >= 720 -> 2_500_000
else -> 1_500_000
}

if (sourceBitrate <= 0) {
return floor
}

val sourcePixels = sourceWidth.toLong() * sourceHeight.toLong()
val targetPixels = targetWidth.toLong() * targetHeight.toLong()
val pixelRatio = if (sourcePixels == 0L) 1.0 else targetPixels.toDouble() / sourcePixels.toDouble()
val sourceFps = max(sourceFrameRate, 1)
val frameRateRatio = targetFrameRate.toDouble() / sourceFps.toDouble()
val scaledBitrate = (sourceBitrate * pixelRatio * max(frameRateRatio, 0.85)).roundToInt()
val sourceCap = (sourceBitrate * 0.95).roundToInt().coerceAtLeast(floor)

return scaledBitrate.coerceAtLeast(floor).coerceAtMost(min(ceiling, sourceCap))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class VideoCompressorClass(private val context: ReactApplicationContext) {
outputWidth: Int,
outputHeight: Int,
bitrate: Int,
frameRate: Int,
listener: CompressionListener,
) {
val uris = mutableListOf<Uri>()
Expand All @@ -36,6 +37,7 @@ class VideoCompressorClass(private val context: ReactApplicationContext) {
outputWidth,
outputHeight,
bitrate,
frameRate,
listener,
destPath
)
Expand All @@ -52,6 +54,7 @@ class VideoCompressorClass(private val context: ReactApplicationContext) {
outputWidth: Int,
outputHeight: Int,
bitrate: Int,
frameRate: Int,
listener: CompressionListener,
destPath: String
) {
Expand All @@ -74,6 +77,7 @@ class VideoCompressorClass(private val context: ReactApplicationContext) {
outputWidth,
outputHeight,
bitrate,
frameRate,
listener,
)

Expand All @@ -96,6 +100,7 @@ class VideoCompressorClass(private val context: ReactApplicationContext) {
outputWidth: Int,
outputHeight: Int,
bitrate: Int,
frameRate: Int,
listener: CompressionListener,
): Result = withContext(Dispatchers.Default) {
return@withContext compressVideo(
Expand All @@ -107,6 +112,7 @@ class VideoCompressorClass(private val context: ReactApplicationContext) {
outputWidth,
outputHeight,
bitrate,
frameRate,
object : CompressionProgressListener {
override fun onProgressChanged(index: Int, percent: Float) {
listener.onProgress(index, percent)
Expand Down
Loading
Loading