Compare commits
19 Commits
loewy/frag
...
fix/hlsmux
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd26812a9c | ||
|
|
b716608379 | ||
| 0ecc3d8210 | |||
| 309e1e9457 | |||
| 71b08e6898 | |||
|
|
699481f6f8 | ||
| 11ce9ba8f6 | |||
| dd9de38a7d | |||
| 3f5d0a2109 | |||
| 6c2319608d | |||
| 27f127fe94 | |||
| 92b29cbd78 | |||
| fb23c57a6c | |||
| 8d06ab9e66 | |||
| f6b6cfb3d5 | |||
| 3ac555a2b3 | |||
| 7e1e074e0f | |||
| b269e9c493 | |||
| 5fe7f35127 |
@@ -428,9 +428,9 @@ 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 orientation for video recording.
|
||||||
// - Counter-clockwise (ROTATION_90) → 90° hint
|
// Surface.ROTATION_90 = device rotated 90° CCW = phone top on LEFT = LANDSCAPE_LEFT
|
||||||
// - Clockwise (ROTATION_270) → 270° hint
|
// Surface.ROTATION_270 = device rotated 90° CW = phone top on RIGHT = LANDSCAPE_RIGHT
|
||||||
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) {
|
||||||
@@ -441,6 +441,8 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
|
|||||||
else -> Orientation.PORTRAIT
|
else -> Orientation.PORTRAIT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "startRecording: orientation=${recordingOrientation.toDegrees()}° (deviceRotation=$deviceRotation)")
|
||||||
|
|
||||||
val recording = RecordingSession(
|
val recording = RecordingSession(
|
||||||
context,
|
context,
|
||||||
cameraId,
|
cameraId,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import java.io.File
|
|||||||
*/
|
*/
|
||||||
class FragmentedRecordingManager(
|
class FragmentedRecordingManager(
|
||||||
private val encoder: MediaCodec,
|
private val encoder: MediaCodec,
|
||||||
private val muxer: HlsMuxer,
|
private val muxer: HlsMuxer
|
||||||
private val configuredFps: Int
|
|
||||||
) : MediaCodec.Callback(), ChunkedRecorderInterface {
|
) : MediaCodec.Callback(), ChunkedRecorderInterface {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -40,21 +39,18 @@ class FragmentedRecordingManager(
|
|||||||
segmentDurationSeconds: Int = DEFAULT_SEGMENT_DURATION_SECONDS
|
segmentDurationSeconds: Int = DEFAULT_SEGMENT_DURATION_SECONDS
|
||||||
): FragmentedRecordingManager {
|
): FragmentedRecordingManager {
|
||||||
val mimeType = options.videoCodec.toMimeType()
|
val mimeType = options.videoCodec.toMimeType()
|
||||||
// Use cameraOrientation from Android (computed from device rotation)
|
// Use cameraOrientation (from WindowManager) for rotation metadata
|
||||||
// instead of options.orientation from JS which may be stale
|
// The options.orientation from JavaScript is unreliable on Android when rotating between landscape modes
|
||||||
val recordingOrientationDegrees = cameraOrientation.toDegrees()
|
val orientationDegrees = cameraOrientation.toDegrees()
|
||||||
|
|
||||||
// Swap dimensions based on orientation - same logic as ChunkedRecordingManager
|
// Swap dimensions based on camera orientation, same 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}, " +
|
Log.d(TAG, "Recording: ${width}x${height}, orientation=$orientationDegrees°")
|
||||||
"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)
|
||||||
@@ -64,16 +60,18 @@ class FragmentedRecordingManager(
|
|||||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
val effectiveFps = fps ?: 30
|
// Use 30fps as conservative default since many Android devices can't sustain
|
||||||
|
// higher frame rates at high resolutions. This affects:
|
||||||
|
// - Encoder: bitrate allocation and I-frame interval calculation
|
||||||
|
// - HlsMuxer: timescale for accurate sample durations
|
||||||
|
// The actual frame timing comes from camera timestamps regardless of this setting.
|
||||||
|
val effectiveFps = 30
|
||||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, effectiveFps)
|
format.setInteger(MediaFormat.KEY_FRAME_RATE, effectiveFps)
|
||||||
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, orientation: $recordingOrientationDegrees")
|
|
||||||
|
|
||||||
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
|
||||||
// Create muxer with callbacks and orientation
|
|
||||||
val muxer = HlsMuxer(
|
val muxer = HlsMuxer(
|
||||||
outputDirectory = outputDirectory,
|
outputDirectory = outputDirectory,
|
||||||
callback = object : HlsMuxer.Callback {
|
callback = object : HlsMuxer.Callback {
|
||||||
@@ -85,13 +83,12 @@ class FragmentedRecordingManager(
|
|||||||
callbacks.onVideoChunkReady(file, index, durationUs)
|
callbacks.onVideoChunkReady(file, index, durationUs)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orientationDegrees = recordingOrientationDegrees
|
orientationDegrees = orientationDegrees,
|
||||||
|
fps = effectiveFps
|
||||||
)
|
)
|
||||||
muxer.setSegmentDuration(segmentDurationSeconds * 1_000_000L)
|
muxer.setSegmentDuration(segmentDurationSeconds * 1_000_000L)
|
||||||
|
|
||||||
Log.d(TAG, "Created HlsMuxer with orientation: $recordingOrientationDegrees degrees, fps: $effectiveFps")
|
return FragmentedRecordingManager(codec, muxer)
|
||||||
|
|
||||||
return FragmentedRecordingManager(codec, muxer, effectiveFps)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +168,7 @@ class FragmentedRecordingManager(
|
|||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
Log.i(TAG, "Output format changed: $format")
|
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)
|
||||||
trackIndex = muxer.addTrack(format, configuredFps)
|
|
||||||
muxer.start()
|
muxer.start()
|
||||||
muxerStarted = true
|
muxerStarted = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import java.nio.ByteBuffer
|
|||||||
class HlsMuxer(
|
class HlsMuxer(
|
||||||
private val outputDirectory: File,
|
private val outputDirectory: File,
|
||||||
private val callback: Callback,
|
private val callback: Callback,
|
||||||
private val orientationDegrees: Int = 0
|
private val orientationDegrees: Int = 0,
|
||||||
|
private val fps: Int = 30
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "HlsMuxer"
|
private const val TAG = "HlsMuxer"
|
||||||
@@ -41,8 +42,7 @@ class HlsMuxer(
|
|||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
private var targetSegmentDurationUs: Long = DEFAULT_SEGMENT_DURATION_US
|
private var targetSegmentDurationUs: Long = DEFAULT_SEGMENT_DURATION_US
|
||||||
private var timescale: Int = 30000 // Default, updated from format
|
private var timescale: Int = 30000 // Default, updated in addTrack() to fps * 1000
|
||||||
private var configuredFps: Int = 30 // Configured fps from user, used for VUI timing
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
private var state = State.UNINITIALIZED
|
private var state = State.UNINITIALIZED
|
||||||
@@ -55,9 +55,14 @@ class HlsMuxer(
|
|||||||
private var segmentStartTimeUs = -1L
|
private var segmentStartTimeUs = -1L
|
||||||
private var lastPresentationTimeUs = 0L
|
private var lastPresentationTimeUs = 0L
|
||||||
|
|
||||||
// Timestamp normalization - first timestamp becomes time 0
|
// Timestamp normalization - MediaCodec timestamps are device uptime, not starting from 0
|
||||||
private var firstPresentationTimeUs = -1L
|
private var firstPresentationTimeUs = -1L
|
||||||
|
|
||||||
|
// Actual fps detection from frame timestamps
|
||||||
|
private var detectedFps: Int? = null
|
||||||
|
private var fpsDetectionSamples = mutableListOf<Long>()
|
||||||
|
private val FPS_DETECTION_SAMPLE_COUNT = 30
|
||||||
|
|
||||||
private enum class State {
|
private enum class State {
|
||||||
UNINITIALIZED,
|
UNINITIALIZED,
|
||||||
INITIALIZED,
|
INITIALIZED,
|
||||||
@@ -79,13 +84,21 @@ class HlsMuxer(
|
|||||||
* Normalizes a presentation timestamp to start from 0.
|
* Normalizes a presentation timestamp to start from 0.
|
||||||
* The first timestamp received becomes time 0, and all subsequent
|
* The first timestamp received becomes time 0, and all subsequent
|
||||||
* timestamps are relative to that.
|
* timestamps are relative to that.
|
||||||
|
*
|
||||||
|
* This is necessary because MediaCodec timestamps are based on device uptime,
|
||||||
|
* not starting from 0. HLS players expect timestamps to start at or near 0.
|
||||||
*/
|
*/
|
||||||
private fun normalizeTimestamp(rawPresentationTimeUs: Long): Long {
|
private fun normalizeTimestamp(rawPresentationTimeUs: Long): Long {
|
||||||
if (firstPresentationTimeUs < 0) {
|
if (firstPresentationTimeUs < 0) {
|
||||||
firstPresentationTimeUs = rawPresentationTimeUs
|
firstPresentationTimeUs = rawPresentationTimeUs
|
||||||
Log.d(TAG, "First timestamp: ${rawPresentationTimeUs}us, normalizing to 0")
|
Log.d(TAG, "First timestamp captured: ${rawPresentationTimeUs}us (${rawPresentationTimeUs / 1_000_000.0}s), normalizing to 0")
|
||||||
}
|
}
|
||||||
return rawPresentationTimeUs - firstPresentationTimeUs
|
val normalized = rawPresentationTimeUs - firstPresentationTimeUs
|
||||||
|
// Log first few normalizations to debug
|
||||||
|
if (normalized < 1_000_000) { // First second
|
||||||
|
Log.d(TAG, "Timestamp: raw=${rawPresentationTimeUs}us -> normalized=${normalized}us")
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Annex-B to AVCC Conversion ====================
|
// ==================== Annex-B to AVCC Conversion ====================
|
||||||
@@ -213,15 +226,18 @@ class HlsMuxer(
|
|||||||
* Adds a track to the muxer.
|
* Adds a track to the muxer.
|
||||||
*
|
*
|
||||||
* @param format The MediaFormat describing the track
|
* @param format The MediaFormat describing the track
|
||||||
* @param fps The configured frame rate (used for VUI timing, overrides format's fps)
|
|
||||||
* @return Track index (always 0 for now, single video track)
|
* @return Track index (always 0 for now, single video track)
|
||||||
*/
|
*/
|
||||||
fun addTrack(format: MediaFormat, fps: Int = 30): Int {
|
fun addTrack(format: MediaFormat): Int {
|
||||||
check(state == State.UNINITIALIZED) { "addTrack() must be called before start()" }
|
check(state == State.UNINITIALIZED) { "addTrack() must be called before start()" }
|
||||||
|
|
||||||
trackFormat = format
|
trackFormat = format
|
||||||
configuredFps = fps
|
|
||||||
timescale = fps * 1000 // Use fps * 1000 for good precision
|
// Use fps * 1000 as timescale for good precision (1000 timescale units per frame).
|
||||||
|
// This ensures accurate sample durations without integer truncation issues.
|
||||||
|
// Note: ffprobe may report r_frame_rate based on timescale, so the backend
|
||||||
|
// should use the explicit framesPerSecond from the API mutation, not ffprobe.
|
||||||
|
timescale = fps * 1000
|
||||||
|
|
||||||
state = State.INITIALIZED
|
state = State.INITIALIZED
|
||||||
|
|
||||||
@@ -229,7 +245,7 @@ class HlsMuxer(
|
|||||||
val formatHeight = try { format.getInteger(MediaFormat.KEY_HEIGHT) } catch (e: Exception) { -1 }
|
val formatHeight = try { format.getInteger(MediaFormat.KEY_HEIGHT) } catch (e: Exception) { -1 }
|
||||||
Log.d(TAG, "Added track: ${format.getString(MediaFormat.KEY_MIME)}, " +
|
Log.d(TAG, "Added track: ${format.getString(MediaFormat.KEY_MIME)}, " +
|
||||||
"encoder output: ${formatWidth}x${formatHeight}, " +
|
"encoder output: ${formatWidth}x${formatHeight}, " +
|
||||||
"configuredFps=$configuredFps, timescale=$timescale, orientation=$orientationDegrees°")
|
"fps=$fps, timescale=$timescale, orientation=$orientationDegrees°")
|
||||||
|
|
||||||
return 0 // Single track, index 0
|
return 0 // Single track, index 0
|
||||||
}
|
}
|
||||||
@@ -241,16 +257,30 @@ class HlsMuxer(
|
|||||||
check(state == State.INITIALIZED) { "Must call addTrack() before start()" }
|
check(state == State.INITIALIZED) { "Must call addTrack() before start()" }
|
||||||
val format = trackFormat ?: throw IllegalStateException("No track format")
|
val format = trackFormat ?: throw IllegalStateException("No track format")
|
||||||
|
|
||||||
// Create output directory if needed
|
// Create output directory if needed, with proper error handling
|
||||||
if (!outputDirectory.exists()) {
|
if (!outputDirectory.exists()) {
|
||||||
outputDirectory.mkdirs()
|
val created = outputDirectory.mkdirs()
|
||||||
|
if (!created && !outputDirectory.exists()) {
|
||||||
|
throw IllegalStateException(
|
||||||
|
"Failed to create output directory: ${outputDirectory.absolutePath}. " +
|
||||||
|
"Parent exists: ${outputDirectory.parentFile?.exists()}, " +
|
||||||
|
"Parent path: ${outputDirectory.parentFile?.absolutePath}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Created output directory: ${outputDirectory.absolutePath}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write init segment
|
// Write init segment
|
||||||
val initBytes = buildInitSegment(format)
|
val initBytes = buildInitSegment(format)
|
||||||
val initFile = File(outputDirectory, "init.mp4")
|
val initFile = File(outputDirectory, "init.mp4")
|
||||||
FileOutputStream(initFile).use { it.write(initBytes) }
|
FileOutputStream(initFile).use { it.write(initBytes) }
|
||||||
|
|
||||||
|
// Log frame rate metadata for debugging
|
||||||
|
val defaultSampleDuration = timescale / fps
|
||||||
Log.d(TAG, "Created init segment: ${initFile.absolutePath} (${initBytes.size} bytes)")
|
Log.d(TAG, "Created init segment: ${initFile.absolutePath} (${initBytes.size} bytes)")
|
||||||
|
Log.d(TAG, "Frame rate metadata: timescale=$timescale, fps=$fps, " +
|
||||||
|
"default_sample_duration=$defaultSampleDuration (ffprobe should calculate ${timescale}/${defaultSampleDuration}=${fps}fps)")
|
||||||
|
|
||||||
callback.onInitSegmentReady(initFile)
|
callback.onInitSegmentReady(initFile)
|
||||||
|
|
||||||
state = State.STARTED
|
state = State.STARTED
|
||||||
@@ -273,13 +303,40 @@ class HlsMuxer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val isKeyFrame = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0
|
val isKeyFrame = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0
|
||||||
|
// Normalize timestamp to start from 0 (MediaCodec uses device uptime)
|
||||||
val presentationTimeUs = normalizeTimestamp(bufferInfo.presentationTimeUs)
|
val presentationTimeUs = normalizeTimestamp(bufferInfo.presentationTimeUs)
|
||||||
|
|
||||||
|
// Detect actual fps from first N samples
|
||||||
|
if (detectedFps == null) {
|
||||||
|
fpsDetectionSamples.add(presentationTimeUs)
|
||||||
|
if (fpsDetectionSamples.size >= FPS_DETECTION_SAMPLE_COUNT) {
|
||||||
|
val elapsed = fpsDetectionSamples.last() - fpsDetectionSamples.first()
|
||||||
|
if (elapsed > 0) {
|
||||||
|
val actualFps = ((FPS_DETECTION_SAMPLE_COUNT - 1) * 1_000_000.0 / elapsed).toInt()
|
||||||
|
detectedFps = actualFps
|
||||||
|
if (kotlin.math.abs(actualFps - fps) > 5) {
|
||||||
|
Log.w(TAG, "Actual fps ($actualFps) differs significantly from configured fps ($fps)! " +
|
||||||
|
"This may cause processing issues if backend uses configured fps.")
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Detected actual fps: $actualFps (configured: $fps)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fpsDetectionSamples.clear() // Free memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize segment start time
|
// Initialize segment start time
|
||||||
if (segmentStartTimeUs < 0) {
|
if (segmentStartTimeUs < 0) {
|
||||||
segmentStartTimeUs = presentationTimeUs
|
segmentStartTimeUs = presentationTimeUs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update duration of previous sample BEFORE finalization check
|
||||||
|
// This ensures the last sample has correct duration when segment is finalized
|
||||||
|
if (pendingSamples.isNotEmpty()) {
|
||||||
|
val lastSample = pendingSamples.last()
|
||||||
|
lastSample.durationUs = presentationTimeUs - lastSample.presentationTimeUs
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we should finalize current segment (at keyframe boundaries)
|
// Check if we should finalize current segment (at keyframe boundaries)
|
||||||
if (isKeyFrame && pendingSamples.isNotEmpty()) {
|
if (isKeyFrame && pendingSamples.isNotEmpty()) {
|
||||||
val segmentDurationUs = presentationTimeUs - segmentStartTimeUs
|
val segmentDurationUs = presentationTimeUs - segmentStartTimeUs
|
||||||
@@ -298,12 +355,6 @@ class HlsMuxer(
|
|||||||
// Convert Annex-B (start codes) to AVCC (length prefixes)
|
// Convert Annex-B (start codes) to AVCC (length prefixes)
|
||||||
val data = convertAnnexBToAvcc(rawData)
|
val data = convertAnnexBToAvcc(rawData)
|
||||||
|
|
||||||
// Update duration of previous sample
|
|
||||||
if (pendingSamples.isNotEmpty()) {
|
|
||||||
val lastSample = pendingSamples.last()
|
|
||||||
lastSample.durationUs = presentationTimeUs - lastSample.presentationTimeUs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate duration (will be corrected by next sample)
|
// Estimate duration (will be corrected by next sample)
|
||||||
val estimatedDurationUs = if (lastPresentationTimeUs > 0) {
|
val estimatedDurationUs = if (lastPresentationTimeUs > 0) {
|
||||||
presentationTimeUs - lastPresentationTimeUs
|
presentationTimeUs - lastPresentationTimeUs
|
||||||
@@ -365,6 +416,7 @@ class HlsMuxer(
|
|||||||
val durationUs = (lastSample.presentationTimeUs - firstPts) + lastSample.durationUs
|
val durationUs = (lastSample.presentationTimeUs - firstPts) + lastSample.durationUs
|
||||||
|
|
||||||
Log.d(TAG, "Created segment $segmentIndex: samples=${pendingSamples.size}, " +
|
Log.d(TAG, "Created segment $segmentIndex: samples=${pendingSamples.size}, " +
|
||||||
|
"baseDecodeTime=${baseDecodeTimeUs}us (${baseDecodeTimeUs / 1_000_000.0}s), " +
|
||||||
"duration=${durationUs / 1000}ms, size=${fragmentBytes.size} bytes")
|
"duration=${durationUs / 1000}ms, size=${fragmentBytes.size} bytes")
|
||||||
|
|
||||||
callback.onMediaSegmentReady(segmentFile, segmentIndex, durationUs)
|
callback.onMediaSegmentReady(segmentFile, segmentIndex, durationUs)
|
||||||
@@ -378,303 +430,6 @@ class HlsMuxer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SPS VUI Timing Injection ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Injects VUI timing parameters into an H.264 SPS NAL unit.
|
|
||||||
* This ensures proper frame rate detection by players/decoders.
|
|
||||||
*
|
|
||||||
* The SPS from MediaCodec lacks VUI timing info, causing tools like
|
|
||||||
* ffprobe to misinterpret the frame rate.
|
|
||||||
*/
|
|
||||||
private fun injectVuiTiming(sps: ByteArray, fps: Int): ByteArray {
|
|
||||||
try {
|
|
||||||
val reader = BitReader(sps)
|
|
||||||
val writer = BitWriter()
|
|
||||||
|
|
||||||
// NAL header (1 byte: forbidden_zero_bit, nal_ref_idc, nal_unit_type)
|
|
||||||
writer.writeBits(reader.readBits(8), 8)
|
|
||||||
|
|
||||||
// profile_idc (1 byte)
|
|
||||||
val profileIdc = reader.readBits(8)
|
|
||||||
writer.writeBits(profileIdc, 8)
|
|
||||||
|
|
||||||
// constraint_set flags (1 byte)
|
|
||||||
writer.writeBits(reader.readBits(8), 8)
|
|
||||||
|
|
||||||
// level_idc (1 byte)
|
|
||||||
writer.writeBits(reader.readBits(8), 8)
|
|
||||||
|
|
||||||
// seq_parameter_set_id (ue(v))
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
|
|
||||||
// Profile-specific fields for High profile (100) and others
|
|
||||||
if (profileIdc == 100 || profileIdc == 110 || profileIdc == 122 ||
|
|
||||||
profileIdc == 244 || profileIdc == 44 || profileIdc == 83 ||
|
|
||||||
profileIdc == 86 || profileIdc == 118 || profileIdc == 128 ||
|
|
||||||
profileIdc == 138 || profileIdc == 139 || profileIdc == 134 ||
|
|
||||||
profileIdc == 135) {
|
|
||||||
|
|
||||||
// chroma_format_idc (ue(v))
|
|
||||||
val chromaFormatIdc = copyExpGolombAndReturn(reader, writer)
|
|
||||||
|
|
||||||
if (chromaFormatIdc == 3) {
|
|
||||||
// separate_colour_plane_flag (1 bit)
|
|
||||||
writer.writeBits(reader.readBits(1), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// bit_depth_luma_minus8 (ue(v))
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
|
|
||||||
// bit_depth_chroma_minus8 (ue(v))
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
|
|
||||||
// qpprime_y_zero_transform_bypass_flag (1 bit)
|
|
||||||
writer.writeBits(reader.readBits(1), 1)
|
|
||||||
|
|
||||||
// seq_scaling_matrix_present_flag (1 bit)
|
|
||||||
val scalingMatrixFlag = reader.readBits(1)
|
|
||||||
writer.writeBits(scalingMatrixFlag, 1)
|
|
||||||
|
|
||||||
if (scalingMatrixFlag == 1) {
|
|
||||||
// Skip scaling lists - this is complex, just copy remaining and give up
|
|
||||||
Log.w(TAG, "SPS has scaling matrix, skipping VUI injection")
|
|
||||||
return sps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// log2_max_frame_num_minus4 (ue(v))
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
|
|
||||||
// pic_order_cnt_type (ue(v))
|
|
||||||
val picOrderCntType = copyExpGolombAndReturn(reader, writer)
|
|
||||||
|
|
||||||
if (picOrderCntType == 0) {
|
|
||||||
// log2_max_pic_order_cnt_lsb_minus4 (ue(v))
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
} else if (picOrderCntType == 1) {
|
|
||||||
// delta_pic_order_always_zero_flag (1 bit)
|
|
||||||
writer.writeBits(reader.readBits(1), 1)
|
|
||||||
// offset_for_non_ref_pic (se(v))
|
|
||||||
copySignedExpGolomb(reader, writer)
|
|
||||||
// offset_for_top_to_bottom_field (se(v))
|
|
||||||
copySignedExpGolomb(reader, writer)
|
|
||||||
// num_ref_frames_in_pic_order_cnt_cycle (ue(v))
|
|
||||||
val numRefFrames = copyExpGolombAndReturn(reader, writer)
|
|
||||||
for (i in 0 until numRefFrames) {
|
|
||||||
// offset_for_ref_frame[i] (se(v))
|
|
||||||
copySignedExpGolomb(reader, writer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// max_num_ref_frames (ue(v))
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
|
|
||||||
// gaps_in_frame_num_value_allowed_flag (1 bit)
|
|
||||||
writer.writeBits(reader.readBits(1), 1)
|
|
||||||
|
|
||||||
// pic_width_in_mbs_minus1 (ue(v))
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
|
|
||||||
// pic_height_in_map_units_minus1 (ue(v))
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
|
|
||||||
// frame_mbs_only_flag (1 bit)
|
|
||||||
val frameMbsOnlyFlag = reader.readBits(1)
|
|
||||||
writer.writeBits(frameMbsOnlyFlag, 1)
|
|
||||||
|
|
||||||
if (frameMbsOnlyFlag == 0) {
|
|
||||||
// mb_adaptive_frame_field_flag (1 bit)
|
|
||||||
writer.writeBits(reader.readBits(1), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// direct_8x8_inference_flag (1 bit)
|
|
||||||
writer.writeBits(reader.readBits(1), 1)
|
|
||||||
|
|
||||||
// frame_cropping_flag (1 bit)
|
|
||||||
val frameCroppingFlag = reader.readBits(1)
|
|
||||||
writer.writeBits(frameCroppingFlag, 1)
|
|
||||||
|
|
||||||
if (frameCroppingFlag == 1) {
|
|
||||||
// frame_crop_left_offset, right, top, bottom (ue(v) each)
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
copyExpGolomb(reader, writer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// vui_parameters_present_flag - we'll set this to 1 and add our VUI
|
|
||||||
val originalVuiFlag = reader.readBits(1)
|
|
||||||
writer.writeBits(1, 1) // Set VUI present
|
|
||||||
|
|
||||||
// Write VUI parameters with timing info
|
|
||||||
writeVuiWithTiming(writer, fps, originalVuiFlag == 1, reader)
|
|
||||||
|
|
||||||
// Add RBSP trailing bits
|
|
||||||
writer.writeRbspTrailingBits()
|
|
||||||
|
|
||||||
val result = writer.toByteArray()
|
|
||||||
Log.d(TAG, "Injected VUI timing for ${fps}fps, SPS grew from ${sps.size} to ${result.size} bytes")
|
|
||||||
return result
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to inject VUI timing: ${e.message}, using original SPS")
|
|
||||||
return sps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes VUI parameters with timing info.
|
|
||||||
*/
|
|
||||||
private fun writeVuiWithTiming(writer: BitWriter, fps: Int, hadVui: Boolean, reader: BitReader) {
|
|
||||||
// aspect_ratio_info_present_flag
|
|
||||||
writer.writeBits(0, 1)
|
|
||||||
|
|
||||||
// overscan_info_present_flag
|
|
||||||
writer.writeBits(0, 1)
|
|
||||||
|
|
||||||
// video_signal_type_present_flag
|
|
||||||
writer.writeBits(0, 1)
|
|
||||||
|
|
||||||
// chroma_loc_info_present_flag
|
|
||||||
writer.writeBits(0, 1)
|
|
||||||
|
|
||||||
// timing_info_present_flag = 1
|
|
||||||
writer.writeBits(1, 1)
|
|
||||||
|
|
||||||
// num_units_in_tick (32 bits) = 1
|
|
||||||
writer.writeBits(1, 32)
|
|
||||||
|
|
||||||
// time_scale (32 bits) = fps * 2 (because each frame = 2 field counts)
|
|
||||||
writer.writeBits(fps * 2, 32)
|
|
||||||
|
|
||||||
// fixed_frame_rate_flag = 1
|
|
||||||
writer.writeBits(1, 1)
|
|
||||||
|
|
||||||
// nal_hrd_parameters_present_flag
|
|
||||||
writer.writeBits(0, 1)
|
|
||||||
|
|
||||||
// vcl_hrd_parameters_present_flag
|
|
||||||
writer.writeBits(0, 1)
|
|
||||||
|
|
||||||
// pic_struct_present_flag
|
|
||||||
writer.writeBits(0, 1)
|
|
||||||
|
|
||||||
// bitstream_restriction_flag
|
|
||||||
writer.writeBits(0, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Bit Manipulation Helpers ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bit-level reader for parsing H.264 NAL units.
|
|
||||||
*/
|
|
||||||
private class BitReader(private val data: ByteArray) {
|
|
||||||
private var bytePos = 0
|
|
||||||
private var bitPos = 0
|
|
||||||
|
|
||||||
fun readBits(count: Int): Int {
|
|
||||||
var result = 0
|
|
||||||
for (i in 0 until count) {
|
|
||||||
if (bytePos >= data.size) throw IllegalStateException("End of data")
|
|
||||||
val bit = (data[bytePos].toInt() shr (7 - bitPos)) and 1
|
|
||||||
result = (result shl 1) or bit
|
|
||||||
bitPos++
|
|
||||||
if (bitPos == 8) {
|
|
||||||
bitPos = 0
|
|
||||||
bytePos++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readExpGolomb(): Int {
|
|
||||||
var leadingZeros = 0
|
|
||||||
while (readBits(1) == 0) {
|
|
||||||
leadingZeros++
|
|
||||||
if (leadingZeros > 31) throw IllegalStateException("Invalid exp-golomb")
|
|
||||||
}
|
|
||||||
if (leadingZeros == 0) return 0
|
|
||||||
val suffix = readBits(leadingZeros)
|
|
||||||
return (1 shl leadingZeros) - 1 + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readSignedExpGolomb(): Int {
|
|
||||||
val code = readExpGolomb()
|
|
||||||
return if (code % 2 == 0) -(code / 2) else (code + 1) / 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bit-level writer for constructing H.264 NAL units.
|
|
||||||
*/
|
|
||||||
private class BitWriter {
|
|
||||||
private val bytes = mutableListOf<Byte>()
|
|
||||||
private var currentByte = 0
|
|
||||||
private var bitPos = 0
|
|
||||||
|
|
||||||
fun writeBits(value: Int, count: Int) {
|
|
||||||
for (i in count - 1 downTo 0) {
|
|
||||||
val bit = (value shr i) and 1
|
|
||||||
currentByte = (currentByte shl 1) or bit
|
|
||||||
bitPos++
|
|
||||||
if (bitPos == 8) {
|
|
||||||
bytes.add(currentByte.toByte())
|
|
||||||
currentByte = 0
|
|
||||||
bitPos = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeExpGolomb(value: Int) {
|
|
||||||
val code = value + 1
|
|
||||||
val bits = 32 - Integer.numberOfLeadingZeros(code)
|
|
||||||
// Write leading zeros
|
|
||||||
for (i in 0 until bits - 1) {
|
|
||||||
writeBits(0, 1)
|
|
||||||
}
|
|
||||||
// Write the code
|
|
||||||
writeBits(code, bits)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeSignedExpGolomb(value: Int) {
|
|
||||||
val code = if (value <= 0) -2 * value else 2 * value - 1
|
|
||||||
writeExpGolomb(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeRbspTrailingBits() {
|
|
||||||
writeBits(1, 1) // rbsp_stop_one_bit
|
|
||||||
while (bitPos != 0) {
|
|
||||||
writeBits(0, 1) // rbsp_alignment_zero_bit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toByteArray(): ByteArray {
|
|
||||||
// Flush remaining bits
|
|
||||||
if (bitPos > 0) {
|
|
||||||
currentByte = currentByte shl (8 - bitPos)
|
|
||||||
bytes.add(currentByte.toByte())
|
|
||||||
}
|
|
||||||
return bytes.toByteArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyExpGolomb(reader: BitReader, writer: BitWriter) {
|
|
||||||
val value = reader.readExpGolomb()
|
|
||||||
writer.writeExpGolomb(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyExpGolombAndReturn(reader: BitReader, writer: BitWriter): Int {
|
|
||||||
val value = reader.readExpGolomb()
|
|
||||||
writer.writeExpGolomb(value)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copySignedExpGolomb(reader: BitReader, writer: BitWriter) {
|
|
||||||
val value = reader.readSignedExpGolomb()
|
|
||||||
writer.writeSignedExpGolomb(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Init Segment Building ====================
|
// ==================== Init Segment Building ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -684,19 +439,11 @@ class HlsMuxer(
|
|||||||
val width = format.getInteger(MediaFormat.KEY_WIDTH)
|
val width = format.getInteger(MediaFormat.KEY_WIDTH)
|
||||||
val height = format.getInteger(MediaFormat.KEY_HEIGHT)
|
val height = format.getInteger(MediaFormat.KEY_HEIGHT)
|
||||||
|
|
||||||
val rawSps = format.getByteBuffer("csd-0")?.let { extractNalUnit(it) }
|
val sps = format.getByteBuffer("csd-0")?.let { extractNalUnit(it) }
|
||||||
?: throw IllegalArgumentException("Missing SPS (csd-0)")
|
?: throw IllegalArgumentException("Missing SPS (csd-0)")
|
||||||
val pps = format.getByteBuffer("csd-1")?.let { extractNalUnit(it) }
|
val pps = format.getByteBuffer("csd-1")?.let { extractNalUnit(it) }
|
||||||
?: throw IllegalArgumentException("Missing PPS (csd-1)")
|
?: throw IllegalArgumentException("Missing PPS (csd-1)")
|
||||||
|
|
||||||
Log.d(TAG, "Original SPS size: ${rawSps.size} bytes, PPS size: ${pps.size} bytes")
|
|
||||||
Log.d(TAG, "Original SPS hex: ${rawSps.joinToString("") { "%02x".format(it) }}")
|
|
||||||
|
|
||||||
// Inject VUI timing info into SPS using configured fps (not encoder output format fps)
|
|
||||||
val sps = injectVuiTiming(rawSps, configuredFps)
|
|
||||||
Log.d(TAG, "Modified SPS size: ${sps.size} bytes")
|
|
||||||
Log.d(TAG, "Modified SPS hex: ${sps.joinToString("") { "%02x".format(it) }}")
|
|
||||||
|
|
||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
|
|
||||||
// ftyp
|
// ftyp
|
||||||
@@ -797,56 +544,91 @@ class HlsMuxer(
|
|||||||
dos.writeShort(0) // volume (0 for video)
|
dos.writeShort(0) // volume (0 for video)
|
||||||
dos.writeShort(0) // reserved
|
dos.writeShort(0) // reserved
|
||||||
|
|
||||||
// Rotation matrix
|
// Rotation matrix based on orientationDegrees
|
||||||
writeRotationMatrix(dos)
|
writeRotationMatrix(dos, width, height)
|
||||||
|
|
||||||
// Display dimensions should be post-rotation dimensions
|
// For 90° and 270° rotations, the display dimensions are swapped
|
||||||
// For 90° or 270° rotation, swap width and height
|
// The tkhd width/height represent the final display size after rotation
|
||||||
val (displayWidth, displayHeight) = when (orientationDegrees) {
|
val (displayWidth, displayHeight) = when (orientationDegrees) {
|
||||||
90, 270 -> height to width
|
90, 270 -> Pair(height, width)
|
||||||
else -> width to height
|
else -> Pair(width, height)
|
||||||
}
|
}
|
||||||
dos.writeInt(displayWidth shl 16) // width (16.16 fixed point)
|
dos.writeInt(displayWidth shl 16) // width (16.16 fixed point)
|
||||||
dos.writeInt(displayHeight shl 16) // height (16.16 fixed point)
|
dos.writeInt(displayHeight shl 16) // height (16.16 fixed point)
|
||||||
|
|
||||||
Log.d(TAG, "tkhd: encoded=${width}x${height}, display=${displayWidth}x${displayHeight}, rotation=$orientationDegrees")
|
Log.d(TAG, "tkhd: encoder=${width}x${height}, display=${displayWidth}x${displayHeight}, rotation=$orientationDegrees")
|
||||||
|
|
||||||
return wrapBox("tkhd", output.toByteArray())
|
return wrapBox("tkhd", output.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes the 3x3 transformation matrix for video rotation.
|
* Writes the 3x3 transformation matrix for video rotation.
|
||||||
|
* The matrix is applied to rotate the video content for correct display.
|
||||||
|
*
|
||||||
|
* Matrix format in tkhd box (all values in fixed-point):
|
||||||
|
* | a b u | where a,b,c,d are 16.16 fixed-point
|
||||||
|
* | c d v | and u,v are 2.30 fixed-point (always 0)
|
||||||
|
* | x y w | x,y are 16.16, w is 2.30 (always 1.0)
|
||||||
|
*
|
||||||
|
* For rotation by θ: a=cos(θ), b=sin(θ), c=-sin(θ), d=cos(θ)
|
||||||
|
* Translation (x,y) keeps the rotated video in the visible area.
|
||||||
*/
|
*/
|
||||||
private fun writeRotationMatrix(dos: DataOutputStream) {
|
private fun writeRotationMatrix(dos: DataOutputStream, width: Int, height: Int) {
|
||||||
val one = 0x00010000 // 1.0 in 16.16
|
// Fixed-point constants
|
||||||
val negOne = 0xFFFF0000.toInt() // -1.0 in 16.16
|
val one = 0x00010000 // 1.0 in 16.16
|
||||||
val w = 0x40000000 // 1.0 in 2.30
|
val negOne = -0x00010000 // -1.0 in 16.16 (will be written as unsigned)
|
||||||
|
val w = 0x40000000 // 1.0 in 2.30
|
||||||
// For 270° device orientation (landscape-right), apply 90° CW rotation
|
|
||||||
// For 90° device orientation (landscape-left), apply 270° CW rotation
|
|
||||||
val a: Int
|
|
||||||
val b: Int
|
|
||||||
val c: Int
|
|
||||||
val d: Int
|
|
||||||
|
|
||||||
when (orientationDegrees) {
|
when (orientationDegrees) {
|
||||||
90 -> { a = 0; b = negOne; c = one; d = 0 }
|
90 -> {
|
||||||
180 -> { a = negOne; b = 0; c = 0; d = negOne }
|
// 90° rotation: x' = y, y' = -x + width
|
||||||
270 -> { a = 0; b = one; c = negOne; d = 0 }
|
dos.writeInt(0) // a = 0
|
||||||
else -> { a = one; b = 0; c = 0; d = one }
|
dos.writeInt(negOne) // b = -1
|
||||||
|
dos.writeInt(0) // u = 0
|
||||||
|
dos.writeInt(one) // c = 1
|
||||||
|
dos.writeInt(0) // d = 0
|
||||||
|
dos.writeInt(0) // v = 0
|
||||||
|
dos.writeInt(0) // x = 0
|
||||||
|
dos.writeInt(width shl 16) // y = width (translation)
|
||||||
|
dos.writeInt(w) // w = 1
|
||||||
|
}
|
||||||
|
180 -> {
|
||||||
|
// 180° rotation
|
||||||
|
dos.writeInt(negOne) // a = -1
|
||||||
|
dos.writeInt(0) // b = 0
|
||||||
|
dos.writeInt(0) // u = 0
|
||||||
|
dos.writeInt(0) // c = 0
|
||||||
|
dos.writeInt(negOne) // d = -1
|
||||||
|
dos.writeInt(0) // v = 0
|
||||||
|
dos.writeInt(width shl 16) // x = width (translation)
|
||||||
|
dos.writeInt(height shl 16) // y = height (translation)
|
||||||
|
dos.writeInt(w) // w = 1
|
||||||
|
}
|
||||||
|
270 -> {
|
||||||
|
// 270° rotation: x' = -y + height, y' = x
|
||||||
|
dos.writeInt(0) // a = 0
|
||||||
|
dos.writeInt(one) // b = 1
|
||||||
|
dos.writeInt(0) // u = 0
|
||||||
|
dos.writeInt(negOne) // c = -1
|
||||||
|
dos.writeInt(0) // d = 0
|
||||||
|
dos.writeInt(0) // v = 0
|
||||||
|
dos.writeInt(height shl 16) // x = height (translation)
|
||||||
|
dos.writeInt(0) // y = 0
|
||||||
|
dos.writeInt(w) // w = 1
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// 0° or unknown: identity matrix
|
||||||
|
dos.writeInt(one) // a = 1
|
||||||
|
dos.writeInt(0) // b = 0
|
||||||
|
dos.writeInt(0) // u = 0
|
||||||
|
dos.writeInt(0) // c = 0
|
||||||
|
dos.writeInt(one) // d = 1
|
||||||
|
dos.writeInt(0) // v = 0
|
||||||
|
dos.writeInt(0) // x = 0
|
||||||
|
dos.writeInt(0) // y = 0
|
||||||
|
dos.writeInt(w) // w = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dos.writeInt(a)
|
|
||||||
dos.writeInt(b)
|
|
||||||
dos.writeInt(0) // u = 0
|
|
||||||
dos.writeInt(c)
|
|
||||||
dos.writeInt(d)
|
|
||||||
dos.writeInt(0) // v = 0
|
|
||||||
dos.writeInt(0) // tx = 0
|
|
||||||
dos.writeInt(0) // ty = 0
|
|
||||||
dos.writeInt(w) // w = 1.0
|
|
||||||
|
|
||||||
Log.d(TAG, "Rotation matrix for $orientationDegrees°")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildMdiaBox(width: Int, height: Int, sps: ByteArray, pps: ByteArray): ByteArray {
|
private fun buildMdiaBox(width: Int, height: Int, sps: ByteArray, pps: ByteArray): ByteArray {
|
||||||
@@ -933,7 +715,7 @@ class HlsMuxer(
|
|||||||
private fun buildStblBox(width: Int, height: Int, sps: ByteArray, pps: ByteArray): ByteArray {
|
private fun buildStblBox(width: Int, height: Int, sps: ByteArray, pps: ByteArray): ByteArray {
|
||||||
val content = ByteArrayOutputStream()
|
val content = ByteArrayOutputStream()
|
||||||
content.write(buildStsdBox(width, height, sps, pps))
|
content.write(buildStsdBox(width, height, sps, pps))
|
||||||
content.write(buildEmptySttsBox())
|
content.write(buildSttsBox()) // Contains default timing for ffprobe frame rate detection
|
||||||
content.write(buildEmptyStscBox())
|
content.write(buildEmptyStscBox())
|
||||||
content.write(buildEmptyStszBox())
|
content.write(buildEmptyStszBox())
|
||||||
content.write(buildEmptyStcoBox())
|
content.write(buildEmptyStcoBox())
|
||||||
@@ -971,15 +753,29 @@ 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)
|
||||||
|
|
||||||
// SPS layout: [0]=NAL header (0x67), [1]=profile_idc, [2]=constraint_flags, [3]=level_idc
|
// SPS NAL unit format: [NAL header, profile_idc, constraint_flags, level_idc, ...]
|
||||||
|
// Skip byte 0 (NAL header, typically 0x67) to get the actual profile data
|
||||||
val profileIdc = if (sps.size > 1) sps[1].toInt() and 0xFF else 0x42
|
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 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
|
val levelIdc = if (sps.size > 3) sps[3].toInt() and 0xFF else 0x1F
|
||||||
@@ -1001,11 +797,21 @@ class HlsMuxer(
|
|||||||
return wrapBox("avcC", output.toByteArray())
|
return wrapBox("avcC", output.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildEmptySttsBox(): ByteArray {
|
private fun buildSttsBox(): ByteArray {
|
||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
val dos = DataOutputStream(output)
|
val dos = DataOutputStream(output)
|
||||||
|
|
||||||
|
// For fragmented MP4, stts is normally empty as timing is in trun boxes.
|
||||||
|
// However, ffprobe uses stts to calculate r_frame_rate when present.
|
||||||
|
// We add a single entry with the default sample duration so ffprobe
|
||||||
|
// can derive: r_frame_rate = timescale / sample_delta = 30000/1000 = 30
|
||||||
|
val defaultSampleDuration = timescale / fps
|
||||||
|
|
||||||
dos.writeInt(0) // version & flags
|
dos.writeInt(0) // version & flags
|
||||||
dos.writeInt(0) // entry count
|
dos.writeInt(1) // entry count (1 entry for default timing)
|
||||||
|
dos.writeInt(1) // sample_count (indicates this is the default duration)
|
||||||
|
dos.writeInt(defaultSampleDuration) // sample_delta in timescale units
|
||||||
|
|
||||||
return wrapBox("stts", output.toByteArray())
|
return wrapBox("stts", output.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,9 +848,10 @@ class HlsMuxer(
|
|||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
val dos = DataOutputStream(output)
|
val dos = DataOutputStream(output)
|
||||||
|
|
||||||
// Default sample duration: timescale / fps
|
// Calculate default sample duration so ffprobe can derive correct fps
|
||||||
// Since timescale = fps * 1000, duration = 1000 for any fps
|
// fps = timescale / default_sample_duration
|
||||||
val defaultSampleDuration = 1000
|
// At 30fps with timescale=30000: duration=1000, ffprobe calculates 30000/1000=30
|
||||||
|
val defaultSampleDuration = timescale / fps
|
||||||
|
|
||||||
dos.writeInt(0) // version & flags
|
dos.writeInt(0) // version & flags
|
||||||
dos.writeInt(1) // track ID
|
dos.writeInt(1) // track ID
|
||||||
@@ -1093,9 +900,11 @@ class HlsMuxer(
|
|||||||
): ByteArray {
|
): ByteArray {
|
||||||
// Calculate sizes to determine data offset
|
// Calculate sizes to determine data offset
|
||||||
val mfhdBox = buildMfhdBox(sequenceNumber)
|
val mfhdBox = buildMfhdBox(sequenceNumber)
|
||||||
val tfhdSize = 8 + 8 // box header + content (version/flags + track_id)
|
// tfhd: 8 header + 4 version/flags + 4 track_id + 4 duration + 4 size + 4 flags = 28 bytes
|
||||||
|
val tfhdSize = 8 + 20
|
||||||
val tfdtSize = 8 + 12 // box header + version 1 content
|
val tfdtSize = 8 + 12 // box header + version 1 content
|
||||||
val trunSize = 8 + 12 + (samples.size * 12) // header + fixed + per-sample (no composition offset)
|
// trun: 8 header + 12 fixed + per-sample (size + flags only, no duration)
|
||||||
|
val trunSize = 8 + 12 + (samples.size * 8)
|
||||||
val trafSize = 8 + tfhdSize + tfdtSize + trunSize
|
val trafSize = 8 + tfhdSize + tfdtSize + trunSize
|
||||||
val moofSize = 8 + mfhdBox.size + trafSize
|
val moofSize = 8 + mfhdBox.size + trafSize
|
||||||
|
|
||||||
@@ -1130,9 +939,21 @@ class HlsMuxer(
|
|||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
val dos = DataOutputStream(output)
|
val dos = DataOutputStream(output)
|
||||||
|
|
||||||
// Flags: default-base-is-moof (0x020000)
|
// Calculate default sample duration for this fragment
|
||||||
dos.writeInt(0x00020000)
|
// This helps ffprobe calculate correct frame rate when reading via HLS
|
||||||
|
val defaultSampleDuration = timescale / fps // e.g., 30000/30 = 1000
|
||||||
|
|
||||||
|
// Match iOS AVFoundation's tfhd structure (28 bytes total)
|
||||||
|
// Flags: default-base-is-moof (0x020000) + default-sample-duration (0x000008)
|
||||||
|
// + default-sample-size (0x000010) + default-sample-flags (0x000020)
|
||||||
|
val flags = 0x00020000 or 0x000008 or 0x000010 or 0x000020
|
||||||
|
dos.writeInt(flags)
|
||||||
dos.writeInt(1) // track ID
|
dos.writeInt(1) // track ID
|
||||||
|
dos.writeInt(defaultSampleDuration) // default sample duration in timescale units
|
||||||
|
dos.writeInt(0) // default sample size (0 = variable, specified in trun)
|
||||||
|
dos.writeInt(0x01010000) // default sample flags (non-keyframe, depends on others)
|
||||||
|
|
||||||
|
Log.d(TAG, "tfhd: default_sample_duration=$defaultSampleDuration (timescale=$timescale, fps=$fps)")
|
||||||
|
|
||||||
return wrapBox("tfhd", output.toByteArray())
|
return wrapBox("tfhd", output.toByteArray())
|
||||||
}
|
}
|
||||||
@@ -1155,19 +976,17 @@ class HlsMuxer(
|
|||||||
val output = ByteArrayOutputStream()
|
val output = ByteArrayOutputStream()
|
||||||
val dos = DataOutputStream(output)
|
val dos = DataOutputStream(output)
|
||||||
|
|
||||||
// Flags: data-offset + sample-duration + sample-size + sample-flags
|
// Flags: data-offset + sample-size + sample-flags
|
||||||
val flags = 0x000001 or 0x000100 or 0x000200 or 0x000400
|
// NOTE: We intentionally OMIT sample-duration (0x000100) so ffprobe uses
|
||||||
|
// the default_sample_duration from tfhd instead of per-sample durations.
|
||||||
|
// This ensures consistent frame rate calculation via HLS.
|
||||||
|
val flags = 0x000001 or 0x000200 or 0x000400
|
||||||
dos.writeInt(flags)
|
dos.writeInt(flags)
|
||||||
dos.writeInt(samples.size)
|
dos.writeInt(samples.size)
|
||||||
dos.writeInt(dataOffset)
|
dos.writeInt(dataOffset)
|
||||||
|
|
||||||
// Use constant duration based on configured fps for consistent frame rate
|
|
||||||
// This ensures ffprobe reports correct fps instead of calculating from variable timing
|
|
||||||
val constantDuration = timescale / configuredFps // e.g., 30000/30 = 1000 ticks
|
|
||||||
Log.d(TAG, "Writing ${samples.size} samples with constant duration=${constantDuration} ticks (${configuredFps}fps)")
|
|
||||||
|
|
||||||
for (sample in samples) {
|
for (sample in samples) {
|
||||||
dos.writeInt(constantDuration)
|
// No duration - using default from tfhd
|
||||||
dos.writeInt(sample.data.size)
|
dos.writeInt(sample.data.size)
|
||||||
dos.writeInt(buildSampleFlags(sample.isKeyFrame))
|
dos.writeInt(buildSampleFlags(sample.isKeyFrame))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -44,13 +43,15 @@ 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)
|
||||||
|
|
||||||
private val outputPath: File = File(filePath)
|
// Normalize path - expo-file-system passes file:// URIs but File expects raw paths
|
||||||
|
// Handle both file:// and file:/ variants
|
||||||
|
private val outputPath: File = File(filePath.replace(Regex("^file:/+"), "/"))
|
||||||
|
|
||||||
private val bitRate = getBitRate()
|
private val bitRate = getBitRate()
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -82,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ extension CameraSession {
|
|||||||
onError: @escaping (_ error: CameraError) -> Void) {
|
onError: @escaping (_ error: CameraError) -> Void) {
|
||||||
// Run on Camera Queue
|
// Run on Camera Queue
|
||||||
CameraQueues.cameraQueue.async {
|
CameraQueues.cameraQueue.async {
|
||||||
|
// Normalize path - expo-file-system passes file:// URIs but FileManager expects raw paths
|
||||||
|
let normalizedPath = filePath.hasPrefix("file://") ? String(filePath.dropFirst(7)) : filePath
|
||||||
|
|
||||||
let start = DispatchTime.now()
|
let start = DispatchTime.now()
|
||||||
ReactLogger.log(level: .info, message: "Starting Video recording...")
|
ReactLogger.log(level: .info, message: "Starting Video recording...")
|
||||||
|
|
||||||
@@ -38,11 +41,27 @@ extension CameraSession {
|
|||||||
// Callback for when new chunks are ready
|
// Callback for when new chunks are ready
|
||||||
let onChunkReady: (ChunkedRecorder.Chunk) -> Void = { chunk in
|
let onChunkReady: (ChunkedRecorder.Chunk) -> Void = { chunk in
|
||||||
guard let delegate = self.delegate else {
|
guard let delegate = self.delegate else {
|
||||||
|
ReactLogger.log(level: .warning, message: "Chunk ready but delegate is nil, dropping chunk: \(chunk)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delegate.onVideoChunkReady(chunk: chunk)
|
delegate.onVideoChunkReady(chunk: chunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Callback for when a chunk write fails (e.g. init file write failure)
|
||||||
|
let onChunkError: (Error) -> Void = { error in
|
||||||
|
ReactLogger.log(level: .error, message: "Chunk write error, stopping recording: \(error.localizedDescription)")
|
||||||
|
// Stop recording immediately
|
||||||
|
if let session = self.recordingSession {
|
||||||
|
session.stop(clock: self.captureSession.clock)
|
||||||
|
}
|
||||||
|
// Surface error to RN
|
||||||
|
if let cameraError = error as? CameraError {
|
||||||
|
onError(cameraError)
|
||||||
|
} else {
|
||||||
|
onError(.capture(.fileError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Callback for when the recording ends
|
// Callback for when the recording ends
|
||||||
let onFinish = { (recordingSession: RecordingSession, status: AVAssetWriter.Status, error: Error?) in
|
let onFinish = { (recordingSession: RecordingSession, status: AVAssetWriter.Status, error: Error?) in
|
||||||
defer {
|
defer {
|
||||||
@@ -82,22 +101,23 @@ extension CameraSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !FileManager.default.fileExists(atPath: filePath) {
|
if !FileManager.default.fileExists(atPath: normalizedPath) {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(atPath: filePath, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(atPath: normalizedPath, withIntermediateDirectories: true)
|
||||||
} catch {
|
} catch {
|
||||||
onError(.capture(.createRecordingDirectoryError(message: error.localizedDescription)))
|
onError(.capture(.createRecordingDirectoryError(message: error.localizedDescription)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactLogger.log(level: .info, message: "Will record to temporary file: \(filePath)")
|
ReactLogger.log(level: .info, message: "Will record to temporary file: \(normalizedPath)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Create RecordingSession for the temp file
|
// Create RecordingSession for the temp file
|
||||||
let recordingSession = try RecordingSession(outputDiretory: filePath,
|
let recordingSession = try RecordingSession(outputDiretory: normalizedPath,
|
||||||
fileType: options.fileType,
|
fileType: options.fileType,
|
||||||
onChunkReady: onChunkReady,
|
onChunkReady: onChunkReady,
|
||||||
|
onChunkError: onChunkError,
|
||||||
completion: onFinish)
|
completion: onFinish)
|
||||||
|
|
||||||
// Init Audio + Activate Audio Session (optional)
|
// Init Audio + Activate Audio Session (optional)
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ class ChunkedRecorder: NSObject {
|
|||||||
|
|
||||||
let outputURL: URL
|
let outputURL: URL
|
||||||
let onChunkReady: ((Chunk) -> Void)
|
let onChunkReady: ((Chunk) -> Void)
|
||||||
|
let onError: ((Error) -> Void)?
|
||||||
|
|
||||||
private var chunkIndex: UInt64 = 0
|
private var chunkIndex: UInt64 = 0
|
||||||
|
|
||||||
init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void)) throws {
|
init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void), onError: ((Error) -> Void)? = nil) throws {
|
||||||
self.outputURL = outputURL
|
self.outputURL = outputURL
|
||||||
self.onChunkReady = onChunkReady
|
self.onChunkReady = onChunkReady
|
||||||
|
self.onError = onError
|
||||||
guard FileManager.default.fileExists(atPath: outputURL.path) else {
|
guard FileManager.default.fileExists(atPath: outputURL.path) else {
|
||||||
throw CameraError.unknown(message: "output directory does not exist at: \(outputURL.path)", cause: nil)
|
throw CameraError.unknown(message: "output directory does not exist at: \(outputURL.path)", cause: nil)
|
||||||
}
|
}
|
||||||
@@ -56,28 +58,36 @@ extension ChunkedRecorder: AVAssetWriterDelegate {
|
|||||||
|
|
||||||
private func saveInitSegment(_ data: Data) {
|
private func saveInitSegment(_ data: Data) {
|
||||||
let url = outputURL.appendingPathComponent("init.mp4")
|
let url = outputURL.appendingPathComponent("init.mp4")
|
||||||
save(data: data, url: url)
|
do {
|
||||||
onChunkReady(url: url, type: .initialization)
|
try data.write(to: url)
|
||||||
|
onChunkReady(url: url, type: .initialization)
|
||||||
|
} catch {
|
||||||
|
ReactLogger.log(level: .error, message: "Failed to write init file \(url): \(error.localizedDescription)")
|
||||||
|
onError?(CameraError.capture(.fileError))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveSegment(_ data: Data, report: AVAssetSegmentReport?) {
|
private func saveSegment(_ data: Data, report: AVAssetSegmentReport?) {
|
||||||
let name = "\(chunkIndex).mp4"
|
let name = "\(chunkIndex).mp4"
|
||||||
let url = outputURL.appendingPathComponent(name)
|
let url = outputURL.appendingPathComponent(name)
|
||||||
save(data: data, url: url)
|
if save(data: data, url: url) {
|
||||||
let duration = report?
|
let duration = report?
|
||||||
.trackReports
|
.trackReports
|
||||||
.filter { $0.mediaType == .video }
|
.filter { $0.mediaType == .video }
|
||||||
.first?
|
.first?
|
||||||
.duration
|
.duration
|
||||||
onChunkReady(url: url, type: .data(index: chunkIndex, duration: duration))
|
onChunkReady(url: url, type: .data(index: chunkIndex, duration: duration))
|
||||||
chunkIndex += 1
|
chunkIndex += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func save(data: Data, url: URL) {
|
private func save(data: Data, url: URL) -> Bool {
|
||||||
do {
|
do {
|
||||||
try data.write(to: url)
|
try data.write(to: url)
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
ReactLogger.log(level: .error, message: "Unable to write \(url): \(error.localizedDescription)")
|
ReactLogger.log(level: .error, message: "Unable to write \(url): \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,12 +74,13 @@ class RecordingSession {
|
|||||||
init(outputDiretory: String,
|
init(outputDiretory: String,
|
||||||
fileType: AVFileType,
|
fileType: AVFileType,
|
||||||
onChunkReady: @escaping ((ChunkedRecorder.Chunk) -> Void),
|
onChunkReady: @escaping ((ChunkedRecorder.Chunk) -> Void),
|
||||||
|
onChunkError: ((Error) -> Void)? = nil,
|
||||||
completion: @escaping (RecordingSession, AVAssetWriter.Status, Error?) -> Void) throws {
|
completion: @escaping (RecordingSession, AVAssetWriter.Status, Error?) -> Void) throws {
|
||||||
completionHandler = completion
|
completionHandler = completion
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let outputURL = URL(fileURLWithPath: outputDiretory)
|
let outputURL = URL(fileURLWithPath: outputDiretory)
|
||||||
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady)
|
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady, onError: onChunkError)
|
||||||
assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!)
|
assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!)
|
||||||
assetWriter.shouldOptimizeForNetworkUse = false
|
assetWriter.shouldOptimizeForNetworkUse = false
|
||||||
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS
|
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS
|
||||||
|
|||||||
@@ -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