Compare commits

..

6 Commits

Author SHA1 Message Date
de5e15b347 use window manager to determine device rotation in android 2026-01-02 09:55:17 -08:00
c0b352dc1d fix fps (alter tfhd and trun size, add logs 2025-12-30 15:01:41 -05:00
a307f9b7e8 normalize timestamps and fix framerate metadata in init file 2025-12-29 16:48:47 -05:00
b79f876114 Fix orientation issues 2025-12-28 01:14:44 -08:00
e60c1a4eb1 Write our own muxer to make hls uupload actually work 2025-12-21 16:45:04 -08:00
a2d218580c feat: Add fragmented MP4 (fMP4) support for Android
Implements HLS-compatible fragmented MP4 recording on Android using
AndroidX Media3 FragmentedMp4Muxer, matching the iOS implementation.

Changes:
- Add FragmentedRecordingManager for fMP4 segment output
- Add ChunkedRecorderInterface to abstract recorder implementations
- Add onInitSegmentReady callback for init segment (init.mp4)
- Update onVideoChunkReady to include segment duration
- RecordingSession now uses FragmentedRecordingManager by default

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 12:29:03 -08:00
9 changed files with 40 additions and 118 deletions

View File

@@ -30,15 +30,8 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
iFrameInterval: Int = 5
): ChunkedRecordingManager {
val mimeType = options.videoCodec.toMimeType()
// Use cameraOrientation (from WindowManager) for rotation metadata
// 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 cameraOrientationDegrees = cameraOrientation.toDegrees()
val recordingOrientationDegrees = (options.orientation ?: Orientation.PORTRAIT).toDegrees();
val (width, height) = if (cameraOrientation.isLandscape()) {
size.height to size.width
} else {
@@ -62,12 +55,12 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval)
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
Log.d(TAG, "Video Format: $format, orientation: $orientationDegrees")
Log.d(TAG, "Video Format: $format, camera orientation $cameraOrientationDegrees, recordingOrientation: $recordingOrientationDegrees")
// 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.
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
return ChunkedRecordingManager(
codec, outputDirectory, orientationDegrees, iFrameInterval, callbacks
codec, outputDirectory, recordingOrientationDegrees, iFrameInterval, callbacks
)
}
}
@@ -98,13 +91,12 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
muxer.start()
}
fun writeSample(buffer: java.nio.ByteBuffer, bufferInfo: BufferInfo) {
muxer.writeSampleData(videoTrack, buffer, bufferInfo)
}
fun finish() {
muxer.stop()
muxer.release()
// Calculate duration from start time - this is approximate
// The new FragmentedRecordingManager provides accurate duration
callbacks.onVideoChunkReady(filepath, chunkIndex, null)
}
}
@@ -178,7 +170,7 @@ class ChunkedRecordingManager(private val encoder: MediaCodec, private val outpu
encoder.releaseOutputBuffer(index, false)
return
}
context.writeSample(encodedData, bufferInfo)
context.muxer.writeSampleData(context.videoTrack, encodedData, bufferInfo)
encoder.releaseOutputBuffer(index, false)
}
}

View File

