diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..05cc74d1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,251 @@
+# CLAUDE.md — react-native-compressor
+
+This file is a project guide for Claude Code / AI agents working on `react-native-compressor`. It describes the repository layout, how the library is structured, how to build and test, common pitfalls, and the conventions used when making changes.
+
+## 1. Project overview
+
+`react-native-compressor` is a React Native module that compresses images, videos, and audio, creates video thumbnails, downloads remote media, and performs background file uploads.
+
+- **Language stack:** TypeScript (JS wrapper), Kotlin (Android), Swift/Objective-C++ (iOS).
+- **Module systems:** Supports both the legacy Native Modules bridge and the New Architecture TurboModules spec. The current `1.19.3` branch remains on TurboModules; `main` (2.x) has migrated to Nitro Modules (see §8).
+- **Package manager:** Yarn 4 with workspaces. The repository contains two example apps under `examples/`.
+- **Node engine:** >= 22.11.0.
+
+## 2. Repository layout
+
+```text
+react-native-compressor/
+├── android/ # Android library (Kotlin, Gradle)
+├── ios/ # iOS library (Swift, Objective-C++)
+├── src/ # TypeScript source and TurboModule spec
+│ ├── Audio/
+│ ├── Image/
+│ ├── Video/
+│ ├── Spec/ # TurboModule spec
+│ ├── expo-plugin/
+│ ├── utils/ # Upload, download, helpers
+│ ├── Main.tsx # Module resolution (Turbo / legacy)
+│ ├── index.tsx # Public exports
+│ └── global.d.ts
+├── __tests__/ # Jest unit tests
+├── examples/
+│ ├── bare/ # Bare React Native example
+│ └── expo/ # Expo example
+├── harness/ # react-native-harness configuration
+├── media/ # Test assets
+├── package.json
+├── CONTRIBUTING.md
+├── TRIAGE.md # Upstream issue audit
+└── README.md
+```
+
+## 3. Public API
+
+The default export and named exports live in `src/index.tsx`:
+
+```ts
+import {
+ Image,
+ Video,
+ Audio,
+ backgroundUpload,
+ cancelUpload,
+ download,
+ getDetails,
+ uuidv4,
+ generateFilePath,
+ getRealPath,
+ getVideoMetaData,
+ getImageMetaData,
+ createVideoThumbnail,
+ clearCache,
+ getFileSize,
+ UploadType,
+ UploaderHttpMethod,
+} from 'react-native-compressor';
+```
+
+Key behavioral rules:
+
+- `Image.compress(value, options?)` rejects empty `value` before calling native code and strips `data:image/...;base64,` headers.
+- `Video.compress(fileUrl, options?, onProgress?)` always generates an internal UUID, defaults `compressionMethod` to `'auto'` and `maxSize` to `640`, and supports `stripAudio`.
+- `Audio.compress(url, options?)` defaults to `{ quality: 'medium' }`.
+- `backgroundUpload(url, fileUrl, options, onProgress?, abortSignal?)` removes the `file://` prefix on Android and wires an `AbortSignal` to `cancelUpload`.
+- `download(fileUrl, progress?, progressDivider?)` also strips `file://` on Android.
+
+## 4. Native module resolution
+
+`src/Main.tsx` resolves the native module:
+
+```ts
+const isTurboModuleEnabled = global.__turboModuleProxy != null;
+const CompressorModule = isTurboModuleEnabled
+ ? require('./Spec/NativeCompressor').default
+ : NativeModules.Compressor;
+```
+
+The TurboModule spec is in `src/Spec/NativeCompressor.ts`. Do **not** rename these methods without updating all native call sites.
+
+## 5. Development workflow
+
+Install dependencies:
+
+```sh
+yarn
+```
+
+Run the examples:
+
+```sh
+yarn example:bare start
+yarn example:bare android
+yarn example:bare ios
+yarn example:expo start
+```
+
+Code quality gates:
+
+```sh
+yarn typecheck # tsc --noEmit
+yarn lint # eslint "**/*.{js,ts,tsx}"
+yarn lint --fix # auto-fix formatting / lint issues
+yarn test # Jest unit tests
+yarn test:pr # test + typecheck + lint (CI gate)
+```
+
+Clean build folders:
+
+```sh
+yarn clean
+```
+
+Release (maintainers only):
+
+```sh
+yarn release
+```
+
+## 6. Testing
+
+### Unit tests
+
+`__tests__/compressor.test.ts` mocks the native module and validates the JS wrapper contract. When you change option forwarding, event-emitter wiring, or the export surface, update this test.
+
+Run:
+
+```sh
+yarn test
+```
+
+### Native harness tests
+
+For changes that affect compression/upload/download native behavior, run the harness tests on a real device or simulator:
+
+```sh
+yarn test:harness:android
+yarn test:harness:ios
+```
+
+Harness config lives in `examples/bare/jest.harness.config.mjs`. The harness tests exercise actual media decoding and are the source of truth for native behavior.
+
+### Example apps
+
+Always test manually in `examples/bare` and `examples/expo` when touching:
+
+- Native Android/iOS code
+- The Expo plugin (`app.plugin.js`, `src/expo-plugin/`)
+- Upload/download progress wiring
+
+## 7. Git conventions
+
+- **Commit format:** Conventional Commits.
+ - `fix:` bug fixes
+ - `feat:` new features
+ - `refactor:` code refactoring
+ - `docs:` documentation changes
+ - `test:` test changes
+ - `chore:` tooling / CI
+- **Pre-commit hooks** (`lefthook.yml`) run ESLint and `tsc --noEmit` on staged files; `commit-msg` runs `commitlint`.
+- **PR template:** `.github/PULL_REQUEST_TEMPLATE.md` requires a Summary, Changelog entry, and Test Plan.
+
+Always run the PR gate before pushing:
+
+```sh
+yarn test:pr
+```
+
+## 8. Branch strategy and the Nitro migration
+
+- `main` is on 2.x and has migrated to **Nitro Modules** (`feat: migrate react-native-compressor to Nitro Modules #401`).
+- The current working branch is `1.19.3`, which keeps the TurboModule / legacy bridge structure.
+- Fixes that land on `1.19.3` may need to be forward-ported to `main`/Nitro, and vice versa. Check both branches when resolving regressions.
+
+## 9. Platform-specific notes
+
+### Android
+
+- Source lives in `android/src/main/java/com/reactnativecompressor/`.
+- Uses Kotlin coroutines (`kotlinx-coroutines-core/android 1.6.4`).
+- Uses `org.mp4parser:isoparser:1.9.56` for MP4 container manipulation.
+- Uses `com.github.kaushik-naik:TAndroidLame` for audio encoding.
+- `android/build.gradle` enables `buildConfig` for AGP 8+ compatibility.
+- `file://` prefixes are stripped before passing paths to native methods (done in JS for download/upload).
+
+### iOS
+
+- Source lives in `ios/` and is organized into `Audio/`, `Image/`, `Video/`, and `Utils/`.
+- Entry point: `ios/Compressor.mm` with `CompressorManager.swift` coordinating work.
+- `AssetsLibrary` has been removed (iOS 26 / modern SDK support).
+- Guard against audio-only / missing video tracks; exports must fail explicitly rather than silently producing audio-only MP4s.
+
+## 10. Common pitfalls
+
+1. **Native method names.** The spec in `src/Spec/NativeCompressor.ts` must match the Android/iOS method names. Renames require three-way updates.
+2. **UUID wiring.** Progress events are keyed by UUID. If a native call does not receive the UUID, progress callbacks will not fire.
+3. **File URI normalization.** Android native APIs expect plain filesystem paths; `file://` stripping happens in `src/utils/Uploader.tsx` and `Downloader.tsx`. iOS generally accepts `file://` URIs.
+4. **Event listener cleanup.** Wrappers add `NativeEventEmitter` listeners in `try` and remove them in `finally`. Always preserve this pattern to avoid leaks.
+5. **Base64 images.** `Image.compress` strips the base64 data URL header before calling `image_compress`. Do not double-strip in native code.
+6. **Expo plugin changes.** If you change config-plugin behavior, test in `examples/expo` and verify `app.plugin.js` still resolves correctly.
+7. **New Architecture.** Builds with `newArchEnabled=true` generate code into `android/build/generated/source/codegen/java`. Clean builds are required when the spec changes.
+8. **Swift errors vs NSException.** Do not call `NSException.raise()` in iOS Swift code invoked by a TurboModule — it aborts the process and no JS catch can intercept it. Use Swift `throw` with a typed error; the calling Swift code wraps in `do/catch` and calls `reject`. This applies to all iOS image/video/audio compression handlers.
+
+## 11. What to do when touching specific areas
+
+| Area | Files to read / update | Validation |
+| --- | --- | --- |
+| Image compression | `src/Image/index.tsx`, `ios/Image/`, `android/src/.../Image/` | Unit test + bare example |
+| Video compression | `src/Video/index.tsx`, `ios/Video/`, `android/src/.../Video/` | Harness test + bare example |
+| Audio compression | `src/Audio/index.tsx`, `ios/Audio/`, `android/src/.../Audio/` | Bare example |
+| Upload / download | `src/utils/Uploader.tsx`, `src/utils/Downloader.tsx` | Unit test + harness test |
+| Thumbnails / metadata | `src/utils/index.tsx`, native utils | Unit test + bare example |
+| Expo plugin | `app.plugin.js`, `src/expo-plugin/` | `examples/expo` build |
+| TurboModule spec | `src/Spec/NativeCompressor.ts` | `yarn typecheck`, rebuild native apps |
+| CI / tooling | `.github/workflows/`, `package.json`, `lefthook.yml` | Push to a PR and inspect Actions |
+
+## 12. Useful commands
+
+```sh
+# Full PR gate
+yarn test:pr
+
+# Android build (debug)
+yarn build:android
+
+# iOS build (debug simulator)
+yarn build:ios
+
+# Native harness
+yarn test:harness:android
+yarn test:harness:ios
+
+# Clean everything
+yarn clean
+```
+
+## 13. Release notes
+
+Published versions use `release-it` with the conventional-changelog plugin. The changelog is generated from commit messages, so write clear, scoped commits.
+
+---
+
+When in doubt, keep changes minimal, add or update unit tests, and run `yarn test:pr` before asking for review.
diff --git a/README.md b/README.md
index 49b76df1..b980ebcf 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-
+
@@ -14,6 +14,12 @@
+> ⚠️ **Legacy Notice**
+>
+> This repository is now in **maintenance mode** for the v1 series. Version **1.19.3** will be the last release of v1. Development has moved to **[v2](https://github.com/numandev1/react-native-compressor)** which uses **Nitro Modules** for improved performance and maintainability.
+>
+> New features, bug fixes and enhancements will land in v2 only.
+
**REACT-NATIVE-COMPRESSOR** is a react-native package, which helps us to Compress `Image`, `Video`, and `Audio` before uploading, same like **Whatsapp** without knowing the compression `algorithm`
diff --git a/TRIAGE.md b/TRIAGE.md
index b45157c9..29e870a3 100644
--- a/TRIAGE.md
+++ b/TRIAGE.md
@@ -1,6 +1,6 @@
# Upstream issue triage
-Audited against `numandev1/react-native-compressor` open issues on 2026-04-27 and compared with the current tree in this fork.
+Audited against `numandev1/react-native-compressor` open issues on 2026-07-14 and compared with the current tree in this fork.
Legend:
- `real` = credible library issue
@@ -80,6 +80,45 @@ These should be closed upstream unless a current repro still exists on the lates
- #370
- #318
+## Recent PRs on the 1.19.3 branch
+
+Since the last audit, the branch has integrated or backported work reflected in the following areas:
+
+| PR | What changed |
+| --- | --- |
+| #408 | Fixed Expo plugin/release build references so managed Expo apps can build against the current module structure. |
+| #407 | Corrected Nitro module imports/paths to keep `main` working after the 2.0.0 Nitro migration. |
+| #411 | iOS image compression: replaced `NSException.raise()` with Swift `throw` so errors properly reject the promise instead of aborting the whole app. |
+| #403 / #400 | iOS export session now fails explicitly instead of silently returning an audio-only MP4 when the video track is missing or cannot be written. |
+| #402 | Dolby Vision compatibility improvements on iOS. |
+| #399 | Dolby Vision crash fix, up to ~50% faster iOS transcode, and corrected fps/bitrate/GPS metadata handling. |
+| #397 | Cross-platform compressor hardening and review-feedback fixes. |
+| #396 | Additional test coverage for compression paths. |
+| #395 | Refined video compression profiles after upstream issue triage. |
+| #393 | New `stripAudio` option on video compression to drop the audio track from the output. |
+| #392 | Upstream issue triage and high-resolution video compression hardening. |
+| #388 | iOS background upload now resolves with the server response body, matching Android behavior. |
+| #391 | Tooling, CI, and example-app modernization (Yarn 4, React Native 0.85, updated harness). |
+| #386 | Android 14+ orientation correction for images. |
+| #385 | Out-of-memory crash mitigation during large-file processing. |
+| #374 | Base64 image compression handling on iOS. |
+| #372 | 16 KB page-size support on Android. |
+| #368 | Removed deprecated iOS `AssetsLibrary` API and fixed an iOS runtime crash. |
+| #351 | Replaced `toLowerCase()` with Kotlin `lowercase()`. |
+| #342 | Moved `uuidv4` to a dedicated helper to avoid circular dependencies with React Compiler. |
+| #341 | Refactored `createVideoThumbnail` internals. |
+| #339 | Preserved original audio channel count to fix iOS playback for certain Android-compressed videos. |
+| #334 | Fixed iOS crash when processing file size of an unparsed URL. |
+| #328 | Documentation repetition fix. |
+| #325 / #324 | Build fixes for missing `kAudioFormatAPAC` symbol on some Xcode versions. |
+| #321 | iPhone 16 / Pro Max compression fix. |
+| #320 | Yarn upgrade and workspace tooling refresh. |
+| #311 | `isoparser` 1.9.x migration and old-code cleanup. |
+| #305 | Same `isoparser` modernization. |
+| #295 | iOS manual image compression source fix. |
+| #290 | mp4parser compatibility fix. |
+| #284 / #281 | Android upload and speed fixes. |
+
## Minor fixes made in this branch
- Android: enable `buildConfig` generation for AGP 8+ builds
diff --git a/android/build.gradle b/android/build.gradle
index bb2e287a..24802206 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -119,7 +119,6 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
implementation 'org.mp4parser:isoparser:1.9.56'
implementation 'com.github.kaushik-naik:TAndroidLame:277c2ab4b0'
- implementation 'javazoom:jlayer:1.0.1'
}
if (isNewArchitectureEnabled()) {
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
index 9292d3ee..12b545c1 100644
--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
@@ -10,8 +10,6 @@ import com.naman14.androidlame.WaveReader
import com.reactnativecompressor.Utils.MediaCache
import com.reactnativecompressor.Utils.Utils
import com.reactnativecompressor.Utils.Utils.addLog
-import javazoom.jl.converter.Converter
-import javazoom.jl.decoder.JavaLayerException
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileNotFoundException
@@ -20,11 +18,12 @@ import java.io.IOException
class AudioCompressor {
companion object {
- val TAG="AudioMain"
+ val TAG = "AudioMain"
private const val OUTPUT_STREAM_BUFFER = 8192
var outputStream: BufferedOutputStream? = null
var waveReader: WaveReader? = null
+
@JvmStatic
fun CompressAudio(
fileUrl: String,
@@ -33,59 +32,76 @@ class AudioCompressor {
promise: Promise,
) {
val realPath = Utils.getRealPath(fileUrl, context)
- var _fileUrl=realPath
+ var _fileUrl = realPath
val filePathWithoutFileUri = realPath!!.replace("file://", "")
+
+ // These are declared outside the try block so they can be cleaned up
+ // in the catch handler. They must be nullable because the transcoding
+ // branch may not execute.
+ var wavPath: String? = null
+ var isNonWav: Boolean = false
+
try {
- var wavPath=filePathWithoutFileUri;
- var isNonWav:Boolean=false
- if (fileUrl.endsWith(".mp4", ignoreCase = true))
- {
- addLog("mp4 file found")
- val mp3Path= Utils.generateCacheFilePath("mp3", context)
- AudioExtractor().genVideoUsingMuxer(fileUrl, mp3Path, -1, -1, true, false)
- _fileUrl=Utils.slashifyFilePath(mp3Path)
- wavPath= Utils.generateCacheFilePath("wav", context)
+ wavPath = filePathWithoutFileUri
+ // LAME's WaveReader requires PCM 16-bit LE WAV. Anything else
+ // (m4a/AAC, mp3, ogg, flac, video audio tracks, ...) must be
+ // decoded to WAV first via the platform MediaCodec pipeline —
+ // jlayer's MP3-only Converter silently produced empty WAVs for
+ // every non-MP3 input, which LAME then encoded as a few seconds
+ // of silence (see #410 thread, m4a/AAC support).
+ //
+ // Check the resolved local path, not the original fileUrl: a
+ // content:// or remote URL won't carry the file extension, so
+ // checking fileUrl would mis-classify WAV inputs as non-WAV and
+ // route them through an unnecessary (and potentially failing)
+ // MediaCodec decode pass.
+ if (!filePathWithoutFileUri.endsWith(".wav", ignoreCase = true)) {
+ addLog("non wav file found, transcoding to PCM WAV")
+ wavPath = Utils.generateCacheFilePath("wav", context)
try {
- val converter = Converter()
- converter.convert(mp3Path, wavPath)
- } catch (e: JavaLayerException) {
- addLog("JavaLayerException error"+e.localizedMessage)
- e.printStackTrace();
+ AudioTranscoder.decodeToWav(filePathWithoutFileUri, wavPath)
+ } catch (e: IOException) {
+ addLog("AudioTranscoder failed: " + e.localizedMessage)
+ // Surface the failure rather than silently returning a
+ // degenerate MP3 of silence.
+ File(wavPath).delete()
+ promise.reject("audio_transcode_failed", e.localizedMessage ?: "Audio decode failed", e)
+ return
}
- isNonWav=true
- }
- else if (!fileUrl.endsWith(".wav", ignoreCase = true))
- {
- addLog("non wav file found")
- wavPath= Utils.generateCacheFilePath("wav", context)
- try {
- val converter = Converter()
- converter.convert(filePathWithoutFileUri, wavPath)
- } catch (e: JavaLayerException) {
- addLog("JavaLayerException error"+e.localizedMessage)
- e.printStackTrace();
- }
- isNonWav=true
+ isNonWav = true
}
-
- autoCompressHelper(wavPath,filePathWithoutFileUri, optionMap,context) { mp3Path, finished ->
+ autoCompressHelper(wavPath, filePathWithoutFileUri, optionMap, context) { mp3Path, finished ->
if (finished) {
- val returnableFilePath:String="file://$mp3Path"
+ val returnableFilePath: String = "file://$mp3Path"
addLog("finished: " + returnableFilePath)
MediaCache.removeCompletedImagePath(fileUrl)
- if(isNonWav)
- {
+ if (isNonWav) {
File(wavPath).delete()
}
promise.resolve(returnableFilePath)
} else {
- addLog("error: "+mp3Path)
+ addLog("error: " + mp3Path)
+ // Clean up the temp WAV on failure too; previously it was
+ // leaked because only the success path deleted it.
+ if (isNonWav) {
+ File(wavPath).delete()
+ }
promise.resolve(_fileUrl)
}
}
} catch (e: Exception) {
- promise.resolve(_fileUrl)
+ addLog("CompressAudio failed: " + e.localizedMessage)
+ // Clean up any temp WAV that was created before the failure.
+ val temp = wavPath
+ if (isNonWav && temp != null) {
+ try {
+ File(temp).delete()
+ } catch (_: Throwable) {
+ // ignore cleanup failures
+ }
+ }
+ promise.reject("audio_compress_failed", e.localizedMessage ?: "Audio compression failed", e)
}
}
@@ -101,35 +117,34 @@ class AudioCompressor {
val options = AudioHelper.fromMap(optionMap)
val quality = options.quality
- var isCompletedCallbackTriggered:Boolean=false
+ var isCompletedCallbackTriggered: Boolean = false
try {
var mp3Path = Utils.generateCacheFilePath("mp3", context)
val input = File(fileUrl)
val output = File(mp3Path)
val CHUNK_SIZE = 8192
- addLog("Initialising wav reader")
+ addLog("Initialising wav reader")
- waveReader = WaveReader(input)
+ waveReader = WaveReader(input)
- try {
- waveReader!!.openWave()
- } catch (e: IOException) {
- e.printStackTrace()
- }
+ try {
+ waveReader!!.openWave()
+ } catch (e: IOException) {
+ addLog("openWave failed: " + e.localizedMessage)
+ completeCallback(e.localizedMessage ?: "Failed to open WAV", false)
+ return
+ }
- addLog("Intitialising encoder")
+ addLog("Intitialising encoder")
// for bitrate
- var audioBitrate:Int
- if(options.bitrate != -1)
- {
- audioBitrate= options.bitrate/1000
- }
- else
- {
- audioBitrate=AudioHelper.getDestinationBitrateByQuality(actualFileUrl, quality!!)
+ var audioBitrate: Int
+ if (options.bitrate != -1) {
+ audioBitrate = options.bitrate / 1000
+ } else {
+ audioBitrate = AudioHelper.getDestinationBitrateByQuality(actualFileUrl, quality!!)
Utils.addLog("dest bitrate: $audioBitrate")
}
@@ -137,128 +152,126 @@ class AudioCompressor {
androidLame.setOutBitrate(audioBitrate)
// for channels
- var audioChannels:Int
- if(options.channels != -1){
- audioChannels= options.channels!!
- }
- else
- {
- audioChannels=waveReader!!.channels
+ var audioChannels: Int
+ if (options.channels != -1) {
+ audioChannels = options.channels!!
+ } else {
+ audioChannels = waveReader!!.channels
}
androidLame.setOutChannels(audioChannels)
// for sample rate
androidLame.setInSampleRate(waveReader!!.sampleRate)
- var audioSampleRate:Int
- if(options.samplerate != -1){
- audioSampleRate= options.samplerate!!
- }
- else
- {
- audioSampleRate=waveReader!!.sampleRate
+ var audioSampleRate: Int
+ if (options.samplerate != -1) {
+ audioSampleRate = options.samplerate!!
+ } else {
+ audioSampleRate = waveReader!!.sampleRate
}
androidLame.setOutSampleRate(audioSampleRate)
- val androidLameBuild=androidLame.build()
+ val androidLameBuild = androidLame.build()
- try {
- outputStream = BufferedOutputStream(FileOutputStream(output), OUTPUT_STREAM_BUFFER)
- } catch (e: FileNotFoundException) {
- e.printStackTrace()
- }
+ try {
+ outputStream = BufferedOutputStream(FileOutputStream(output), OUTPUT_STREAM_BUFFER)
+ } catch (e: FileNotFoundException) {
+ addLog("Failed to create output stream: " + e.localizedMessage)
+ completeCallback(e.localizedMessage ?: "Failed to create output file", false)
+ return
+ }
- var bytesRead = 0
+ var bytesRead = 0
- val buffer_l = ShortArray(CHUNK_SIZE)
- val buffer_r = ShortArray(CHUNK_SIZE)
- val mp3Buf = ByteArray(CHUNK_SIZE)
+ val buffer_l = ShortArray(CHUNK_SIZE)
+ val buffer_r = ShortArray(CHUNK_SIZE)
+ val mp3Buf = ByteArray(CHUNK_SIZE)
- val channels = waveReader!!.channels
+ val channels = waveReader!!.channels
- addLog("started encoding")
- while (true) {
- try {
- if (channels == 2) {
+ addLog("started encoding")
+ while (true) {
+ try {
+ if (channels == 2) {
+
+ bytesRead = waveReader!!.read(buffer_l, buffer_r, CHUNK_SIZE)
+ addLog("bytes read=$bytesRead")
- bytesRead = waveReader!!.read(buffer_l, buffer_r, CHUNK_SIZE)
- addLog("bytes read=$bytesRead")
+ if (bytesRead > 0) {
- if (bytesRead > 0) {
+ var bytesEncoded = 0
+ bytesEncoded = androidLameBuild.encode(buffer_l, buffer_r, bytesRead, mp3Buf)
+ addLog("bytes encoded=$bytesEncoded")
- var bytesEncoded = 0
- bytesEncoded = androidLameBuild.encode(buffer_l, buffer_r, bytesRead, mp3Buf)
- addLog("bytes encoded=$bytesEncoded")
+ if (bytesEncoded > 0) {
+ try {
+ addLog("writing mp3 buffer to outputstream with $bytesEncoded bytes")
+ outputStream!!.write(mp3Buf, 0, bytesEncoded)
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
- if (bytesEncoded > 0) {
- try {
- addLog("writing mp3 buffer to outputstream with $bytesEncoded bytes")
- outputStream!!.write(mp3Buf, 0, bytesEncoded)
- } catch (e: IOException) {
- e.printStackTrace()
}
- }
+ } else
+ break
+ } else {
- } else
- break
- } else {
+ bytesRead = waveReader!!.read(buffer_l, CHUNK_SIZE)
+ addLog("bytes read=$bytesRead")
- bytesRead = waveReader!!.read(buffer_l, CHUNK_SIZE)
- addLog("bytes read=$bytesRead")
+ if (bytesRead > 0) {
+ var bytesEncoded = 0
- if (bytesRead > 0) {
- var bytesEncoded = 0
+ bytesEncoded = androidLameBuild.encode(buffer_l, buffer_l, bytesRead, mp3Buf)
+ addLog("bytes encoded=$bytesEncoded")
- bytesEncoded = androidLameBuild.encode(buffer_l, buffer_l, bytesRead, mp3Buf)
- addLog("bytes encoded=$bytesEncoded")
+ if (bytesEncoded > 0) {
+ try {
+ addLog("writing mp3 buffer to outputstream with $bytesEncoded bytes")
+ outputStream!!.write(mp3Buf, 0, bytesEncoded)
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
- if (bytesEncoded > 0) {
- try {
- addLog("writing mp3 buffer to outputstream with $bytesEncoded bytes")
- outputStream!!.write(mp3Buf, 0, bytesEncoded)
- } catch (e: IOException) {
- e.printStackTrace()
}
- }
+ } else
+ break
+ }
- } else
- break
- }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
- } catch (e: IOException) {
- e.printStackTrace()
}
- }
-
- addLog("flushing final mp3buffer")
- val outputMp3buf = androidLameBuild.flush(mp3Buf)
- addLog("flushed $outputMp3buf bytes")
- if (outputMp3buf > 0) {
- try {
- addLog("writing final mp3buffer to outputstream")
- outputStream!!.write(mp3Buf, 0, outputMp3buf)
- addLog("closing output stream")
- outputStream!!.close()
- completeCallback(output.absolutePath, true)
- isCompletedCallbackTriggered=true
- } catch (e: IOException) {
- completeCallback(e.localizedMessage, false)
- e.printStackTrace()
+ addLog("flushing final mp3buffer")
+ val outputMp3buf = androidLameBuild.flush(mp3Buf)
+ addLog("flushed $outputMp3buf bytes")
+ if (outputMp3buf > 0) {
+ try {
+ addLog("writing final mp3buffer to outputstream")
+ outputStream!!.write(mp3Buf, 0, outputMp3buf)
+ addLog("closing output stream")
+ outputStream!!.close()
+ completeCallback(output.absolutePath, true)
+ isCompletedCallbackTriggered = true
+ } catch (e: IOException) {
+ completeCallback(e.localizedMessage, false)
+ isCompletedCallbackTriggered = true
+ e.printStackTrace()
+ }
}
- }
} catch (e: IOException) {
completeCallback(e.localizedMessage, false)
+ isCompletedCallbackTriggered = true
}
- if(!isCompletedCallbackTriggered)
- {
+ if (!isCompletedCallbackTriggered) {
completeCallback("something went wrong", false)
}
}
-
}
}
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 c6551828..00000000
--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package com.reactnativecompressor.Audio
-
-import android.annotation.SuppressLint
-import android.media.MediaCodec
-import android.media.MediaExtractor
-import android.media.MediaFormat
-import android.media.MediaMetadataRetriever
-import android.media.MediaMuxer
-import android.util.Log
-import java.io.IOException
-import java.nio.ByteBuffer
-
-
-class AudioExtractor {
- /**
- * @param srcPath the path of source video file.
- * @param dstPath the path of destination video file.
- * @param startMs starting time in milliseconds for trimming. Set to
- * negative if starting from beginning.
- * @param endMs end time for trimming in milliseconds. Set to negative if
- * no trimming at the end.
- * @param useAudio true if keep the audio track from the source.
- * @param useVideo true if keep the video track from the source.
- * @throws IOException
- */
- @SuppressLint("NewApi", "WrongConstant")
- @Throws(IOException::class)
- fun genVideoUsingMuxer(srcPath: String?, dstPath: String?, startMs: Int, endMs: Int, useAudio: Boolean, useVideo: Boolean) {
- // Set up MediaExtractor to read from the source.
- val extractor = MediaExtractor()
- extractor.setDataSource(srcPath!!)
- val trackCount = extractor.trackCount
- // Set up MediaMuxer for the destination.
- val muxer: MediaMuxer
- muxer = MediaMuxer(dstPath!!, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
- // Set up the tracks and retrieve the max buffer size for selected
- // tracks.
- val indexMap = HashMap(trackCount)
- var bufferSize = -1
- for (i in 0 until trackCount) {
- val format = extractor.getTrackFormat(i)
- val mime = format.getString(MediaFormat.KEY_MIME)
- var selectCurrentTrack = false
- if (mime!!.startsWith("audio/") && useAudio) {
- selectCurrentTrack = true
- } else if (mime.startsWith("video/") && useVideo) {
- selectCurrentTrack = true
- }
- if (selectCurrentTrack) {
- extractor.selectTrack(i)
- val dstIndex = muxer.addTrack(format)
- indexMap[i] = dstIndex
- if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
- val newSize = format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
- bufferSize = if (newSize > bufferSize) newSize else bufferSize
- }
- }
- }
- if (bufferSize < 0) {
- bufferSize = DEFAULT_BUFFER_SIZE
- }
- // Set up the orientation and starting time for extractor.
- val retrieverSrc = MediaMetadataRetriever()
- retrieverSrc.setDataSource(srcPath)
- val degreesString = retrieverSrc.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
- if (degreesString != null) {
- val degrees = degreesString.toInt()
- if (degrees >= 0) {
- muxer.setOrientationHint(degrees)
- }
- }
- if (startMs > 0) {
- extractor.seekTo((startMs * 1000).toLong(), MediaExtractor.SEEK_TO_CLOSEST_SYNC)
- }
- // Copy the samples from MediaExtractor to MediaMuxer. We will loop
- // for copying each sample and stop when we get to the end of the source
- // file or exceed the end time of the trimming.
- val offset = 0
- var trackIndex = -1
- val dstBuf = ByteBuffer.allocate(bufferSize)
- val bufferInfo = MediaCodec.BufferInfo()
- muxer.start()
- while (true) {
- bufferInfo.offset = offset
- bufferInfo.size = extractor.readSampleData(dstBuf, offset)
- if (bufferInfo.size < 0) {
- Log.d(TAG, "Saw input EOS.")
- bufferInfo.size = 0
- break
- } else {
- bufferInfo.presentationTimeUs = extractor.sampleTime
- if (endMs > 0 && bufferInfo.presentationTimeUs > endMs * 1000) {
- Log.d(TAG, "The current sample is over the trim end time.")
- break
- } else {
- bufferInfo.flags = extractor.sampleFlags
- trackIndex = extractor.sampleTrackIndex
- muxer.writeSampleData(indexMap[trackIndex]!!, dstBuf, bufferInfo)
- extractor.advance()
- }
- }
- }
- muxer.stop()
- muxer.release()
- return
- }
-
- companion object {
- private const val DEFAULT_BUFFER_SIZE = 1 * 1024 * 1024
- private const val TAG = "AudioExtractorDecoder"
- }
-}
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioTranscoder.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioTranscoder.kt
new file mode 100644
index 00000000..e374560e
--- /dev/null
+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioTranscoder.kt
@@ -0,0 +1,204 @@
+package com.reactnativecompressor.Audio
+
+import android.media.MediaCodec
+import android.media.MediaExtractor
+import android.media.MediaFormat
+import com.reactnativecompressor.Utils.Utils.addLog
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.RandomAccessFile
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * Decodes any audio container/codec Android can play (m4a/AAC, mp3, ogg,
+ * flac, ...) into a 16-bit PCM little-endian WAV file that LAME can consume.
+ *
+ * LAME's WaveReader only accepts PCM 16-bit LE WAV, so for any compressed
+ * (or non-matching) input we must transcode through a real decoder — jlayer's
+ * Converter is MP3-only and silently fails for m4a, ogg, flac, etc.
+ */
+internal object AudioTranscoder {
+
+ private const val TIMEOUT_US = 10_000L
+ private const val WAV_HEADER_SIZE = 44
+
+ /**
+ * Decode [srcPath] into a 16-bit PCM WAV at [dstPath].
+ *
+ * @throws IOException if the file cannot be opened, has no audio track,
+ * cannot be decoded on this device, or the output cannot be written.
+ */
+ @Throws(IOException::class)
+ fun decodeToWav(srcPath: String, dstPath: String) {
+ addLog("decodeToWav: $srcPath -> $dstPath")
+
+ val extractor = MediaExtractor()
+ var decoder: MediaCodec? = null
+ var output: BufferedOutputStream? = null
+
+ try {
+ // -- setup extractor -------------------------------------------------
+ extractor.setDataSource(srcPath)
+
+ val audioTrackIndex = findAudioTrackIndex(extractor)
+ ?: throw IOException("No audio track found in $srcPath")
+ extractor.selectTrack(audioTrackIndex)
+
+ val inputFormat = extractor.getTrackFormat(audioTrackIndex)
+ val mime = inputFormat.getString(MediaFormat.KEY_MIME)
+ ?: throw IOException("Audio track has no mime in $srcPath")
+ val sampleRate = if (inputFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+ inputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
+ } else {
+ throw IOException("Audio track has no sample rate in $srcPath")
+ }
+ val channelCount = if (inputFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
+ inputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
+ } else {
+ throw IOException("Audio track has no channel count in $srcPath")
+ }
+
+ // -- create + configure decoder ---------------------------------------
+ try {
+ decoder = MediaCodec.createDecoderByType(mime)
+ } catch (e: IllegalArgumentException) {
+ throw IOException("Device cannot decode audio mime $mime", e)
+ } catch (e: Exception) {
+ throw IOException("Failed to create decoder for $mime", e)
+ }
+ decoder!!.configure(inputFormat, null, null, 0)
+ decoder!!.start()
+
+ // Capture a non-null val so Kotlin smart-cast works in the loop below.
+ val d = decoder!!
+
+ // -- prepare output WAV file ------------------------------------------
+ val outFile = File(dstPath)
+ outFile.parentFile?.mkdirs()
+ output = BufferedOutputStream(FileOutputStream(outFile))
+ // Reserve 44 bytes for the RIFF/WAVE header; we'll come back and
+ // overwrite with the correct sizes once we know how many PCM bytes
+ // were produced.
+ output.write(ByteArray(WAV_HEADER_SIZE))
+
+ var totalBytesWritten = 0L
+ val info = MediaCodec.BufferInfo()
+ var inputDone = false
+ var outputDone = false
+
+ // -- decode loop ------------------------------------------------------
+ while (!outputDone) {
+ // Feed compressed samples into the decoder.
+ if (!inputDone) {
+ val inIndex = d.dequeueInputBuffer(TIMEOUT_US)
+ if (inIndex >= 0) {
+ val inBuf = d.getInputBuffer(inIndex)
+ ?: throw IOException("Decoder returned null input buffer")
+ inBuf.clear()
+ val sampleSize = extractor.readSampleData(inBuf, 0)
+ if (sampleSize < 0) {
+ d.queueInputBuffer(
+ inIndex, 0, 0, 0,
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM
+ )
+ inputDone = true
+ } else {
+ d.queueInputBuffer(
+ inIndex, 0, sampleSize, extractor.sampleTime, 0
+ )
+ extractor.advance()
+ }
+ }
+ }
+
+ // Drain decoded PCM and append it to the file (after the header
+ // placeholder).
+ val outIndex = d.dequeueOutputBuffer(info, TIMEOUT_US)
+ if (outIndex >= 0) {
+ val outBuf = d.getOutputBuffer(outIndex)
+ ?: throw IOException("Decoder returned null output buffer")
+ if (info.size > 0) {
+ // outBuf is a direct ByteBuffer; data is laid out in native byte
+ // order in [info.offset, info.offset + info.size). Copy the whole
+ // run in a single contiguous read.
+ val copy = ByteArray(info.size)
+ outBuf.position(info.offset)
+ outBuf.get(copy)
+ output.write(copy)
+ totalBytesWritten += info.size
+ }
+ d.releaseOutputBuffer(outIndex, false)
+
+ if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
+ outputDone = true
+ }
+ } else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ // The final decoded format may differ from what we read at
+ // configure time (e.g. AAC decoder may change channel layout
+ // after the first frame). Log it for diagnostics; the header we
+ // write at the end will use the configure-time values, which is
+ // the standard PCM layout that LAME expects.
+ addLog("decoder output format changed: ${d.outputFormat}")
+ }
+ }
+
+ output.flush()
+
+ // Patch the WAV header with the final sizes now that we know them.
+ writeWavHeader(outFile, sampleRate, channelCount, totalBytesWritten)
+
+ addLog("decodeToWav: wrote $totalBytesWritten PCM bytes")
+ } finally {
+ runCatching { decoder?.stop() }
+ runCatching { decoder?.release() }
+ runCatching { extractor.release() }
+ runCatching { output?.close() }
+ }
+ }
+
+ private fun findAudioTrackIndex(extractor: MediaExtractor): Int? {
+ for (i in 0 until extractor.trackCount) {
+ val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME)
+ if (mime != null && mime.startsWith("audio/")) return i
+ }
+ return null
+ }
+
+ /**
+ * Write a standard 44-byte PCM 16-bit little-endian WAV header into the
+ * first [WAV_HEADER_SIZE] bytes of [outFile], anchored to the [dataBytes]
+ * sample body that follows.
+ */
+ private fun writeWavHeader(outFile: File, sampleRate: Int, channels: Int, dataBytes: Long) {
+ val header = ByteBuffer.allocate(WAV_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN)
+ val byteRate = sampleRate * channels * 2 // 16-bit = 2 bytes per sample
+ val blockAlign = (channels * 2).toShort()
+ val chunkSize = 36L + dataBytes
+
+ header.put("RIFF".toByteArray(Charsets.US_ASCII))
+ header.putInt(chunkSize.toInt())
+ header.put("WAVE".toByteArray(Charsets.US_ASCII))
+ header.put("fmt ".toByteArray(Charsets.US_ASCII))
+ header.putInt(16) // PCM fmt chunk size
+ header.putShort(1) // PCM format
+ header.putShort(channels.toShort())
+ header.putInt(sampleRate)
+ header.putInt(byteRate)
+ header.putShort(blockAlign)
+ header.putShort(16) // bits per sample
+ header.put("data".toByteArray(Charsets.US_ASCII))
+ header.putInt(dataBytes.toInt())
+
+ val raf = RandomAccessFile(outFile, "rw")
+ try {
+ raf.seek(0)
+ raf.write(header.array(), 0, WAV_HEADER_SIZE)
+ } finally {
+ raf.close()
+ }
+ }
+
+}
diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt
index 9963b782..f2e346f5 100644
--- a/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt
+++ b/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt
@@ -151,6 +151,7 @@ object Compressor {
newWidth = tempHeight
0
}
+
180 -> 0
else -> rotation
}
@@ -311,189 +312,196 @@ object Compressor {
while (!outputDone) {
if (!inputDone) {
- // Feed the decoder until it has no free input slots or the
- // extractor is empty. HW codecs typically have 4-8 input
- // slots; queuing only one sample per outer iteration starves
- // the pipeline and forces serial decode-render-encode.
- feedLoop@ while (!inputDone) {
- val index = extractor.sampleTrackIndex
-
- if (index == videoIndex) {
- val inputBufferIndex =
- decoder.dequeueInputBuffer(0L)
- if (inputBufferIndex < 0) break@feedLoop
- val inputBuffer = decoder.getInputBuffer(inputBufferIndex)
- val chunkSize = extractor.readSampleData(inputBuffer!!, 0)
- when {
- chunkSize < 0 -> {
- decoder.queueInputBuffer(
- inputBufferIndex,
- 0,
- 0,
- 0L,
- MediaCodec.BUFFER_FLAG_END_OF_STREAM
- )
- inputDone = true
- }
- else -> {
- decoder.queueInputBuffer(
- inputBufferIndex,
- 0,
- chunkSize,
- extractor.sampleTime,
- 0
- )
- extractor.advance()
- }
- }
- } else if (index == -1) { //end of file
- val inputBufferIndex =
- decoder.dequeueInputBuffer(0L)
- if (inputBufferIndex < 0) break@feedLoop
- decoder.queueInputBuffer(
- inputBufferIndex,
- 0,
- 0,
- 0L,
- MediaCodec.BUFFER_FLAG_END_OF_STREAM
- )
- inputDone = true
- } else {
- // Different track type at head of extractor (audio etc.).
- break@feedLoop
- }
- }
- }
-
- var decoderOutputAvailable = true
- var encoderOutputAvailable = true
-
- loop@ while (decoderOutputAvailable || encoderOutputAvailable) {
-
- if (!isRunning) {
- dispose(
- videoIndex,
- decoder,
- encoder,
- inputSurface,
- outputSurface,
- extractor
- )
-
- compressionProgressListener.onProgressCancelled(id)
- return Result(
- id,
- success = false,
- failureMessage = "The compression has stopped!"
- )
- }
-
- //Encoder
- val encoderStatus =
- encoder.dequeueOutputBuffer(bufferInfo, MEDIACODEC_TIMEOUT_DEFAULT)
-
- when {
- encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderOutputAvailable =
- false
- encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
- val newFormat = encoder.outputFormat
- if (videoTrackIndex == -5)
- videoTrackIndex = mediaMuxer.addTrack(newFormat, false)
- }
- encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED -> {
- // ignore this status
- }
- encoderStatus < 0 -> throw RuntimeException("unexpected result from encoder.dequeueOutputBuffer: $encoderStatus")
- else -> {
- val encodedData = encoder.getOutputBuffer(encoderStatus)
- ?: throw RuntimeException("encoderOutputBuffer $encoderStatus was null")
-
- if (bufferInfo.size > 1) {
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
- mediaMuxer.writeSampleData(
- videoTrackIndex,
- encodedData, bufferInfo, false
- )
- }
-
- }
-
- outputDone =
- bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
- encoder.releaseOutputBuffer(encoderStatus, false)
- }
- }
- if (encoderStatus != MediaCodec.INFO_TRY_AGAIN_LATER) continue@loop
-
- //Decoder
- val decoderStatus =
- decoder.dequeueOutputBuffer(bufferInfo, MEDIACODEC_TIMEOUT_DEFAULT)
- when {
- decoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER -> decoderOutputAvailable =
- false
- decoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED -> {
- // ignore this status
- }
- decoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
- // ignore this status
- }
- decoderStatus < 0 -> throw RuntimeException("unexpected result from decoder.dequeueOutputBuffer: $decoderStatus")
- else -> {
- val isEos = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
- var doRender = bufferInfo.size != 0 && !isEos
-
- // Drop frames whose PTS falls before the next target slot.
- // Anchor the next slot to the ideal grid (previous slot +
- // interval) instead of to the actual PTS — anchoring to PTS
- // lets source-side jitter compound into extra drops, which
- // collapses the output frame rate well below the target.
- if (doRender && targetFrameIntervalUs > 0L) {
- if (bufferInfo.presentationTimeUs < nextTargetPtsUs) {
- doRender = false
- } else {
- nextTargetPtsUs += targetFrameIntervalUs
- // Snap forward when the source skips past a slot
- // (gap, seek, very low source fps) so the gate doesn't
- // burst-emit every following frame.
- if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
- nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
- }
- }
- }
-
- decoder.releaseOutputBuffer(decoderStatus, doRender)
- if (doRender) {
- var errorWait = false
- try {
- outputSurface.awaitNewImage()
- } catch (e: Exception) {
- errorWait = true
- Log.e(
- "Compressor",
- e.message ?: "Compression failed at swapping buffer"
- )
- }
-
- if (!errorWait) {
- outputSurface.drawImage()
-
- inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
-
- compressionProgressListener.onProgressChanged(
- id,
- bufferInfo.presentationTimeUs.toFloat() / duration.toFloat() * 100
- )
-
- inputSurface.swapBuffers()
- }
- }
- if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
- decoderOutputAvailable = false
- encoder.signalEndOfInputStream()
- }
- }
- }
- }
+ // Feed the decoder until it has no free input slots or the
+ // extractor is empty. HW codecs typically have 4-8 input
+ // slots; queuing only one sample per outer iteration starves
+ // the pipeline and forces serial decode-render-encode.
+ feedLoop@ while (!inputDone) {
+ val index = extractor.sampleTrackIndex
+
+ if (index == videoIndex) {
+ val inputBufferIndex =
+ decoder.dequeueInputBuffer(0L)
+ if (inputBufferIndex < 0) break@feedLoop
+ val inputBuffer = decoder.getInputBuffer(inputBufferIndex)
+ val chunkSize = extractor.readSampleData(inputBuffer!!, 0)
+ when {
+ chunkSize < 0 -> {
+ decoder.queueInputBuffer(
+ inputBufferIndex,
+ 0,
+ 0,
+ 0L,
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM
+ )
+ inputDone = true
+ }
+
+ else -> {
+ decoder.queueInputBuffer(
+ inputBufferIndex,
+ 0,
+ chunkSize,
+ extractor.sampleTime,
+ 0
+ )
+ extractor.advance()
+ }
+ }
+ } else if (index == -1) { //end of file
+ val inputBufferIndex =
+ decoder.dequeueInputBuffer(0L)
+ if (inputBufferIndex < 0) break@feedLoop
+ decoder.queueInputBuffer(
+ inputBufferIndex,
+ 0,
+ 0,
+ 0L,
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM
+ )
+ inputDone = true
+ } else {
+ // Different track type at head of extractor (audio etc.).
+ break@feedLoop
+ }
+ }
+ }
+
+ var decoderOutputAvailable = true
+ var encoderOutputAvailable = true
+
+ loop@ while (decoderOutputAvailable || encoderOutputAvailable) {
+
+ if (!isRunning) {
+ dispose(
+ videoIndex,
+ decoder,
+ encoder,
+ inputSurface,
+ outputSurface,
+ extractor
+ )
+
+ compressionProgressListener.onProgressCancelled(id)
+ return Result(
+ id,
+ success = false,
+ failureMessage = "The compression has stopped!"
+ )
+ }
+
+ //Encoder
+ val encoderStatus =
+ encoder.dequeueOutputBuffer(bufferInfo, MEDIACODEC_TIMEOUT_DEFAULT)
+
+ when {
+ encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderOutputAvailable =
+ false
+
+ encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
+ val newFormat = encoder.outputFormat
+ if (videoTrackIndex == -5)
+ videoTrackIndex = mediaMuxer.addTrack(newFormat, false)
+ }
+
+ encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED -> {
+ // ignore this status
+ }
+
+ encoderStatus < 0 -> throw RuntimeException("unexpected result from encoder.dequeueOutputBuffer: $encoderStatus")
+ else -> {
+ val encodedData = encoder.getOutputBuffer(encoderStatus)
+ ?: throw RuntimeException("encoderOutputBuffer $encoderStatus was null")
+
+ if (bufferInfo.size > 1) {
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) {
+ mediaMuxer.writeSampleData(
+ videoTrackIndex,
+ encodedData, bufferInfo, false
+ )
+ }
+
+ }
+
+ outputDone =
+ bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
+ encoder.releaseOutputBuffer(encoderStatus, false)
+ }
+ }
+ if (encoderStatus != MediaCodec.INFO_TRY_AGAIN_LATER) continue@loop
+
+ //Decoder
+ val decoderStatus =
+ decoder.dequeueOutputBuffer(bufferInfo, MEDIACODEC_TIMEOUT_DEFAULT)
+ when {
+ decoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER -> decoderOutputAvailable =
+ false
+
+ decoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED -> {
+ // ignore this status
+ }
+
+ decoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
+ // ignore this status
+ }
+
+ decoderStatus < 0 -> throw RuntimeException("unexpected result from decoder.dequeueOutputBuffer: $decoderStatus")
+ else -> {
+ val isEos = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
+ var doRender = bufferInfo.size != 0 && !isEos
+
+ // Drop frames whose PTS falls before the next target slot.
+ // Anchor the next slot to the ideal grid (previous slot +
+ // interval) instead of to the actual PTS — anchoring to PTS
+ // lets source-side jitter compound into extra drops, which
+ // collapses the output frame rate well below the target.
+ if (doRender && targetFrameIntervalUs > 0L) {
+ if (bufferInfo.presentationTimeUs < nextTargetPtsUs) {
+ doRender = false
+ } else {
+ nextTargetPtsUs += targetFrameIntervalUs
+ // Snap forward when the source skips past a slot
+ // (gap, seek, very low source fps) so the gate doesn't
+ // burst-emit every following frame.
+ if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
+ nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
+ }
+ }
+ }
+
+ decoder.releaseOutputBuffer(decoderStatus, doRender)
+ if (doRender) {
+ var errorWait = false
+ try {
+ outputSurface.awaitNewImage()
+ } catch (e: Exception) {
+ errorWait = true
+ Log.e(
+ "Compressor",
+ e.message ?: "Compression failed at swapping buffer"
+ )
+ }
+
+ if (!errorWait) {
+ outputSurface.drawImage()
+
+ inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
+
+ compressionProgressListener.onProgressChanged(
+ id,
+ bufferInfo.presentationTimeUs.toFloat() / duration.toFloat() * 100
+ )
+
+ inputSurface.swapBuffers()
+ }
+ }
+ if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ decoderOutputAvailable = false
+ encoder.signalEndOfInputStream()
+ }
+ }
+ }
+ }
}
} catch (exception: Throwable) {
@@ -605,207 +613,221 @@ object Compressor {
}
// Function to process audio
- private fun processAudio(
- mediaMuxer: MP4Builder,
- bufferInfo: MediaCodec.BufferInfo,
- disableAudio: Boolean,
- extractor: MediaExtractor
- ) {
- val audioIndex = findTrack(extractor, isVideo = false)
- if (audioIndex >= 0 && !disableAudio) {
- extractor.selectTrack(audioIndex)
- val audioFormat = extractor.getTrackFormat(audioIndex)
- if (!isSupportedAudioFormat(audioFormat)) {
- extractor.unselectTrack(audioIndex)
- return
- }
- val muxerTrackIndex = mediaMuxer.addTrack(audioFormat, true)
- var maxBufferSize = if (audioFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
- audioFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
- } else {
- 64 * 1024
- }
+ private fun processAudio(
+ mediaMuxer: MP4Builder,
+ bufferInfo: MediaCodec.BufferInfo,
+ disableAudio: Boolean,
+ extractor: MediaExtractor
+ ) {
+ val audioIndex = findTrack(extractor, isVideo = false)
+ if (audioIndex >= 0 && !disableAudio) {
+ extractor.selectTrack(audioIndex)
+ val audioFormat = extractor.getTrackFormat(audioIndex)
+ if (!isSupportedAudioFormat(audioFormat)) {
+ extractor.unselectTrack(audioIndex)
+ return
+ }
+ val muxerTrackIndex = mediaMuxer.addTrack(audioFormat, true)
+ var maxBufferSize = if (audioFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
+ audioFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
+ } else {
+ 64 * 1024
+ }
- if (maxBufferSize <= 0) {
- maxBufferSize = 64 * 1024
- }
+ if (maxBufferSize <= 0) {
+ maxBufferSize = 64 * 1024
+ }
- var buffer: ByteBuffer = ByteBuffer.allocateDirect(maxBufferSize)
- if (Build.VERSION.SDK_INT >= 28) {
- val size = extractor.sampleSize
- if (size > maxBufferSize) {
- maxBufferSize = (size + 1024).toInt()
- buffer = ByteBuffer.allocateDirect(maxBufferSize)
- }
+ var buffer: ByteBuffer = ByteBuffer.allocateDirect(maxBufferSize)
+ if (Build.VERSION.SDK_INT >= 28) {
+ val size = extractor.sampleSize
+ if (size > maxBufferSize) {
+ maxBufferSize = (size + 1024).toInt()
+ buffer = ByteBuffer.allocateDirect(maxBufferSize)
+ }
+ }
+ var inputDone = false
+ extractor.seekTo(0, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
+
+ while (!inputDone) {
+ val index = extractor.sampleTrackIndex
+ if (index == audioIndex) {
+ bufferInfo.size = extractor.readSampleData(buffer, 0)
+
+ if (bufferInfo.size >= 0) {
+ bufferInfo.apply {
+ presentationTimeUs = extractor.sampleTime
+ offset = 0
+ flags = MediaCodec.BUFFER_FLAG_KEY_FRAME
}
- var inputDone = false
- extractor.seekTo(0, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
-
- while (!inputDone) {
- val index = extractor.sampleTrackIndex
- if (index == audioIndex) {
- bufferInfo.size = extractor.readSampleData(buffer, 0)
-
- if (bufferInfo.size >= 0) {
- bufferInfo.apply {
- presentationTimeUs = extractor.sampleTime
- offset = 0
- flags = MediaCodec.BUFFER_FLAG_KEY_FRAME
- }
- mediaMuxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo, true)
- extractor.advance()
+ mediaMuxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo, true)
+ extractor.advance()
- } else {
- bufferInfo.size = 0
- inputDone = true
- }
- } else if (index == -1) {
- inputDone = true
- }
- }
- extractor.unselectTrack(audioIndex)
+ } else {
+ bufferInfo.size = 0
+ inputDone = true
+ }
+ } else if (index == -1) {
+ inputDone = true
}
+ }
+ extractor.unselectTrack(audioIndex)
}
+ }
- private fun isSupportedAudioFormat(audioFormat: MediaFormat): Boolean {
- if (!audioFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE) ||
- !audioFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
- return false
- }
- val sampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
- val channelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
- return channelCount > 0 && sampleRate in SUPPORTED_AUDIO_SAMPLE_RATES
+ private fun isSupportedAudioFormat(audioFormat: MediaFormat): Boolean {
+ if (!audioFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE) ||
+ !audioFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)
+ ) {
+ return false
}
+ val sampleRate = audioFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
+ val channelCount = audioFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
+ return channelCount > 0 && sampleRate in SUPPORTED_AUDIO_SAMPLE_RATES
+ }
// Function to prepare the video encoder
- private fun prepareEncoder(
- outputFormat: MediaFormat,
- hasQTI: Boolean,
- baselineFormatProvider: () -> MediaFormat,
- ): MediaCodec {
- // Prefer hardware AVC encoder while skipping known-broken QTI codec that
- // produces files unplayable on Mac/iOS (c2.qti.avc.encoder).
- val encoder = pickAvcEncoder(outputFormat, hasQTI)
- try {
- encoder.configure(
- outputFormat, null, null,
- MediaCodec.CONFIGURE_FLAG_ENCODE
- )
- Log.i("Compressor", "encoder selected: ${encoder.name}")
- return encoder
- } catch (e: Exception) {
- // Some encoders reject the throughput-tuning keys (VBR bitrate mode,
- // priority, operating rate) at configure() time. A codec that throws
- // from configure() is unusable, so release it and retry on a fresh
- // codec with a baseline format (default rate control) rather than
- // failing the whole compression.
- Log.w(
- "Compressor",
- "encoder.configure rejected tuned format; retrying with default settings",
- e
- )
- runCatching { encoder.release() }
- }
+ private fun prepareEncoder(
+ outputFormat: MediaFormat,
+ hasQTI: Boolean,
+ baselineFormatProvider: () -> MediaFormat,
+ ): MediaCodec {
+ // Prefer hardware AVC encoder while skipping known-broken QTI codec that
+ // produces files unplayable on Mac/iOS (c2.qti.avc.encoder).
+ val encoder = pickAvcEncoder(outputFormat, hasQTI)
+ try {
+ encoder.configure(
+ outputFormat, null, null,
+ MediaCodec.CONFIGURE_FLAG_ENCODE
+ )
+ Log.i("Compressor", "encoder selected: ${encoder.name}")
+ return encoder
+ } catch (e: Exception) {
+ // Some encoders reject the throughput-tuning keys (VBR bitrate mode,
+ // priority, operating rate) at configure() time. A codec that throws
+ // from configure() is unusable, so release it and retry on a fresh
+ // codec with a baseline format (default rate control) rather than
+ // failing the whole compression.
+ Log.w(
+ "Compressor",
+ "encoder.configure rejected tuned format; retrying with default settings",
+ e
+ )
+ runCatching { encoder.release() }
+ }
- val baseline = baselineFormatProvider()
- val fallback = pickAvcEncoder(baseline, hasQTI)
- try {
- fallback.configure(
- baseline, null, null,
- MediaCodec.CONFIGURE_FLAG_ENCODE
- )
- } catch (e: Exception) {
- // Even the baseline format was rejected; release the codec so it
- // doesn't leak, then let start()'s outer catch report the failure.
- runCatching { fallback.release() }
- throw e
- }
- Log.i("Compressor", "encoder selected (fallback, default rate control): ${fallback.name}")
- return fallback
+ val baseline = baselineFormatProvider()
+ val fallback = pickAvcEncoder(baseline, hasQTI)
+ try {
+ fallback.configure(
+ baseline, null, null,
+ MediaCodec.CONFIGURE_FLAG_ENCODE
+ )
+ } catch (e: Exception) {
+ // Even the baseline format was rejected; release the codec so it
+ // doesn't leak, then let start()'s outer catch report the failure.
+ runCatching { fallback.release() }
+ throw e
}
+ Log.i("Compressor", "encoder selected (fallback, default rate control): ${fallback.name}")
+ return fallback
+ }
- private fun pickAvcEncoder(outputFormat: MediaFormat, hasQTI: Boolean): MediaCodec {
- // ALL_CODECS surfaces vendor codecs that REGULAR_CODECS hides (e.g. some
- // Exynos / MTK HW encoders). We still filter blacklisted / SW codecs below.
- val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
- val candidates = codecList.codecInfos.filter { info ->
- info.isEncoder && info.supportedTypes.any { it.equals(MIME_TYPE, ignoreCase = true) }
- }
+ private fun pickAvcEncoder(outputFormat: MediaFormat, hasQTI: Boolean): MediaCodec {
+ // ALL_CODECS surfaces vendor codecs that REGULAR_CODECS hides (e.g. some
+ // Exynos / MTK HW encoders). We still filter blacklisted / SW codecs below.
+ val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
+ val candidates = codecList.codecInfos.filter { info ->
+ info.isEncoder && info.supportedTypes.any { it.equals(MIME_TYPE, ignoreCase = true) }
+ }
- fun isBlacklisted(name: String): Boolean {
- val lower = name.lowercase()
- return lower.contains("c2.qti.avc.encoder") || lower.contains("omx.qcom.video.encoder.avc.secure")
- }
+ fun isBlacklisted(name: String): Boolean {
+ val lower = name.lowercase()
+ return lower.contains("c2.qti.avc.encoder") || lower.contains("omx.qcom.video.encoder.avc.secure")
+ }
- fun isSoftware(info: MediaCodecInfo): Boolean {
- val name = info.name.lowercase()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- if (info.isSoftwareOnly) return true
- }
- return name.startsWith("omx.google.") ||
- name.startsWith("c2.android.") ||
- name.contains(".sw.")
- }
+ fun isSoftware(info: MediaCodecInfo): Boolean {
+ val name = info.name.lowercase()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (info.isSoftwareOnly) return true
+ }
+ return name.startsWith("omx.google.") ||
+ name.startsWith("c2.android.") ||
+ name.contains(".sw.")
+ }
- val supportsFormat = candidates.filter { info ->
- runCatching {
- info.getCapabilitiesForType(MIME_TYPE).isFormatSupported(outputFormat)
- }.getOrDefault(false) && !isBlacklisted(info.name)
- }
+ val supportsFormat = candidates.filter { info ->
+ runCatching {
+ info.getCapabilitiesForType(MIME_TYPE).isFormatSupported(outputFormat)
+ }.getOrDefault(false) && !isBlacklisted(info.name)
+ }
- val hardwareFirst = supportsFormat.firstOrNull { !isSoftware(it) }
- val chosen = hardwareFirst ?: supportsFormat.firstOrNull()
+ val hardwareFirst = supportsFormat.firstOrNull { !isSoftware(it) }
+ val chosen = hardwareFirst ?: supportsFormat.firstOrNull()
- if (chosen != null) {
- return MediaCodec.createByCodecName(chosen.name)
- }
+ if (chosen != null) {
+ return MediaCodec.createByCodecName(chosen.name)
+ }
- // Fallback: keep historical QTI-safe path when format probing fails.
- return if (hasQTI) {
- MediaCodec.createByCodecName("c2.android.avc.encoder")
- } else {
- MediaCodec.createEncoderByType(MIME_TYPE)
- }
+ // Fallback: keep historical QTI-safe path when format probing fails.
+ return if (hasQTI) {
+ MediaCodec.createByCodecName("c2.android.avc.encoder")
+ } else {
+ MediaCodec.createEncoderByType(MIME_TYPE)
}
+ }
// Dolby Vision profile 5 (0x20) carries no HEVC base layer, so no standard
// Android decoder can render it. Detect it up front so start() can reject the
// input before allocating the muxer/encoder/EGL surfaces. Profiles 8.x do carry
// an HEVC base layer and are remapped to HEVC in prepareDecoder.
- private fun isUnsupportedDolbyVision(inputFormat: MediaFormat): Boolean {
- val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: return false
- if (!mime.equals("video/dolby-vision", ignoreCase = true)) return false
- val profile = if (inputFormat.containsKey(MediaFormat.KEY_PROFILE)) {
- inputFormat.getInteger(MediaFormat.KEY_PROFILE)
- } else {
- -1
- }
- return profile == 0x20
+ private fun isUnsupportedDolbyVision(inputFormat: MediaFormat): Boolean {
+ val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: return false
+ if (!mime.equals("video/dolby-vision", ignoreCase = true)) return false
+ val profile = if (inputFormat.containsKey(MediaFormat.KEY_PROFILE)) {
+ inputFormat.getInteger(MediaFormat.KEY_PROFILE)
+ } else {
+ -1
+ }
+ return profile == 0x20
+ }
+
+ // Some inputs (e.g. iPhone .MOV files) report a "video/dolby-vision" MIME
+ // type that many devices cannot decode. Profiles 8.x carry an HEVC base
+ // layer that the standard HEVC decoder can render, so we remap them to
+ // HEVC here. Profile 5 has no compatible base layer and is rejected by
+ // isUnsupportedDolbyVision() in start() before any codec/surface is
+ // created, so it never reaches this helper (#398).
+ private fun ensureDecodableVideoFormat(inputFormat: MediaFormat) {
+ val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: return
+ if (mime.equals("video/dolby-vision", ignoreCase = true)) {
+ inputFormat.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC)
}
+ }
// Function to prepare the video decoder
- private fun prepareDecoder(
- inputFormat: MediaFormat,
- outputSurface: OutputSurface,
- ): MediaCodec {
- // Some inputs (e.g. iPhone .MOV files) report a "video/dolby-vision" MIME
- // type that many devices cannot decode. Remap to a decodable base-layer
- // codec, or fail with a clear error, before creating the decoder (#398).
- ensureDecodableVideoFormat(inputFormat)
-
- // Clear Dolby Vision specific profile and level to prevent configuration failures
- // when the MIME type has been remapped to AVC/HEVC.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- inputFormat.removeKey(MediaFormat.KEY_PROFILE)
- inputFormat.removeKey(MediaFormat.KEY_LEVEL)
- }
+ private fun prepareDecoder(
+ inputFormat: MediaFormat,
+ outputSurface: OutputSurface,
+ ): MediaCodec {
+ // Some inputs (e.g. iPhone .MOV files) report a "video/dolby-vision" MIME
+ // type that many devices cannot decode. Remap to a decodable base-layer
+ // codec, or fail with a clear error, before creating the decoder (#398).
+ ensureDecodableVideoFormat(inputFormat)
+
+ // Clear Dolby Vision specific profile and level to prevent configuration failures
+ // when the MIME type has been remapped to AVC/HEVC.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ inputFormat.removeKey(MediaFormat.KEY_PROFILE)
+ inputFormat.removeKey(MediaFormat.KEY_LEVEL)
+ }
- val decoder = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME)!!)
+ val decoder = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME)!!)
- decoder.configure(inputFormat, outputSurface.getSurface(), null, 0)
+ decoder.configure(inputFormat, outputSurface.getSurface(), null, 0)
- return decoder
- }
+ return decoder
+ }
// Function to release resources.
// Every call is wrapped in runCatching so a failure in one teardown step
@@ -818,30 +840,30 @@ object Compressor {
// decoder / inputSurface / outputSurface are nullable so this also serves the
// partial-init cleanup path, where a setup failure leaves some handles
// uncreated. The encoder is always created before teardown is reachable.
- private fun dispose(
- videoIndex: Int,
- decoder: MediaCodec?,
- encoder: MediaCodec,
- inputSurface: InputSurface?,
- outputSurface: OutputSurface?,
- extractor: MediaExtractor
- ) {
- runCatching { extractor.unselectTrack(videoIndex) }
- .onFailure { Log.w("Compressor", "extractor.unselectTrack failed", it) }
-
- runCatching { decoder?.stop() }
- .onFailure { Log.w("Compressor", "decoder.stop failed", it) }
- runCatching { decoder?.release() }
- .onFailure { Log.w("Compressor", "decoder.release failed", it) }
-
- runCatching { encoder.stop() }
- .onFailure { Log.w("Compressor", "encoder.stop failed", it) }
- runCatching { encoder.release() }
- .onFailure { Log.w("Compressor", "encoder.release failed", it) }
-
- runCatching { inputSurface?.release() }
- .onFailure { Log.w("Compressor", "inputSurface.release failed", it) }
- runCatching { outputSurface?.release() }
- .onFailure { Log.w("Compressor", "outputSurface.release failed", it) }
- }
+ private fun dispose(
+ videoIndex: Int,
+ decoder: MediaCodec?,
+ encoder: MediaCodec,
+ inputSurface: InputSurface?,
+ outputSurface: OutputSurface?,
+ extractor: MediaExtractor
+ ) {
+ runCatching { extractor.unselectTrack(videoIndex) }
+ .onFailure { Log.w("Compressor", "extractor.unselectTrack failed", it) }
+
+ runCatching { decoder?.stop() }
+ .onFailure { Log.w("Compressor", "decoder.stop failed", it) }
+ runCatching { decoder?.release() }
+ .onFailure { Log.w("Compressor", "decoder.release failed", it) }
+
+ runCatching { encoder.stop() }
+ .onFailure { Log.w("Compressor", "encoder.stop failed", it) }
+ runCatching { encoder.release() }
+ .onFailure { Log.w("Compressor", "encoder.release failed", it) }
+
+ runCatching { inputSurface?.release() }
+ .onFailure { Log.w("Compressor", "inputSurface.release failed", it) }
+ runCatching { outputSurface?.release() }
+ .onFailure { Log.w("Compressor", "outputSurface.release failed", it) }
+ }
}
diff --git a/ios/Image/ImageCompressor.swift b/ios/Image/ImageCompressor.swift
index b7cb642a..d380e3dd 100644
--- a/ios/Image/ImageCompressor.swift
+++ b/ios/Image/ImageCompressor.swift
@@ -44,7 +44,7 @@ class ImageCompressor {
}
- static func manualResize(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> UIImage {
+ static func manualResize(_ image: UIImage, maxWidth: Int, maxHeight: Int) throws -> UIImage {
let targetSize = findTargetSize(image, maxWidth: maxWidth, maxHeight: maxHeight)
if let cgImage = image.cgImage {
@@ -82,6 +82,10 @@ class ImageCompressor {
free(&targetData)
let exception = NSException(name: NSExceptionName(rawValue: "drawing_error"), reason: "Problem while rendering your image", userInfo: nil)
exception.raise()
+ // Throw instead of raising an NSException (process abort no JS
+ // catch survives) — the caller rejects the promise.
+ throw NSError(domain: "drawing_error", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Problem while rendering your image"])
}
let targetContext = CGContext(data: &targetData,
@@ -164,7 +168,7 @@ class ImageCompressor {
return destinationData as Data
}
- static func writeImage(_ image: UIImage, output: Int, quality: Float, outputExtension: String, isBase64: Bool, disablePngTransparency: Bool, isEnableAutoCompress: Bool, actualImagePath: String?)-> String {
+ static func writeImage(_ image: UIImage, output: Int, quality: Float, outputExtension: String, isBase64: Bool, disablePngTransparency: Bool, isEnableAutoCompress: Bool, actualImagePath: String?) throws -> String {
var data: Data
var exception: NSException?
let normalizedQuality = CGFloat(min(max(quality, 0), 1))
@@ -212,14 +216,19 @@ class ImageCompressor {
} catch {
exception = NSException(name: NSExceptionName(rawValue: "file_error"), reason: "Error writing file", userInfo: nil)
exception?.raise()
+ // Throw instead of raising an NSException: a raise inside a
+ // TurboModule invocation aborts the whole app and no JS catch
+ // can intercept it — the caller rejects the promise instead.
+ throw NSError(domain: "file_error", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Error writing file: \(error.localizedDescription)"])
}
}
return ""
}
- static func manualCompress(_ image: UIImage, output: Int, quality: Float, outputExtension: String, isBase64: Bool, disablePngTransparency: Bool, actualImagePath: String?) -> String {
- return writeImage(image, output: output, quality: quality, outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: disablePngTransparency, isEnableAutoCompress: false, actualImagePath: actualImagePath)
+ static func manualCompress(_ image: UIImage, output: Int, quality: Float, outputExtension: String, isBase64: Bool, disablePngTransparency: Bool, actualImagePath: String?) throws -> String {
+ return try writeImage(image, output: output, quality: quality, outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: disablePngTransparency, isEnableAutoCompress: false, actualImagePath: actualImagePath)
}
@@ -288,7 +297,7 @@ class ImageCompressor {
}
- static func manualCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> String {
+ static func manualCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) throws -> String {
var exception: NSException?
var image: UIImage?
@@ -306,22 +315,28 @@ class ImageCompressor {
if let _image = image {
image = ImageCompressor.scaleAndRotateImage(_image)
let outputExtension = ImageCompressorOptions.getOutputInString(options.output)
- let resizedImage = ImageCompressor.manualResize(image!, maxWidth: options.maxWidth, maxHeight: options.maxHeight)
+ let resizedImage = try ImageCompressor.manualResize(image!, maxWidth: options.maxWidth, maxHeight: options.maxHeight)
let isBase64 = options.returnableOutputType == .rbase64
- return ImageCompressor.manualCompress(resizedImage, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64,disablePngTransparency: options.disablePngTransparency, actualImagePath: imagePath)
+ return try ImageCompressor.manualCompress(resizedImage, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64,disablePngTransparency: options.disablePngTransparency, actualImagePath: imagePath)
} else {
exception = NSException(name: NSExceptionName(rawValue: "unsupported_value"), reason: "Unsupported value type.", userInfo: nil)
exception?.raise()
+ // Throw instead of raising an NSException (process abort no JS
+ // catch survives — e.g. a stored file path invalidated by an iOS
+ // container relocation boot-looped an app at startup). The caller
+ // rejects the promise with the same name/message.
+ throw NSError(domain: "unsupported_value", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Unsupported value type."])
}
return ""
}
- static func autoCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> String {
+ static func autoCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) throws -> String {
var exception: NSException?
var image: UIImage?
-
+
switch options.input {
case .base64:
if let _base64 = base64 {
@@ -332,11 +347,11 @@ class ImageCompressor {
image = ImageCompressor.loadImage(_imagePath)
}
}
-
+
if var image = image {
image = ImageCompressor.scaleAndRotateImage(image)
let outputExtension = ImageCompressorOptions.getOutputInString(options.output)
-
+
var actualHeight = image.size.height
var actualWidth = image.size.width
let maxHeight: CGFloat = CGFloat(options.maxHeight)
@@ -344,7 +359,7 @@ class ImageCompressor {
var imgRatio = actualWidth / actualHeight
let maxRatio = maxWidth / maxHeight
let compressionQuality: CGFloat = CGFloat(options.quality)
-
+
if actualHeight > maxHeight || actualWidth > maxWidth {
if imgRatio < maxRatio {
imgRatio = maxHeight / actualHeight
@@ -359,20 +374,26 @@ class ImageCompressor {
actualWidth = maxWidth
}
}
-
+
let rect = CGRect(x: 0.0, y: 0.0, width: actualWidth, height: actualHeight)
UIGraphicsBeginImageContext(rect.size)
image.draw(in: rect)
let isBase64 = options.returnableOutputType == .rbase64
-
+
if let img = UIGraphicsGetImageFromCurrentImageContext() {
- return writeImage(img, output: options.output.rawValue, quality: Float(compressionQuality), outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, isEnableAutoCompress: true, actualImagePath: imagePath)
+ return try writeImage(img, output: options.output.rawValue, quality: Float(compressionQuality), outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, isEnableAutoCompress: true, actualImagePath: imagePath)
}
} else {
exception = NSException(name: NSExceptionName(rawValue: "unsupported_value"), reason: "Unsupported value type.", userInfo: nil)
exception?.raise()
+ // Throw instead of raising an NSException (process abort no JS
+ // catch survives — e.g. a stored file path invalidated by an iOS
+ // container relocation boot-looped an app at startup). The caller
+ // rejects the promise with the same name/message.
+ throw NSError(domain: "unsupported_value", code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Unsupported value type."])
}
-
+
return ""
}
diff --git a/ios/Image/ImageMain.swift b/ios/Image/ImageMain.swift
index 2e6e9fc0..6040812f 100644
--- a/ios/Image/ImageMain.swift
+++ b/ios/Image/ImageMain.swift
@@ -13,22 +13,32 @@ class ImageMain {
if options.input != InputType.base64 {
ImageCompressor.getAbsoluteImagePath(value, options: options) { absoluteImagePath in
+ // The escaping completion runs outside the outer do/catch,
+ // so failures here need their own catch to reject.
+ do {
+ if options.autoCompress {
+ let result = try ImageCompressor.autoCompressHandler(imagePath: absoluteImagePath, base64: nil, options: options)
+ resolve(result)
+ } else {
+ let result = try ImageCompressor.manualCompressHandler(imagePath: absoluteImagePath, base64: nil, options: options)
+ resolve(result)
+ }
+ } catch {
+ reject((error as NSError).domain, error.localizedDescription, error)
+ }
+ MediaCache.removeCompletedImagePath(absoluteImagePath)
+ }
+ } else {
+ do {
if options.autoCompress {
- let result = ImageCompressor.autoCompressHandler(imagePath: absoluteImagePath, base64: nil, options: options)
+ let result = try ImageCompressor.autoCompressHandler(imagePath: nil, base64: value, options: options)
resolve(result)
} else {
- let result = ImageCompressor.manualCompressHandler(imagePath: absoluteImagePath, base64: nil, options: options)
+ let result = try ImageCompressor.manualCompressHandler(imagePath: nil, base64: value, options: options)
resolve(result)
}
- MediaCache.removeCompletedImagePath(absoluteImagePath)
- }
- } else {
- if options.autoCompress {
- let result = ImageCompressor.autoCompressHandler(imagePath: nil, base64: value, options: options)
- resolve(result)
- } else {
- let result = ImageCompressor.manualCompressHandler(imagePath: nil, base64: value, options: options)
- resolve(result)
+ } catch {
+ reject((error as NSError).domain, error.localizedDescription, error)
}
}
} catch {
diff --git a/package.json b/package.json
index b9b6985a..a2804924 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-native-compressor",
- "version": "1.19.2",
+ "version": "1.19.3",
"description": "Compress Image, Video, and Audio same like Whatsapp & Auto/Manual Compression | Background Upload | Download File | Create Video Thumbnail",
"main": "lib/commonjs/index",
"module": "lib/module/index",