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
4 changed files with 25 additions and 50 deletions

View File

@@ -44,8 +44,14 @@ 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
private val outputPath: File = File(filePath.removePrefix("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()

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,36 +56,28 @@ extension ChunkedRecorder: AVAssetWriterDelegate {
private func saveInitSegment(_ data: Data) {
let url = outputURL.appendingPathComponent("init.mp4")
do {
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))
}
save(data: data, url: url)
onChunkReady(url: url, type: .initialization)
}
private func saveSegment(_ data: Data, report: AVAssetSegmentReport?) {
let name = "\(chunkIndex).mp4"
let url = outputURL.appendingPathComponent(name)
if save(data: data, url: url) {
let duration = report?
.trackReports
.filter { $0.mediaType == .video }
.first?
.duration
onChunkReady(url: url, type: .data(index: chunkIndex, duration: duration))
chunkIndex += 1
}
save(data: data, url: url)
let duration = report?
.trackReports
.filter { $0.mediaType == .video }
.first?
.duration
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