Skip to content
Open
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
126 changes: 126 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# CLAUDE.md

Guidance for AI agents (Claude, Copilot, etc.) and new contributors working in
this repository. Keep this file up to date when project structure, conventions,
or workflows change.

## What this project is

`react-native-compressor` is a React Native library that compresses **video**,
**image**, and **audio** files (and provides background upload/download helpers)
with results comparable to WhatsApp-style compression. It ships native code for
both **iOS** (Swift/Obj-C) and **Android** (Kotlin), exposed to JavaScript through
a TurboModule-capable spec, and supports both the old and new React Native
architectures plus an Expo config plugin.

- Package name: `react-native-compressor`
- Package manager: **Yarn 4** (`packageManager: yarn@4.14.1`); Yarn workspaces with `examples/*`.
- Upstream: https://github.com/numandev1/react-native-compressor — this is a fork
(`XChikuX/react-native-compressor`) that triages and fixes upstream issues.

## Repository layout

```
src/ JavaScript/TypeScript public API (the npm entry point)
index.tsx Re-exports the public surface
Main.tsx Aggregates the default export object
Spec/NativeCompressor.ts TurboModule spec (single source of truth for native methods)
Video/ Image/ Audio/ Per-domain JS wrappers (compress(), options, events)
utils/ Uploader/Downloader/helpers (uuid, path normalization)
expo-plugin/ Expo config plugin
ios/ Native iOS implementation (Swift + Obj-C bridge)
Video/VideoMain.swift Video compression entry (auto/manual helpers)
Video/NextLevelSessionExporter.swift AVAssetReader/Writer export engine
Image/ Audio/ Utils/ Image, audio, upload/download, thumbnails
Compressor.mm / Compressor.h Obj-C bridge to the Swift module
android/ Native Android implementation (Kotlin)
src/main/java/com/reactnativecompressor/
Video/ Video compression (MediaCodec transcode pipeline)
VideoCompressor/compressor/Compressor.kt Core encode/decode loop
VideoCompressor/utils/CompressorUtils.kt Format/codec helpers
Image/ Audio/ Utils/ Image, audio, upload/download, helpers
src/oldarch / src/newarch Architecture-specific TurboModule specs
__tests__/ Jest unit tests for the JS wrapper (native is mocked)
harness/ react-native-harness on-device smoke test definitions
examples/bare Bare React Native example app (build + harness target)
examples/expo Expo example app
TRIAGE.md Running triage of upstream issues and fixes in this fork
```

## Public API surface

The default export aggregates these modules/functions (see
`__tests__/compressor.test.ts` for the authoritative list):
`Audio`, `Image`, `Video`, `UploadType`, `UploaderHttpMethod`, `backgroundUpload`,
`cancelUpload`, `clearCache`, `createVideoThumbnail`, `download`,
`generateFilePath`, `getDetails`, `getFileSize`, `getImageMetaData`,
`getRealPath`, `getVideoMetaData`, `uuidv4`.

Video compression supports `compressionMethod: 'auto' | 'manual'`, `maxSize`,
`bitrate`, `progressDivider`, `minimumFileSizeForCompress`, and `stripAudio`.

## Build, test, and validate

Run JS-level checks from the repo root:

| Command | Purpose |
| --- | --- |
| `yarn install` | Install dependencies (Yarn 4) |
| `yarn jest` / `yarn test` | Run the JS wrapper unit tests |
| `yarn typecheck` | `tsc --noEmit` |
| `yarn lint` | ESLint over `**/*.{js,ts,tsx}` |
| `yarn test:pr` | `test --runInBand && typecheck && lint` (run before opening a PR) |
| `yarn build:android` | Assemble the bare example (`arm64-v8a`) |
| `yarn build:ios` | Build the bare example for the iOS simulator |
| `yarn test:harness:android` / `yarn test:harness:ios` | On-device/simulator smoke tests |

**Important:** The Jest tests mock the native module, so they validate only the
JS contract. Real media decoding/encoding **cannot** be verified by unit tests —
it must be smoke-tested in the example app on a simulator or device. When you
change native Swift/Kotlin code, state clearly that it was not runtime-verified
in CI and, where possible, validate via the example app or harness.

## Native video pipeline notes (high-signal, easy to get wrong)

