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",