Compare commits

...

36 Commits

Author SHA1 Message Date
0ecc3d8210 Merge pull request 'fix: Handle both file:// and file:/ URI prefixes' (#19) from dean/fix-file-prefix-fmp4 into main
Reviewed-on: #19
Reviewed-by: Ivan Malison <ivanmalison@gmail.com>
2026-01-06 19:41:32 +00:00
309e1e9457 Merge branch 'main' into dean/fix-file-prefix-fmp4 2026-01-06 17:38:24 +00:00
71b08e6898 Merge pull request 'Android Fmp4' (#17) from loewy/android-fmp4-normalize-timestamp-fix-fps into main
Reviewed-on: #17
2026-01-06 17:21:29 +00:00
Dean
699481f6f8 fix: Handle both file:// and file:/ URI prefixes
The previous code only stripped file:// (double slash) but some paths
come with file:/ (single slash), causing FileNotFoundException.

Fixes RAILBIRD-FRONTEND-1JH

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 08:45:39 -08:00
11ce9ba8f6 ensure compatability with rb chunked and fmp4, move orientation detection for rb chunked to chunked manager 2026-01-03 13:40:09 -08:00
dd9de38a7d use window manager to determine device rotation in android 2026-01-02 10:04:49 -08:00
3f5d0a2109 fix fps (alter tfhd and trun size, add logs 2026-01-02 10:04:49 -08:00
6c2319608d normalize timestamps and fix framerate metadata in init file 2026-01-02 10:04:47 -08:00
27f127fe94 Fix orientation issues 2026-01-02 10:02:51 -08:00
92b29cbd78 Write our own muxer to make hls uupload actually work 2026-01-02 10:02:51 -08:00
fb23c57a6c feat: Add fragmented MP4 (fMP4) support for Android
Implements HLS-compatible fragmented MP4 recording on Android using
AndroidX Media3 FragmentedMp4Muxer, matching the iOS implementation.

Changes:
- Add FragmentedRecordingManager for fMP4 segment output
- Add ChunkedRecorderInterface to abstract recorder implementations
- Add onInitSegmentReady callback for init segment (init.mp4)
- Update onVideoChunkReady to include segment duration
- RecordingSession now uses FragmentedRecordingManager by default

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:02:51 -08:00
8d06ab9e66 Merge pull request 'Strip file prefix ( for expo-file-system)' (#15) from loewy/stripe-file-prefix into main
Reviewed-on: #15
Reviewed-by: Ivan Malison <ivanmalison@gmail.com>
2025-12-26 17:26:42 +00:00
f6b6cfb3d5 strip file prefix 2025-12-19 12:55:41 -08:00
3ac555a2b3 Merge pull request 'Stop recording on init write failure' (#14) from loewy/stop-recording-on-init-write-failure into main
Reviewed-on: #14
2025-12-19 20:53:06 +00:00
7e1e074e0f force recording to stop on init write failure and fix silent failure 2025-12-18 13:29:31 -08:00
b269e9c493 fix silent init file write failure 2025-12-18 13:09:34 -08:00
5fe7f35127 Merge pull request 'Fix orientation Android - get orientation directly from WindowManager' (#11) from loewy/fix-android-orientation-bugs into main
Reviewed-on: #11
2025-12-17 23:20:52 +00:00
61863149c0 flip orientation in camera session 2025-12-11 14:17:34 -08:00
09b50938d2 get orientation change from WindowManager for android 2025-12-11 13:02:00 -08:00
a158ed8350 Merge pull request 'Bump react native w/ api 35 compatability' (#10) from bump-react-native-with-api35 into main
Reviewed-on: #10
2025-12-11 18:04:51 +00:00
Dean
e7b295546a fix: Add null safety checks in ChunkedRecordingManager
Replace !! operators with proper null checks to prevent
NullPointerExceptions when encodedFormat or muxerContext are null.
This can happen if createNextMuxer is called before
onOutputFormatChanged sets the format.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 10:04:18 -08:00
Dean
d87ed8ced2 fix: Handle null error message in promise rejection
Prevents crash when an exception with null message is caught and rejected
through the React Native bridge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 09:48:32 -08:00
f055119735 respect frame processor flag when compiling and force 16kb page alignment 2025-12-01 11:24:24 -07:00
35d80b13d6 disable frame processor jni bindings, preserve video pipeline registration 2025-11-24 10:30:33 -08:00
3d09106e45 skip native library loading for frame processors, wip: failing to launch app 2025-11-17 18:58:13 -08:00
b523e1884f Fix React Native 0.79 Kotlin compilation errors
- Fix currentActivity reference in CameraViewModule
- Convert Map to MutableMap in CameraViewManager for RN 0.79 compatibility
2025-11-17 13:38:30 -08:00
5fcc1a4f77 Bumps and fixes for react native version bump 2025-11-17 13:35:59 -08:00
364171a107 Update Java/Kotlin versions and add opt-in flags for RN 0.79 2025-11-17 13:35:25 -08:00
f90e11897f Fix CMake target for React Native 0.79 compatibility 2025-11-17 11:45:07 -08:00
4798aad464 Merge pull request 'fix/android-api-35-bitmap-config' (#9) from fix/android-api-35-bitmap-config into main
Reviewed-on: #9
2025-10-29 03:22:49 +00:00
Dean
2c8d503e66 Fix Bitmap.Config null-safety for Android API 35 2025-10-28 13:54:54 -07:00
5b52acda26 Gross hack to make things sort of work 2024-11-10 17:51:15 -07:00
17f675657e WIP 2024-11-09 19:52:05 -07:00
c64516693c Merge pull request 'Fix Preview View Aspect Ratio Orientation Issues in android' (#8) from ivan/fix-android-preview-view-aspect-ratio-orientation-issues into main
Reviewed-on: #8
2024-10-12 16:21:18 -06:00
e9f08ef488 Fix Preview View Aspect Ratio Orientation Issues in android 2024-10-12 16:20:23 -06:00
bf122db919 Merge pull request 'Ensure custom exposure mode is supported' (#7) from ivan/ensure-capture-mode-is-supported into main
Reviewed-on: #7
2024-10-10 15:18:12 -06:00
33 changed files with 4025 additions and 3290 deletions

View File

@@ -1,5 +0,0 @@
use flake . --impure
if [ -f .envrc.local ]; then
source .envrc.local
fi

View File

@@ -19,9 +19,7 @@ endif()
# Add react-native-vision-camera sources # Add react-native-vision-camera sources
add_library( set(SOURCES
${PACKAGE_NAME}
SHARED
# Shared C++ # Shared C++
../cpp/MutableRawBuffer.cpp ../cpp/MutableRawBuffer.cpp
# Java JNI # Java JNI
@@ -31,7 +29,11 @@ add_library(
src/main/cpp/OpenGLContext.cpp src/main/cpp/OpenGLContext.cpp
src/main/cpp/OpenGLRenderer.cpp src/main/cpp/OpenGLRenderer.cpp
src/main/cpp/MutableJByteBuffer.cpp src/main/cpp/MutableJByteBuffer.cpp
# Frame Processor )
# Only add Frame Processor sources if enabled
if (ENABLE_FRAME_PROCESSORS)
list(APPEND SOURCES
src/main/cpp/frameprocessor/FrameHostObject.cpp src/main/cpp/frameprocessor/FrameHostObject.cpp
src/main/cpp/frameprocessor/FrameProcessorPluginHostObject.cpp src/main/cpp/frameprocessor/FrameProcessorPluginHostObject.cpp
src/main/cpp/frameprocessor/JSIJNIConversion.cpp src/main/cpp/frameprocessor/JSIJNIConversion.cpp
@@ -43,6 +45,18 @@ add_library(
src/main/cpp/frameprocessor/java-bindings/JVisionCameraProxy.cpp src/main/cpp/frameprocessor/java-bindings/JVisionCameraProxy.cpp
src/main/cpp/frameprocessor/java-bindings/JVisionCameraScheduler.cpp src/main/cpp/frameprocessor/java-bindings/JVisionCameraScheduler.cpp
) )
endif()
add_library(
${PACKAGE_NAME}
SHARED
${SOURCES}
)
# Force 16KB page alignment for Android 15+ compatibility
set_target_properties(${PACKAGE_NAME} PROPERTIES
LINK_FLAGS "-Wl,-z,max-page-size=16384"
)
# Header Search Paths (includes) # Header Search Paths (includes)
target_include_directories( target_include_directories(
@@ -63,7 +77,7 @@ target_link_libraries(
${LOG_LIB} # <-- Logcat logger ${LOG_LIB} # <-- Logcat logger
android # <-- Android JNI core android # <-- Android JNI core
ReactAndroid::jsi # <-- RN: JSI ReactAndroid::jsi # <-- RN: JSI
ReactAndroid::reactnativejni # <-- RN: React Native JNI bindings ReactAndroid::reactnative # <-- RN: React Native JNI bindings (RN 0.76+)
fbjni::fbjni # <-- fbjni fbjni::fbjni # <-- fbjni
GLESv2 # <-- OpenGL (for VideoPipeline) GLESv2 # <-- OpenGL (for VideoPipeline)
EGL # <-- OpenGL (EGL) (for VideoPipeline) EGL # <-- OpenGL (EGL) (for VideoPipeline)

View File

@@ -133,8 +133,16 @@ android {
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += [
"-opt-in=kotlin.RequiresOptIn",
"-opt-in=com.facebook.react.annotations.UnstableReactNativeAPI"
]
} }
externalNativeBuild { externalNativeBuild {
@@ -157,6 +165,7 @@ android {
"**/libhermes-executor-debug.so", "**/libhermes-executor-debug.so",
"**/libhermes_executor.so", "**/libhermes_executor.so",
"**/libreactnativejni.so", "**/libreactnativejni.so",
"**/libreactnative.so",
"**/libturbomodulejsijni.so", "**/libturbomodulejsijni.so",
"**/libreact_nativemodule_core.so", "**/libreact_nativemodule_core.so",
"**/libjscexecutor.so" "**/libjscexecutor.so"

View File

@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@@ -9,11 +9,13 @@
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
return facebook::jni::initialize(vm, [] { return facebook::jni::initialize(vm, [] {
// VideoPipeline is needed for video recording even without Frame Processors
vision::VideoPipeline::registerNatives();
#if VISION_CAMERA_ENABLE_FRAME_PROCESSORS
// Frame Processor JNI bindings - only register when Frame Processors are enabled
vision::VisionCameraInstaller::registerNatives(); vision::VisionCameraInstaller::registerNatives();
vision::JVisionCameraProxy::registerNatives(); vision::JVisionCameraProxy::registerNatives();
vision::JVisionCameraScheduler::registerNatives(); vision::JVisionCameraScheduler::registerNatives();
vision::VideoPipeline::registerNatives();
#if VISION_CAMERA_ENABLE_FRAME_PROCESSORS
vision::JFrameProcessor::registerNatives(); vision::JFrameProcessor::registerNatives();
vision::JSharedArray::registerNatives(); vision::JSharedArray::registerNatives();
#endif #endif

View File

@@ -40,15 +40,26 @@ fun CameraView.invokeOnStopped() {
this.sendEvent(event) this.sendEvent(event)
} }
fun CameraView.invokeOnChunkReady(filepath: File, index: Int) { fun CameraView.invokeOnChunkReady(filepath: File, index: Int, durationUs: Long?) {
Log.e(CameraView.TAG, "invokeOnError(...):") Log.i(CameraView.TAG, "invokeOnChunkReady(...): index=$index, filepath=$filepath, durationUs=$durationUs")
val event = Arguments.createMap() val event = Arguments.createMap()
event.putInt("index", index) event.putInt("index", index)
event.putString("filepath", filepath.toString()) event.putString("filepath", filepath.toString())
if (durationUs != null) {
event.putDouble("duration", durationUs / 1_000_000.0) // Convert microseconds to seconds
}
val reactContext = context as ReactContext val reactContext = context as ReactContext
reactContext.getJSModule(RCTEventEmitter::class.java).receiveEvent(id, "onVideoChunkReady", event) reactContext.getJSModule(RCTEventEmitter::class.java).receiveEvent(id, "onVideoChunkReady", event)
} }
fun CameraView.invokeOnInitReady(filepath: File) {
Log.i(CameraView.TAG, "invokeOnInitReady(...): filepath=$filepath")
val event = Arguments.createMap()
event.putString("filepath", filepath.toString())
val reactContext = context as ReactContext
reactContext.getJSModule(RCTEventEmitter::class.java).receiveEvent(id, "onInitReady", event)
}
fun CameraView.invokeOnError(error: Throwable) { fun CameraView.invokeOnError(error: Throwable) {
Log.e(CameraView.TAG, "invokeOnError(...):") Log.e(CameraView.TAG, "invokeOnError(...):")
error.printStackTrace() error.printStackTrace()

View File

@@ -13,70 +13,37 @@ import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableMap
import com.mrousavy.camera.core.CameraSession import com.mrousavy.camera.core.CameraSession
import com.mrousavy.camera.core.InsufficientStorageError import com.mrousavy.camera.core.InsufficientStorageError
import com.mrousavy.camera.utils.FileUtils
import com.mrousavy.camera.types.Flash import com.mrousavy.camera.types.Flash
import com.mrousavy.camera.types.QualityPrioritization
import com.mrousavy.camera.utils.* import com.mrousavy.camera.utils.*
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.* import kotlinx.coroutines.*
private const val TAG = "CameraView.takePhoto" private const val TAG = "CameraView.takeSnapshot"
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap { suspend fun CameraView.takePhoto(optionsMap: ReadableMap): WritableMap {
val options = optionsMap.toHashMap() val options = optionsMap.toHashMap()
Log.i(TAG, "Taking photo... Options: $options") Log.i(TAG, "Taking snapshot... Options: $options")
val bitmap = previewView.getBitmap() ?: throw Error()
val qualityPrioritization = options["qualityPrioritization"] as? String ?: "balanced" val file = FileUtils.createTempFile(context, "png");
val flash = options["flash"] as? String ?: "off"
val enableAutoStabilization = options["enableAutoStabilization"] == true
val enableShutterSound = options["enableShutterSound"] as? Boolean ?: true
val enablePrecapture = options["enablePrecapture"] as? Boolean ?: false
// TODO: Implement Red Eye Reduction // Write snapshot to .jpg file
options["enableAutoRedEyeReduction"] FileUtils.writeBitmapTofile(bitmap, file, 100)
val flashMode = Flash.fromUnionValue(flash) Log.i(TAG, "Successfully saved snapshot to file!")
val qualityPrioritizationMode = QualityPrioritization.fromUnionValue(qualityPrioritization)
val photo = cameraSession.takePhoto(
qualityPrioritizationMode,
flashMode,
enableShutterSound,
enableAutoStabilization,
enablePrecapture,
orientation
)
photo.use {
Log.i(TAG, "Successfully captured ${photo.image.width} x ${photo.image.height} photo!")
val cameraCharacteristics = cameraManager.getCameraCharacteristics(cameraId!!)
val path = try {
savePhotoToFile(context, cameraCharacteristics, photo)
} catch (e: IOException) {
if (e.message?.contains("no space left", true) == true) {
throw InsufficientStorageError()
} else {
throw e
}
}
Log.i(TAG, "Successfully saved photo to file! $path")
// Parse output data
val map = Arguments.createMap() val map = Arguments.createMap()
map.putString("path", path) map.putString("path", file.absolutePath)
map.putInt("width", photo.image.width) map.putInt("width", bitmap.width)
map.putInt("height", photo.image.height) map.putInt("height", bitmap.height)
map.putString("orientation", photo.orientation.unionValue) map.putBoolean("isMirrored", false)
map.putBoolean("isRawPhoto", photo.format == ImageFormat.RAW_SENSOR)
map.putBoolean("isMirrored", photo.isMirrored)
return map return map
} }
}
private fun writePhotoToFile(photo: CameraSession.CapturedPhoto, file: File) { private fun writePhotoToFile(photo: CameraSession.CapturedPhoto, file: File) {
val byteBuffer = photo.image.planes[0].buffer val byteBuffer = photo.image.planes[0].buffer

View File

@@ -102,7 +102,7 @@ class CameraView(context: Context) :
// session // session
internal val cameraSession: CameraSession internal val cameraSession: CameraSession
private val previewView: PreviewView val previewView: PreviewView
private var currentConfigureCall: Long = System.currentTimeMillis() private var currentConfigureCall: Long = System.currentTimeMillis()
internal var frameProcessor: FrameProcessor? = null internal var frameProcessor: FrameProcessor? = null
@@ -271,8 +271,12 @@ class CameraView(context: Context) :
invokeOnStopped() invokeOnStopped()
} }
override fun onVideoChunkReady(filepath: File, index: Int) { override fun onVideoChunkReady(filepath: File, index: Int, durationUs: Long?) {
invokeOnChunkReady(filepath, index) invokeOnChunkReady(filepath, index, durationUs)
}
override fun onInitSegmentReady(filepath: File) {
invokeOnInitReady(filepath)
} }
override fun onCodeScanned(codes: List<Barcode>, scannerFrame: CodeScannerFrame) { override fun onCodeScanned(codes: List<Barcode>, scannerFrame: CodeScannerFrame) {

View File

@@ -32,7 +32,8 @@ class CameraViewManager : ViewGroupManager<CameraView>() {
.put("cameraError", MapBuilder.of("registrationName", "onError")) .put("cameraError", MapBuilder.of("registrationName", "onError"))
.put("cameraCodeScanned", MapBuilder.of("registrationName", "onCodeScanned")) .put("cameraCodeScanned", MapBuilder.of("registrationName", "onCodeScanned"))
.put("onVideoChunkReady", MapBuilder.of("registrationName", "onVideoChunkReady")) .put("onVideoChunkReady", MapBuilder.of("registrationName", "onVideoChunkReady"))
.build() .put("onInitReady", MapBuilder.of("registrationName", "onInitReady"))
.build()?.toMutableMap()
override fun getName(): String = TAG override fun getName(): String = TAG

View File

@@ -31,10 +31,12 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase
init { init {
try { try {
// Load the native part of VisionCamera. // Load the native part of VisionCamera.
// Includes the OpenGL VideoPipeline, as well as Frame Processor JSI bindings // Includes the OpenGL VideoPipeline (needed for video recording)
// Frame Processors remain disabled for RN 0.79+ compatibility
System.loadLibrary("VisionCamera") System.loadLibrary("VisionCamera")
Log.i(TAG, "VisionCamera native library loaded successfully")
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
Log.e(VisionCameraProxy.TAG, "Failed to load VisionCamera C++ library!", e) Log.e(TAG, "Failed to load VisionCamera C++ library!", e)
throw e throw e
} }
} }
@@ -73,14 +75,10 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase
} }
@ReactMethod(isBlockingSynchronousMethod = true) @ReactMethod(isBlockingSynchronousMethod = true)
fun installFrameProcessorBindings(): Boolean = fun installFrameProcessorBindings(): Boolean {
try { // Frame Processors are disabled for React Native 0.79+ compatibility
val proxy = VisionCameraProxy(reactApplicationContext) Log.i(TAG, "Frame Processor bindings not installed - Frame Processors disabled for RN 0.79+ compatibility")
VisionCameraInstaller.install(proxy) return false
true
} catch (e: Error) {
Log.e(TAG, "Failed to install Frame Processor JSI Bindings!", e)
false
} }
@ReactMethod @ReactMethod
@@ -157,7 +155,7 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase
} }
private fun canRequestPermission(permission: String): Boolean { private fun canRequestPermission(permission: String): Boolean {
val activity = currentActivity as? PermissionAwareActivity val activity = reactApplicationContext.currentActivity as? PermissionAwareActivity
return activity?.shouldShowRequestPermissionRationale(permission) ?: false return activity?.shouldShowRequestPermissionRationale(permission) ?: false
} }

View File

@@ -15,6 +15,7 @@ import android.util.Log
import android.util.Size import android.util.Size
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.WindowManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
import com.mrousavy.camera.core.capture.RepeatingCaptureRequest import com.mrousavy.camera.core.capture.RepeatingCaptureRequest
@@ -425,6 +426,23 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
val fps = configuration?.fps ?: 30 val fps = configuration?.fps ?: 30
// Get actual device rotation from WindowManager since the React Native orientation hook
// doesn't update when rotating between landscape-left and landscape-right on Android.
// Map device rotation to the correct orientation for video recording.
// Surface.ROTATION_90 = device rotated 90° CCW = phone top on LEFT = LANDSCAPE_LEFT
// Surface.ROTATION_270 = device rotated 90° CW = phone top on RIGHT = LANDSCAPE_RIGHT
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val deviceRotation = windowManager.defaultDisplay.rotation
val recordingOrientation = when (deviceRotation) {
Surface.ROTATION_0 -> Orientation.PORTRAIT
Surface.ROTATION_90 -> Orientation.LANDSCAPE_LEFT
Surface.ROTATION_180 -> Orientation.PORTRAIT_UPSIDE_DOWN
Surface.ROTATION_270 -> Orientation.LANDSCAPE_RIGHT
else -> Orientation.PORTRAIT
}
Log.i(TAG, "startRecording: orientation=${recordingOrientation.toDegrees()}° (deviceRotation=$deviceRotation)")
val recording = RecordingSession( val recording = RecordingSession(
context, context,
cameraId, cameraId,
@@ -432,7 +450,7 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
enableAudio, enableAudio,
fps, fps,
videoOutput.enableHdr, videoOutput.enableHdr,
orientation, recordingOrientation,
options, options,
filePath, filePath,
callback, callback,
@@ -497,7 +515,8 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
fun onInitialized() fun onInitialized()
fun onStarted() fun onStarted()
fun onStopped() fun onStopped()
fun onVideoChunkReady(filepath: File, index: Int) fun onVideoChunkReady(filepath: File, index: Int, durationUs: Long?)
fun onInitSegmentReady(filepath: File)
fun onCodeScanned(codes: List<Barcode>, scannerFrame: CodeScannerFrame) fun onCodeScanned(codes: List<Barcode>, scannerFrame: CodeScannerFrame)
} }
} }

View File

@@ -14,7 +14,7 @@ import java.io.File
import java.nio.ByteBuffer import java.nio.ByteBuffer
class ChunkedRecordingManager(private val encoder: MediaCodec, private val outputDirectory: File, private val orientationHint: Int, private val iFrameInterval: Int, private val callbacks: CameraSession.Callback) : class ChunkedRecordingManager(private val encoder: MediaCodec, private val outputDirectory: File, private val orientationHint: Int, private val iFrameInterval: Int, private val callbacks: CameraSession.Callback) :
MediaCodec.Callback() { MediaCodec.Callback(), ChunkedRecorderInterface {
companion object { companion object {
private const val TAG = "ChunkedRecorder" private const val TAG = "ChunkedRecorder"
@@ -30,8 +30,15 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
iFrameInterval: Int = 5 iFrameInterval: Int = 5
): ChunkedRecordingManager { ): ChunkedRecordingManager {
val mimeType = options.videoCodec.toMimeType() val mimeType = options.videoCodec.toMimeType()
val cameraOrientationDegrees = cameraOrientation.toDegrees() // Use cameraOrientation (from WindowManager) for rotation metadata
val recordingOrientationDegrees = (options.orientation ?: Orientation.PORTRAIT).toDegrees(); // The options.orientation from JavaScript is unreliable on Android when rotating between landscape modes
// Note: MediaMuxer.setOrientationHint() uses opposite convention from HlsMuxer's rotation matrix
// We need to invert the rotation: 90 <-> 270, while 0 and 180 stay the same
val orientationDegrees = when (cameraOrientation.toDegrees()) {
90 -> 270
270 -> 90
else -> cameraOrientation.toDegrees()
}
val (width, height) = if (cameraOrientation.isLandscape()) { val (width, height) = if (cameraOrientation.isLandscape()) {
size.height to size.width size.height to size.width
} else { } else {
@@ -55,12 +62,12 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval)
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
Log.d(TAG, "Video Format: $format, camera orientation $cameraOrientationDegrees, recordingOrientation: $recordingOrientationDegrees") Log.d(TAG, "Video Format: $format, orientation: $orientationDegrees")
// Create a MediaCodec encoder, and configure it with our format. Get a Surface // Create a MediaCodec encoder, and configure it with our format. Get a Surface
// we can use for input and wrap it with a class that handles the EGL work. // we can use for input and wrap it with a class that handles the EGL work.
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
return ChunkedRecordingManager( return ChunkedRecordingManager(
codec, outputDirectory, recordingOrientationDegrees, iFrameInterval, callbacks codec, outputDirectory, orientationDegrees, iFrameInterval, callbacks
) )
} }
} }
@@ -73,7 +80,7 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
private val targetDurationUs = iFrameInterval * 1000000 private val targetDurationUs = iFrameInterval * 1000000
val surface: Surface = encoder.createInputSurface() override val surface: Surface = encoder.createInputSurface()
init { init {
if (!this.outputDirectory.exists()) { if (!this.outputDirectory.exists()) {
@@ -91,11 +98,14 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
muxer.start() muxer.start()
} }
fun writeSample(buffer: java.nio.ByteBuffer, bufferInfo: BufferInfo) {
muxer.writeSampleData(videoTrack, buffer, bufferInfo)
}
fun finish() { fun finish() {
muxer.stop() muxer.stop()
muxer.release() muxer.release()
callbacks.onVideoChunkReady(filepath, chunkIndex) callbacks.onVideoChunkReady(filepath, chunkIndex, null)
} }
} }
@@ -105,6 +115,12 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
muxerContext?.finish() muxerContext?.finish()
chunkIndex++ chunkIndex++
val format = this.encodedFormat
if (format == null) {
Log.e(TAG, "Cannot create muxer: encodedFormat is null (onOutputFormatChanged not called yet)")
return
}
val newFileName = "$chunkIndex.mp4" val newFileName = "$chunkIndex.mp4"
val newOutputFile = File(this.outputDirectory, newFileName) val newOutputFile = File(this.outputDirectory, newFileName)
Log.i(TAG, "Creating new muxer for file: $newFileName") Log.i(TAG, "Creating new muxer for file: $newFileName")
@@ -114,7 +130,7 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
) )
muxer.setOrientationHint(orientationHint) muxer.setOrientationHint(orientationHint)
muxerContext = MuxerContext( muxerContext = MuxerContext(
muxer, newOutputFile, chunkIndex, bufferInfo.presentationTimeUs, this.encodedFormat!!, this.callbacks muxer, newOutputFile, chunkIndex, bufferInfo.presentationTimeUs, format, this.callbacks
) )
} }
@@ -123,15 +139,16 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
} }
private fun chunkLengthUs(bufferInfo: BufferInfo): Long { private fun chunkLengthUs(bufferInfo: BufferInfo): Long {
return bufferInfo.presentationTimeUs - muxerContext!!.startTimeUs val context = muxerContext ?: return 0L
return bufferInfo.presentationTimeUs - context.startTimeUs
} }
fun start() { override fun start() {
encoder.start() encoder.start()
recording = true recording = true
} }
fun finish() { override fun finish() {
synchronized(this) { synchronized(this) {
muxerContext?.finish() muxerContext?.finish()
recording = false recording = false
@@ -155,7 +172,13 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
if (muxerContext == null || (atKeyframe(bufferInfo) && chunkLengthUs(bufferInfo) >= targetDurationUs)) { if (muxerContext == null || (atKeyframe(bufferInfo) && chunkLengthUs(bufferInfo) >= targetDurationUs)) {
this.createNextMuxer(bufferInfo) this.createNextMuxer(bufferInfo)
} }
muxerContext!!.muxer.writeSampleData(muxerContext!!.videoTrack, encodedData, bufferInfo) val context = muxerContext
if (context == null) {
Log.e(TAG, "Cannot write sample data: muxerContext is null")
encoder.releaseOutputBuffer(index, false)
return
}
context.writeSample(encodedData, bufferInfo)
encoder.releaseOutputBuffer(index, false) encoder.releaseOutputBuffer(index, false)
} }
} }

View File

@@ -0,0 +1,15 @@
package com.mrousavy.camera.core
import android.view.Surface
/**
* Common interface for chunked video recorders.
* Implemented by both ChunkedRecordingManager (regular MP4) and
* FragmentedRecordingManager (HLS-compatible fMP4).
*/
interface ChunkedRecorderInterface {
val surface: Surface
fun start()
fun finish()
}

View File

@@ -0,0 +1,176 @@
package com.mrousavy.camera.core
import android.media.MediaCodec
import android.media.MediaCodec.BufferInfo
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.util.Log
import android.util.Size
import android.view.Surface
import com.mrousavy.camera.types.Orientation
import com.mrousavy.camera.types.RecordVideoOptions
import java.io.File
/**
* A recording manager that produces HLS-compatible fragmented MP4 segments.
*
* Uses HlsMuxer (following Android's MediaMuxer pattern) to produce:
* - init.mp4: Initialization segment (ftyp + moov with mvex)
* - 0.mp4, 1.mp4, ...: Media segments (moof + mdat)
*/
class FragmentedRecordingManager(
private val encoder: MediaCodec,
private val muxer: HlsMuxer
) : MediaCodec.Callback(), ChunkedRecorderInterface {
companion object {
private const val TAG = "FragmentedRecorder"
private const val DEFAULT_SEGMENT_DURATION_SECONDS = 6
fun fromParams(
callbacks: CameraSession.Callback,
size: Size,
enableAudio: Boolean,
fps: Int? = null,
cameraOrientation: Orientation,
bitRate: Int,
options: RecordVideoOptions,
outputDirectory: File,
segmentDurationSeconds: Int = DEFAULT_SEGMENT_DURATION_SECONDS
): FragmentedRecordingManager {
val mimeType = options.videoCodec.toMimeType()
// Use cameraOrientation (from WindowManager) for rotation metadata
// The options.orientation from JavaScript is unreliable on Android when rotating between landscape modes
val orientationDegrees = cameraOrientation.toDegrees()
// Swap dimensions based on camera orientation, same as ChunkedRecordingManager
val (width, height) = if (cameraOrientation.isLandscape()) {
size.height to size.width
} else {
size.width to size.height
}
Log.d(TAG, "Recording: ${width}x${height}, orientation=$orientationDegrees°")
val format = MediaFormat.createVideoFormat(mimeType, width, height)
val codec = MediaCodec.createEncoderByType(mimeType)
format.setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
// Use 30fps as conservative default since many Android devices can't sustain
// higher frame rates at high resolutions. This affects:
// - Encoder: bitrate allocation and I-frame interval calculation
// - HlsMuxer: timescale for accurate sample durations
// The actual frame timing comes from camera timestamps regardless of this setting.
val effectiveFps = 30
format.setInteger(MediaFormat.KEY_FRAME_RATE, effectiveFps)
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, segmentDurationSeconds)
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
val muxer = HlsMuxer(
outputDirectory = outputDirectory,
callback = object : HlsMuxer.Callback {
override fun onInitSegmentReady(file: File) {
callbacks.onInitSegmentReady(file)
}
override fun onMediaSegmentReady(file: File, index: Int, durationUs: Long) {
callbacks.onVideoChunkReady(file, index, durationUs)
}
},
orientationDegrees = orientationDegrees,
fps = effectiveFps
)
muxer.setSegmentDuration(segmentDurationSeconds * 1_000_000L)
return FragmentedRecordingManager(codec, muxer)
}
}
private var recording = false
private var muxerStarted = false
private var trackIndex = -1
override val surface: Surface = encoder.createInputSurface()
init {
encoder.setCallback(this)
}
override fun start() {
encoder.start()
recording = true
}
override fun finish() {
synchronized(this) {
recording = false
if (muxerStarted) {
muxer.stop()
muxer.release()
}
try {
encoder.stop()
encoder.release()
} catch (e: Exception) {
Log.e(TAG, "Error stopping encoder", e)
}
}
}
// MediaCodec.Callback methods
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
// Not used for Surface input
}
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, bufferInfo: BufferInfo) {
synchronized(this) {
if (!recording) {
encoder.releaseOutputBuffer(index, false)
return
}
if (!muxerStarted) {
encoder.releaseOutputBuffer(index, false)
return
}
val buffer = encoder.getOutputBuffer(index)
if (buffer == null) {
Log.e(TAG, "getOutputBuffer returned null")
encoder.releaseOutputBuffer(index, false)
return
}
try {
muxer.writeSampleData(trackIndex, buffer, bufferInfo)
} catch (e: Exception) {
Log.e(TAG, "Error writing sample", e)
}
encoder.releaseOutputBuffer(index, false)
}
}
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
Log.e(TAG, "Codec error: ${e.message}")
}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
synchronized(this) {
Log.i(TAG, "Output format changed: $format")
trackIndex = muxer.addTrack(format)
muxer.start()
muxerStarted = true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,85 @@ package com.mrousavy.camera.core
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.graphics.Point import android.graphics.Point
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Size import android.util.Size
import android.view.PixelCopy
import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.WindowManager
import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.UiThreadUtil
import com.mrousavy.camera.extensions.resize import com.mrousavy.camera.extensions.resize
import com.mrousavy.camera.extensions.rotatedBy import com.mrousavy.camera.extensions.rotatedBy
import com.mrousavy.camera.types.Orientation import com.mrousavy.camera.types.Orientation
import com.mrousavy.camera.types.ResizeMode import com.mrousavy.camera.types.ResizeMode
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import android.graphics.Bitmap
import android.graphics.Matrix
fun rotateBitmap90CounterClockwise(source: Bitmap): Bitmap {
val width = source.width
val height = source.height
// Create a new Bitmap with swapped width and height
val rotatedBitmap = Bitmap.createBitmap(height, width, source.config ?: Bitmap.Config.ARGB_8888)
for (y in 0 until height) {
for (x in 0 until width) {
// Set the pixel in the new position
rotatedBitmap.setPixel(y, width - 1 - x, source.getPixel(x, y))
}
}
return rotatedBitmap
}
fun Bitmap.transformBitmap(orientation: Orientation): Bitmap {
return when (orientation) {
Orientation.PORTRAIT -> this // No transformation needed
Orientation.LANDSCAPE_LEFT -> {
// Transpose (swap width and height)
val transposedBitmap = Bitmap.createBitmap(height, width, config ?: Bitmap.Config.ARGB_8888)
for (y in 0 until height) {
for (x in 0 until width) {
transposedBitmap.setPixel(y, width - 1 - x, getPixel(x, y))
}
}
transposedBitmap
}
Orientation.PORTRAIT_UPSIDE_DOWN -> {
// Invert vertically and horizontally (180-degree rotation)
val invertedBitmap = Bitmap.createBitmap(width, height, config ?: Bitmap.Config.ARGB_8888)
for (y in 0 until height) {
for (x in 0 until width) {
invertedBitmap.setPixel(width - 1 - x, height - 1 - y, getPixel(x, y))
}
}
invertedBitmap
}
Orientation.LANDSCAPE_RIGHT -> {
// Transpose (swap width and height) and invert vertically
val transposedBitmap = Bitmap.createBitmap(height, width, config ?: Bitmap.Config.ARGB_8888)
for (y in 0 until height) {
for (x in 0 until width) {
transposedBitmap.setPixel(height - 1 - y, x, getPixel(x, y))
}
}
transposedBitmap
}
}
}
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class PreviewView(context: Context, callback: SurfaceHolder.Callback) : class PreviewView(context: Context, callback: SurfaceHolder.Callback) :
@@ -80,6 +146,52 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) :
} }
} }
suspend fun getBitmap(): Bitmap? = withContext(Dispatchers.Main) {
val frame = holder.getSurfaceFrame()
val width = frame.width()
val height = frame.height()
// Create bitmap matching surface frame dimensions for PixelCopy
// The original code swapped dimensions assuming landscape input - keep that for consistency
val bitmap = Bitmap.createBitmap(height, width, Bitmap.Config.ARGB_8888)
// Use a coroutine to suspend until the PixelCopy request is complete
suspendCancellableCoroutine<Bitmap?> { continuation ->
PixelCopy.request(
holder.surface,
bitmap,
{ copyResult ->
if (copyResult == PixelCopy.SUCCESS) {
// Get actual device rotation from WindowManager instead of relying on
// the orientation prop, which may not update on Android when rotating
// between landscape-left and landscape-right.
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val deviceRotation = windowManager.defaultDisplay.rotation
val actualOrientation = when (deviceRotation) {
Surface.ROTATION_0 -> Orientation.PORTRAIT
Surface.ROTATION_90 -> Orientation.LANDSCAPE_LEFT
Surface.ROTATION_180 -> Orientation.PORTRAIT_UPSIDE_DOWN
Surface.ROTATION_270 -> Orientation.LANDSCAPE_RIGHT
else -> Orientation.PORTRAIT
}
Log.i(TAG, "getBitmap: orientation prop = $orientation, deviceRotation = $deviceRotation, actualOrientation = $actualOrientation")
continuation.resume(bitmap.transformBitmap(actualOrientation))
} else {
continuation.resumeWithException(
RuntimeException("PixelCopy failed with error code $copyResult")
)
}
},
Handler(Looper.getMainLooper())
)
}
}
fun convertLayerPointToCameraCoordinates(point: Point, cameraDeviceDetails: CameraDeviceDetails): Point { fun convertLayerPointToCameraCoordinates(point: Point, cameraDeviceDetails: CameraDeviceDetails): Point {
val sensorOrientation = cameraDeviceDetails.sensorOrientation val sensorOrientation = cameraDeviceDetails.sensorOrientation
val cameraSize = Size(cameraDeviceDetails.activeSize.width(), cameraDeviceDetails.activeSize.height()) val cameraSize = Size(cameraDeviceDetails.activeSize.width(), cameraDeviceDetails.activeSize.height())
@@ -97,22 +209,14 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) :
} }
} }
override fun requestLayout() {
super.requestLayout()
// Manually trigger measure & layout, as RN on Android skips those.
// See this issue: https://github.com/facebook/react-native/issues/17968#issuecomment-721958427
post {
measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY))
layout(left, top, right, bottom)
}
}
private fun getSize(contentSize: Size, containerSize: Size, resizeMode: ResizeMode): Size { private fun getSize(contentSize: Size, containerSize: Size, resizeMode: ResizeMode): Size {
var contentSize = contentSize var contentSize = contentSize
// Swap dimensions if orientation is landscape var androidOrientation = context.getResources().getConfiguration().orientation;
if (orientation.isLandscape()) {
if (androidOrientation == Configuration.ORIENTATION_LANDSCAPE) {
contentSize = Size(contentSize.height, contentSize.width) contentSize = Size(contentSize.height, contentSize.width)
} }
val contentAspectRatio = contentSize.width.toDouble() / contentSize.height val contentAspectRatio = contentSize.width.toDouble() / contentSize.height
val containerAspectRatio = containerSize.width.toDouble() / containerSize.height val containerAspectRatio = containerSize.width.toDouble() / containerSize.height
if (!(contentAspectRatio > 0 && containerAspectRatio > 0)) { if (!(contentAspectRatio > 0 && containerAspectRatio > 0)) {

View File

@@ -8,12 +8,14 @@ import com.facebook.common.statfs.StatFsHelper
import com.mrousavy.camera.extensions.getRecommendedBitRate import com.mrousavy.camera.extensions.getRecommendedBitRate
import com.mrousavy.camera.types.Orientation import com.mrousavy.camera.types.Orientation
import com.mrousavy.camera.types.RecordVideoOptions import com.mrousavy.camera.types.RecordVideoOptions
import com.mrousavy.camera.types.StreamSegmentType
import com.mrousavy.camera.utils.FileUtils import com.mrousavy.camera.utils.FileUtils
import java.io.File import java.io.File
import android.os.Environment import android.os.Environment
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.Date import java.util.Date
class RecordingSession( class RecordingSession(
context: Context, context: Context,
val cameraId: String, val cameraId: String,
@@ -26,7 +28,7 @@ class RecordingSession(
private val filePath: String, private val filePath: String,
private val callback: (video: Video) -> Unit, private val callback: (video: Video) -> Unit,
private val onError: (error: CameraError) -> Unit, private val onError: (error: CameraError) -> Unit,
private val allCallbacks: CameraSession.Callback, private val allCallbacks: CameraSession.Callback
) { ) {
companion object { companion object {
private const val TAG = "RecordingSession" private const val TAG = "RecordingSession"
@@ -34,14 +36,35 @@ class RecordingSession(
private const val AUDIO_SAMPLING_RATE = 44_100 private const val AUDIO_SAMPLING_RATE = 44_100
private const val AUDIO_BIT_RATE = 16 * AUDIO_SAMPLING_RATE private const val AUDIO_BIT_RATE = 16 * AUDIO_SAMPLING_RATE
private const val AUDIO_CHANNELS = 1 private const val AUDIO_CHANNELS = 1
// Segment duration in seconds (matching iOS default of 6 seconds)
private const val SEGMENT_DURATION_SECONDS = 6
} }
data class Video(val path: String, val durationMs: Long, val size: Size) data class Video(val path: String, val durationMs: Long, val size: Size)
private val outputPath: File = File(filePath) // Normalize path - expo-file-system passes file:// URIs but File expects raw paths
// Handle both file:// and file:/ variants
private val outputPath: File = File(filePath.replace(Regex("^file:/+"), "/"))
private val bitRate = getBitRate() private val bitRate = getBitRate()
private val recorder = ChunkedRecordingManager.fromParams(
// Use FragmentedRecordingManager for HLS-compatible fMP4 output,
// or fall back to ChunkedRecordingManager for regular MP4 chunks
private val recorder: ChunkedRecorderInterface = if (options.streamSegmentType == StreamSegmentType.FRAGMENTED_MP4) {
FragmentedRecordingManager.fromParams(
allCallbacks,
size,
enableAudio,
fps,
cameraOrientation,
bitRate,
options,
outputPath,
SEGMENT_DURATION_SECONDS
)
} else {
ChunkedRecordingManager.fromParams(
allCallbacks, allCallbacks,
size, size,
enableAudio, enableAudio,
@@ -51,6 +74,7 @@ class RecordingSession(
options, options,
outputPath outputPath
) )
}
private var startTime: Long? = null private var startTime: Long? = null
val surface: Surface val surface: Surface
get() { get() {
@@ -59,7 +83,7 @@ class RecordingSession(
fun start() { fun start() {
synchronized(this) { synchronized(this) {
Log.i(TAG, "Starting RecordingSession..") Log.i(TAG, "Starting RecordingSession with ${options.streamSegmentType} recorder..")
startTime = System.currentTimeMillis() startTime = System.currentTimeMillis()
recorder.start() recorder.start()
} }

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package com.mrousavy.camera.frameprocessor package com.mrousavy.camera.frameprocessor
import android.util.Log import android.util.Log
@@ -7,7 +9,6 @@ import com.facebook.jni.HybridData
import com.facebook.proguard.annotations.DoNotStrip import com.facebook.proguard.annotations.DoNotStrip
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.turbomodule.core.CallInvokerHolderImpl
import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.UIManagerHelper
import com.mrousavy.camera.CameraView import com.mrousavy.camera.CameraView
import com.mrousavy.camera.core.ViewNotFoundError import com.mrousavy.camera.core.ViewNotFoundError
@@ -21,19 +22,26 @@ class VisionCameraProxy(private val reactContext: ReactApplicationContext) {
@DoNotStrip @DoNotStrip
@Keep @Keep
private var mHybridData: HybridData private var mHybridData: HybridData?
private var mContext: WeakReference<ReactApplicationContext> private var mContext: WeakReference<ReactApplicationContext>
private var mScheduler: VisionCameraScheduler private var mScheduler: VisionCameraScheduler
val context: ReactApplicationContext val context: ReactApplicationContext
get() = reactContext get() = reactContext
init { init {
val jsCallInvokerHolder = context.catalystInstance.jsCallInvokerHolder as CallInvokerHolderImpl // TODO: Fix for React Native 0.79+ - these APIs are now framework-only
val jsRuntimeHolder = // Since Frame Processors are disabled anyway (react-native-worklets-core not found),
context.javaScriptContextHolder?.get() ?: throw Error("JSI Runtime is null! VisionCamera does not yet support bridgeless mode..") // we'll disable this functionality to allow the build to complete
Log.w(TAG, "Frame Processor initialization disabled due to React Native 0.79+ API compatibility issues")
mScheduler = VisionCameraScheduler() mScheduler = VisionCameraScheduler()
mContext = WeakReference(context) mContext = WeakReference(context)
mHybridData = initHybrid(jsRuntimeHolder, jsCallInvokerHolder, mScheduler) // Disable Frame Processor functionality since APIs are not compatible
mHybridData = null
// Original code that fails with RN 0.79+:
// val jsCallInvokerHolder = context.catalystInstance.jsCallInvokerHolder as CallInvokerHolderImpl
// val jsRuntimeHolder = context.javaScriptContextHolder?.get() ?: throw Error("JSI Runtime is null!")
// mHybridData = initHybrid(jsRuntimeHolder, jsCallInvokerHolder, mScheduler)
} }
@UiThread @UiThread
@@ -69,5 +77,9 @@ class VisionCameraProxy(private val reactContext: ReactApplicationContext) {
FrameProcessorPluginRegistry.getPlugin(name, this, options) FrameProcessorPluginRegistry.getPlugin(name, this, options)
// private C++ funcs // private C++ funcs
private external fun initHybrid(jsContext: Long, jsCallInvokerHolder: CallInvokerHolderImpl, scheduler: VisionCameraScheduler): HybridData // Frame Processors are disabled - native registration is skipped via VISION_CAMERA_ENABLE_FRAME_PROCESSORS=OFF
// This method is never called or registered, kept for reference only
// @DoNotStrip
// @Keep
// private external fun initHybrid(jsContext: Long, jsCallInvokerHolder: Any, scheduler: VisionCameraScheduler): HybridData
} }

View File

@@ -9,6 +9,7 @@ class RecordVideoOptions(map: ReadableMap) {
var videoBitRateOverride: Double? = null var videoBitRateOverride: Double? = null
var videoBitRateMultiplier: Double? = null var videoBitRateMultiplier: Double? = null
var orientation: Orientation? = null var orientation: Orientation? = null
var streamSegmentType: StreamSegmentType = StreamSegmentType.FRAGMENTED_MP4
init { init {
if (map.hasKey("fileType")) { if (map.hasKey("fileType")) {
@@ -29,5 +30,8 @@ class RecordVideoOptions(map: ReadableMap) {
if (map.hasKey("orientation")) { if (map.hasKey("orientation")) {
orientation = Orientation.fromUnionValue(map.getString("orientation")) orientation = Orientation.fromUnionValue(map.getString("orientation"))
} }
if (map.hasKey("streamSegmentType")) {
streamSegmentType = StreamSegmentType.fromUnionValue(map.getString("streamSegmentType"))
}
} }
} }

View File

@@ -0,0 +1,15 @@
package com.mrousavy.camera.types
enum class StreamSegmentType(override val unionValue: String) : JSUnionValue {
FRAGMENTED_MP4("FRAGMENTED_MP4"),
RB_CHUNKED_MP4("RB_CHUNKED_MP4");
companion object : JSUnionValue.Companion<StreamSegmentType> {
override fun fromUnionValue(unionValue: String?): StreamSegmentType =
when (unionValue) {
"FRAGMENTED_MP4" -> FRAGMENTED_MP4
"RB_CHUNKED_MP4" -> RB_CHUNKED_MP4
else -> FRAGMENTED_MP4 // Default to fMP4
}
}
}

View File

@@ -1,10 +1,30 @@
package com.mrousavy.camera.utils package com.mrousavy.camera.utils
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Size
import java.io.File import java.io.File
import java.io.FileOutputStream
class FileUtils { class FileUtils {
companion object { companion object {
fun writeBitmapTofile(bitmap: Bitmap, file: File, quality: Int) {
FileOutputStream(file).use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, stream)
}
}
fun getImageSize(imagePath: String): Size {
val bitmapOptions = BitmapFactory.Options().also {
it.inJustDecodeBounds = true
}
BitmapFactory.decodeFile(imagePath, bitmapOptions)
val width = bitmapOptions.outWidth
val height = bitmapOptions.outHeight
return Size(width, height)
}
fun createTempFile(context: Context, extension: String): File = fun createTempFile(context: Context, extension: String): File =
File.createTempFile("mrousavy", extension, context.cacheDir).also { File.createTempFile("mrousavy", extension, context.cacheDir).also {
it.deleteOnExit() it.deleteOnExit()

View File

@@ -11,6 +11,6 @@ inline fun withPromise(promise: Promise, closure: () -> Any?) {
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTrace() e.printStackTrace()
val error = if (e is CameraError) e else UnknownCameraError(e) val error = if (e is CameraError) e else UnknownCameraError(e)
promise.reject("${error.domain}/${error.id}", error.message, error.cause) promise.reject("${error.domain}/${error.id}", error.message ?: "Unknown error", error.cause)
} }
} }

View File

@@ -17,8 +17,8 @@
"@react-native-community/blur": "^4.3.2", "@react-native-community/blur": "^4.3.2",
"@react-navigation/native": "^6.1.7", "@react-navigation/native": "^6.1.7",
"@react-navigation/native-stack": "^6.9.13", "@react-navigation/native-stack": "^6.9.13",
"react": "^18.2.0", "react": "^19.1.1",
"react-native": "^0.72.3", "react-native": "^0.81.0",
"react-native-fast-image": "^8.6.3", "react-native-fast-image": "^8.6.3",
"react-native-gesture-handler": "^2.12.1", "react-native-gesture-handler": "^2.12.1",
"react-native-mmkv": "^2.10.2", "react-native-mmkv": "^2.10.2",
@@ -42,7 +42,7 @@
"@types/react-native-vector-icons": "^6.4.13", "@types/react-native-vector-icons": "^6.4.13",
"@types/react-native-video": "^5.0.15", "@types/react-native-video": "^5.0.15",
"babel-plugin-module-resolver": "^5.0.0", "babel-plugin-module-resolver": "^5.0.0",
"eslint": "^8.46.0", "eslint": "^9.33.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"metro-react-native-babel-preset": "^0.77.0", "metro-react-native-babel-preset": "^0.77.0",
"prettier": "^3.2.4", "prettier": "^3.2.4",

View File

@@ -47,7 +47,7 @@ export const RecordingButton: React.FC<RecordingButtonProps> = ({ style, camera,
onMediaCaptured(video, 'video') onMediaCaptured(video, 'video')
onStoppedRecording() onStoppedRecording()
}, },
}) }, 'video.mp4')
console.log('called startRecording()!') console.log('called startRecording()!')
isRecording.current = true isRecording.current = true
setRecordingState(true) setRecordingState(true)

File diff suppressed because it is too large Load Diff

189
package/flake.lock generated
View File

@@ -1,189 +0,0 @@
{
"nodes": {
"android-nixpkgs": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1701980274,
"narHash": "sha256-uKJIFvsahbWw52TsIht7g2iosXBgJDRMSMoCE1fvEAk=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "bce9d437ed54ee1425b66442a12814fee4cdbd51",
"type": "github"
},
"original": {
"owner": "tadfisher",
"repo": "android-nixpkgs",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"android-nixpkgs",
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1701787589,
"narHash": "sha256-ce+oQR4Zq9VOsLoh9bZT8Ip9PaMLcjjBUHVPzW5d7Cw=",
"owner": "numtide",
"repo": "devshell",
"rev": "44ddedcbcfc2d52a76b64fb6122f209881bd3e1e",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_3"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1694102001,
"narHash": "sha256-vky6VPK1n1od6vXbqzOXnekrQpTL4hbPAwUhT5J9c9E=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "9e21c80adf67ebcb077d75bd5e7d724d21eeafd6",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1701718080,
"narHash": "sha256-6ovz0pG76dE0P170pmmZex1wWcQoeiomUZGggfH9XPs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2c7f3c0fb7c08a0814627611d9d7d45ab6d75335",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1702312524,
"narHash": "sha256-gkZJRDBUCpTPBvQk25G0B7vfbpEYM5s5OZqghkjZsnE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a9bf124c46ef298113270b1f84a164865987a91c",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"android-nixpkgs": "android-nixpkgs",
"flake-utils": "flake-utils_2",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,77 +0,0 @@
{
description = "Sample Nix ts-node build";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
gitignore = {
url = "github:hercules-ci/gitignore.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
android-nixpkgs = {
url = "github:tadfisher/android-nixpkgs";
};
};
outputs = {
self,
nixpkgs,
flake-utils,
gitignore,
android-nixpkgs,
...
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {inherit system;};
nodejs = pkgs.nodejs-18_x;
# NOTE: this does not work
appBuild = pkgs.stdenv.mkDerivation {
name = "example-ts-node";
version = "0.1.0";
src = gitignore.lib.gitignoreSource ./.; # uses the gitignore in the repo to only copy files git would see
buildInputs = [nodejs];
# https://nixos.org/manual/nixpkgs/stable/#sec-stdenv-phases
buildPhase = ''
# each phase has pre/postHooks. When you make your own phase be sure to still call the hooks
runHook preBuild
npm ci
npm run build
runHook postBuild
'';
installPhase = ''
runHook preInstall
cp -r node_modules $out/node_modules
cp package.json $out/package.json
cp -r dist $out/dist
runHook postInstall
'';
};
android-sdk = android-nixpkgs.sdk.${system} (sdkPkgs:
with sdkPkgs; [
cmdline-tools-latest
build-tools-30-0-3
build-tools-33-0-0
build-tools-33-0-1
build-tools-34-0-0
platform-tools
platforms-android-33
platforms-android-34
emulator
ndk-23-1-7779620
cmake-3-22-1
system-images-android-33-google-apis-x86-64
system-images-android-34-google-apis-x86-64
]);
in
with pkgs; {
defaultPackage = appBuild;
devShell = mkShell {
buildInputs = [nodejs yarn watchman gradle_7 alejandra nodePackages.prettier ktlint kotlin-language-server];
ANDROID_SDK_BIN = android-sdk;
shellHook = ''
export JAVA_HOME=${pkgs.jdk17.home}
source ${android-sdk.out}/nix-support/setup-hook
export PATH=${android-sdk}/bin:$PATH
ORG_GRADLE_PROJECT_ANDROID_HOME="$ANDROID_HOME"
'';
};
});
}

View File

@@ -20,6 +20,9 @@ extension CameraSession {
onError: @escaping (_ error: CameraError) -> Void) { onError: @escaping (_ error: CameraError) -> Void) {
// Run on Camera Queue // Run on Camera Queue
CameraQueues.cameraQueue.async { CameraQueues.cameraQueue.async {
// Normalize path - expo-file-system passes file:// URIs but FileManager expects raw paths
let normalizedPath = filePath.hasPrefix("file://") ? String(filePath.dropFirst(7)) : filePath
let start = DispatchTime.now() let start = DispatchTime.now()
ReactLogger.log(level: .info, message: "Starting Video recording...") ReactLogger.log(level: .info, message: "Starting Video recording...")
@@ -38,11 +41,27 @@ extension CameraSession {
// Callback for when new chunks are ready // Callback for when new chunks are ready
let onChunkReady: (ChunkedRecorder.Chunk) -> Void = { chunk in let onChunkReady: (ChunkedRecorder.Chunk) -> Void = { chunk in
guard let delegate = self.delegate else { guard let delegate = self.delegate else {
ReactLogger.log(level: .warning, message: "Chunk ready but delegate is nil, dropping chunk: \(chunk)")
return return
} }
delegate.onVideoChunkReady(chunk: chunk) delegate.onVideoChunkReady(chunk: chunk)
} }
// Callback for when a chunk write fails (e.g. init file write failure)
let onChunkError: (Error) -> Void = { error in
ReactLogger.log(level: .error, message: "Chunk write error, stopping recording: \(error.localizedDescription)")
// Stop recording immediately
if let session = self.recordingSession {
session.stop(clock: self.captureSession.clock)
}
// Surface error to RN
if let cameraError = error as? CameraError {
onError(cameraError)
} else {
onError(.capture(.fileError))
}
}
// Callback for when the recording ends // Callback for when the recording ends
let onFinish = { (recordingSession: RecordingSession, status: AVAssetWriter.Status, error: Error?) in let onFinish = { (recordingSession: RecordingSession, status: AVAssetWriter.Status, error: Error?) in
defer { defer {
@@ -82,22 +101,23 @@ extension CameraSession {
} }
} }
if !FileManager.default.fileExists(atPath: filePath) { if !FileManager.default.fileExists(atPath: normalizedPath) {
do { do {
try FileManager.default.createDirectory(atPath: filePath, withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: normalizedPath, withIntermediateDirectories: true)
} catch { } catch {
onError(.capture(.createRecordingDirectoryError(message: error.localizedDescription))) onError(.capture(.createRecordingDirectoryError(message: error.localizedDescription)))
return return
} }
} }
ReactLogger.log(level: .info, message: "Will record to temporary file: \(filePath)") ReactLogger.log(level: .info, message: "Will record to temporary file: \(normalizedPath)")
do { do {
// Create RecordingSession for the temp file // Create RecordingSession for the temp file
let recordingSession = try RecordingSession(outputDiretory: filePath, let recordingSession = try RecordingSession(outputDiretory: normalizedPath,
fileType: options.fileType, fileType: options.fileType,
onChunkReady: onChunkReady, onChunkReady: onChunkReady,
onChunkError: onChunkError,
completion: onFinish) completion: onFinish)
// Init Audio + Activate Audio Session (optional) // Init Audio + Activate Audio Session (optional)

View File

@@ -24,12 +24,14 @@ class ChunkedRecorder: NSObject {
let outputURL: URL let outputURL: URL
let onChunkReady: ((Chunk) -> Void) let onChunkReady: ((Chunk) -> Void)
let onError: ((Error) -> Void)?
private var chunkIndex: UInt64 = 0 private var chunkIndex: UInt64 = 0
init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void)) throws { init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void), onError: ((Error) -> Void)? = nil) throws {
self.outputURL = outputURL self.outputURL = outputURL
self.onChunkReady = onChunkReady self.onChunkReady = onChunkReady
self.onError = onError
guard FileManager.default.fileExists(atPath: outputURL.path) else { guard FileManager.default.fileExists(atPath: outputURL.path) else {
throw CameraError.unknown(message: "output directory does not exist at: \(outputURL.path)", cause: nil) throw CameraError.unknown(message: "output directory does not exist at: \(outputURL.path)", cause: nil)
} }
@@ -56,14 +58,19 @@ extension ChunkedRecorder: AVAssetWriterDelegate {
private func saveInitSegment(_ data: Data) { private func saveInitSegment(_ data: Data) {
let url = outputURL.appendingPathComponent("init.mp4") let url = outputURL.appendingPathComponent("init.mp4")
save(data: data, url: url) do {
try data.write(to: url)
onChunkReady(url: url, type: .initialization) onChunkReady(url: url, type: .initialization)
} catch {
ReactLogger.log(level: .error, message: "Failed to write init file \(url): \(error.localizedDescription)")
onError?(CameraError.capture(.fileError))
}
} }
private func saveSegment(_ data: Data, report: AVAssetSegmentReport?) { private func saveSegment(_ data: Data, report: AVAssetSegmentReport?) {
let name = "\(chunkIndex).mp4" let name = "\(chunkIndex).mp4"
let url = outputURL.appendingPathComponent(name) let url = outputURL.appendingPathComponent(name)
save(data: data, url: url) if save(data: data, url: url) {
let duration = report? let duration = report?
.trackReports .trackReports
.filter { $0.mediaType == .video } .filter { $0.mediaType == .video }
@@ -72,12 +79,15 @@ extension ChunkedRecorder: AVAssetWriterDelegate {
onChunkReady(url: url, type: .data(index: chunkIndex, duration: duration)) onChunkReady(url: url, type: .data(index: chunkIndex, duration: duration))
chunkIndex += 1 chunkIndex += 1
} }
}
private func save(data: Data, url: URL) { private func save(data: Data, url: URL) -> Bool {
do { do {
try data.write(to: url) try data.write(to: url)
return true
} catch { } catch {
ReactLogger.log(level: .error, message: "Unable to write \(url): \(error.localizedDescription)") ReactLogger.log(level: .error, message: "Unable to write \(url): \(error.localizedDescription)")
return false
} }
} }

View File

@@ -74,12 +74,13 @@ class RecordingSession {
init(outputDiretory: String, init(outputDiretory: String,
fileType: AVFileType, fileType: AVFileType,
onChunkReady: @escaping ((ChunkedRecorder.Chunk) -> Void), onChunkReady: @escaping ((ChunkedRecorder.Chunk) -> Void),
onChunkError: ((Error) -> Void)? = nil,
completion: @escaping (RecordingSession, AVAssetWriter.Status, Error?) -> Void) throws { completion: @escaping (RecordingSession, AVAssetWriter.Status, Error?) -> Void) throws {
completionHandler = completion completionHandler = completion
do { do {
let outputURL = URL(fileURLWithPath: outputDiretory) let outputURL = URL(fileURLWithPath: outputDiretory)
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady) recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady, onError: onChunkError)
assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!) assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!)
assetWriter.shouldOptimizeForNetworkUse = false assetWriter.shouldOptimizeForNetworkUse = false
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS assetWriter.outputFileTypeProfile = .mpeg4AppleHLS

View File

@@ -87,16 +87,15 @@
"devDependencies": { "devDependencies": {
"@expo/config-plugins": "^7.2.5", "@expo/config-plugins": "^7.2.5",
"@jamesacarr/eslint-formatter-github-actions": "^0.2.0", "@jamesacarr/eslint-formatter-github-actions": "^0.2.0",
"@react-native/eslint-config": "^0.72.2", "@react-native/eslint-config": "^0.81.0",
"@react-native/typescript-config": "^0.74.0", "@react-native/typescript-config": "^0.81.0",
"@release-it/conventional-changelog": "^7.0.0", "@release-it/conventional-changelog": "^7.0.0",
"@types/react": "^18.2.19", "@types/react": "^18.2.19",
"@types/react-native": "^0.72.2",
"eslint": "^8.46.0", "eslint": "^8.46.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"prettier": "^3.0.1", "prettier": "^3.0.1",
"react": "^18.2.0", "react": "^19.1.1",
"react-native": "^0.72.3", "react-native": "^0.81.0",
"react-native-builder-bob": "^0.21.3", "react-native-builder-bob": "^0.21.3",
"react-native-worklets-core": "^0.3.0", "react-native-worklets-core": "^0.3.0",
"release-it": "^16.1.3", "release-it": "^16.1.3",

View File

@@ -41,6 +41,17 @@ export interface RecordVideoOptions {
* @default 'normal' * @default 'normal'
*/ */
videoBitRate?: 'extra-low' | 'low' | 'normal' | 'high' | 'extra-high' | number videoBitRate?: 'extra-low' | 'low' | 'normal' | 'high' | 'extra-high' | number
/**
* The stream segment type for recording on Android.
* - `FRAGMENTED_MP4`: HLS-compatible segments (init.mp4 + numbered segments)
* - `RB_CHUNKED_MP4`: Legacy chunked MP4 format
*
* iOS always uses FRAGMENTED_MP4 regardless of this setting.
*
* @platform android
* @default 'FRAGMENTED_MP4'
*/
streamSegmentType?: 'FRAGMENTED_MP4' | 'RB_CHUNKED_MP4'
} }
/** /**

File diff suppressed because it is too large Load Diff