### iOS (`ios/Video/`)
- `VideoMain.swift` builds the `videoOutputConfiguration` / `compressionDict`
and drives `NextLevelSessionExporter`.
- `NextLevelSessionExporter.setupVideoOutput` only creates the video writer input
when `writer.canApply(outputSettings:forMediaType:) == true`; otherwise it logs
`"Unsupported output configuration"` and writes **audio only**, yet still ends
as `.completed`. That means a bad `videoOutputConfiguration` can silently yield
an **audio-only** MP4 reported as success.
- Do **not** add undocumented H.264 (`avc1`) compression properties such as
`AVVideoExpectedSourceFrameRateKey` or `AVVideoAverageNonDroppableFrameRateKey`:
`canApply(...)` accepts them but the iOS encoder drops the video track
(regression in #392, fixed for #400). After export, verify the output asset
actually contains a video track before resolving success.

### Android (`android/.../Video/VideoCompressor/`)
- `Compressor.kt` runs an `MediaExtractor` → decoder (Surface) → encoder
(`video/avc`) → `MP4Builder` transcode loop.
- The decoder is created from the **input** track's MIME. Some containers (notably
iPhone `.MOV`) report `video/dolby-vision`, which fails with `NAME_NOT_FOUND`
on devices lacking a Dolby Vision decoder. `CompressorUtils.ensureDecodableVideoFormat`
remaps such inputs to their backward-compatible HEVC/AVC base layer (profiles 8/4
→ HEVC, profile 9 → AVC) or throws a clear error for non-compatible profiles
(5/7). See #398.
- The encoder is intentionally `c2.android.avc.encoder` (when QTI codecs exist) or
`MediaCodec.createEncoderByType("video/avc")`; QTI AVC encoders can produce MP4s
that do not play on Mac/iPhone, so avoid switching this without testing.

## Conventions

- Keep changes surgical and aligned with surrounding style. Native helper objects
(e.g. `CompressorUtils`) use member imports and unqualified calls in
`Compressor.kt` — match that.
- When fixing an upstream issue, record it in `TRIAGE.md` (triage row + the
"Minor fixes made in this branch" list) referencing the issue number.
- Prefer graceful, descriptive failures over cryptic native crashes for
unsupported media (clear error messages that tell the user what happened).

## Merging back upstream

This fork accumulates many incremental commits. When contributing back to
`numandev1/react-native-compressor`, use a **squash merge** so the history lands
as a single, well-described commit rather than the full incremental series.
4 changes: 4 additions & 0 deletions TRIAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Legend:

