Compare commits

...

3 Commits

5 changed files with 1267 additions and 225 deletions

View File

@@ -178,10 +178,6 @@ 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'

View File

@@ -429,15 +429,15 @@ 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: // Map device rotation to the correct orientationHint for video recording:
// - Counter-clockwise (ROTATION_90) → 270° hint // - Counter-clockwise (ROTATION_90) → 90° hint
// - Clockwise (ROTATION_270) → 90° hint // - Clockwise (ROTATION_270) → 270° 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
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
else -> Orientation.PORTRAIT else -> Orientation.PORTRAIT
} }
@@ -448,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,

View File

@@ -7,37 +7,26 @@ import android.media.MediaFormat
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.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.Orientation
import com.mrousavy.camera.types.RecordVideoOptions import com.mrousavy.camera.types.RecordVideoOptions
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer
/** /**
* A recording manager that produces HLS-compatible fragmented MP4 segments. * A recording manager that produces HLS-compatible fragmented MP4 segments.
* *
* This produces output similar to the iOS implementation: * Uses HlsMuxer (following Android's MediaMuxer pattern) to produce:
* - An initialization segment (init.mp4) containing codec configuration * - init.mp4: Initialization segment (ftyp + moov with mvex)
* - Numbered data segments (0.mp4, 1.mp4, ...) containing media data * - 0.mp4, 1.mp4, ...: Media segments (moof + mdat)
*
* Uses AndroidX Media3's FragmentedMp4Muxer which produces proper fMP4 output.
*/ */
@UnstableApi
class FragmentedRecordingManager( class FragmentedRecordingManager(
private val encoder: MediaCodec, private val encoder: MediaCodec,
private val outputDirectory: File, private val muxer: HlsMuxer,
private val orientationDegrees: Int, private val configuredFps: Int
private val targetSegmentDurationUs: Long,
private val callbacks: CameraSession.Callback
) : MediaCodec.Callback(), ChunkedRecorderInterface { ) : MediaCodec.Callback(), ChunkedRecorderInterface {
companion object { companion object {
private const val TAG = "FragmentedRecorder" private const val TAG = "FragmentedRecorder"
private const val DEFAULT_SEGMENT_DURATION_SECONDS = 6
fun fromParams( fun fromParams(
callbacks: CameraSession.Callback, callbacks: CameraSession.Callback,
@@ -48,18 +37,25 @@ class FragmentedRecordingManager(
bitRate: Int, bitRate: Int,
options: RecordVideoOptions, options: RecordVideoOptions,
outputDirectory: File, outputDirectory: File,
segmentDurationSeconds: Int = 6 segmentDurationSeconds: Int = DEFAULT_SEGMENT_DURATION_SECONDS
): FragmentedRecordingManager { ): FragmentedRecordingManager {
val mimeType = options.videoCodec.toMimeType() val mimeType = options.videoCodec.toMimeType()
val cameraOrientationDegrees = cameraOrientation.toDegrees() // Use cameraOrientation from Android (computed from device rotation)
val recordingOrientationDegrees = (options.orientation ?: Orientation.PORTRAIT).toDegrees() // 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()) { val (width, height) = if (cameraOrientation.isLandscape()) {
size.height to size.width size.height to size.width
} else { } else {
size.width to size.height 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 format = MediaFormat.createVideoFormat(mimeType, width, height)
val codec = MediaCodec.createEncoderByType(mimeType) val codec = MediaCodec.createEncoderByType(mimeType)
@@ -67,121 +63,48 @@ class FragmentedRecordingManager(
MediaFormat.KEY_COLOR_FORMAT, MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
) )
fps?.apply {
format.setInteger(MediaFormat.KEY_FRAME_RATE, this) val effectiveFps = fps ?: 30
} format.setInteger(MediaFormat.KEY_FRAME_RATE, effectiveFps)
// I-frame interval affects segment boundaries
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, segmentDurationSeconds) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, segmentDurationSeconds)
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: $recordingOrientationDegrees")
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
return FragmentedRecordingManager( // Create muxer with callbacks and orientation
codec, val muxer = HlsMuxer(
outputDirectory, outputDirectory = outputDirectory,
recordingOrientationDegrees, callback = object : HlsMuxer.Callback {
segmentDurationSeconds * 1_000_000L, override fun onInitSegmentReady(file: File) {
callbacks 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)
} }
} }
// State management
private var chunkIndex = 0
private var encodedFormat: MediaFormat? = null
private var recording = false private var recording = false
private var muxerStarted = false
// Segment tracking private var trackIndex = -1
private var segmentContext: SegmentContext? = null
private var initSegmentEmitted = false
override val surface: Surface = encoder.createInputSurface() override val surface: Surface = encoder.createInputSurface()
init { init {
if (!outputDirectory.exists()) {
outputDirectory.mkdirs()
}
encoder.setCallback(this) 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() { override fun start() {
encoder.start() encoder.start()
recording = true recording = true
@@ -190,8 +113,12 @@ class FragmentedRecordingManager(
override fun finish() { override fun finish() {
synchronized(this) { synchronized(this) {
recording = false recording = false
segmentContext?.finish()
segmentContext = null if (muxerStarted) {
muxer.stop()
muxer.release()
}
try { try {
encoder.stop() encoder.stop()
encoder.release() encoder.release()
@@ -202,6 +129,7 @@ class FragmentedRecordingManager(
} }
// MediaCodec.Callback methods // MediaCodec.Callback methods
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
// Not used for Surface input // Not used for Surface input
} }
@@ -213,37 +141,20 @@ class FragmentedRecordingManager(
return return
} }
val encodedData = encoder.getOutputBuffer(index) if (!muxerStarted) {
if (encodedData == null) { encoder.releaseOutputBuffer(index, false)
return
}
val buffer = encoder.getOutputBuffer(index)
if (buffer == null) {
Log.e(TAG, "getOutputBuffer returned null") Log.e(TAG, "getOutputBuffer returned null")
encoder.releaseOutputBuffer(index, false) encoder.releaseOutputBuffer(index, false)
return 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 { try {
val shouldStartNewSegment = context.writeSample(encodedData, bufferInfo) muxer.writeSampleData(trackIndex, buffer, bufferInfo)
if (shouldStartNewSegment) {
createNewSegment()
// Write this keyframe to the new segment as well
segmentContext?.writeSample(encodedData, bufferInfo)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error writing sample", e) Log.e(TAG, "Error writing sample", e)
} }
@@ -257,76 +168,13 @@ class FragmentedRecordingManager(
} }
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
Log.i(TAG, "Output format changed: $format") synchronized(this) {
encodedFormat = format Log.i(TAG, "Output format changed: $format")
// Create the init segment immediately when we get the format // Pass configured fps to muxer (not the encoder's output format fps which may differ)
// This produces an fMP4 file with just ftyp + moov (no samples) trackIndex = muxer.addTrack(format, configuredFps)
if (!initSegmentEmitted) { muxer.start()
createInitSegment(format) muxerStarted = true
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)
}
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()
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,6 @@ 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
@@ -16,7 +15,6 @@ 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,
@@ -30,7 +28,7 @@ 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 the new FragmentedMp4Muxer-based recorder for HLS-compatible output // Use FragmentedRecordingManager for HLS-compatible fMP4 output
private val useFragmentedMp4: Boolean = true private val useFragmentedMp4: Boolean = true
) { ) {
companion object { companion object {