From f0ed89c7310b865f0d6ec50911738788c440f887 Mon Sep 17 00:00:00 2001 From: Usama Liaqat Date: Tue, 11 Aug 2026 22:03:06 +0500 Subject: [PATCH 1/4] feat(metadata)!: richer, platform-identical video and image metadata Widen getVideoMetaData and getImageMetaData to one shape across iOS and Android: rotation, display dimensions, codec, bitrate, HDR, audio details, parsed GPS. Fix an iOS promise that never settled on audio-only files, an Android NPE on the same, a leaked MediaMetadataRetriever, NaN image dimensions without EXIF, and booleans reaching JS as numbers on iOS. BREAKING CHANGE: getImageMetaData renames ImageWidth/ImageHeight/Orientation to width/height/orientation and drops the raw exif passthrough. See README migration table. BREAKING CHANGE: getVideoMetaData drops the undeclared, Android-only creationTime key. --- README.md | 163 +++++++++++- TRIAGE.md | 6 + __tests__/compressor.test.ts | 128 +++++---- android/build.gradle | 4 + .../reactnativecompressor/Image/ImageMain.kt | 244 +++++++++++++++++- .../reactnativecompressor/Video/VideoMain.kt | 150 ++++++++++- examples/bare/src/Components/MetaDataList.tsx | 79 ++++++ examples/bare/src/Screens/Image/index.tsx | 95 ++++++- examples/bare/src/Screens/Video/index.tsx | 52 +++- harness/native-compressor.harness.ts | 24 +- ios/HybridCompressor.swift | 19 +- ios/Image/ImageMain.swift | 130 +++++++++- ios/Video/VideoMain.swift | 193 +++++++++++--- src/index.tsx | 1 + src/utils/index.tsx | 125 ++++++--- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 17 files changed, 1225 insertions(+), 192 deletions(-) create mode 100644 examples/bare/src/Components/MetaDataList.tsx diff --git a/README.md b/README.md index 49b76df1..801dfda7 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ await clearCache(); // this will clear cache of thumbnails cache directory - ###### `returnableOutputType: ReturnableOutputType` (default: uri) Can be either `uri` or `base64`, defines the Returnable output image format. -**if you wanna get image metadata (exif) then [read this](#get-metadata-of-image)** +**if you wanna get image metadata (dimensions, orientation, GPS, camera settings) then [read this](#get-metadata-of-image)** ## Video @@ -590,21 +590,66 @@ import { getVideoMetaData } from 'react-native-compressor'; const metaData = await getVideoMetaData(filePath); ``` -``` +```json { - "duration": 20.11, - "extension": "mp4", - "height": 1080, - "size": 16940.0, - "width": 1920 + "extension": "mp4", + "size": 29820480, + "duration": 10.0, + + "width": 1920, + "height": 1080, + "displayWidth": 1080, + "displayHeight": 1920, + "rotation": 90, + + "hasVideo": true, + "hasAudio": true, + "isHDR": false, + "colorTransfer": "sdr", + "fileName": "IMG_0042.mp4", + + "bitrate": 17842176, + "frameRate": 30, + "frameCount": 300, + "codec": "hevc", + "trackCount": 2, + + "location": "+37.5090-122.2620/", + "latitude": 37.509, + "longitude": -122.262, + + "audioBitrate": 128000, + "audioSampleRate": 44100, + "audioChannels": 2 } ``` +**Always present on both platforms:** `extension`, `size`, `duration`, `width`, `height`, +`displayWidth`, `displayHeight`, `rotation`, `hasVideo`, `hasAudio`, `isHDR`, `colorTransfer`, +`fileName`. Everything else appears only when the file carries it. + +| Field | Meaning | +| --- | --- | +| `width` / `height` | The **stored** track dimensions, before rotation | +| `displayWidth` / `displayHeight` | Dimensions as the video is meant to be shown, with rotation applied | +| `rotation` | `0`, `90`, `180` or `270`. Phone recordings store a landscape track plus a rotation flag, so comparing `width` to `height` is *not* how you detect a portrait video — read this instead | +| `duration` | Seconds | +| `bitrate` | Bits per second. The video track's own rate where the platform reports it, otherwise the container's overall rate | +| `codec` | Normalized lowercase name — `"h264"`, `"hevc"`, `"av1"`, `"vp9"` | +| `colorTransfer` | One of `"sdr"`, `"hlg"`, `"pq"`, `"linear"` | +| `isHDR` | True for HLG and PQ. HDR footage compressed as if it were SDR comes out washed out | +| `location` | Raw ISO-6709 string | +| `latitude` / `longitude` | Signed decimal degrees, parsed natively | +| `frameCount` | **Android only.** Reading it on iOS would require a full pass over the video samples | + +A file with no video track (an audio-only file) resolves with `hasVideo: false` and zeroed +dimensions rather than rejecting. A corrupt or unreadable file rejects. + - ###### `getVideoMetaData(path: string)` ### Get Metadata Of Image -if you want to get metadata of video than you can use this function +if you want to get metadata of an image than you can use this function ```js import { getImageMetaData } from 'react-native-compressor'; @@ -612,19 +657,109 @@ import { getImageMetaData } from 'react-native-compressor'; const metaData = await getImageMetaData(filePath); ``` -``` +```json { - "ImageWidth": 4032, - "ImageHeight": 3024, - "Orientation": 3, - "size": 4127057, + "width": 4032, + "height": 3024, + "displayWidth": 3024, + "displayHeight": 4032, + "rotation": 90, + "isMirrored": false, + "orientation": 6, + + "size": 3145728, "extension": "jpg", - "exif":{...} + "mimeType": "image/jpeg", + "hasAlpha": false, + "fileName": "IMG_0042.jpg", + + "isAnimated": false, + "frameCount": 1, + "bitDepth": 8, + "colorSpace": "Display P3", + "isHDR": false, + "dpiX": 72, + "dpiY": 72, + + "latitude": 37.509, + "longitude": -122.262, + "altitude": 12.4, + + "make": "Apple", + "model": "iPhone 15 Pro", + "lensModel": "iPhone 15 Pro back triple camera 6.86mm f/1.78", + "software": "17.2", + "iso": 64, + "exposureTime": 0.0166, + "focalLength": 6.86, + "flash": false } ``` +**Always present on both platforms:** `width`, `height`, `displayWidth`, `displayHeight`, +`rotation`, `isMirrored`, `orientation`, `size`, `extension`, `mimeType`, `hasAlpha`, `fileName`. +Everything else appears only when the file carries it — a missing EXIF block is normal (PNGs, +screenshots, most edited images) and is not an error. + +| Field | Meaning | +| --- | --- | +| `width` / `height` | True pixel dimensions from the decoder — never the EXIF tag, so these are correct for images with no EXIF | +| `displayWidth` / `displayHeight` | Dimensions after orientation is applied — swapped when `orientation` is 5–8 | +| `rotation` | `0`, `90`, `180` or `270` | +| `isMirrored` | True for orientations 2, 4, 5 and 7 | +| `orientation` | The raw EXIF value 1–8, as a number. Missing or `0` is reported as `1` | +| `latitude` / `longitude` | Signed decimal degrees — already negated for the southern and western hemispheres | +| `altitude` | Metres, negative below sea level | +| `exposureTime` | Seconds as a number (`0.0166`), not the `"1/60"` string | +| `hasAlpha` | Read exactly on iOS. **Inferred from the container on Android** (PNG/WebP/GIF), since confirming it would mean decoding the bitmap | +| `isHDR` | **iOS only.** Detecting it on Android is unreliable, so the key is omitted there rather than guessed | + +Orientation is the piece most often got wrong, so the full mapping: + +| `orientation` | `rotation` | `isMirrored` | swaps dimensions | +| --- | --- | --- | --- | +| 1 | 0 | false | no | +| 2 | 0 | true | no | +| 3 | 180 | false | no | +| 4 | 180 | true | no | +| 5 | 90 | true | **yes** | +| 6 | 90 | false | **yes** | +| 7 | 270 | true | **yes** | +| 8 | 270 | false | **yes** | + +The image is never fully decoded, so a 100 MP file costs no more memory than a thumbnail. + - ###### `getImageMetaData(path: string)` +#### Migrating from v2 + +`getImageMetaData` returned differently-shaped data on each platform, so the keys were renamed and +the raw `exif` passthrough — an unnormalized platform dump, nested `{Exif}` / `{GPS}` / `{TIFF}` +dictionaries on iOS and a flat all-strings tag map on Android — was removed. Every value it carried +that is worth reading is now a normalized, identically-shaped, top-level key. + +| v2 | v3 | +| --- | --- | +| `ImageWidth` | `width` (and it is no longer `NaN` on Android for images without EXIF) | +| `ImageHeight` | `height` | +| `Orientation` | `orientation` (plus the derived `rotation`, `isMirrored`, `displayWidth`, `displayHeight`) | +| `exif['{GPS}'].Latitude` / `GPSLatitude` | `latitude` — signed, no reference letter to apply | +| `exif['{GPS}'].Longitude` / `GPSLongitude` | `longitude` | +| `exif['{GPS}'].Altitude` / `GPSAltitude` | `altitude` | +| `exif['{TIFF}'].Make` / `Make` | `make` | +| `exif['{TIFF}'].Model` / `Model` | `model` | +| `exif['{TIFF}'].Software` / `Software` | `software` | +| `exif['{Exif}'].LensModel` / `LensModel` | `lensModel` | +| `exif['{Exif}'].ISOSpeedRatings` / `ISOSpeedRatings` | `iso` | +| `exif['{Exif}'].ExposureTime` / `ExposureTime` | `exposureTime` (seconds, as a number) | +| `exif['{Exif}'].FocalLength` / `FocalLength` | `focalLength` | +| `exif['{Exif}'].Flash` / `Flash` | `flash` (boolean: did the flash fire) | +| `exif.PixelWidth` (iOS) / `exif.ImageWidth` (Android) | `width` — the `Platform.OS` branch is gone | + +`getVideoMetaData` keeps every key it had. The undeclared, Android-only `creationTime` — which +returned a raw `"20240115T123456.000Z"` string, or the literal string `"null"` when the file had no +date — was removed. + ### Get Real Path if you want to convert diff --git a/TRIAGE.md b/TRIAGE.md index 40fee07b..7a07212c 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -98,3 +98,9 @@ These should be closed upstream unless a current repro still exists on the lates - iOS: return background-upload response bodies consistently - Android: annotate the `HybridCompressor` impl with `@DoNotStrip @Keep` so the Nitro `Compressor` HybridObject (instantiated via JNI by name) survives R8 in consumer release builds (#406) - Android: rewrite `NitroPromiseAdapter` in Java so the Nitro module compiles across all React Native versions regardless of `Promise.reject`'s `code` nullability (#404) +- iOS: `getVideoMetaData` could never settle — audio-only files and paths not starting with `file://` fell through every branch without resolving or rejecting, hanging the caller's `await` forever. Every path now settles +- Android: `getVideoMetaData` threw a null-pointer exception on audio-only and unusual files, because width/height/duration were read with `!!` and those keys are legitimately absent +- Android: `getVideoMetaData` leaked its `MediaMetadataRetriever` — opened and never released on any path. Now released in a `finally` +- Android: `getImageMetaData` returned `NaN` for `ImageWidth`/`ImageHeight` on any image without EXIF (PNGs, screenshots, WebP, most edited images), because dimensions were read from the EXIF tags rather than the decoder. Now read from a bounds-only header decode +- Android: `getImageMetaData` could reject the whole call for an image with no EXIF block, and could not read HEIC/WebP/PNG/DNG metadata on many devices (deprecated framework EXIF reader, now AndroidX) +- Android/iOS: widened both metadata responses and made them identical in shape across platforms — rotation and display dimensions, codec, bitrate, HDR/colour transfer, audio track details, parsed GPS. Breaking: the image response renames `ImageWidth`/`ImageHeight`/`Orientation` and drops the raw `exif` passthrough; the video response drops the undeclared Android-only `creationTime` diff --git a/__tests__/compressor.test.ts b/__tests__/compressor.test.ts index 9d9a4ddf..5770ec3f 100644 --- a/__tests__/compressor.test.ts +++ b/__tests__/compressor.test.ts @@ -1,23 +1,30 @@ // These unit tests validate the JavaScript wrapper contract. The native // Compressor module is mocked, so real media decoding must be smoke-tested // in an example app on a simulator or device. +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +// A bare `jest.fn()` from @jest/globals carries an unknown signature, which leaves +// mockResolvedValue/mockImplementation uncallable. These tests assert how the JS wrapper calls +// the native module rather than the native signatures themselves, so the stubs stay permissive. +const nativeFn = () => jest.fn<(...args: any[]) => any>(); + const mockCompressor = { - image_compress: jest.fn(), - compress: jest.fn(), - cancelCompression: jest.fn(), - activateBackgroundTask: jest.fn(), - deactivateBackgroundTask: jest.fn(), - compress_audio: jest.fn(), - upload: jest.fn(), - cancelUpload: jest.fn(), - download: jest.fn(), - generateFilePath: jest.fn(), - getRealPath: jest.fn(), - getVideoMetaData: jest.fn(), - getImageMetaData: jest.fn(), - createVideoThumbnail: jest.fn(), - clearCache: jest.fn(), - getFileSize: jest.fn(), + image_compress: nativeFn(), + compress: nativeFn(), + cancelCompression: nativeFn(), + activateBackgroundTask: nativeFn(), + deactivateBackgroundTask: nativeFn(), + compress_audio: nativeFn(), + upload: nativeFn(), + cancelUpload: nativeFn(), + download: nativeFn(), + generateFilePath: nativeFn(), + getRealPath: nativeFn(), + getVideoMetaData: nativeFn(), + getImageMetaData: nativeFn(), + createVideoThumbnail: nativeFn(), + clearCache: nativeFn(), + getFileSize: nativeFn(), }; const localVideoUri = 'file:///tmp/react-native-compressor/input-video.mp4'; @@ -196,39 +203,49 @@ describe('react-native-compressor JS wrapper API', () => { }); }); - it('wraps native utility functions and normalizes image metadata', async () => { + it('wraps native utility functions and passes metadata through unchanged', async () => { reactNative.Platform.OS = 'android'; - mockCompressor.generateFilePath.mockResolvedValue('/tmp/output.jpg'); - mockCompressor.getRealPath.mockResolvedValue('/real/video.mp4'); - mockCompressor.getVideoMetaData.mockResolvedValue({ width: 1920, height: 1080 }); - mockCompressor.getImageMetaData.mockResolvedValue({ - ImageWidth: '640', - ImageLength: '480', - Orientation: '1', + const videoMetaData = { + extension: 'mp4', + size: 29820480, + duration: 10, + width: 1920, + height: 1080, + displayWidth: 1080, + displayHeight: 1920, + rotation: 90, + hasVideo: true, + hasAudio: true, + isHDR: false, + colorTransfer: 'sdr', + fileName: 'IMG_0042.mp4', + }; + const imageMetaData = { + width: 640, + height: 480, + displayWidth: 640, + displayHeight: 480, + rotation: 0, + isMirrored: false, + orientation: 1, size: 123, extension: 'jpg', - }); + mimeType: 'image/jpeg', + hasAlpha: false, + fileName: 'input-image.jpg', + }; + mockCompressor.generateFilePath.mockResolvedValue('/tmp/output.jpg'); + mockCompressor.getRealPath.mockResolvedValue('/real/video.mp4'); + mockCompressor.getVideoMetaData.mockResolvedValue(videoMetaData); + mockCompressor.getImageMetaData.mockResolvedValue(imageMetaData); mockCompressor.createVideoThumbnail.mockResolvedValue({ path: 'thumb.jpg' }); mockCompressor.clearCache.mockResolvedValue('cleared'); mockCompressor.getFileSize.mockResolvedValue('10'); await expect(api.generateFilePath('jpg')).resolves.toBe('file:///tmp/output.jpg'); await expect(api.getRealPath('content://video', 'video')).resolves.toBe('/real/video.mp4'); - await expect(api.getVideoMetaData('file://video.mp4')).resolves.toEqual({ width: 1920, height: 1080 }); - await expect(api.getImageMetaData(localImageUri)).resolves.toEqual({ - ImageWidth: 640, - ImageHeight: 480, - Orientation: 1, - size: 123, - extension: 'jpg', - exif: { - ImageWidth: '640', - ImageLength: '480', - Orientation: '1', - size: 123, - extension: 'jpg', - }, - }); + await expect(api.getVideoMetaData('file://video.mp4')).resolves.toEqual(videoMetaData); + await expect(api.getImageMetaData(localImageUri)).resolves.toEqual(imageMetaData); await expect(api.createVideoThumbnail(localVideoUri, { headers: { Authorization: 'token' } })).resolves.toEqual({ path: 'thumb.jpg', }); @@ -236,22 +253,25 @@ describe('react-native-compressor JS wrapper API', () => { await expect(api.getFileSize(localImageUri)).resolves.toBe('10'); }); - it('uses iOS image metadata keys when running on iOS', async () => { - mockCompressor.getImageMetaData.mockResolvedValue({ - PixelWidth: 1024, - PixelHeight: 768, - Orientation: 6, + it('returns the same image metadata keys on iOS as on Android', async () => { + reactNative.Platform.OS = 'ios'; + const imageMetaData = { + width: 1024, + height: 768, + displayWidth: 768, + displayHeight: 1024, + rotation: 90, + isMirrored: false, + orientation: 6, size: 456, extension: 'heic', - }); + mimeType: 'image/heic', + hasAlpha: false, + fileName: 'input-image.heic', + }; + mockCompressor.getImageMetaData.mockResolvedValue(imageMetaData); - await expect(api.getImageMetaData('file:///tmp/react-native-compressor/input-image.heic')).resolves.toMatchObject({ - ImageWidth: 1024, - ImageHeight: 768, - Orientation: 6, - size: 456, - extension: 'heic', - }); + await expect(api.getImageMetaData('file:///tmp/react-native-compressor/input-image.heic')).resolves.toEqual(imageMetaData); }); it('downloads files, strips Android file prefixes, and reports progress', async () => { @@ -302,7 +322,7 @@ describe('react-native-compressor JS wrapper API', () => { ), ).resolves.toEqual({ status: 200 }); - const uploadOptions = mockCompressor.upload.mock.calls[0][1]; + const uploadOptions = mockCompressor.upload.mock.calls[0]?.[1]; abortController.abort(); expect(mockCompressor.upload).toHaveBeenCalledWith( diff --git a/android/build.gradle b/android/build.gradle index 81cf0c64..0fd0b82e 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -121,6 +121,10 @@ dependencies { // Nitro core (provided as a Gradle subproject by autolinking). implementation project(":react-native-nitro-modules") + // Maintained EXIF reader: unlike the deprecated android.media one it handles HEIC, WebP, PNG and + // DNG, and supplies rotation/mirroring/GPS conversion directly. + implementation "androidx.exifinterface:exifinterface:1.4.1" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" implementation 'org.mp4parser:isoparser:1.9.56' diff --git a/android/src/main/java/com/reactnativecompressor/Image/ImageMain.kt b/android/src/main/java/com/reactnativecompressor/Image/ImageMain.kt index e9e234b0..a2f555fb 100644 --- a/android/src/main/java/com/reactnativecompressor/Image/ImageMain.kt +++ b/android/src/main/java/com/reactnativecompressor/Image/ImageMain.kt @@ -1,15 +1,20 @@ package com.reactnativecompressor.Image -import android.media.ExifInterface +import android.graphics.BitmapFactory import android.net.Uri +import android.os.Build +import androidx.exifinterface.media.ExifInterface import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap import com.reactnativecompressor.Utils.MediaCache import com.reactnativecompressor.Utils.Utils -import com.reactnativecompressor.Utils.Utils.exifAttributes +import java.io.BufferedInputStream +import java.io.DataInputStream import java.io.File +import java.io.FileInputStream class ImageMain(private val reactContext: ReactApplicationContext) { fun image_compress( @@ -40,24 +45,237 @@ class ImageMain(private val reactContext: ReactApplicationContext) { filePath = Utils.getRealPath(filePath, reactContext) val uri = Uri.parse(filePath) val srcPath = uri.path + if (srcPath == null) { + promise.reject("INVALID_PATH", "Could not resolve a file path from $filePath") + return + } val params = Arguments.createMap() val file = File(srcPath) - val sizeInBytes = file.length().toDouble() - val exif = ExifInterface(srcPath!!) - for (tag in exifAttributes) { - val value: String? = exif.getAttribute(tag) - if(value != null) - { - params.putString(tag, value) - } - - } val extension = filePath!!.substring(filePath.lastIndexOf(".") + 1) - params.putDouble("size", sizeInBytes) + params.putDouble("size", file.length().toDouble()) params.putString("extension", extension) + params.putString("fileName", file.name) + + // Bounds-only header read: populates outWidth/outHeight/outMimeType without allocating a + // bitmap, so a 100 MP image costs nothing. These are the true decoder dimensions — reading + // them from the EXIF tags is what used to return NaN for images with no EXIF block. + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(srcPath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + promise.reject("INVALID_IMAGE", "Failed to read image dimensions from $srcPath") + return + } + val mimeType = canonicalMimeType(bounds.outMimeType) ?: mimeTypeFromExtension(extension) + params.putInt("width", bounds.outWidth) + params.putInt("height", bounds.outHeight) + params.putString("mimeType", mimeType) + params.putBoolean("hasAlpha", mimeTypeMayHaveAlpha(mimeType)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + bounds.outColorSpace?.let { params.putString("colorSpace", it.name) } + } + + // A missing or unreadable EXIF block is normal (PNGs, screenshots, edited images) and must + // not fail the call — everything above is already enough to resolve with. + try { + readExif(srcPath, bounds, params) + } catch (e: Exception) { + putOrientation(params, ExifInterface.ORIENTATION_NORMAL, bounds) + } + + readAnimation(file, mimeType, params) promise.resolve(params) } catch (e: Exception) { promise.reject(e) } } + + private fun readExif(srcPath: String, bounds: BitmapFactory.Options, params: WritableMap) { + val exif = ExifInterface(srcPath) + putOrientation(params, exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL), bounds) + + exif.latLong?.let { + params.putDouble("latitude", it[0]) + params.putDouble("longitude", it[1]) + } + val altitude = exif.getAltitude(Double.NaN) + if (!altitude.isNaN()) params.putDouble("altitude", altitude) + + exif.getAttribute(ExifInterface.TAG_MAKE)?.let { params.putString("make", it) } + exif.getAttribute(ExifInterface.TAG_MODEL)?.let { params.putString("model", it) } + exif.getAttribute(ExifInterface.TAG_LENS_MODEL)?.let { params.putString("lensModel", it) } + exif.getAttribute(ExifInterface.TAG_SOFTWARE)?.let { params.putString("software", it) } + + exif.positiveInt(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY)?.let { params.putInt("iso", it) } + // Stored as a rational such as "1/60"; getAttributeDouble resolves it to seconds. + exif.positiveDouble(ExifInterface.TAG_EXPOSURE_TIME)?.let { params.putDouble("exposureTime", it) } + exif.positiveDouble(ExifInterface.TAG_FOCAL_LENGTH)?.let { params.putDouble("focalLength", it) } + // Bit 0 of the flash tag is "flash fired"; the remaining bits describe the mode. + exif.getAttribute(ExifInterface.TAG_FLASH)?.toIntOrNull()?.let { params.putBoolean("flash", it and 0x1 == 1) } + // "8 8 8" for a 24-bit RGB image — the per-channel depth is the first component. + exif.getAttribute(ExifInterface.TAG_BITS_PER_SAMPLE)?.split(" ", ",")?.firstOrNull()?.toIntOrNull() + ?.let { params.putInt("bitDepth", it) } + + // X/YResolution are per ResolutionUnit: 2 = inches (already DPI), 3 = centimetres. + val perCentimetre = exif.getAttributeInt(ExifInterface.TAG_RESOLUTION_UNIT, 2) == 3 + val toDpi = { value: Double -> if (perCentimetre) value * 2.54 else value } + exif.positiveDouble(ExifInterface.TAG_X_RESOLUTION)?.let { params.putDouble("dpiX", toDpi(it)) } + exif.positiveDouble(ExifInterface.TAG_Y_RESOLUTION)?.let { params.putDouble("dpiY", toDpi(it)) } + } + + /** + * Walks the container's block/chunk headers to count frames for the two animated formats Android + * supports. This never decodes pixel data — only the structural bytes are read. + */ + private fun readAnimation(file: File, mimeType: String, params: WritableMap) { + val frameCount = try { + when (mimeType) { + "image/gif" -> countGifFrames(file) + "image/webp" -> countWebPFrames(file) + else -> null + } + } catch (e: Exception) { + null + } ?: return + params.putInt("frameCount", frameCount) + params.putBoolean("isAnimated", frameCount > 1) + } + + companion object { + private fun putOrientation(params: WritableMap, rawOrientation: Int, bounds: BitmapFactory.Options) { + // Orientation 0 is not a legal EXIF value; treat it as "unrotated" like a missing tag. + val orientation = if (rawOrientation in 1..8) rawOrientation else ExifInterface.ORIENTATION_NORMAL + val rotation = when (orientation) { + ExifInterface.ORIENTATION_ROTATE_180, ExifInterface.ORIENTATION_FLIP_VERTICAL -> 180 + ExifInterface.ORIENTATION_TRANSPOSE, ExifInterface.ORIENTATION_ROTATE_90 -> 90 + ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_ROTATE_270 -> 270 + else -> 0 + } + val isMirrored = orientation == ExifInterface.ORIENTATION_FLIP_HORIZONTAL || + orientation == ExifInterface.ORIENTATION_FLIP_VERTICAL || + orientation == ExifInterface.ORIENTATION_TRANSPOSE || + orientation == ExifInterface.ORIENTATION_TRANSVERSE + // Orientations 5-8 are the quarter turns, and only those swap the display axes. + val swapsAxes = orientation >= 5 + params.putInt("orientation", orientation) + params.putInt("rotation", rotation) + params.putBoolean("isMirrored", isMirrored) + params.putInt("displayWidth", if (swapsAxes) bounds.outHeight else bounds.outWidth) + params.putInt("displayHeight", if (swapsAxes) bounds.outWidth else bounds.outHeight) + } + + private fun ExifInterface.positiveInt(tag: String): Int? = + getAttributeInt(tag, -1).takeIf { it > 0 } + + private fun ExifInterface.positiveDouble(tag: String): Double? = + getAttributeDouble(tag, -1.0).takeIf { it > 0 } + + /** + * The decoder's reported type, folded onto the same canonical values iOS returns so the field + * matches across platforms. Returns null when nothing was detected, so the caller can fall back + * to the file extension. + */ + private fun canonicalMimeType(detected: String?): String? = when (detected?.lowercase()) { + null -> null + "image/heic", "image/heif", "image/avci" -> "image/heif" + "image/jpg" -> "image/jpeg" + "image/tif" -> "image/tiff" + else -> detected.lowercase() + } + + private fun mimeTypeFromExtension(extension: String): String = when (extension.lowercase()) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "heic", "heif" -> "image/heif" + "bmp" -> "image/bmp" + "tif", "tiff" -> "image/tiff" + "dng" -> "image/x-adobe-dng" + else -> "image/${extension.lowercase()}" + } + + /** + * Inferred from the container, not read exactly: these formats can carry an alpha channel, but + * confirming that a given file actually uses it would mean decoding the bitmap. + */ + private fun mimeTypeMayHaveAlpha(mimeType: String): Boolean = + mimeType == "image/png" || mimeType == "image/webp" || mimeType == "image/gif" + + private fun countGifFrames(file: File): Int? { + DataInputStream(BufferedInputStream(FileInputStream(file))).use { stream -> + val header = ByteArray(6) + stream.readFully(header) + if (String(header, Charsets.US_ASCII).let { it != "GIF87a" && it != "GIF89a" }) return null + val packed = ByteArray(7).also { stream.readFully(it) }[4].toInt() + if (packed and 0x80 != 0) stream.skipFully(3 * (1 shl ((packed and 0x07) + 1))) + + var frames = 0 + while (true) { + when (stream.read()) { + 0x21 -> { // Extension: a label byte followed by data sub-blocks. + stream.read() + stream.skipSubBlocks() + } + 0x2C -> { // Image descriptor: the only block that introduces a frame. + val descriptor = ByteArray(9).also { stream.readFully(it) }[8].toInt() + if (descriptor and 0x80 != 0) stream.skipFully(3 * (1 shl ((descriptor and 0x07) + 1))) + stream.read() // LZW minimum code size + stream.skipSubBlocks() + frames++ + } + else -> return frames // 0x3B trailer, EOF (-1), or a malformed byte. + } + } + } + } + + private fun countWebPFrames(file: File): Int? { + DataInputStream(BufferedInputStream(FileInputStream(file))).use { stream -> + val riff = ByteArray(12).also { stream.readFully(it) } + if (String(riff, 0, 4, Charsets.US_ASCII) != "RIFF" || String(riff, 8, 4, Charsets.US_ASCII) != "WEBP") { + return null + } + var frames = 0 + val chunkHeader = ByteArray(8) + while (true) { + var read = 0 + while (read < 8) { + val count = stream.read(chunkHeader, read, 8 - read) + if (count < 0) return if (frames > 0) frames else 1 + read += count + } + val fourCC = String(chunkHeader, 0, 4, Charsets.US_ASCII) + // RIFF chunk sizes are little-endian and payloads are padded to an even length. + var size = 0L + for (i in 0 until 4) size = size or ((chunkHeader[4 + i].toLong() and 0xFF) shl (8 * i)) + if (fourCC == "ANMF") frames++ + stream.skipFully(size + (size and 1L)) + } + } + } + + private fun DataInputStream.skipSubBlocks() { + while (true) { + val size = read() + if (size <= 0) return + skipFully(size.toLong()) + } + } + + /** `InputStream.skip` may skip fewer bytes than asked; loop until the whole span is consumed. */ + private fun DataInputStream.skipFully(count: Long) { + var remaining = count + while (remaining > 0) { + val skipped = skip(remaining) + if (skipped <= 0) { + if (read() < 0) return + remaining-- + } else { + remaining -= skipped + } + } + } + + private fun DataInputStream.skipFully(count: Int) = skipFully(count.toLong()) + } } diff --git a/android/src/main/java/com/reactnativecompressor/Video/VideoMain.kt b/android/src/main/java/com/reactnativecompressor/Video/VideoMain.kt index 6fa97ce5..9bf54ed9 100644 --- a/android/src/main/java/com/reactnativecompressor/Video/VideoMain.kt +++ b/android/src/main/java/com/reactnativecompressor/Video/VideoMain.kt @@ -1,5 +1,7 @@ package com.reactnativecompressor.Video +import android.media.MediaExtractor +import android.media.MediaFormat import android.media.MediaMetadataRetriever import android.net.Uri import android.util.Log @@ -7,6 +9,7 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap import com.reactnativecompressor.Utils.Utils import com.reactnativecompressor.Video.VideoCompressorHelper.Companion.video_activateBackgroundTask_helper import com.reactnativecompressor.Video.VideoCompressorHelper.Companion.video_deactivateBackgroundTask_helper @@ -58,29 +61,154 @@ class VideoMain(private val reactContext: ReactApplicationContext) { fun getVideoMetaData(filePath: String, promise: Promise) { var filePath: String? = filePath + var metaRetriever: MediaMetadataRetriever? = null try { filePath = Utils.getRealPath(filePath, reactContext) val uri = Uri.parse(filePath) val srcPath = uri.path - val metaRetriever = MediaMetadataRetriever() + metaRetriever = MediaMetadataRetriever() metaRetriever.setDataSource(srcPath) val file = File(srcPath) - val sizeInBytes = (file.length()).toDouble() - val actualHeight = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!.toInt() - val actualWidth = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!.toInt() - val duration = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!!.toDouble() - val creationTime = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE) val extension = filePath!!.substring(filePath.lastIndexOf(".") + 1) + val params = Arguments.createMap() - params.putDouble("size", sizeInBytes) - params.putInt("width", actualWidth) - params.putInt("height", actualHeight) - params.putDouble("duration", duration / 1000) params.putString("extension", extension) - params.putString("creationTime", creationTime.toString()) + params.putString("fileName", file.name) + params.putDouble("size", file.length().toDouble()) + // METADATA_KEY_DURATION is milliseconds; the JS contract is seconds. + params.putDouble("duration", (metaRetriever.intOf(MediaMetadataRetriever.METADATA_KEY_DURATION) ?: 0) / 1000.0) + + val hasVideo = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) == "yes" + val hasAudio = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes" + params.putBoolean("hasVideo", hasVideo) + params.putBoolean("hasAudio", hasAudio) + + val width = metaRetriever.intOf(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) ?: 0 + val height = metaRetriever.intOf(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) ?: 0 + val rotation = normalizeRotation(metaRetriever.intOf(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) ?: 0) + val swapsAxes = rotation == 90 || rotation == 270 + params.putInt("width", width) + params.putInt("height", height) + params.putInt("displayWidth", if (swapsAxes) height else width) + params.putInt("displayHeight", if (swapsAxes) width else height) + params.putInt("rotation", rotation) + + metaRetriever.intOf(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.let { params.putInt("bitrate", it) } + metaRetriever.intOf(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS)?.let { params.putInt("trackCount", it) } + + val location = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) + if (location != null) { + params.putString("location", location) + parseIso6709(location)?.let { (latitude, longitude) -> + params.putDouble("latitude", latitude) + params.putDouble("longitude", longitude) + } + } + + // Track-level details: format, codec, colour transfer and audio parameters. Failing to read + // them must not lose everything gathered above. + try { + readTrackDetails(srcPath, params, hasVideo) + } catch (e: Exception) { + Log.w("getVideoMetaData", "Failed to read track details", e) + } + if (!params.hasKey("isHDR")) { + params.putBoolean("isHDR", false) + params.putString("colorTransfer", "sdr") + } + promise.resolve(params) } catch (e: Exception) { promise.reject(e) + } finally { + try { + metaRetriever?.release() + } catch (e: Exception) { + Log.w("getVideoMetaData", "Failed to release MediaMetadataRetriever", e) + } + } + } + + /** + * Reads per-track details the retriever does not expose: the video codec and colour transfer, the + * frame rate/count, and the audio track's bitrate, sample rate and channel count. + */ + private fun readTrackDetails(srcPath: String?, params: WritableMap, hasVideo: Boolean) { + val extractor = MediaExtractor() + try { + extractor.setDataSource(srcPath!!) + for (index in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(index) + val mime = format.getString(MediaFormat.KEY_MIME) ?: continue + when { + hasVideo && mime.startsWith("video/") && !params.hasKey("codec") -> { + params.putString("codec", normalizeCodec(mime)) + format.intOrNull(MediaFormat.KEY_FRAME_RATE)?.let { params.putInt("frameRate", it) } + format.intOrNull(MediaFormat.KEY_BIT_RATE)?.let { params.putInt("bitrate", it) } + // KEY_FRAME_COUNT is API 28+; absent on older platforms and on some containers. + format.intOrNull("frame-count")?.let { params.putInt("frameCount", it) } + val transfer = colorTransferName(format.intOrNull(MediaFormat.KEY_COLOR_TRANSFER)) + params.putString("colorTransfer", transfer) + params.putBoolean("isHDR", transfer == "hlg" || transfer == "pq") + } + mime.startsWith("audio/") && !params.hasKey("audioSampleRate") -> { + format.intOrNull(MediaFormat.KEY_BIT_RATE)?.let { params.putInt("audioBitrate", it) } + format.intOrNull(MediaFormat.KEY_SAMPLE_RATE)?.let { params.putInt("audioSampleRate", it) } + format.intOrNull(MediaFormat.KEY_CHANNEL_COUNT)?.let { params.putInt("audioChannels", it) } + } + } + } + } finally { + extractor.release() + } + } + + private fun MediaMetadataRetriever.intOf(key: Int): Int? = extractMetadata(key)?.toIntOrNull() + + private fun MediaFormat.intOrNull(key: String): Int? = if (containsKey(key)) { + try { + getInteger(key) + } catch (e: ClassCastException) { + // Some devices store these as floats (notably frame rate). + getFloat(key).toInt() + } + } else { + null + } + + companion object { + /** Snaps an arbitrary degree value to the nearest quarter turn in `0, 90, 180, 270`. */ + private fun normalizeRotation(degrees: Int): Int { + val quarters = Math.round(degrees / 90.0).toInt() + return ((quarters % 4) + 4) % 4 * 90 + } + + private fun normalizeCodec(mime: String): String = when (mime) { + MediaFormat.MIMETYPE_VIDEO_AVC -> "h264" + MediaFormat.MIMETYPE_VIDEO_HEVC, "video/dolby-vision" -> "hevc" + MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1" + MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9" + MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8" + MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4" + else -> mime.removePrefix("video/").lowercase() + } + + private fun colorTransferName(colorTransfer: Int?): String = when (colorTransfer) { + MediaFormat.COLOR_TRANSFER_HLG -> "hlg" + MediaFormat.COLOR_TRANSFER_ST2084 -> "pq" + MediaFormat.COLOR_TRANSFER_LINEAR -> "linear" + else -> "sdr" + } + + /** + * Parses the ISO-6709 string `MediaMetadataRetriever` returns (`"+37.5090-122.2620/"`) into + * signed decimal degrees. Returns null when the string is not in that form. + */ + private fun parseIso6709(location: String): Pair? { + val match = Regex("([+-]\\d+(?:\\.\\d+)?)([+-]\\d+(?:\\.\\d+)?)").find(location) ?: return null + val latitude = match.groupValues[1].toDoubleOrNull() ?: return null + val longitude = match.groupValues[2].toDoubleOrNull() ?: return null + return latitude to longitude } } } diff --git a/examples/bare/src/Components/MetaDataList.tsx b/examples/bare/src/Components/MetaDataList.tsx new file mode 100644 index 00000000..102343a1 --- /dev/null +++ b/examples/bare/src/Components/MetaDataList.tsx @@ -0,0 +1,79 @@ +import { View, StyleSheet, Text } from 'react-native'; + +type MetaData = Record | undefined | null; + +const formatValue = (value: unknown): string => { + if (typeof value === 'boolean') return value ? 'yes' : 'no'; + // Round the fractional values (duration, exposureTime, dpi) so they stay readable. + if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000); + return String(value); +}; + +/** + * Keys listed in `order` come first, so related fields stay grouped; anything the native side + * returns that is not listed still shows up, appended alphabetically. + */ +const orderedEntries = (data: Record, order: string[]) => { + const rank = (key: string) => { + const index = order.indexOf(key); + return index === -1 ? order.length : index; + }; + return Object.entries(data) + .filter(([, value]) => value !== undefined && value !== null) + .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b)); +}; + +function MetaDataList({ title, data, order = [] }: { title: string; data: MetaData; order?: string[] }) { + if (!data) return null; + const entries = orderedEntries(data, order); + return ( + + {title} + {entries.length === 0 ? ( + no metadata + ) : ( + entries.map(([key, value]) => ( + + + {key} + + + {formatValue(value)} + + + )) + )} + + ); +} + +export default MetaDataList; + +const styles = StyleSheet.create({ + container: { + width: '100%', + paddingVertical: 6, + }, + title: { + fontSize: 13, + fontWeight: '700', + marginBottom: 2, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + key: { + fontSize: 11, + flexShrink: 1, + marginRight: 8, + }, + value: { + fontSize: 11, + fontWeight: '600', + }, + empty: { + fontSize: 11, + fontStyle: 'italic', + }, +}); diff --git a/examples/bare/src/Screens/Image/index.tsx b/examples/bare/src/Screens/Image/index.tsx index 7326b6ce..5acf7d9b 100644 --- a/examples/bare/src/Screens/Image/index.tsx +++ b/examples/bare/src/Screens/Image/index.tsx @@ -1,14 +1,52 @@ -import { useState, useRef } from 'react'; -import { View, StyleSheet, Alert, useWindowDimensions, Image as RNImage, Platform } from 'react-native'; +import { useState, useRef, useEffect } from 'react'; +import { View, StyleSheet, Alert, useWindowDimensions, Image as RNImage, Platform, ScrollView } from 'react-native'; import Button from '../../Components/Button'; import Row from '../../Components/Row'; +import MetaDataList from '../../Components/MetaDataList'; import ProgressBar from '../../Components/ProgressBar'; import type { ProgressBarRafType } from '../../Components/ProgressBar'; import * as ImagePicker from 'react-native-image-picker'; import { CameraRoll } from '@react-native-camera-roll/camera-roll'; import prettyBytes from 'pretty-bytes'; import { Image, getFileSize, getImageMetaData } from 'react-native-compressor'; +import type { ImageMetaData } from 'react-native-compressor'; import { getFileInfo } from '../../Utils'; + +// Groups the response the way the docs describe it: geometry, then file, then the optional +// decoder/EXIF blocks. Anything not listed still renders, appended alphabetically. +const IMAGE_FIELD_ORDER = [ + 'fileName', + 'extension', + 'mimeType', + 'size', + 'width', + 'height', + 'displayWidth', + 'displayHeight', + 'rotation', + 'isMirrored', + 'orientation', + 'hasAlpha', + 'isAnimated', + 'frameCount', + 'bitDepth', + 'colorSpace', + 'isHDR', + 'dpiX', + 'dpiY', + 'latitude', + 'longitude', + 'altitude', + 'make', + 'model', + 'lensModel', + 'software', + 'iso', + 'exposureTime', + 'focalLength', + 'flash', +]; + const Index = () => { const progressRef = useRef(null); const dimension = useWindowDimensions(); @@ -18,6 +56,24 @@ const Index = () => { const [mimeType, setMimeType] = useState(''); const [orignalSize, setOrignalSize] = useState(''); const [compressedSize, setCompressedSize] = useState('0'); + const [orignalMetaData, setOrignalMetaData] = useState(); + const [compressedMetaData, setCompressedMetaData] = useState(); + + // Driven off the URIs so every path that produces an image (gallery, camera, remote, camera + // roll) reports its metadata without each handler having to remember to ask for it. + useEffect(() => { + if (!orignalUri) return; + getImageMetaData(orignalUri) + .then(setOrignalMetaData) + .catch((error) => console.log({ orignalMetaDataError: error })); + }, [orignalUri]); + + useEffect(() => { + if (!commpressedUri) return; + getImageMetaData(commpressedUri) + .then(setCompressedMetaData) + .catch((error) => console.log({ compressedMetaDataError: error })); + }, [commpressedUri]); const compressHandler = async (result: ImagePicker.ImagePickerResponse) => { if (result.didCancel) { @@ -33,8 +89,6 @@ const Index = () => { setMimeType(source.type); setOrignalUri(source.uri); } - const metadata = await getImageMetaData(source.uri); - console.log(JSON.stringify(metadata, null, 4), 'metadata'); Image.compress(source.uri) .then(async (compressedFileUri) => { console.log(compressedFileUri, 'compressedFileUri'); @@ -143,16 +197,24 @@ const Index = () => { /> )} - + + + + + + + + +