Compare commits
7 Commits
dd9de38a7d
...
fix/hlsmux
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd26812a9c | ||
|
|
b716608379 | ||
| 0ecc3d8210 | |||
| 309e1e9457 | |||
| 71b08e6898 | |||
|
|
699481f6f8 | ||
| 11ce9ba8f6 |
@@ -30,8 +30,15 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
|
|||||||
iFrameInterval: Int = 5
|
iFrameInterval: Int = 5
|
||||||
): ChunkedRecordingManager {
|
): ChunkedRecordingManager {
|
||||||
val mimeType = options.videoCodec.toMimeType()
|
val mimeType = options.videoCodec.toMimeType()
|
||||||
val cameraOrientationDegrees = cameraOrientation.toDegrees()
|
// Use cameraOrientation (from WindowManager) for rotation metadata
|
||||||
val recordingOrientationDegrees = (options.orientation ?: Orientation.PORTRAIT).toDegrees();
|
// The options.orientation from JavaScript is unreliable on Android when rotating between landscape modes
|
||||||
|
// Note: MediaMuxer.setOrientationHint() uses opposite convention from HlsMuxer's rotation matrix
|
||||||
|
// We need to invert the rotation: 90 <-> 270, while 0 and 180 stay the same
|
||||||
|
val orientationDegrees = when (cameraOrientation.toDegrees()) {
|
||||||
|
90 -> 270
|
||||||
|
270 -> 90
|
||||||
|
else -> cameraOrientation.toDegrees()
|
||||||
|
}
|
||||||
val (width, height) = if (cameraOrientation.isLandscape()) {
|
val (width, height) = if (cameraOrientation.isLandscape()) {
|
||||||
size.height to size.width
|
size.height to size.width
|
||||||
} else {
|
} else {
|
||||||
@@ -55,12 +62,12 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
|
|||||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval)
|
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval)
|
||||||
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
||||||
|
|
||||||
Log.d(TAG, "Video Format: $format, camera orientation $cameraOrientationDegrees, recordingOrientation: $recordingOrientationDegrees")
|
Log.d(TAG, "Video Format: $format, orientation: $orientationDegrees")
|
||||||
// Create a MediaCodec encoder, and configure it with our format. Get a Surface
|
// Create a MediaCodec encoder, and configure it with our format. Get a Surface
|
||||||
// we can use for input and wrap it with a class that handles the EGL work.
|
// we can use for input and wrap it with a class that handles the EGL work.
|
||||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
return ChunkedRecordingManager(
|
return ChunkedRecordingManager(
|
||||||
codec, outputDirectory, recordingOrientationDegrees, iFrameInterval, callbacks
|
codec, outputDirectory, orientationDegrees, iFrameInterval, callbacks
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,12 +98,13 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
|
|||||||
muxer.start()
|
muxer.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun writeSample(buffer: java.nio.ByteBuffer, bufferInfo: BufferInfo) {
|
||||||
|
muxer.writeSampleData(videoTrack, buffer, bufferInfo)
|
||||||
|
}
|
||||||
|
|
||||||
fun finish() {
|
fun finish() {
|
||||||
muxer.stop()
|
muxer.stop()
|
||||||
muxer.release()
|
muxer.release()
|
||||||
// Calculate duration from start time - this is approximate
|
|
||||||
// The new FragmentedRecordingManager provides accurate duration
|
|
||||||
callbacks.onVideoChunkReady(filepath, chunkIndex, null)
|
callbacks.onVideoChunkReady(filepath, chunkIndex, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +178,7 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
|
|||||||
encoder.releaseOutputBuffer(index, false)
|
encoder.releaseOutputBuffer(index, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
context.muxer.writeSampleData(context.videoTrack, encodedData, bufferInfo)
|
context.writeSample(encodedData, bufferInfo)
|
||||||
encoder.releaseOutputBuffer(index, false)
|
encoder.releaseOutputBuffer(index, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -753,17 +753,32 @@ class HlsMuxer(
|
|||||||
dos.writeShort(-1) // pre-defined
|
dos.writeShort(-1) // pre-defined
|
||||||
|
|
||||||
output.write(buildAvcCBox(sps, pps))
|
output.write(buildAvcCBox(sps, pps))
|
||||||
|
output.write(buildPaspBox())
|
||||||
|
|
||||||
return wrapBox("avc1", output.toByteArray())
|
return wrapBox("avc1", output.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds pixel aspect ratio box to explicitly declare square pixels (1:1).
|
||||||
|
* This helps players correctly interpret video dimensions without SAR scaling.
|
||||||
|
*/
|
||||||
|
private fun buildPaspBox(): ByteArray {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val dos = DataOutputStream(output)
|
||||||
|
dos.writeInt(1) // hSpacing (horizontal)
|
||||||
|
dos.writeInt(1) // vSpacing (vertical)
|
||||||
|
return wrapBox("pasp", output.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildAvcCBox(sps: ByteArray, pps: ByteArray): ByteArray {
|
private fun buildAvcCBox(sps: ByteArray, pps: ByteArray): ByteArray {
|
||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
val dos = DataOutputStream(output)
|
val dos = DataOutputStream(output)
|
||||||
|
|
||||||
val profileIdc = if (sps.isNotEmpty()) sps[0].toInt() and 0xFF else 0x42
|
// SPS NAL unit format: [NAL header, profile_idc, constraint_flags, level_idc, ...]
|
||||||
val profileCompat = if (sps.size > 1) sps[1].toInt() and 0xFF else 0x00
|
// Skip byte 0 (NAL header, typically 0x67) to get the actual profile data
|
||||||
val levelIdc = if (sps.size > 2) sps[2].toInt() and 0xFF else 0x1F
|
val profileIdc = if (sps.size > 1) sps[1].toInt() and 0xFF else 0x42
|
||||||
|
val profileCompat = if (sps.size > 2) sps[2].toInt() and 0xFF else 0x00
|
||||||
|
val levelIdc = if (sps.size > 3) sps[3].toInt() and 0xFF else 0x1F
|
||||||
|
|
||||||
dos.writeByte(1) // configuration version
|
dos.writeByte(1) // configuration version
|
||||||
dos.writeByte(profileIdc) // AVC profile
|
dos.writeByte(profileIdc) // AVC profile
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.facebook.common.statfs.StatFsHelper
|
|||||||
import com.mrousavy.camera.extensions.getRecommendedBitRate
|
import com.mrousavy.camera.extensions.getRecommendedBitRate
|
||||||
import com.mrousavy.camera.types.Orientation
|
import com.mrousavy.camera.types.Orientation
|
||||||
import com.mrousavy.camera.types.RecordVideoOptions
|
import com.mrousavy.camera.types.RecordVideoOptions
|
||||||
|
import com.mrousavy.camera.types.StreamSegmentType
|
||||||
import com.mrousavy.camera.utils.FileUtils
|
import com.mrousavy.camera.utils.FileUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
@@ -27,9 +28,7 @@ class RecordingSession(
|
|||||||
private val filePath: String,
|
private val filePath: String,
|
||||||
private val callback: (video: Video) -> Unit,
|
private val callback: (video: Video) -> Unit,
|
||||||
private val onError: (error: CameraError) -> Unit,
|
private val onError: (error: CameraError) -> Unit,
|
||||||
private val allCallbacks: CameraSession.Callback,
|
private val allCallbacks: CameraSession.Callback
|
||||||
// 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"
|
||||||
@@ -45,13 +44,14 @@ class RecordingSession(
|
|||||||
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
|
// Normalize path - expo-file-system passes file:// URIs but File expects raw paths
|
||||||
private val outputPath: File = File(filePath.removePrefix("file://"))
|
// Handle both file:// and file:/ variants
|
||||||
|
private val outputPath: File = File(filePath.replace(Regex("^file:/+"), "/"))
|
||||||
|
|
||||||
private val bitRate = getBitRate()
|
private val bitRate = getBitRate()
|
||||||
|
|
||||||
// Use FragmentedRecordingManager for HLS-compatible fMP4 output,
|
// Use FragmentedRecordingManager for HLS-compatible fMP4 output,
|
||||||
// or fall back to ChunkedRecordingManager for regular MP4 chunks
|
// or fall back to ChunkedRecordingManager for regular MP4 chunks
|
||||||
private val recorder: ChunkedRecorderInterface = if (useFragmentedMp4) {
|
private val recorder: ChunkedRecorderInterface = if (options.streamSegmentType == StreamSegmentType.FRAGMENTED_MP4) {
|
||||||
FragmentedRecordingManager.fromParams(
|
FragmentedRecordingManager.fromParams(
|
||||||
allCallbacks,
|
allCallbacks,
|
||||||
size,
|
size,
|
||||||
@@ -83,7 +83,7 @@ class RecordingSession(
|
|||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
Log.i(TAG, "Starting RecordingSession..")
|
Log.i(TAG, "Starting RecordingSession with ${options.streamSegmentType} recorder..")
|
||||||
startTime = System.currentTimeMillis()
|
startTime = System.currentTimeMillis()
|
||||||
recorder.start()
|
recorder.start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class RecordVideoOptions(map: ReadableMap) {
|
|||||||
var videoBitRateOverride: Double? = null
|
var videoBitRateOverride: Double? = null
|
||||||
var videoBitRateMultiplier: Double? = null
|
var videoBitRateMultiplier: Double? = null
|
||||||
var orientation: Orientation? = null
|
var orientation: Orientation? = null
|
||||||
|
var streamSegmentType: StreamSegmentType = StreamSegmentType.FRAGMENTED_MP4
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (map.hasKey("fileType")) {
|
if (map.hasKey("fileType")) {
|
||||||
@@ -29,5 +30,8 @@ class RecordVideoOptions(map: ReadableMap) {
|
|||||||
if (map.hasKey("orientation")) {
|
if (map.hasKey("orientation")) {
|
||||||
orientation = Orientation.fromUnionValue(map.getString("orientation"))
|
orientation = Orientation.fromUnionValue(map.getString("orientation"))
|
||||||
}
|
}
|
||||||
|
if (map.hasKey("streamSegmentType")) {
|
||||||
|
streamSegmentType = StreamSegmentType.fromUnionValue(map.getString("streamSegmentType"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.mrousavy.camera.types
|
||||||
|
|
||||||
|
enum class StreamSegmentType(override val unionValue: String) : JSUnionValue {
|
||||||
|
FRAGMENTED_MP4("FRAGMENTED_MP4"),
|
||||||
|
RB_CHUNKED_MP4("RB_CHUNKED_MP4");
|
||||||
|
|
||||||
|
companion object : JSUnionValue.Companion<StreamSegmentType> {
|
||||||
|
override fun fromUnionValue(unionValue: String?): StreamSegmentType =
|
||||||
|
when (unionValue) {
|
||||||
|
"FRAGMENTED_MP4" -> FRAGMENTED_MP4
|
||||||
|
"RB_CHUNKED_MP4" -> RB_CHUNKED_MP4
|
||||||
|
else -> FRAGMENTED_MP4 // Default to fMP4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,17 @@ export interface RecordVideoOptions {
|
|||||||
* @default 'normal'
|
* @default 'normal'
|
||||||
*/
|
*/
|
||||||
videoBitRate?: 'extra-low' | 'low' | 'normal' | 'high' | 'extra-high' | number
|
videoBitRate?: 'extra-low' | 'low' | 'normal' | 'high' | 'extra-high' | number
|
||||||
|
/**
|
||||||
|
* The stream segment type for recording on Android.
|
||||||
|
* - `FRAGMENTED_MP4`: HLS-compatible segments (init.mp4 + numbered segments)
|
||||||
|
* - `RB_CHUNKED_MP4`: Legacy chunked MP4 format
|
||||||
|
*
|
||||||
|
* iOS always uses FRAGMENTED_MP4 regardless of this setting.
|
||||||
|
*
|
||||||
|
* @platform android
|
||||||
|
* @default 'FRAGMENTED_MP4'
|
||||||
|
*/
|
||||||
|
streamSegmentType?: 'FRAGMENTED_MP4' | 'RB_CHUNKED_MP4'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user