| Issue | Triage | Notes |
| --- | --- | --- |
| #400 | real, fixed here | iOS regression from #392: H.264 `videoOutputConfiguration` added `AVVideoExpectedSourceFrameRateKey` / `AVVideoAverageNonDroppableFrameRateKey`, which `canApply(...)` accepts but the iOS encoder silently drops the video track for, yielding an audio-only MP4 reported as success. This branch removes those keys and verifies the exported file actually contains a video track. |
| #398 | real, fixed here | Android could not compress Dolby Vision `.MOV` inputs (iPhone HDR): `MediaCodec.createDecoderByType("video/dolby-vision")` fails with `NAME_NOT_FOUND` on devices without a Dolby Vision decoder. This branch remaps the input to its backward-compatible HEVC/AVC base layer when possible, and otherwise fails with a clear, actionable error. |
| #390 | not a bug | Reports `start` / `end` time behavior for video compression, but the current public video API does not expose trim parameters. |
| #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. |
Expand Down Expand Up @@ -86,7 +88,9 @@ These should be closed upstream unless a current repro still exists on the lates
- Android: clamp metadata parsing and reject invalid transcode output
- Android: adaptive video compression profile for high-resolution inputs
- Android: fast-start compressed MP4 outputs and skip unsupported copied audio sample metadata
- Android: decode the backward-compatible base layer of Dolby Vision `.MOV` inputs, or fail with a clear error (#398)
- Android/iOS: clamp image and thumbnail JPEG quality values
- Android/iOS: harden thumbnail frame extraction for difficult source videos
- iOS: guard missing video tracks and use the same adaptive sizing/bitrate strategy
- iOS: drop unsupported H.264 frame-rate keys and verify the output has a video track to prevent silent audio-only results (#400)
- iOS: return background-upload response bodies consistently
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.graphics.Paint
import android.media.ExifInterface
import android.net.Uri
import android.util.Base64
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.reactnativecompressor.Utils.MediaCache
import com.reactnativecompressor.Utils.Utils.exifAttributes
Expand All @@ -21,6 +22,8 @@ import java.io.IOException
import java.net.MalformedURLException

object ImageCompressor {
private const val TAG = "ImageCompressor"

fun getRNFileUrl(filePath: String?): String? {
var filePath = filePath
val returnAbleFile = File(filePath)
Expand Down Expand Up @@ -56,25 +59,38 @@ object ImageCompressor {
return BitmapFactory.decodeFile(filePath)
}

fun copyExifInfo(imagePath:String, outputUri:String){
try {
// for copy exif info
val sourceExif = ExifInterface(imagePath)
val compressedExif = ExifInterface(outputUri)
for (tag in exifAttributes) {
val compressedValue = compressedExif.getAttribute(tag)
if(compressedValue==null)
{
val sourceValue = sourceExif.getAttribute(tag)
if (sourceValue != null) {
compressedExif.setAttribute(tag, sourceValue)
/**
* Strip "file://" / "content://" scheme so legacy ExifInterface can open
* the underlying JPEG. ExifInterface(String) only accepts raw filesystem
* paths — passing a URI string makes it fail silently inside the
* try/catch and drops every EXIF tag, including GPS.
*/
private fun normalizeToFilePath(input: String): String {
if (input.startsWith("file://") || input.startsWith("content://")) {
return Uri.parse(input).path ?: input
}
return input
}

fun copyExifInfo(imagePath: String, outputUri: String) {
try {
val sourcePath = normalizeToFilePath(imagePath)
val outPath = normalizeToFilePath(outputUri)
val sourceExif = ExifInterface(sourcePath)
val compressedExif = ExifInterface(outPath)
var copied = 0
for (tag in exifAttributes) {
val sourceValue = sourceExif.getAttribute(tag) ?: continue
if (compressedExif.getAttribute(tag) == null) {
compressedExif.setAttribute(tag, sourceValue)
copied++
}
}
}
compressedExif.saveAttributes()
Log.i(TAG, "copyExifInfo copied $copied tags from $sourcePath -> $outPath")
} catch (e: Exception) {
Log.w(TAG, "copyExifInfo failed for $imagePath", e)
}
compressedExif.saveAttributes()
} catch (e: Exception) {
e.printStackTrace()
}
}

fun encodeImage(imageDataByteArrayOutputStream: ByteArrayOutputStream, isBase64: Boolean, outputExtension: String?,imagePath: String?, reactContext: ReactApplicationContext?): String? {
Expand All @@ -84,10 +100,14 @@ object ImageCompressor {
} else {
val outputUri = generateCacheFilePath(outputExtension!!, reactContext!!)
try {
val fos = FileOutputStream(outputUri)
imageDataByteArrayOutputStream.writeTo(fos)
// Close the stream before ExifInterface re-opens the file so
// the JPEG bytes are fully flushed; otherwise saveAttributes()
// may truncate the in-flight write.
FileOutputStream(outputUri).use { fos ->
imageDataByteArrayOutputStream.writeTo(fos)
}

copyExifInfo(imagePath!!, outputUri)
copyExifInfo(imagePath!!, outputUri)

return getRNFileUrl(outputUri)
} catch (e: Exception) {
Expand Down Expand Up @@ -262,7 +282,7 @@ object ImageCompressor {
if (bitmap == null || imagePath == null) return bitmap

return try {
val exif = ExifInterface(imagePath)
val exif = ExifInterface(normalizeToFilePath(imagePath))
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
val matrix = Matrix()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ object AutoVideoCompression {
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)
val frameRate = VideoCompressorHelper.getSourceFrameRate(metaRetriever)
if (actualHeight <= 0 || actualWidth <= 0) {
promise.reject(Throwable("Failed to read the input video dimensions"))
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ data class VideoCompressionProfile(
)

object VideoCompressionProfileFactory {
// Fallback when the source frame rate cannot be detected.
private const val DEFAULT_FRAME_RATE = 30
// Hard upper bound. 60 fps covers every modern phone capture (24/25/30/
// 50/60). Capping at 30 — the previous behaviour — silently halved 60
// fps recordings and made the output look choppy.
private const val MAX_FRAME_RATE = 60

fun createAuto(
sourceWidth: Int,
Expand Down Expand Up @@ -99,7 +104,7 @@ object VideoCompressionProfileFactory {
return DEFAULT_FRAME_RATE
}

return sourceFrameRate.coerceIn(1, DEFAULT_FRAME_RATE)
return sourceFrameRate.coerceIn(1, MAX_FRAME_RATE)
}

private fun estimateBitrate(
Expand All @@ -111,20 +116,25 @@ object VideoCompressionProfileFactory {
targetHeight: Int,
targetFrameRate: Int,
): Int {
// WhatsApp-style bitrate envelope. The previous floors/ceilings
// were ~2-3x larger and produced "compressed" outputs that were
// still 20-40 MB for short clips. These bands target ~1.5 Mbps at
// 720p, which matches WhatsApp's typical output size while keeping
// visual quality acceptable for chat playback.
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
targetLongSide >= 1920 -> 2_000_000
targetLongSide >= 1280 -> 1_200_000
targetLongSide >= 960 -> 900_000
targetLongSide >= 720 -> 700_000
else -> 500_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
targetLongSide >= 1920 -> 3_500_000
targetLongSide >= 1280 -> 2_000_000
targetLongSide >= 960 -> 1_500_000
targetLongSide >= 720 -> 1_200_000
else -> 900_000
}

if (sourceBitrate <= 0) {
Expand Down
Loading