Compare commits
18 Commits
bump-react
...
loewy/frag
| Author | SHA1 | Date | |
|---|---|---|---|
| eceab60d7c | |||
| c43f4d3a80 | |||
| e60c1a4eb1 | |||
| a2d218580c | |||
| 61863149c0 | |||
| 09b50938d2 | |||
| a158ed8350 | |||
|
|
e7b295546a | ||
|
|
d87ed8ced2 | ||
| f055119735 | |||
| 35d80b13d6 | |||
| 3d09106e45 | |||
| b523e1884f | |||
| 5fcc1a4f77 | |||
| 364171a107 | |||
| f90e11897f | |||
| 4798aad464 | |||
|
|
2c8d503e66 |
@@ -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 # <-- Temporarily disabled for RN 0.79+ compatibility
|
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,21 @@ 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 orientationHint for video recording:
|
||||||
|
// - Counter-clockwise (ROTATION_90) → 90° hint
|
||||||
|
// - Clockwise (ROTATION_270) → 270° hint
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
val recording = RecordingSession(
|
val recording = RecordingSession(
|
||||||
context,
|
context,
|
||||||
cameraId,
|
cameraId,
|
||||||
@@ -432,7 +448,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 +513,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -73,7 +73,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()) {
|
||||||
@@ -95,7 +95,9 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
|
|||||||
fun finish() {
|
fun finish() {
|
||||||
muxer.stop()
|
muxer.stop()
|
||||||
muxer.release()
|
muxer.release()
|
||||||
callbacks.onVideoChunkReady(filepath, chunkIndex)
|
// Calculate duration from start time - this is approximate
|
||||||
|
// The new FragmentedRecordingManager provides accurate duration
|
||||||
|
callbacks.onVideoChunkReady(filepath, chunkIndex, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +107,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 +122,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 +131,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 +164,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.muxer.writeSampleData(context.videoTrack, encodedData, bufferInfo)
|
||||||
encoder.releaseOutputBuffer(index, false)
|
encoder.releaseOutputBuffer(index, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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,
|
||||||
|
private val configuredFps: Int
|
||||||
|
) : 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 Android (computed from device rotation)
|
||||||
|
// instead of options.orientation from JS which may be stale
|
||||||
|
val recordingOrientationDegrees = cameraOrientation.toDegrees()
|
||||||
|
|
||||||
|
// Swap dimensions based on orientation - same logic as ChunkedRecordingManager
|
||||||
|
// When camera is in landscape orientation, we need to swap width/height for the encoder
|
||||||
|
val (width, height) = if (cameraOrientation.isLandscape()) {
|
||||||
|
size.height to size.width
|
||||||
|
} else {
|
||||||
|
size.width to size.height
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Input size: ${size.width}x${size.height}, " +
|
||||||
|
"encoder size: ${width}x${height}, " +
|
||||||
|
"orientation: $cameraOrientation ($recordingOrientationDegrees°)")
|
||||||
|
|
||||||
|
val format = MediaFormat.createVideoFormat(mimeType, width, height)
|
||||||
|
val codec = MediaCodec.createEncoderByType(mimeType)
|
||||||
|
|
||||||
|
format.setInteger(
|
||||||
|
MediaFormat.KEY_COLOR_FORMAT,
|
||||||
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
val effectiveFps = fps ?: 30
|
||||||
|
format.setInteger(MediaFormat.KEY_FRAME_RATE, effectiveFps)
|
||||||
|
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, segmentDurationSeconds)
|
||||||
|
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
||||||
|
|
||||||
|
Log.d(TAG, "Video Format: $format, orientation: $recordingOrientationDegrees")
|
||||||
|
|
||||||
|
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
|
||||||
|
// Create muxer with callbacks and orientation
|
||||||
|
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 = recordingOrientationDegrees
|
||||||
|
)
|
||||||
|
muxer.setSegmentDuration(segmentDurationSeconds * 1_000_000L)
|
||||||
|
|
||||||
|
Log.d(TAG, "Created HlsMuxer with orientation: $recordingOrientationDegrees degrees, fps: $effectiveFps")
|
||||||
|
|
||||||
|
return FragmentedRecordingManager(codec, muxer, effectiveFps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
// Pass configured fps to muxer (not the encoder's output format fps which may differ)
|
||||||
|
trackIndex = muxer.addTrack(format, configuredFps)
|
||||||
|
muxer.start()
|
||||||
|
muxerStarted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1200
package/android/src/main/java/com/mrousavy/camera/core/HlsMuxer.kt
Normal file
1200
package/android/src/main/java/com/mrousavy/camera/core/HlsMuxer.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,10 @@ 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.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
|
||||||
@@ -150,6 +152,8 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) :
|
|||||||
val width = frame.width()
|
val width = frame.width()
|
||||||
val height = frame.height()
|
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)
|
val bitmap = Bitmap.createBitmap(height, width, Bitmap.Config.ARGB_8888)
|
||||||
|
|
||||||
// Use a coroutine to suspend until the PixelCopy request is complete
|
// Use a coroutine to suspend until the PixelCopy request is complete
|
||||||
@@ -159,7 +163,23 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) :
|
|||||||
bitmap,
|
bitmap,
|
||||||
{ copyResult ->
|
{ copyResult ->
|
||||||
if (copyResult == PixelCopy.SUCCESS) {
|
if (copyResult == PixelCopy.SUCCESS) {
|
||||||
continuation.resume(rotateBitmap90CounterClockwise(bitmap))
|
// 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 {
|
} else {
|
||||||
continuation.resumeWithException(
|
continuation.resumeWithException(
|
||||||
RuntimeException("PixelCopy failed with error code $copyResult")
|
RuntimeException("PixelCopy failed with error code $copyResult")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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,
|
||||||
@@ -27,6 +28,8 @@ class RecordingSession(
|
|||||||
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,
|
||||||
|
// Use FragmentedRecordingManager for HLS-compatible fMP4 output
|
||||||
|
private val useFragmentedMp4: Boolean = true
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "RecordingSession"
|
private const val TAG = "RecordingSession"
|
||||||
@@ -34,6 +37,9 @@ 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)
|
||||||
@@ -41,7 +47,23 @@ class RecordingSession(
|
|||||||
private val outputPath: File = File(filePath)
|
private val outputPath: File = File(filePath)
|
||||||
|
|
||||||
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 (useFragmentedMp4) {
|
||||||
|
FragmentedRecordingManager.fromParams(
|
||||||
|
allCallbacks,
|
||||||
|
size,
|
||||||
|
enableAudio,
|
||||||
|
fps,
|
||||||
|
cameraOrientation,
|
||||||
|
bitRate,
|
||||||
|
options,
|
||||||
|
outputPath,
|
||||||
|
SEGMENT_DURATION_SECONDS
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ChunkedRecordingManager.fromParams(
|
||||||
allCallbacks,
|
allCallbacks,
|
||||||
size,
|
size,
|
||||||
enableAudio,
|
enableAudio,
|
||||||
@@ -51,6 +73,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() {
|
||||||
|
|||||||
@@ -9,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 // Commented out due to RN 0.79+ compatibility
|
|
||||||
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
|
||||||
@@ -78,6 +77,9 @@ class VisionCameraProxy(private val reactContext: ReactApplicationContext) {
|
|||||||
FrameProcessorPluginRegistry.getPlugin(name, this, options)
|
FrameProcessorPluginRegistry.getPlugin(name, this, options)
|
||||||
|
|
||||||
// private C++ funcs
|
// private C++ funcs
|
||||||
// Commented out due to React Native 0.79+ API compatibility issues
|
// Frame Processors are disabled - native registration is skipped via VISION_CAMERA_ENABLE_FRAME_PROCESSORS=OFF
|
||||||
// private external fun initHybrid(jsContext: Long, jsCallInvokerHolder: CallInvokerHolderImpl, scheduler: VisionCameraScheduler): HybridData
|
// This method is never called or registered, kept for reference only
|
||||||
|
// @DoNotStrip
|
||||||
|
// @Keep
|
||||||
|
// private external fun initHybrid(jsContext: Long, jsCallInvokerHolder: Any, scheduler: VisionCameraScheduler): HybridData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user