Compare commits
3 Commits
loewy/stri
...
loewy/frag
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b0a3cbb98 | |||
| 49fba9ed60 | |||
| a2d218580c |
@@ -178,6 +178,10 @@ dependencies {
|
|||||||
implementation "com.facebook.react:react-android:+"
|
implementation "com.facebook.react:react-android:+"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||||
|
|
||||||
|
// Media3 muxer for fragmented MP4 (HLS-compatible) recording
|
||||||
|
implementation "androidx.media3:media3-muxer:1.5.0"
|
||||||
|
implementation "androidx.media3:media3-common:1.5.0"
|
||||||
|
|
||||||
if (enableCodeScanner) {
|
if (enableCodeScanner) {
|
||||||
// User enabled code-scanner, so we bundle the 2.4 MB model in the app.
|
// User enabled code-scanner, so we bundle the 2.4 MB model in the app.
|
||||||
implementation 'com.google.mlkit:barcode-scanning:17.2.0'
|
implementation 'com.google.mlkit:barcode-scanning:17.2.0'
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ OpenGLRenderer::OpenGLRenderer(std::shared_ptr<OpenGLContext> context, ANativeWi
|
|||||||
_outputSurface = surface;
|
_outputSurface = surface;
|
||||||
_width = ANativeWindow_getWidth(surface);
|
_width = ANativeWindow_getWidth(surface);
|
||||||
_height = ANativeWindow_getHeight(surface);
|
_height = ANativeWindow_getHeight(surface);
|
||||||
|
__android_log_print(ANDROID_LOG_INFO, TAG, "ROTATION_DEBUG OpenGLRenderer created with output surface dimensions: %dx%d", _width, _height);
|
||||||
}
|
}
|
||||||
|
|
||||||
OpenGLRenderer::~OpenGLRenderer() {
|
OpenGLRenderer::~OpenGLRenderer() {
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ void VideoPipeline::setRecordingSessionOutputSurface(jobject surface) {
|
|||||||
_recordingSessionOutput = OpenGLRenderer::CreateWithWindowSurface(_context, window);
|
_recordingSessionOutput = OpenGLRenderer::CreateWithWindowSurface(_context, window);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void VideoPipeline::setRecordingOrientation(int orientation) {
|
||||||
|
_recordingOrientation = orientation;
|
||||||
|
__android_log_print(ANDROID_LOG_INFO, TAG, "Recording orientation set to: %d", orientation);
|
||||||
|
}
|
||||||
|
|
||||||
int VideoPipeline::getInputTextureId() {
|
int VideoPipeline::getInputTextureId() {
|
||||||
if (_inputTexture == std::nullopt) {
|
if (_inputTexture == std::nullopt) {
|
||||||
_inputTexture = _context->createTexture(OpenGLTexture::Type::ExternalOES, _width, _height);
|
_inputTexture = _context->createTexture(OpenGLTexture::Type::ExternalOES, _width, _height);
|
||||||
@@ -78,8 +83,29 @@ void VideoPipeline::onFrame(jni::alias_ref<jni::JArrayFloat> transformMatrixPara
|
|||||||
OpenGLTexture& texture = _inputTexture.value();
|
OpenGLTexture& texture = _inputTexture.value();
|
||||||
|
|
||||||
if (_recordingSessionOutput) {
|
if (_recordingSessionOutput) {
|
||||||
__android_log_print(ANDROID_LOG_INFO, TAG, "Rendering to RecordingSession..");
|
__android_log_print(ANDROID_LOG_INFO, TAG, "Rendering to RecordingSession.. orientation=%d", _recordingOrientation);
|
||||||
_recordingSessionOutput->renderTextureToSurface(texture, transformMatrix);
|
|
||||||
|
// For recording, use a simple transform matrix instead of the display transform.
|
||||||
|
// The display transform includes rotations for preview which we don't want in recordings.
|
||||||
|
float recordingMatrix[16];
|
||||||
|
|
||||||
|
if (_recordingOrientation == 1) {
|
||||||
|
// LANDSCAPE_RIGHT (CW): Y-flip + 180° rotation = flip both X and Y
|
||||||
|
// This negates both X and Y, then translates by (1,1)
|
||||||
|
recordingMatrix[0] = -1.0f; recordingMatrix[1] = 0.0f; recordingMatrix[2] = 0.0f; recordingMatrix[3] = 0.0f;
|
||||||
|
recordingMatrix[4] = 0.0f; recordingMatrix[5] = 1.0f; recordingMatrix[6] = 0.0f; recordingMatrix[7] = 0.0f;
|
||||||
|
recordingMatrix[8] = 0.0f; recordingMatrix[9] = 0.0f; recordingMatrix[10] = 1.0f; recordingMatrix[11] = 0.0f;
|
||||||
|
recordingMatrix[12] = 1.0f; recordingMatrix[13] = 0.0f; recordingMatrix[14] = 0.0f; recordingMatrix[15] = 1.0f;
|
||||||
|
} else {
|
||||||
|
// LANDSCAPE_LEFT (CCW): Simple Y-flip
|
||||||
|
// OpenGL origin is bottom-left, video expects top-left
|
||||||
|
recordingMatrix[0] = 1.0f; recordingMatrix[1] = 0.0f; recordingMatrix[2] = 0.0f; recordingMatrix[3] = 0.0f;
|
||||||
|
recordingMatrix[4] = 0.0f; recordingMatrix[5] = -1.0f; recordingMatrix[6] = 0.0f; recordingMatrix[7] = 0.0f;
|
||||||
|
recordingMatrix[8] = 0.0f; recordingMatrix[9] = 0.0f; recordingMatrix[10] = 1.0f; recordingMatrix[11] = 0.0f;
|
||||||
|
recordingMatrix[12] = 0.0f; recordingMatrix[13] = 1.0f; recordingMatrix[14] = 0.0f; recordingMatrix[15] = 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordingSessionOutput->renderTextureToSurface(texture, recordingMatrix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +114,7 @@ void VideoPipeline::registerNatives() {
|
|||||||
makeNativeMethod("initHybrid", VideoPipeline::initHybrid),
|
makeNativeMethod("initHybrid", VideoPipeline::initHybrid),
|
||||||
makeNativeMethod("setRecordingSessionOutputSurface", VideoPipeline::setRecordingSessionOutputSurface),
|
makeNativeMethod("setRecordingSessionOutputSurface", VideoPipeline::setRecordingSessionOutputSurface),
|
||||||
makeNativeMethod("removeRecordingSessionOutputSurface", VideoPipeline::removeRecordingSessionOutputSurface),
|
makeNativeMethod("removeRecordingSessionOutputSurface", VideoPipeline::removeRecordingSessionOutputSurface),
|
||||||
|
makeNativeMethod("setRecordingOrientation", VideoPipeline::setRecordingOrientation),
|
||||||
makeNativeMethod("getInputTextureId", VideoPipeline::getInputTextureId),
|
makeNativeMethod("getInputTextureId", VideoPipeline::getInputTextureId),
|
||||||
makeNativeMethod("onBeforeFrame", VideoPipeline::onBeforeFrame),
|
makeNativeMethod("onBeforeFrame", VideoPipeline::onBeforeFrame),
|
||||||
makeNativeMethod("onFrame", VideoPipeline::onFrame),
|
makeNativeMethod("onFrame", VideoPipeline::onFrame),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public:
|
|||||||
// <- MediaRecorder output
|
// <- MediaRecorder output
|
||||||
void setRecordingSessionOutputSurface(jobject surface);
|
void setRecordingSessionOutputSurface(jobject surface);
|
||||||
void removeRecordingSessionOutputSurface();
|
void removeRecordingSessionOutputSurface();
|
||||||
|
void setRecordingOrientation(int orientation);
|
||||||
|
|
||||||
// Frame callbacks
|
// Frame callbacks
|
||||||
void onBeforeFrame();
|
void onBeforeFrame();
|
||||||
@@ -47,6 +48,7 @@ private:
|
|||||||
std::optional<OpenGLTexture> _inputTexture = std::nullopt;
|
std::optional<OpenGLTexture> _inputTexture = std::nullopt;
|
||||||
int _width = 0;
|
int _width = 0;
|
||||||
int _height = 0;
|
int _height = 0;
|
||||||
|
int _recordingOrientation = 0; // 0=LANDSCAPE_LEFT, 1=LANDSCAPE_RIGHT
|
||||||
|
|
||||||
// Output Contexts
|
// Output Contexts
|
||||||
std::shared_ptr<OpenGLContext> _context = nullptr;
|
std::shared_ptr<OpenGLContext> _context = nullptr;
|
||||||
|
|||||||
@@ -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,6 +32,7 @@ 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"))
|
||||||
|
.put("onInitReady", MapBuilder.of("registrationName", "onInitReady"))
|
||||||
.build()?.toMutableMap()
|
.build()?.toMutableMap()
|
||||||
|
|
||||||
override fun getName(): String = TAG
|
override fun getName(): String = TAG
|
||||||
|
|||||||
@@ -409,7 +409,8 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
|
|||||||
private fun updateVideoOutputs() {
|
private fun updateVideoOutputs() {
|
||||||
val videoOutput = videoOutput ?: return
|
val videoOutput = videoOutput ?: return
|
||||||
Log.i(TAG, "Updating Video Outputs...")
|
Log.i(TAG, "Updating Video Outputs...")
|
||||||
videoOutput.videoPipeline.setRecordingSessionOutput(recording)
|
val orientation = recording?.cameraOrientation ?: Orientation.LANDSCAPE_LEFT
|
||||||
|
videoOutput.videoPipeline.setRecordingSessionOutput(recording, orientation)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun startRecording(
|
suspend fun startRecording(
|
||||||
@@ -428,18 +429,16 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
|
|||||||
|
|
||||||
// Get actual device rotation from WindowManager since the React Native orientation hook
|
// 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.
|
// 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) → 270° hint
|
|
||||||
// - Clockwise (ROTATION_270) → 90° hint
|
|
||||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
val deviceRotation = windowManager.defaultDisplay.rotation
|
val deviceRotation = windowManager.defaultDisplay.rotation
|
||||||
val recordingOrientation = when (deviceRotation) {
|
val recordingOrientation = when (deviceRotation) {
|
||||||
Surface.ROTATION_0 -> Orientation.PORTRAIT
|
Surface.ROTATION_0 -> Orientation.PORTRAIT
|
||||||
Surface.ROTATION_90 -> Orientation.LANDSCAPE_RIGHT
|
Surface.ROTATION_90 -> Orientation.LANDSCAPE_LEFT // CCW rotation, top to left
|
||||||
Surface.ROTATION_180 -> Orientation.PORTRAIT_UPSIDE_DOWN
|
Surface.ROTATION_180 -> Orientation.PORTRAIT_UPSIDE_DOWN
|
||||||
Surface.ROTATION_270 -> Orientation.LANDSCAPE_LEFT
|
Surface.ROTATION_270 -> Orientation.LANDSCAPE_RIGHT // CW rotation, top to right
|
||||||
else -> Orientation.PORTRAIT
|
else -> Orientation.PORTRAIT
|
||||||
}
|
}
|
||||||
|
Log.i(TAG, "ROTATION_DEBUG: deviceRotation=$deviceRotation, recordingOrientation=$recordingOrientation, options.orientation=${options.orientation}")
|
||||||
|
|
||||||
val recording = RecordingSession(
|
val recording = RecordingSession(
|
||||||
context,
|
context,
|
||||||
@@ -448,7 +447,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,
|
||||||
@@ -513,7 +512,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +135,12 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
|
|||||||
return bufferInfo.presentationTimeUs - context.startTimeUs
|
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
|
||||||
|
|||||||
@@ -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,340 @@
|
|||||||
|
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 androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.muxer.FragmentedMp4Muxer
|
||||||
|
import androidx.media3.muxer.Muxer
|
||||||
|
import com.mrousavy.camera.types.Orientation
|
||||||
|
import com.mrousavy.camera.types.RecordVideoOptions
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recording manager that produces HLS-compatible fragmented MP4 segments.
|
||||||
|
*
|
||||||
|
* This produces output similar to the iOS implementation:
|
||||||
|
* - An initialization segment (init.mp4) containing codec configuration
|
||||||
|
* - Numbered data segments (0.mp4, 1.mp4, ...) containing media data
|
||||||
|
*
|
||||||
|
* Uses AndroidX Media3's FragmentedMp4Muxer which produces proper fMP4 output.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
class FragmentedRecordingManager(
|
||||||
|
private val encoder: MediaCodec,
|
||||||
|
private val outputDirectory: File,
|
||||||
|
private val orientationDegrees: Int,
|
||||||
|
private val targetSegmentDurationUs: Long,
|
||||||
|
private val callbacks: CameraSession.Callback
|
||||||
|
) : MediaCodec.Callback(), ChunkedRecorderInterface {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FragmentedRecorder"
|
||||||
|
|
||||||
|
fun fromParams(
|
||||||
|
callbacks: CameraSession.Callback,
|
||||||
|
size: Size,
|
||||||
|
enableAudio: Boolean,
|
||||||
|
fps: Int? = null,
|
||||||
|
cameraOrientation: Orientation,
|
||||||
|
bitRate: Int,
|
||||||
|
options: RecordVideoOptions,
|
||||||
|
outputDirectory: File,
|
||||||
|
segmentDurationSeconds: Int = 6
|
||||||
|
): FragmentedRecordingManager {
|
||||||
|
val mimeType = options.videoCodec.toMimeType()
|
||||||
|
// For fragmented MP4: DON'T swap dimensions, use camera's native dimensions.
|
||||||
|
// The C++ VideoPipeline now uses a simple Y-flip matrix (not the display transform).
|
||||||
|
// This gives us raw sensor frames, and we rely on rotation metadata for playback.
|
||||||
|
val cameraOrientationDegrees = when (cameraOrientation) {
|
||||||
|
Orientation.LANDSCAPE_LEFT -> 0 // CCW landscape - works!
|
||||||
|
Orientation.LANDSCAPE_RIGHT -> 0 // CW landscape - same as CCW (Y-flip handles it)
|
||||||
|
Orientation.PORTRAIT -> 90 // Portrait typically needs 90° on Android
|
||||||
|
Orientation.PORTRAIT_UPSIDE_DOWN -> 270
|
||||||
|
}
|
||||||
|
Log.i(TAG, "ROTATION_DEBUG FragmentedRecordingManager: cameraOrientation=$cameraOrientation, cameraOrientationDegrees=$cameraOrientationDegrees, inputSize=${size.width}x${size.height}")
|
||||||
|
|
||||||
|
// Keep original dimensions - don't swap. Let rotation metadata handle orientation.
|
||||||
|
val width = size.width
|
||||||
|
val height = size.height
|
||||||
|
Log.i(TAG, "ROTATION_DEBUG FragmentedRecordingManager: outputDimensions=${width}x${height} (no swap)")
|
||||||
|
|
||||||
|
val format = MediaFormat.createVideoFormat(mimeType, width, height)
|
||||||
|
val codec = MediaCodec.createEncoderByType(mimeType)
|
||||||
|
|
||||||
|
format.setInteger(
|
||||||
|
MediaFormat.KEY_COLOR_FORMAT,
|
||||||
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||||
|
)
|
||||||
|
fps?.apply {
|
||||||
|
format.setInteger(MediaFormat.KEY_FRAME_RATE, this)
|
||||||
|
}
|
||||||
|
// I-frame interval affects segment boundaries
|
||||||
|
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, segmentDurationSeconds)
|
||||||
|
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
||||||
|
|
||||||
|
Log.d(TAG, "Video Format: $format, camera orientation $cameraOrientationDegrees")
|
||||||
|
|
||||||
|
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
|
||||||
|
return FragmentedRecordingManager(
|
||||||
|
codec,
|
||||||
|
outputDirectory,
|
||||||
|
cameraOrientationDegrees,
|
||||||
|
segmentDurationSeconds * 1_000_000L,
|
||||||
|
callbacks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State management
|
||||||
|
private var chunkIndex = 0
|
||||||
|
private var encodedFormat: MediaFormat? = null
|
||||||
|
private var recording = false
|
||||||
|
|
||||||
|
// Segment tracking
|
||||||
|
private var segmentContext: SegmentContext? = null
|
||||||
|
private var initSegmentEmitted = false
|
||||||
|
|
||||||
|
override val surface: Surface = encoder.createInputSurface()
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!outputDirectory.exists()) {
|
||||||
|
outputDirectory.mkdirs()
|
||||||
|
}
|
||||||
|
encoder.setCallback(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context for a single data segment being written.
|
||||||
|
* Init segments are created separately via createInitSegment().
|
||||||
|
*/
|
||||||
|
private inner class SegmentContext(
|
||||||
|
private val format: MediaFormat,
|
||||||
|
private val segmentIndex: Int
|
||||||
|
) {
|
||||||
|
private val filename = "$segmentIndex.mp4"
|
||||||
|
private val file = File(outputDirectory, filename)
|
||||||
|
private val outputStream = FileOutputStream(file)
|
||||||
|
private val muxer = FragmentedMp4Muxer.Builder(outputStream).build()
|
||||||
|
private lateinit var videoTrack: Muxer.TrackToken
|
||||||
|
private var startTimeUs: Long = -1L
|
||||||
|
private var lastTimeUs: Long = 0L
|
||||||
|
private var sampleCount = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
val media3Format = convertToMedia3Format(format)
|
||||||
|
videoTrack = muxer.addTrack(media3Format)
|
||||||
|
Log.d(TAG, "Created segment context: $filename")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeSample(buffer: ByteBuffer, bufferInfo: BufferInfo): Boolean {
|
||||||
|
if (startTimeUs < 0) {
|
||||||
|
startTimeUs = bufferInfo.presentationTimeUs
|
||||||
|
}
|
||||||
|
lastTimeUs = bufferInfo.presentationTimeUs
|
||||||
|
|
||||||
|
val isKeyFrame = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0
|
||||||
|
|
||||||
|
muxer.writeSampleData(videoTrack, buffer, bufferInfo)
|
||||||
|
sampleCount++
|
||||||
|
|
||||||
|
// Check if we should start a new segment at the next keyframe
|
||||||
|
if (isKeyFrame && sampleCount > 1) {
|
||||||
|
val segmentDurationUs = bufferInfo.presentationTimeUs - startTimeUs
|
||||||
|
if (segmentDurationUs >= targetSegmentDurationUs) {
|
||||||
|
return true // Signal to create new segment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish(): Long {
|
||||||
|
try {
|
||||||
|
muxer.close()
|
||||||
|
outputStream.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error closing segment", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
val durationUs = if (lastTimeUs > startTimeUs) lastTimeUs - startTimeUs else 0L
|
||||||
|
callbacks.onVideoChunkReady(file, segmentIndex, durationUs)
|
||||||
|
|
||||||
|
Log.d(TAG, "Finished segment: $filename, samples=$sampleCount, duration=${durationUs/1000}ms")
|
||||||
|
return durationUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNewSegment() {
|
||||||
|
val format = encodedFormat
|
||||||
|
if (format == null) {
|
||||||
|
Log.e(TAG, "Cannot create segment: encodedFormat is null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close previous segment
|
||||||
|
segmentContext?.finish()
|
||||||
|
|
||||||
|
// Create new data segment (init segments are created separately)
|
||||||
|
segmentContext = SegmentContext(format, chunkIndex)
|
||||||
|
chunkIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
encoder.start()
|
||||||
|
recording = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun finish() {
|
||||||
|
synchronized(this) {
|
||||||
|
recording = false
|
||||||
|
segmentContext?.finish()
|
||||||
|
segmentContext = null
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
val encodedData = encoder.getOutputBuffer(index)
|
||||||
|
if (encodedData == null) {
|
||||||
|
Log.e(TAG, "getOutputBuffer returned null")
|
||||||
|
encoder.releaseOutputBuffer(index, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until init segment is emitted (happens in onOutputFormatChanged)
|
||||||
|
if (!initSegmentEmitted) {
|
||||||
|
encoder.releaseOutputBuffer(index, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create first data segment if needed
|
||||||
|
if (segmentContext == null) {
|
||||||
|
createNewSegment()
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = segmentContext
|
||||||
|
if (context == null) {
|
||||||
|
encoder.releaseOutputBuffer(index, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val shouldStartNewSegment = context.writeSample(encodedData, bufferInfo)
|
||||||
|
if (shouldStartNewSegment) {
|
||||||
|
createNewSegment()
|
||||||
|
// Write this keyframe to the new segment as well
|
||||||
|
segmentContext?.writeSample(encodedData, 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) {
|
||||||
|
Log.i(TAG, "Output format changed: $format")
|
||||||
|
encodedFormat = format
|
||||||
|
|
||||||
|
// Create the init segment immediately when we get the format
|
||||||
|
// This produces an fMP4 file with just ftyp + moov (no samples)
|
||||||
|
if (!initSegmentEmitted) {
|
||||||
|
createInitSegment(format)
|
||||||
|
initSegmentEmitted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an initialization segment containing only codec configuration (ftyp + moov).
|
||||||
|
* This is done by creating a muxer, adding the track, and immediately closing it
|
||||||
|
* without writing any samples.
|
||||||
|
*/
|
||||||
|
private fun createInitSegment(format: MediaFormat) {
|
||||||
|
val initFile = File(outputDirectory, "init.mp4")
|
||||||
|
try {
|
||||||
|
val outputStream = FileOutputStream(initFile)
|
||||||
|
val muxer = FragmentedMp4Muxer.Builder(outputStream).build()
|
||||||
|
|
||||||
|
// Convert and add the track
|
||||||
|
val media3Format = convertToMedia3Format(format)
|
||||||
|
muxer.addTrack(media3Format)
|
||||||
|
|
||||||
|
// Close immediately - this writes just the header (ftyp + moov)
|
||||||
|
muxer.close()
|
||||||
|
outputStream.close()
|
||||||
|
|
||||||
|
Log.d(TAG, "Created init segment: ${initFile.absolutePath}")
|
||||||
|
callbacks.onInitSegmentReady(initFile)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error creating init segment", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertToMedia3Format(mediaFormat: MediaFormat): Format {
|
||||||
|
val mimeType = mediaFormat.getString(MediaFormat.KEY_MIME) ?: MimeTypes.VIDEO_H264
|
||||||
|
val width = mediaFormat.getInteger(MediaFormat.KEY_WIDTH)
|
||||||
|
val height = mediaFormat.getInteger(MediaFormat.KEY_HEIGHT)
|
||||||
|
val bitRate = try { mediaFormat.getInteger(MediaFormat.KEY_BIT_RATE) } catch (e: Exception) { -1 }
|
||||||
|
val frameRate = try { mediaFormat.getInteger(MediaFormat.KEY_FRAME_RATE) } catch (e: Exception) { -1 }
|
||||||
|
|
||||||
|
// Get CSD (Codec Specific Data) if available - required for init segment
|
||||||
|
val csd0 = mediaFormat.getByteBuffer("csd-0")
|
||||||
|
val csd1 = mediaFormat.getByteBuffer("csd-1")
|
||||||
|
|
||||||
|
val initData = mutableListOf<ByteArray>()
|
||||||
|
csd0?.let {
|
||||||
|
val bytes = ByteArray(it.remaining())
|
||||||
|
it.duplicate().get(bytes)
|
||||||
|
initData.add(bytes)
|
||||||
|
}
|
||||||
|
csd1?.let {
|
||||||
|
val bytes = ByteArray(it.remaining())
|
||||||
|
it.duplicate().get(bytes)
|
||||||
|
initData.add(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "ROTATION_DEBUG convertToMedia3Format: orientationDegrees=$orientationDegrees, width=$width, height=$height")
|
||||||
|
return Format.Builder()
|
||||||
|
.setSampleMimeType(mimeType)
|
||||||
|
.setWidth(width)
|
||||||
|
.setHeight(height)
|
||||||
|
.setRotationDegrees(orientationDegrees)
|
||||||
|
.apply {
|
||||||
|
if (bitRate > 0) setAverageBitrate(bitRate)
|
||||||
|
if (frameRate > 0) setFrameRate(frameRate.toFloat())
|
||||||
|
if (initData.isNotEmpty()) setInitializationData(initData)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.facebook.common.statfs.StatFsHelper
|
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
|
||||||
@@ -14,6 +15,8 @@ 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
|
||||||
|
|
||||||
|
@UnstableApi
|
||||||
class RecordingSession(
|
class RecordingSession(
|
||||||
context: Context,
|
context: Context,
|
||||||
val cameraId: String,
|
val cameraId: String,
|
||||||
@@ -21,12 +24,14 @@ class RecordingSession(
|
|||||||
private val enableAudio: Boolean,
|
private val enableAudio: Boolean,
|
||||||
private val fps: Int? = null,
|
private val fps: Int? = null,
|
||||||
private val hdr: Boolean = false,
|
private val hdr: Boolean = false,
|
||||||
private val cameraOrientation: Orientation,
|
val cameraOrientation: Orientation,
|
||||||
private val options: RecordVideoOptions,
|
private val options: RecordVideoOptions,
|
||||||
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,
|
||||||
|
// Use the new FragmentedMp4Muxer-based recorder for HLS-compatible output
|
||||||
|
private val useFragmentedMp4: Boolean = true
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "RecordingSession"
|
private const val TAG = "RecordingSession"
|
||||||
@@ -34,15 +39,33 @@ 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)
|
||||||
|
|
||||||
// Normalize path - expo-file-system passes file:// URIs but File expects raw paths
|
private val outputPath: File = File(filePath)
|
||||||
private val outputPath: File = File(filePath.removePrefix("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 (useFragmentedMp4) {
|
||||||
|
FragmentedRecordingManager.fromParams(
|
||||||
|
allCallbacks,
|
||||||
|
size,
|
||||||
|
enableAudio,
|
||||||
|
fps,
|
||||||
|
cameraOrientation,
|
||||||
|
bitRate,
|
||||||
|
options,
|
||||||
|
outputPath,
|
||||||
|
SEGMENT_DURATION_SECONDS
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ChunkedRecordingManager.fromParams(
|
||||||
allCallbacks,
|
allCallbacks,
|
||||||
size,
|
size,
|
||||||
enableAudio,
|
enableAudio,
|
||||||
@@ -52,6 +75,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() {
|
||||||
|
|||||||
@@ -162,6 +162,14 @@ class VideoPipeline(
|
|||||||
// 4. Get the transform matrix from the SurfaceTexture (rotations/scales applied by Camera)
|
// 4. Get the transform matrix from the SurfaceTexture (rotations/scales applied by Camera)
|
||||||
surfaceTexture.getTransformMatrix(transformMatrix)
|
surfaceTexture.getTransformMatrix(transformMatrix)
|
||||||
|
|
||||||
|
// Log transform matrix for debugging rotation issues (only when recording)
|
||||||
|
if (recordingSession != null) {
|
||||||
|
Log.i(TAG, "ROTATION_DEBUG TransformMatrix: [${transformMatrix[0]}, ${transformMatrix[1]}, ${transformMatrix[2]}, ${transformMatrix[3]}], " +
|
||||||
|
"[${transformMatrix[4]}, ${transformMatrix[5]}, ${transformMatrix[6]}, ${transformMatrix[7]}], " +
|
||||||
|
"[${transformMatrix[8]}, ${transformMatrix[9]}, ${transformMatrix[10]}, ${transformMatrix[11]}], " +
|
||||||
|
"[${transformMatrix[12]}, ${transformMatrix[13]}, ${transformMatrix[14]}, ${transformMatrix[15]}]")
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Draw it with applied rotation/mirroring
|
// 5. Draw it with applied rotation/mirroring
|
||||||
onFrame(transformMatrix)
|
onFrame(transformMatrix)
|
||||||
|
|
||||||
@@ -181,11 +189,15 @@ class VideoPipeline(
|
|||||||
/**
|
/**
|
||||||
* Configures the Pipeline to also write Frames to a Surface from a `MediaRecorder` (or null)
|
* Configures the Pipeline to also write Frames to a Surface from a `MediaRecorder` (or null)
|
||||||
*/
|
*/
|
||||||
fun setRecordingSessionOutput(recordingSession: RecordingSession?) {
|
fun setRecordingSessionOutput(recordingSession: RecordingSession?, orientation: Orientation = Orientation.LANDSCAPE_LEFT) {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (recordingSession != null) {
|
if (recordingSession != null) {
|
||||||
// Configure OpenGL pipeline to stream Frames into the Recording Session's surface
|
// Configure OpenGL pipeline to stream Frames into the Recording Session's surface
|
||||||
Log.i(TAG, "Setting ${recordingSession.size} RecordingSession Output...")
|
Log.i(TAG, "Setting ${recordingSession.size} RecordingSession Output with orientation=$orientation...")
|
||||||
|
// Set the recording orientation for the native layer
|
||||||
|
// 0 = LANDSCAPE_LEFT (CCW), 1 = LANDSCAPE_RIGHT (CW)
|
||||||
|
val orientationValue = if (orientation == Orientation.LANDSCAPE_RIGHT) 1 else 0
|
||||||
|
setRecordingOrientation(orientationValue)
|
||||||
setRecordingSessionOutputSurface(recordingSession.surface)
|
setRecordingSessionOutputSurface(recordingSession.surface)
|
||||||
this.recordingSession = recordingSession
|
this.recordingSession = recordingSession
|
||||||
} else {
|
} else {
|
||||||
@@ -252,5 +264,6 @@ class VideoPipeline(
|
|||||||
private external fun onFrame(transformMatrix: FloatArray)
|
private external fun onFrame(transformMatrix: FloatArray)
|
||||||
private external fun setRecordingSessionOutputSurface(surface: Any)
|
private external fun setRecordingSessionOutputSurface(surface: Any)
|
||||||
private external fun removeRecordingSessionOutputSurface()
|
private external fun removeRecordingSessionOutputSurface()
|
||||||
|
private external fun setRecordingOrientation(orientation: Int)
|
||||||
private external fun initHybrid(width: Int, height: Int): HybridData
|
private external fun initHybrid(width: Int, height: Int): HybridData
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ 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...")
|
||||||
|
|
||||||
@@ -41,27 +38,11 @@ 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 {
|
||||||
@@ -101,23 +82,22 @@ extension CameraSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !FileManager.default.fileExists(atPath: normalizedPath) {
|
if !FileManager.default.fileExists(atPath: filePath) {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(atPath: normalizedPath, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(atPath: filePath, 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: \(normalizedPath)")
|
ReactLogger.log(level: .info, message: "Will record to temporary file: \(filePath)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Create RecordingSession for the temp file
|
// Create RecordingSession for the temp file
|
||||||
let recordingSession = try RecordingSession(outputDiretory: normalizedPath,
|
let recordingSession = try RecordingSession(outputDiretory: filePath,
|
||||||
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)
|
||||||
|
|||||||
@@ -24,14 +24,12 @@ 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), onError: ((Error) -> Void)? = nil) throws {
|
init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void)) 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)
|
||||||
}
|
}
|
||||||
@@ -58,19 +56,14 @@ 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")
|
||||||
do {
|
save(data: data, url: url)
|
||||||
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)
|
||||||
if save(data: data, url: url) {
|
save(data: data, url: url)
|
||||||
let duration = report?
|
let duration = report?
|
||||||
.trackReports
|
.trackReports
|
||||||
.filter { $0.mediaType == .video }
|
.filter { $0.mediaType == .video }
|
||||||
@@ -79,15 +72,12 @@ 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) -> Bool {
|
private func save(data: Data, url: URL) {
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,13 +74,12 @@ 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, onError: onChunkError)
|
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady)
|
||||||
assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!)
|
assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!)
|
||||||
assetWriter.shouldOptimizeForNetworkUse = false
|
assetWriter.shouldOptimizeForNetworkUse = false
|
||||||
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS
|
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS
|
||||||
|
|||||||
Reference in New Issue
Block a user