Skip to content

Commit e27f8f1

Browse files
XChikuXCopilot
andauthored
Better compatibility with Dolby vision (#402)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 4b1b288 commit e27f8f1

5 files changed

Lines changed: 223 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# CLAUDE.md
2+
3+
Guidance for AI agents (Claude, Copilot, etc.) and new contributors working in
4+
this repository. Keep this file up to date when project structure, conventions,
5+
or workflows change.
6+
7+
## What this project is
8+
9+
`react-native-compressor` is a React Native library that compresses **video**,
10+
**image**, and **audio** files (and provides background upload/download helpers)
11+
with results comparable to WhatsApp-style compression. It ships native code for
12+
both **iOS** (Swift/Obj-C) and **Android** (Kotlin), exposed to JavaScript through
13+
a TurboModule-capable spec, and supports both the old and new React Native
14+
architectures plus an Expo config plugin.
15+
16+
- Package name: `react-native-compressor`
17+
- Package manager: **Yarn 4** (`packageManager: yarn@4.14.1`); Yarn workspaces with `examples/*`.
18+
- Upstream: https://github.com/numandev1/react-native-compressor — this is a fork
19+
(`XChikuX/react-native-compressor`) that triages and fixes upstream issues.
20+
21+
## Repository layout
22+
23+
```
24+
src/ JavaScript/TypeScript public API (the npm entry point)
25+
index.tsx Re-exports the public surface
26+
Main.tsx Aggregates the default export object
27+
Spec/NativeCompressor.ts TurboModule spec (single source of truth for native methods)
28+
Video/ Image/ Audio/ Per-domain JS wrappers (compress(), options, events)
29+
utils/ Uploader/Downloader/helpers (uuid, path normalization)
30+
expo-plugin/ Expo config plugin
31+
ios/ Native iOS implementation (Swift + Obj-C bridge)
32+
Video/VideoMain.swift Video compression entry (auto/manual helpers)
33+
Video/NextLevelSessionExporter.swift AVAssetReader/Writer export engine
34+
Image/ Audio/ Utils/ Image, audio, upload/download, thumbnails
35+
Compressor.mm / Compressor.h Obj-C bridge to the Swift module
36+
android/ Native Android implementation (Kotlin)
37+
src/main/java/com/reactnativecompressor/
38+
Video/ Video compression (MediaCodec transcode pipeline)
39+
VideoCompressor/compressor/Compressor.kt Core encode/decode loop
40+
VideoCompressor/utils/CompressorUtils.kt Format/codec helpers
41+
Image/ Audio/ Utils/ Image, audio, upload/download, helpers
42+
src/oldarch / src/newarch Architecture-specific TurboModule specs
43+
__tests__/ Jest unit tests for the JS wrapper (native is mocked)
44+
harness/ react-native-harness on-device smoke test definitions
45+
examples/bare Bare React Native example app (build + harness target)
46+
examples/expo Expo example app
47+
TRIAGE.md Running triage of upstream issues and fixes in this fork
48+
```
49+
50+
## Public API surface
51+
52+
The default export aggregates these modules/functions (see
53+
`__tests__/compressor.test.ts` for the authoritative list):
54+
`Audio`, `Image`, `Video`, `UploadType`, `UploaderHttpMethod`, `backgroundUpload`,
55+
`cancelUpload`, `clearCache`, `createVideoThumbnail`, `download`,
56+
`generateFilePath`, `getDetails`, `getFileSize`, `getImageMetaData`,
57+
`getRealPath`, `getVideoMetaData`, `uuidv4`.
58+
59+
Video compression supports `compressionMethod: 'auto' | 'manual'`, `maxSize`,
60+
`bitrate`, `progressDivider`, `minimumFileSizeForCompress`, and `stripAudio`.
61+
62+
## Build, test, and validate
63+
64+
Run JS-level checks from the repo root:
65+
66+
| Command | Purpose |
67+
| --- | --- |
68+
| `yarn install` | Install dependencies (Yarn 4) |
69+
| `yarn jest` / `yarn test` | Run the JS wrapper unit tests |
70+
| `yarn typecheck` | `tsc --noEmit` |
71+
| `yarn lint` | ESLint over `**/*.{js,ts,tsx}` |
72+
| `yarn test:pr` | `test --runInBand && typecheck && lint` (run before opening a PR) |
73+
| `yarn build:android` | Assemble the bare example (`arm64-v8a`) |
74+
| `yarn build:ios` | Build the bare example for the iOS simulator |
75+
| `yarn test:harness:android` / `yarn test:harness:ios` | On-device/simulator smoke tests |
76+
77+
**Important:** The Jest tests mock the native module, so they validate only the
78+
JS contract. Real media decoding/encoding **cannot** be verified by unit tests —
79+
it must be smoke-tested in the example app on a simulator or device. When you
80+
change native Swift/Kotlin code, state clearly that it was not runtime-verified
81+
in CI and, where possible, validate via the example app or harness.
82+
83+
## Native video pipeline notes (high-signal, easy to get wrong)
84+
85+
### iOS (`ios/Video/`)
86+
- `VideoMain.swift` builds the `videoOutputConfiguration` / `compressionDict`
87+
and drives `NextLevelSessionExporter`.
88+
- `NextLevelSessionExporter.setupVideoOutput` only creates the video writer input
89+
when `writer.canApply(outputSettings:forMediaType:) == true`; otherwise it logs
90+
`"Unsupported output configuration"` and writes **audio only**, yet still ends
91+
as `.completed`. That means a bad `videoOutputConfiguration` can silently yield
92+
an **audio-only** MP4 reported as success.
93+
- Do **not** add undocumented H.264 (`avc1`) compression properties such as
94+
`AVVideoExpectedSourceFrameRateKey` or `AVVideoAverageNonDroppableFrameRateKey`:
95+
`canApply(...)` accepts them but the iOS encoder drops the video track
96+
(regression in #392, fixed for #400). After export, verify the output asset
97+
actually contains a video track before resolving success.
98+
99+
### Android (`android/.../Video/VideoCompressor/`)
100+
- `Compressor.kt` runs an `MediaExtractor` → decoder (Surface) → encoder
101+
(`video/avc`) → `MP4Builder` transcode loop.
102+
- The decoder is created from the **input** track's MIME. Some containers (notably
103+
iPhone `.MOV`) report `video/dolby-vision`, which fails with `NAME_NOT_FOUND`
104+
on devices lacking a Dolby Vision decoder. `CompressorUtils.ensureDecodableVideoFormat`
105+
remaps such inputs to their backward-compatible HEVC/AVC base layer (profiles 8/4
106+
→ HEVC, profile 9 → AVC) or throws a clear error for non-compatible profiles
107+
(5/7). See #398.
108+
- The encoder is intentionally `c2.android.avc.encoder` (when QTI codecs exist) or
109+
`MediaCodec.createEncoderByType("video/avc")`; QTI AVC encoders can produce MP4s
110+
that do not play on Mac/iPhone, so avoid switching this without testing.
111+
112+
## Conventions
113+
114+
- Keep changes surgical and aligned with surrounding style. Native helper objects
115+
(e.g. `CompressorUtils`) use member imports and unqualified calls in
116+
`Compressor.kt` — match that.
117+
- When fixing an upstream issue, record it in `TRIAGE.md` (triage row + the
118+
"Minor fixes made in this branch" list) referencing the issue number.
119+
- Prefer graceful, descriptive failures over cryptic native crashes for
120+
unsupported media (clear error messages that tell the user what happened).
121+
122+
## Merging back upstream
123+
124+
This fork accumulates many incremental commits. When contributing back to
125+
`numandev1/react-native-compressor`, use a **squash merge** so the history lands
126+
as a single, well-described commit rather than the full incremental series.

TRIAGE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Legend:
1313

1414
| Issue | Triage | Notes |
1515
| --- | --- | --- |
16+
| #400 | real, fixed here | iOS regression from #392: H.264 `videoOutputConfiguration` added `AVVideoExpectedSourceFrameRateKey` / `AVVideoAverageNonDroppableFrameRateKey`, which `canApply(...)` accepts but the iOS encoder silently drops the video track for, yielding an audio-only MP4 reported as success. This branch removes those keys and verifies the exported file actually contains a video track. |
17+
| #398 | real, fixed here | Android could not compress Dolby Vision `.MOV` inputs (iPhone HDR): `MediaCodec.createDecoderByType("video/dolby-vision")` fails with `NAME_NOT_FOUND` on devices without a Dolby Vision decoder. This branch remaps the input to its backward-compatible HEVC/AVC base layer when possible, and otherwise fails with a clear, actionable error. |
1618
| #390 | not a bug | Reports `start` / `end` time behavior for video compression, but the current public video API does not expose trim parameters. |
1719
| #387 | needs info | Gradle binary store corruption looks environment-specific; report does not isolate a library code change. |
1820
| #384 | needs info | Performance question, not a reproducible defect report. |
@@ -86,7 +88,9 @@ These should be closed upstream unless a current repro still exists on the lates
8688
- Android: clamp metadata parsing and reject invalid transcode output
8789
- Android: adaptive video compression profile for high-resolution inputs
8890
- Android: fast-start compressed MP4 outputs and skip unsupported copied audio sample metadata
91+
- Android: decode the backward-compatible base layer of Dolby Vision `.MOV` inputs, or fail with a clear error (#398)
8992
- Android/iOS: clamp image and thumbnail JPEG quality values
9093
- Android/iOS: harden thumbnail frame extraction for difficult source videos
9194
- iOS: guard missing video tracks and use the same adaptive sizing/bitrate strategy
95+
- iOS: drop unsupported H.264 frame-rate keys and verify the output has a video track to prevent silent audio-only results (#400)
9296
- iOS: return background-upload response bodies consistently

android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import android.net.Uri
1111
import android.os.Build
1212
import android.util.Log
1313
import com.reactnativecompressor.Video.VideoCompressor.CompressionProgressListener
14+
import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.ensureDecodableVideoFormat
1415
import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.findTrack
1516
import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.hasQTI
1617
import com.reactnativecompressor.Video.VideoCompressor.utils.CompressorUtils.prepareVideoHeight
@@ -790,6 +791,10 @@ object Compressor {
790791
): MediaCodec {
791792
val originalMime = inputFormat.getString(MediaFormat.KEY_MIME)!!
792793

794+
// Some inputs (e.g. iPhone .MOV files) report a "video/dolby-vision" MIME
795+
// type that many devices cannot decode. Remap to a decodable base-layer
796+
// codec, or fail with a clear error, before creating the decoder (#398).
797+
ensureDecodableVideoFormat(inputFormat)
793798
// Dolby Vision (video/dolby-vision) has no standalone decoder on most Android
794799
// devices and throws NAME_NOT_FOUND. Profiles 8.1/8.4 carry an HEVC base layer
795800
// that the standard HEVC decoder can render, so we remap them to HEVC. Profile 5

android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/utils/CompressorUtils.kt

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,76 @@ object CompressorUtils {
207207
}
208208
return false
209209
}
210+
211+
// MIME type reported by MediaExtractor for Dolby Vision tracks (e.g. iPhone .MOV files)
212+
private const val MIMETYPE_VIDEO_DOLBY_VISION = "video/dolby-vision"
213+
214+
/**
215+
* Check whether the device exposes a decoder for the given MIME type.
216+
*/
217+
private fun hasDecoderForMime(mime: String): Boolean {
218+
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
219+
for (codec in codecList.codecInfos) {
220+
if (codec.isEncoder) continue
221+
for (type in codec.supportedTypes) {
222+
if (type.equals(mime, ignoreCase = true)) return true
223+
}
224+
}
225+
return false
226+
}
227+
228+
/**
229+
* Resolve the backward-compatible base-layer MIME type for a Dolby Vision track.
230+
*
231+
* Dolby Vision profiles 8.x (DvheSt) and 4 (DvheDtr) carry an HEVC base layer, and
232+
* profile 9 (DvavSe) carries an AVC base layer; these can be decoded by the standard
233+
* HEVC/AVC decoders. Profiles 5 (DvheStn) and 7 (DvheDtb) have no usable single base
234+
* layer, so they return null. When the profile is unknown we assume HEVC, which covers
235+
* the common consumer case (e.g. iPhone records Dolby Vision profile 8).
236+
*/
237+
private fun dolbyVisionBaseLayerMime(inputFormat: MediaFormat): String? {
238+
if (!inputFormat.containsKey(MediaFormat.KEY_PROFILE)) {
239+
return MediaFormat.MIMETYPE_VIDEO_HEVC
240+
}
241+
return when (inputFormat.getInteger(MediaFormat.KEY_PROFILE)) {
242+
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavSe -> MediaFormat.MIMETYPE_VIDEO_AVC
243+
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt,
244+
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr -> MediaFormat.MIMETYPE_VIDEO_HEVC
245+
else -> null
246+
}
247+
}
248+
249+
/**
250+
* Ensure the input video format can be decoded on this device.
251+
*
252+
* Some containers (notably iPhone `.MOV` files) expose the video track as
253+
* `video/dolby-vision`. Many Android devices have no Dolby Vision decoder, so
254+
* `MediaCodec.createDecoderByType("video/dolby-vision")` fails with NAME_NOT_FOUND.
255+
* When the dedicated decoder is missing but the stream carries a backward-compatible
256+
* base layer, this rewrites the format MIME to the base-layer codec so the standard
257+
* HEVC/AVC decoder can decode it. If no compatible decoder exists, it throws a clear
258+
* error instead of letting the cryptic native failure surface (see issue #398).
259+
*/
260+
fun ensureDecodableVideoFormat(inputFormat: MediaFormat) {
261+
val mime = inputFormat.getString(MediaFormat.KEY_MIME) ?: return
262+
if (hasDecoderForMime(mime)) return
263+
264+
if (mime.equals(MIMETYPE_VIDEO_DOLBY_VISION, ignoreCase = true)) {
265+
val fallbackMime = dolbyVisionBaseLayerMime(inputFormat)
266+
if (fallbackMime != null && hasDecoderForMime(fallbackMime)) {
267+
Log.w(
268+
"Compressor",
269+
"No Dolby Vision decoder on this device; decoding the $fallbackMime base layer instead."
270+
)
271+
inputFormat.setString(MediaFormat.KEY_MIME, fallbackMime)
272+
return
273+
}
274+
throw IllegalStateException(
275+
"This video uses Dolby Vision, which is not supported by this device's decoders " +
276+
"and has no backward-compatible base layer to fall back to."
277+
)
278+
}
279+
280+
throw IllegalStateException("No decoder available for video format: $mime")
281+
}
210282
}

ios/Video/VideoMain.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,15 @@ class VideoCompressor {
320320
exporter.outputURL = tmpURL
321321
exporter.outputFileType = AVFileType.mp4
322322

323+
// NOTE: Do not add AVVideoExpectedSourceFrameRateKey or
324+
// AVVideoAverageNonDroppableFrameRateKey here. They are not documented
325+
// H.264 (avc1) compression properties on iOS and, while AVFoundation's
326+
// `canApply(...)` check still returns true, the iOS encoder silently
327+
// drops the video track, producing an audio-only MP4 that still reports
328+
// success. See issue #400.
323329
let compressionDict: [String: Any] = [
324330
AVVideoAverageBitRateKey: bitRate,
325331
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
326-
AVVideoExpectedSourceFrameRateKey: frameRate,
327-
AVVideoAverageNonDroppableFrameRateKey: frameRate,
328332
]
329333
exporter.optimizeForNetworkUse = true;
330334
exporter.videoOutputConfiguration = [
@@ -366,7 +370,16 @@ class VideoCompressor {
366370
switch result {
367371
case .success:
368372
if let outputURL = exporter.outputURL {
369-
onCompletion(outputURL)
373+
// Guard against the iOS encoder silently dropping the video track
374+
// and producing an audio-only file that still reports success.
375+
// See issue #400.
376+
let outputAsset = AVAsset(url: outputURL)
377+
if outputAsset.tracks(withMediaType: AVMediaType.video).isEmpty {
378+
try? FileManager.default.removeItem(at: outputURL)
379+
onFailure(CompressionError(message: "Compression produced a file with no video track"))
380+
} else {
381+
onCompletion(outputURL)
382+
}
370383
} else {
371384
onFailure(CompressionError(message: "Compression succeeded but output URL is unavailable"))
372385
}

0 commit comments

Comments
 (0)