@@ -753,32 +753,17 @@ class HlsMuxer(
dos.writeShort(-1) // pre-defined
output.write(buildAvcCBox(sps, pps))
output.write(buildPaspBox())
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 {
val output = ByteArrayOutputStream()
val dos = DataOutputStream(output)
// 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 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 profileIdc = if (sps.isNotEmpty()) sps[0].toInt() and 0xFF else 0x42
val profileCompat = if (sps.size > 1) sps[1].toInt() and 0xFF else 0x00
val levelIdc = if (sps.size > 2) sps[2].toInt() and 0xFF else 0x1F
dos.writeByte(1) // configuration version
dos.writeByte(profileIdc) // AVC profile

View File

@@ -8,7 +8,6 @@ import com.facebook.common.statfs.StatFsHelper
import com.mrousavy.camera.extensions.getRecommendedBitRate
import com.mrousavy.camera.types.Orientation
import com.mrousavy.camera.types.RecordVideoOptions
import com.mrousavy.camera.types.StreamSegmentType
import com.mrousavy.camera.utils.FileUtils
import java.io.File
import android.os.Environment
@@ -28,7 +27,9 @@ class RecordingSession(
private val filePath: String,
private val callback: (video: Video) -> 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 {
private const val TAG = "RecordingSession"
@@ -43,15 +44,20 @@ class RecordingSession(
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
// Handle both file:// and file:/ variants
private val outputPath: File = File(filePath.replace(Regex("^file:/+"), "/"))
// Strip file:// prefix if present (expo-file-system returns URIs with this prefix)
private val outputPath: File = File(
if (filePath.startsWith("file://")) {
filePath.removePrefix("file://")
} else {
filePath
}
)
private val bitRate = getBitRate()
// Use FragmentedRecordingManager for HLS-compatible fMP4 output,
// or fall back to ChunkedRecordingManager for regular MP4 chunks
private val recorder: ChunkedRecorderInterface = if (options.streamSegmentType == StreamSegmentType.FRAGMENTED_MP4) {
private val recorder: ChunkedRecorderInterface = if (useFragmentedMp4) {
FragmentedRecordingManager.fromParams(
allCallbacks,
size,
@@ -83,7 +89,7 @@ class RecordingSession(
fun start() {
synchronized(this) {
Log.i(TAG, "Starting RecordingSession with ${options.streamSegmentType} recorder..")
Log.i(TAG, "Starting RecordingSession..")
startTime = System.currentTimeMillis()
recorder.start()
}

View File

@@ -9,7 +9,6 @@ class RecordVideoOptions(map: ReadableMap) {
var videoBitRateOverride: Double? = null
var videoBitRateMultiplier: Double? = null
var orientation: Orientation? = null
var streamSegmentType: StreamSegmentType = StreamSegmentType.FRAGMENTED_MP4
init {
if (map.hasKey("fileType")) {
@@ -30,8 +29,5 @@ class RecordVideoOptions(map: ReadableMap) {
if (map.hasKey("orientation")) {
orientation = Orientation.fromUnionValue(map.getString("orientation"))
}
if (map.hasKey("streamSegmentType")) {
streamSegmentType = StreamSegmentType.fromUnionValue(map.getString("streamSegmentType"))
}
}
}

View File

@@ -1,15 +0,0 @@
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
}
}
}

View File

@@ -20,9 +20,6 @@ extension CameraSession {
onError: @escaping (_ error: CameraError) -> Void) {
// Run on Camera Queue
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()
ReactLogger.log(level: .info, message: "Starting Video recording...")
@@ -41,27 +38,11 @@ extension CameraSession {
// Callback for when new chunks are ready
let onChunkReady: (ChunkedRecorder.Chunk) -> Void = { chunk in
guard let delegate = self.delegate else {
ReactLogger.log(level: .warning, message: "Chunk ready but delegate is nil, dropping chunk: \(chunk)")
return
}
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
let onFinish = { (recordingSession: RecordingSession, status: AVAssetWriter.Status, error: Error?) in
defer {
@@ -101,23 +82,22 @@ extension CameraSession {
}
}
if !FileManager.default.fileExists(atPath: normalizedPath) {
if !FileManager.default.fileExists(atPath: filePath) {
do {
try FileManager.default.createDirectory(atPath: normalizedPath, withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: filePath, withIntermediateDirectories: true)
} catch {
onError(.capture(.createRecordingDirectoryError(message: error.localizedDescription)))
return
}
}
ReactLogger.log(level: .info, message: "Will record to temporary file: \(normalizedPath)")
ReactLogger.log(level: .info, message: "Will record to temporary file: \(filePath)")
do {
// Create RecordingSession for the temp file
let recordingSession = try RecordingSession(outputDiretory: normalizedPath,
let recordingSession = try RecordingSession(outputDiretory: filePath,
fileType: options.fileType,
onChunkReady: onChunkReady,
onChunkError: onChunkError,
completion: onFinish)
// Init Audio + Activate Audio Session (optional)

View File

@@ -24,14 +24,12 @@ class ChunkedRecorder: NSObject {
let outputURL: URL
let onChunkReady: ((Chunk) -> Void)
let onError: ((Error) -> Void)?
private var chunkIndex: UInt64 = 0
init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void), onError: ((Error) -> Void)? = nil) throws {
init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void)) throws {
self.outputURL = outputURL
self.onChunkReady = onChunkReady
self.onError = onError
guard FileManager.default.fileExists(atPath: outputURL.path) else {
throw CameraError.unknown(message: "output directory does not exist at: \(outputURL.path)", cause: nil)
}
@@ -58,19 +56,14 @@ extension ChunkedRecorder: AVAssetWriterDelegate {
private func saveInitSegment(_ data: Data) {
let url = outputURL.appendingPathComponent("init.mp4")
do {
try data.write(to: url)
save(data: data, url: 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?) {
let name = "\(chunkIndex).mp4"
let url = outputURL.appendingPathComponent(name)
if save(data: data, url: url) {
save(data: data, url: url)
let duration = report?
.trackReports
.filter { $0.mediaType == .video }
@@ -79,15 +72,12 @@ extension ChunkedRecorder: AVAssetWriterDelegate {
onChunkReady(url: url, type: .data(index: chunkIndex, duration: duration))
chunkIndex += 1
}
}
private func save(data: Data, url: URL) -> Bool {
private func save(data: Data, url: URL) {
do {
try data.write(to: url)
return true
} catch {
ReactLogger.log(level: .error, message: "Unable to write \(url): \(error.localizedDescription)")
return false
}
}

View File

@@ -74,13 +74,12 @@ class RecordingSession {
init(outputDiretory: String,
fileType: AVFileType,
onChunkReady: @escaping ((ChunkedRecorder.Chunk) -> Void),
onChunkError: ((Error) -> Void)? = nil,
completion: @escaping (RecordingSession, AVAssetWriter.Status, Error?) -> Void) throws {
completionHandler = completion
do {
let outputURL = URL(fileURLWithPath: outputDiretory)
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady, onError: onChunkError)
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady)
assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!)
assetWriter.shouldOptimizeForNetworkUse = false
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS

View File

@@ -41,17 +41,6 @@ export interface RecordVideoOptions {
* @default 'normal'
*/
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'
}
/**